test_strategies.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. from __future__ import annotations
  2. from pathlib import Path
  3. from tempfile import TemporaryDirectory
  4. from fastapi.testclient import TestClient
  5. from src.trader_mcp import strategy_registry, strategy_store
  6. from src.trader_mcp.server import app
  7. from src.trader_mcp.strategy_context import StrategyContext
  8. from src.trader_mcp.strategy_sdk import Strategy as BaseStrategy
  9. from strategies.exposure_protector import Strategy as ExposureStrategy
  10. from strategies.grid_trader import Strategy as GridStrategy
  11. from strategies.trend_follower import Strategy as TrendStrategy
  12. STRATEGY_CODE = '''
  13. from src.trader_mcp.strategy_sdk import Strategy
  14. class Strategy(Strategy):
  15. def init(self):
  16. return {"started": True, "config_copy": dict(self.config)}
  17. '''
  18. def test_strategies_endpoints_roundtrip():
  19. with TemporaryDirectory() as tmpdir:
  20. strategy_store.DB_PATH = Path(tmpdir) / "trader_mcp.sqlite3"
  21. from src.trader_mcp import strategy_registry
  22. strategy_registry.STRATEGIES_DIR = Path(tmpdir) / "strategies"
  23. strategy_registry.STRATEGIES_DIR.mkdir()
  24. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  25. client = TestClient(app)
  26. r = client.get("/strategies")
  27. assert r.status_code == 200
  28. body = r.json()
  29. assert "available" in body
  30. assert "configured" in body
  31. r = client.post(
  32. "/strategies",
  33. json={
  34. "id": "demo-1",
  35. "strategy_type": "demo",
  36. "account_id": "acct-1",
  37. "client_id": "strategy:test",
  38. "mode": "observe",
  39. "config": {"risk": 0.01},
  40. },
  41. )
  42. assert r.status_code == 200
  43. assert r.json()["id"] == "demo-1"
  44. r = client.get("/strategies")
  45. assert any(item["id"] == "demo-1" for item in r.json()["configured"])
  46. r = client.delete("/strategies/demo-1")
  47. assert r.status_code == 200
  48. assert r.json()["ok"] is True
  49. def test_strategy_context_binds_identity(monkeypatch):
  50. calls = {}
  51. def fake_place_order(arguments):
  52. calls["place_order"] = arguments
  53. return {"ok": True}
  54. def fake_open_orders(account_id, client_id=None):
  55. calls["open_orders"] = {"account_id": account_id, "client_id": client_id}
  56. return {"ok": True}
  57. def fake_cancel_all(account_id, client_id=None):
  58. calls["cancel_all"] = {"account_id": account_id, "client_id": client_id}
  59. return {"ok": True}
  60. monkeypatch.setattr("src.trader_mcp.strategy_context.place_order", fake_place_order)
  61. monkeypatch.setattr("src.trader_mcp.strategy_context.list_open_orders", fake_open_orders)
  62. monkeypatch.setattr("src.trader_mcp.strategy_context.cancel_all_orders", fake_cancel_all)
  63. ctx = StrategyContext(id="inst-1", account_id="acct-1", client_id="client-1", mode="active")
  64. ctx.place_order(side="sell", market="xrpusd", order_type="limit", amount="10", price="2")
  65. ctx.get_open_orders()
  66. ctx.cancel_all_orders()
  67. assert calls["place_order"]["account_id"] == "acct-1"
  68. assert calls["place_order"]["client_id"] == "client-1"
  69. assert calls["open_orders"] == {"account_id": "acct-1", "client_id": "client-1"}
  70. assert calls["cancel_all"] == {"account_id": "acct-1", "client_id": "client-1"}
  71. def test_stop_loss_strategy_loads_with_aligned_regime_config(tmp_path):
  72. original_db = strategy_store.DB_PATH
  73. original_dir = strategy_registry.STRATEGIES_DIR
  74. try:
  75. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  76. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  77. strategy_registry.STRATEGIES_DIR.mkdir()
  78. (strategy_registry.STRATEGIES_DIR / "grid_trader.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "grid_trader.py").read_text())
  79. (strategy_registry.STRATEGIES_DIR / "exposure_protector.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "exposure_protector.py").read_text())
  80. grid_defaults = strategy_registry.get_strategy_default_config("grid_trader")
  81. stop_defaults = strategy_registry.get_strategy_default_config("exposure_protector")
  82. assert grid_defaults["trade_sides"] == "both"
  83. assert grid_defaults["grid_step_pct"] == 0.012
  84. assert stop_defaults["trail_distance_pct"] == 0.03
  85. assert stop_defaults["rebalance_target_ratio"] == 0.5
  86. assert stop_defaults["min_rebalance_seconds"] == 180
  87. assert stop_defaults["min_price_move_pct"] == 0.005
  88. finally:
  89. strategy_store.DB_PATH = original_db
  90. strategy_registry.STRATEGIES_DIR = original_dir
  91. def test_grid_supervision_reports_factual_capacity_not_handoff_commands():
  92. class FakeContext:
  93. account_id = "acct-1"
  94. market_symbol = "xrpusd"
  95. base_currency = "XRP"
  96. counter_currency = "USD"
  97. mode = "active"
  98. strategy = GridStrategy(FakeContext(), {})
  99. strategy.state.update({
  100. "last_price": 1.45,
  101. "base_available": 50.0,
  102. "counter_available": 38.7,
  103. "regimes": {"1h": {"trend": {"state": "bull"}}},
  104. })
  105. supervision = strategy._supervision()
  106. assert supervision["inventory_pressure"] == "base_heavy"
  107. assert supervision["capacity_available"] is False
  108. assert supervision["side_capacity"] == {"buy": True, "sell": True}
  109. strategy.state.update({
  110. "base_available": 88.0,
  111. "counter_available": 4.0,
  112. })
  113. supervision = strategy._supervision()
  114. assert supervision["inventory_pressure"] == "base_side_depleted"
  115. assert supervision["side_capacity"] == {"buy": True, "sell": False}
  116. def test_trend_and_protector_supervision_reports_facts_only():
  117. class FakeContext:
  118. account_id = "acct-1"
  119. market_symbol = "xrpusd"
  120. base_currency = "XRP"
  121. counter_currency = "USD"
  122. mode = "active"
  123. trend = TrendStrategy(FakeContext(), {})
  124. trend.state.update({"last_price": 1.45, "last_strength": 0.42, "last_signal": "up", "base_available": 20.0, "counter_available": 20.0})
  125. trend_supervision = trend._supervision()
  126. assert trend_supervision["trend_strength"] == 0.42
  127. assert trend_supervision["signal"] == "up"
  128. assert "switch_readiness" not in trend_supervision
  129. assert "desired_companion" not in trend_supervision
  130. protector = ExposureStrategy(FakeContext(), {})
  131. protector.state.update({"last_price": 1.45, "base_available": 40.0, "counter_available": 10.0})
  132. protector_supervision = protector._supervision()
  133. assert protector_supervision["rebalance_needed"] is True
  134. assert "switch_readiness" not in protector_supervision
  135. assert "desired_companion" not in protector_supervision
  136. def test_exposure_protector_holds_inside_hysteresis_band(monkeypatch):
  137. class FakeContext:
  138. account_id = "acct-1"
  139. market_symbol = "xrpusd"
  140. base_currency = "XRP"
  141. counter_currency = "USD"
  142. mode = "active"
  143. def __init__(self):
  144. self.placed_orders = []
  145. def get_account_info(self):
  146. return {"balances": [{"asset_code": "XRP", "available": 9.2}, {"asset_code": "USD", "available": 10.0}]}
  147. def get_price(self, market):
  148. return {"price": 1.0}
  149. def get_fee_rates(self, market):
  150. return {"maker": 0.0, "taker": 0.004}
  151. def place_order(self, **kwargs):
  152. self.placed_orders.append(kwargs)
  153. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  154. ctx = FakeContext()
  155. strategy = ExposureStrategy(ctx, {"rebalance_target_ratio": 0.5, "rebalance_step_ratio": 0.15, "balance_tolerance": 0.05, "cooldown_ticks": 0, "min_rebalance_seconds": 0, "trail_distance_pct": 0.03})
  156. strategy.state["last_rebalance_side"] = "sell"
  157. strategy.state["last_order_at"] = 0
  158. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  159. result = strategy.on_tick({})
  160. assert result["action"] == "hold"
  161. assert result["reason"] == "within rebalance hysteresis"
  162. assert ctx.placed_orders == []
  163. def test_grid_apply_policy_keeps_explicit_grid_levels():
  164. class FakeContext:
  165. account_id = "acct-1"
  166. market_symbol = "xrpusd"
  167. base_currency = "XRP"
  168. counter_currency = "USD"
  169. mode = "active"
  170. strategy = GridStrategy(FakeContext(), {"grid_levels": 5, "policy": {"risk_posture": "normal"}})
  171. strategy.apply_policy()
  172. assert strategy.config["grid_levels"] == 5
  173. assert strategy.state["policy_derived"]["grid_levels"] == 5
  174. def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
  175. class FakeContext:
  176. base_currency = "XRP"
  177. counter_currency = "USD"
  178. market_symbol = "xrpusd"
  179. minimum_order_value = 10.0
  180. mode = "active"
  181. def __init__(self):
  182. self.cancelled_all = 0
  183. self.placed_orders = []
  184. def get_fee_rates(self, market):
  185. return {"maker": 0.0, "taker": 0.004}
  186. def get_account_info(self):
  187. raise RuntimeError("Bitstamp auth breaker active, retry later")
  188. def cancel_all_orders(self):
  189. self.cancelled_all += 1
  190. return {"ok": True}
  191. def suggest_order_amount(self, **kwargs):
  192. return 10.0
  193. def place_order(self, **kwargs):
  194. self.placed_orders.append(kwargs)
  195. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  196. ctx = FakeContext()
  197. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  198. strategy.state["center_price"] = 1.4397
  199. strategy.state["seeded"] = True
  200. strategy.state["orders"] = [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}]
  201. strategy.state["order_ids"] = ["o1"]
  202. monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}])
  203. monkeypatch.setattr(strategy, "_price", lambda: 1.4397)
  204. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  205. result = strategy.on_tick({})
  206. assert result["action"] == "hold"
  207. assert result["reason"] == "balance refresh unavailable"
  208. assert ctx.cancelled_all == 0
  209. assert ctx.placed_orders == []
  210. def test_grid_skips_shape_rebuild_when_balance_reads_turn_inconclusive(monkeypatch):
  211. class FakeContext:
  212. base_currency = "XRP"
  213. counter_currency = "USD"
  214. market_symbol = "xrpusd"
  215. minimum_order_value = 10.0
  216. mode = "active"
  217. def __init__(self):
  218. self.cancelled_all = 0
  219. self.placed_orders = []
  220. self.calls = 0
  221. def get_fee_rates(self, market):
  222. return {"maker": 0.0, "taker": 0.004}
  223. def get_account_info(self):
  224. self.calls += 1
  225. if self.calls == 1:
  226. return {
  227. "balances": [
  228. {"asset_code": "USD", "available": 41.29},
  229. {"asset_code": "XRP", "available": 9.98954},
  230. ]
  231. }
  232. raise RuntimeError("Bitstamp auth breaker active, retry later")
  233. def cancel_all_orders(self):
  234. self.cancelled_all += 1
  235. return {"ok": True}
  236. def suggest_order_amount(self, **kwargs):
  237. return 10.0
  238. def place_order(self, **kwargs):
  239. self.placed_orders.append(kwargs)
  240. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  241. ctx = FakeContext()
  242. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  243. strategy.state["center_price"] = 1.3285
  244. strategy.state["seeded"] = True
  245. strategy.state["orders"] = [
  246. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  247. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  248. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  249. ]
  250. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  251. def fake_sync_open_orders_state():
  252. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  253. strategy.state["orders"] = live
  254. strategy.state["order_ids"] = ["sell-1"]
  255. strategy.state["open_order_count"] = 1
  256. return live
  257. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  258. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  259. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  260. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  261. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  262. result = strategy.on_tick({})
  263. assert result["action"] == "hold"
  264. assert ctx.cancelled_all == 0
  265. assert ctx.placed_orders == []
  266. def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
  267. class FakeContext:
  268. base_currency = "XRP"
  269. counter_currency = "USD"
  270. market_symbol = "xrpusd"
  271. minimum_order_value = 10.0
  272. mode = "active"
  273. def __init__(self):
  274. self.cancelled_all = 0
  275. self.placed_orders = []
  276. def get_fee_rates(self, market):
  277. return {"maker": 0.0, "taker": 0.004}
  278. def get_account_info(self):
  279. return {
  280. "balances": [
  281. {"asset_code": "USD", "available": 13.55},
  282. {"asset_code": "XRP", "available": 22.0103},
  283. ]
  284. }
  285. def suggest_order_amount(
  286. self,
  287. *,
  288. side,
  289. price,
  290. levels,
  291. min_notional,
  292. fee_rate,
  293. max_notional_per_order=0.0,
  294. dust_collect=False,
  295. order_size=0.0,
  296. safety=0.995,
  297. ):
  298. if side == "buy":
  299. quote_available = 13.55
  300. spendable_quote = quote_available * safety
  301. quote_cap = min(spendable_quote, max_notional_per_order) if max_notional_per_order > 0 else spendable_quote
  302. if quote_cap < min_notional * (1 + fee_rate):
  303. return 0.0
  304. return quote_cap / (price * (1 + fee_rate))
  305. return 0.0
  306. def cancel_all_orders(self):
  307. self.cancelled_all += 1
  308. return {"ok": True}
  309. def place_order(self, **kwargs):
  310. self.placed_orders.append(kwargs)
  311. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  312. ctx = FakeContext()
  313. strategy = GridStrategy(
  314. ctx,
  315. {
  316. "grid_levels": 2,
  317. "grid_step_pct": 0.0062,
  318. "grid_step_min_pct": 0.0033,
  319. "grid_step_max_pct": 0.012,
  320. "max_notional_per_order": 12,
  321. "order_call_delay_ms": 0,
  322. "trade_sides": "both",
  323. "debug_orders": True,
  324. "dust_collect": True,
  325. "enable_trend_guard": False,
  326. "fee_rate": 0.004,
  327. },
  328. )
  329. strategy.state["center_price"] = 1.3285
  330. strategy.state["seeded"] = True
  331. strategy.state["base_available"] = 22.0103
  332. strategy.state["counter_available"] = 13.55
  333. strategy.state["orders"] = [
  334. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  335. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  336. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  337. ]
  338. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  339. def fake_sync_open_orders_state():
  340. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  341. strategy.state["orders"] = live
  342. strategy.state["order_ids"] = ["sell-1"]
  343. strategy.state["open_order_count"] = 1
  344. return live
  345. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  346. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  347. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  348. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  349. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  350. result = strategy.on_tick({})
  351. assert result["action"] in {"seed", "reseed"}
  352. assert ctx.cancelled_all == 1
  353. assert len(ctx.placed_orders) > 0
  354. assert strategy.state["last_action"] == "reseeded"
  355. def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
  356. class FakeContext:
  357. base_currency = "XRP"
  358. counter_currency = "USD"
  359. market_symbol = "xrpusd"
  360. minimum_order_value = 10.0
  361. mode = "active"
  362. def __init__(self):
  363. self.cancelled_all = 0
  364. self.placed_orders = []
  365. def get_fee_rates(self, market):
  366. return {"maker": 0.0, "taker": 0.004}
  367. def get_account_info(self):
  368. return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
  369. def cancel_all_orders(self):
  370. self.cancelled_all += 1
  371. return {"ok": True}
  372. def suggest_order_amount(self, **kwargs):
  373. return 10.0
  374. def place_order(self, **kwargs):
  375. self.placed_orders.append(kwargs)
  376. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  377. ctx = FakeContext()
  378. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  379. strategy.state["center_price"] = 1.3907
  380. strategy.state["seeded"] = True
  381. strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
  382. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  383. def fake_sync_open_orders_state():
  384. live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
  385. strategy.state["orders"] = live
  386. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  387. strategy.state["open_order_count"] = 5
  388. return live
  389. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  390. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
  391. monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
  392. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  393. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  394. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  395. result = strategy.on_tick({})
  396. assert result["action"] in {"seed", "reseed"}
  397. assert ctx.cancelled_all == 1
  398. assert len(ctx.placed_orders) > 0
  399. def test_grid_recenters_exactly_on_live_price():
  400. class FakeContext:
  401. base_currency = "XRP"
  402. counter_currency = "USD"
  403. market_symbol = "xrpusd"
  404. minimum_order_value = 10.0
  405. mode = "active"
  406. def cancel_all_orders(self):
  407. return {"ok": True}
  408. def get_fee_rates(self, market):
  409. return {"maker": 0.0, "taker": 0.0}
  410. def suggest_order_amount(self, **kwargs):
  411. return 0.1
  412. def place_order(self, **kwargs):
  413. return {"status": "ok", "id": "oid-1"}
  414. strategy = GridStrategy(FakeContext(), {})
  415. strategy.state["center_price"] = 100.0
  416. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  417. assert strategy.state["center_price"] == 160.0
  418. def test_grid_stop_cancels_all_open_orders():
  419. class FakeContext:
  420. base_currency = "XRP"
  421. counter_currency = "USD"
  422. market_symbol = "xrpusd"
  423. minimum_order_value = 10.0
  424. mode = "active"
  425. def __init__(self):
  426. self.cancelled = False
  427. def cancel_all_orders(self):
  428. self.cancelled = True
  429. return {"ok": True}
  430. def get_fee_rates(self, market):
  431. return {"maker": 0.0, "taker": 0.0}
  432. strategy = GridStrategy(FakeContext(), {})
  433. strategy.state["orders"] = [{"id": "o1"}]
  434. strategy.state["order_ids"] = ["o1"]
  435. strategy.state["open_order_count"] = 1
  436. strategy.on_stop()
  437. assert strategy.context.cancelled is True
  438. assert strategy.state["open_order_count"] == 0
  439. assert strategy.state["last_action"] == "stopped"
  440. def test_base_strategy_report_uses_context_snapshot():
  441. class FakeContext:
  442. id = "s-1"
  443. account_id = "acct-1"
  444. market_symbol = "xrpusd"
  445. base_currency = "XRP"
  446. counter_currency = "USD"
  447. mode = "active"
  448. def get_strategy_snapshot(self):
  449. return {
  450. "identity": {"strategy_id": "s-1", "strategy_name": "Demo", "account_id": "acct-1", "market": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"},
  451. "control": {"enabled_state": "on", "mode": "active"},
  452. "position": {"balances": [{"asset_code": "XRP", "available": 1.0}]},
  453. "orders": {"open_orders": [{"id": "o1"}]},
  454. "execution": {"execution_quality": "good"},
  455. }
  456. class DemoStrategy(BaseStrategy):
  457. LABEL = "Demo"
  458. report = DemoStrategy(FakeContext(), {}).report()
  459. assert report["identity"]["strategy_id"] == "s-1"
  460. assert report["control"]["mode"] == "active"
  461. assert report["position"]["open_orders"][0]["id"] == "o1"
  462. def test_trend_follower_uses_policy_and_reports_fit():
  463. class FakeContext:
  464. id = "s-2"
  465. account_id = "acct-2"
  466. client_id = "cid-2"
  467. mode = "active"
  468. market_symbol = "xrpusd"
  469. base_currency = "XRP"
  470. counter_currency = "USD"
  471. def get_price(self, symbol):
  472. return {"price": 1.2}
  473. def get_regime(self, symbol, timeframe="1h"):
  474. return {"trend": {"state": "bull", "strength": 0.9}}
  475. def place_order(self, **kwargs):
  476. return {"ok": True, "order": kwargs}
  477. def get_strategy_snapshot(self):
  478. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  479. strat = TrendStrategy(FakeContext(), {"order_size": 1.5})
  480. strat.apply_policy()
  481. report = strat.report()
  482. assert report["fit"]["risk_profile"] == "growth"
  483. assert strat.state["policy_derived"]["order_size"] > 0
  484. def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
  485. class FakeContext:
  486. id = "s-bull"
  487. account_id = "acct-1"
  488. client_id = "cid-1"
  489. mode = "active"
  490. market_symbol = "xrpusd"
  491. base_currency = "XRP"
  492. counter_currency = "USD"
  493. def __init__(self):
  494. self.orders = []
  495. def get_price(self, symbol):
  496. return {"price": 1.2}
  497. def get_regime(self, symbol, timeframe="1h"):
  498. return {
  499. "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
  500. "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
  501. }
  502. def place_order(self, **kwargs):
  503. self.orders.append(kwargs)
  504. return {"ok": True, "order": kwargs}
  505. def get_account_info(self):
  506. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  507. minimum_order_value = 10.0
  508. def suggest_order_amount(self, **kwargs):
  509. return 10.0
  510. def get_strategy_snapshot(self):
  511. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  512. ctx = FakeContext()
  513. strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
  514. result = strat.on_tick({})
  515. assert result["action"] == "buy"
  516. assert ctx.orders[-1]["side"] == "buy"
  517. assert ctx.orders[-1]["amount"] == 10.0
  518. assert strat.state["last_action"] == "buy_trend"
  519. assert strat.state["last_strength"] >= 0.65
  520. def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
  521. class FakeContext:
  522. id = "s-bear"
  523. account_id = "acct-1"
  524. client_id = "cid-1"
  525. mode = "active"
  526. market_symbol = "xrpusd"
  527. base_currency = "XRP"
  528. counter_currency = "USD"
  529. def __init__(self):
  530. self.orders = []
  531. def get_price(self, symbol):
  532. return {"price": 1.2}
  533. def get_regime(self, symbol, timeframe="1h"):
  534. return {
  535. "trend": {"state": "bear", "ema_fast": 1.17, "ema_slow": 1.2},
  536. "momentum": {"state": "bear", "rsi": 36, "macd_histogram": -0.002},
  537. }
  538. def place_order(self, **kwargs):
  539. self.orders.append(kwargs)
  540. return {"ok": True, "order": kwargs}
  541. def get_account_info(self):
  542. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  543. minimum_order_value = 10.0
  544. def suggest_order_amount(self, **kwargs):
  545. return 6.0
  546. def get_strategy_snapshot(self):
  547. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  548. ctx = FakeContext()
  549. strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
  550. result = strat.on_tick({})
  551. assert result["action"] == "sell"
  552. assert ctx.orders[-1]["side"] == "sell"
  553. assert ctx.orders[-1]["amount"] == 6.0
  554. assert strat.state["last_action"] == "sell_trend"
  555. assert strat.state["last_strength"] >= 0.65
  556. def test_trend_follower_policy_does_not_override_explicit_order_size():
  557. class FakeContext:
  558. id = "s-explicit"
  559. account_id = "acct-1"
  560. client_id = "cid-1"
  561. mode = "active"
  562. market_symbol = "xrpusd"
  563. base_currency = "XRP"
  564. counter_currency = "USD"
  565. def get_price(self, symbol):
  566. return {"price": 1.2}
  567. def get_regime(self, symbol, timeframe="1h"):
  568. return {"trend": {"state": "bull", "strength": 0.9}}
  569. def get_strategy_snapshot(self):
  570. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  571. strat = TrendStrategy(FakeContext(), {"order_size": 10.5})
  572. strat.apply_policy()
  573. assert strat.config["order_size"] == 10.5
  574. assert strat.state["policy_derived"]["order_size"] == 10.5
  575. def test_trend_follower_passes_live_fee_rate_into_sizing_helper():
  576. class FakeContext:
  577. id = "s-fee"
  578. account_id = "acct-1"
  579. client_id = "cid-1"
  580. mode = "active"
  581. market_symbol = "xrpusd"
  582. base_currency = "XRP"
  583. counter_currency = "USD"
  584. minimum_order_value = 10.0
  585. def __init__(self):
  586. self.fee_calls = []
  587. self.suggest_calls = []
  588. def get_price(self, symbol):
  589. return {"price": 1.2}
  590. def get_regime(self, symbol, timeframe="1h"):
  591. return {"trend": {"state": "bull", "strength": 0.9}}
  592. def get_fee_rates(self, market_symbol=None):
  593. self.fee_calls.append(market_symbol)
  594. return {"maker": 0.0025, "taker": 0.004}
  595. def suggest_order_amount(self, **kwargs):
  596. self.suggest_calls.append(kwargs)
  597. return 8.0
  598. def place_order(self, **kwargs):
  599. return {"ok": True, "order": kwargs}
  600. def get_account_info(self):
  601. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  602. def get_strategy_snapshot(self):
  603. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  604. ctx = FakeContext()
  605. strat = TrendStrategy(ctx, {"order_size": 10.5, "trend_timeframe": "15m"})
  606. strat.on_tick({})
  607. assert ctx.fee_calls == ["xrpusd"]
  608. assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025