test_strategies.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  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.grid_trader import Strategy as GridStrategy
  10. from strategies.trend_follower import Strategy as TrendStrategy
  11. STRATEGY_CODE = '''
  12. from src.trader_mcp.strategy_sdk import Strategy
  13. class Strategy(Strategy):
  14. def init(self):
  15. return {"started": True, "config_copy": dict(self.config)}
  16. '''
  17. def test_strategies_endpoints_roundtrip():
  18. with TemporaryDirectory() as tmpdir:
  19. strategy_store.DB_PATH = Path(tmpdir) / "trader_mcp.sqlite3"
  20. from src.trader_mcp import strategy_registry
  21. strategy_registry.STRATEGIES_DIR = Path(tmpdir) / "strategies"
  22. strategy_registry.STRATEGIES_DIR.mkdir()
  23. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  24. client = TestClient(app)
  25. r = client.get("/strategies")
  26. assert r.status_code == 200
  27. body = r.json()
  28. assert "available" in body
  29. assert "configured" in body
  30. r = client.post(
  31. "/strategies",
  32. json={
  33. "id": "demo-1",
  34. "strategy_type": "demo",
  35. "account_id": "acct-1",
  36. "client_id": "strategy:test",
  37. "mode": "observe",
  38. "config": {"risk": 0.01},
  39. },
  40. )
  41. assert r.status_code == 200
  42. assert r.json()["id"] == "demo-1"
  43. r = client.get("/strategies")
  44. assert any(item["id"] == "demo-1" for item in r.json()["configured"])
  45. r = client.delete("/strategies/demo-1")
  46. assert r.status_code == 200
  47. assert r.json()["ok"] is True
  48. def test_strategy_context_binds_identity(monkeypatch):
  49. calls = {}
  50. def fake_place_order(arguments):
  51. calls["place_order"] = arguments
  52. return {"ok": True}
  53. def fake_open_orders(account_id, client_id=None):
  54. calls["open_orders"] = {"account_id": account_id, "client_id": client_id}
  55. return {"ok": True}
  56. def fake_cancel_all(account_id, client_id=None):
  57. calls["cancel_all"] = {"account_id": account_id, "client_id": client_id}
  58. return {"ok": True}
  59. monkeypatch.setattr("src.trader_mcp.strategy_context.place_order", fake_place_order)
  60. monkeypatch.setattr("src.trader_mcp.strategy_context.list_open_orders", fake_open_orders)
  61. monkeypatch.setattr("src.trader_mcp.strategy_context.cancel_all_orders", fake_cancel_all)
  62. ctx = StrategyContext(id="inst-1", account_id="acct-1", client_id="client-1", mode="active")
  63. ctx.place_order(side="sell", market="xrpusd", order_type="limit", amount="10", price="2")
  64. ctx.get_open_orders()
  65. ctx.cancel_all_orders()
  66. assert calls["place_order"]["account_id"] == "acct-1"
  67. assert calls["place_order"]["client_id"] == "client-1"
  68. assert calls["open_orders"] == {"account_id": "acct-1", "client_id": "client-1"}
  69. assert calls["cancel_all"] == {"account_id": "acct-1", "client_id": "client-1"}
  70. def test_stop_loss_strategy_loads_with_aligned_regime_config(tmp_path):
  71. original_db = strategy_store.DB_PATH
  72. original_dir = strategy_registry.STRATEGIES_DIR
  73. try:
  74. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  75. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  76. strategy_registry.STRATEGIES_DIR.mkdir()
  77. (strategy_registry.STRATEGIES_DIR / "grid_trader.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "grid_trader.py").read_text())
  78. (strategy_registry.STRATEGIES_DIR / "exposure_protector.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "exposure_protector.py").read_text())
  79. grid_defaults = strategy_registry.get_strategy_default_config("grid_trader")
  80. stop_defaults = strategy_registry.get_strategy_default_config("exposure_protector")
  81. assert grid_defaults["trade_sides"] == "both"
  82. assert grid_defaults["grid_step_pct"] == 0.012
  83. assert stop_defaults["trail_distance_pct"] == 0.03
  84. assert stop_defaults["rebalance_target_ratio"] == 0.5
  85. assert stop_defaults["min_rebalance_seconds"] == 300
  86. finally:
  87. strategy_store.DB_PATH = original_db
  88. strategy_registry.STRATEGIES_DIR = original_dir
  89. def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
  90. class FakeContext:
  91. base_currency = "XRP"
  92. counter_currency = "USD"
  93. market_symbol = "xrpusd"
  94. minimum_order_value = 10.0
  95. mode = "active"
  96. def __init__(self):
  97. self.cancelled_all = 0
  98. self.placed_orders = []
  99. def get_fee_rates(self, market):
  100. return {"maker": 0.0, "taker": 0.004}
  101. def get_account_info(self):
  102. return {
  103. "balances": [
  104. {"asset_code": "USD", "available": 13.55},
  105. {"asset_code": "XRP", "available": 22.0103},
  106. ]
  107. }
  108. def suggest_order_amount(
  109. self,
  110. *,
  111. side,
  112. price,
  113. levels,
  114. min_notional,
  115. fee_rate,
  116. max_notional_per_order=0.0,
  117. dust_collect=False,
  118. order_size=0.0,
  119. safety=0.995,
  120. ):
  121. if side == "buy":
  122. quote_available = 13.55
  123. spendable_quote = quote_available * safety
  124. quote_cap = min(spendable_quote, max_notional_per_order) if max_notional_per_order > 0 else spendable_quote
  125. if quote_cap < min_notional * (1 + fee_rate):
  126. return 0.0
  127. return quote_cap / (price * (1 + fee_rate))
  128. return 0.0
  129. def cancel_all_orders(self):
  130. self.cancelled_all += 1
  131. return {"ok": True}
  132. def place_order(self, **kwargs):
  133. self.placed_orders.append(kwargs)
  134. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  135. ctx = FakeContext()
  136. strategy = GridStrategy(
  137. ctx,
  138. {
  139. "grid_levels": 2,
  140. "grid_step_pct": 0.0062,
  141. "grid_step_min_pct": 0.0033,
  142. "grid_step_max_pct": 0.012,
  143. "max_notional_per_order": 12,
  144. "order_call_delay_ms": 0,
  145. "trade_sides": "both",
  146. "debug_orders": True,
  147. "dust_collect": True,
  148. "enable_trend_guard": False,
  149. "fee_rate": 0.004,
  150. },
  151. )
  152. strategy.state["center_price"] = 1.3285
  153. strategy.state["seeded"] = True
  154. strategy.state["base_available"] = 22.0103
  155. strategy.state["counter_available"] = 13.55
  156. strategy.state["orders"] = [
  157. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  158. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  159. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  160. ]
  161. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  162. def fake_sync_open_orders_state():
  163. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  164. strategy.state["orders"] = live
  165. strategy.state["order_ids"] = ["sell-1"]
  166. strategy.state["open_order_count"] = 1
  167. return live
  168. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  169. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  170. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  171. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  172. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  173. result = strategy.on_tick({})
  174. assert result["action"] in {"seed", "reseed"}
  175. assert ctx.cancelled_all == 1
  176. assert len(ctx.placed_orders) > 0
  177. assert strategy.state["last_action"] == "reseeded"
  178. def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
  179. class FakeContext:
  180. base_currency = "XRP"
  181. counter_currency = "USD"
  182. market_symbol = "xrpusd"
  183. minimum_order_value = 10.0
  184. mode = "active"
  185. def __init__(self):
  186. self.cancelled_all = 0
  187. self.placed_orders = []
  188. def get_fee_rates(self, market):
  189. return {"maker": 0.0, "taker": 0.004}
  190. def get_account_info(self):
  191. return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
  192. def cancel_all_orders(self):
  193. self.cancelled_all += 1
  194. return {"ok": True}
  195. def suggest_order_amount(self, **kwargs):
  196. return 10.0
  197. def place_order(self, **kwargs):
  198. self.placed_orders.append(kwargs)
  199. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  200. ctx = FakeContext()
  201. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  202. strategy.state["center_price"] = 1.3907
  203. strategy.state["seeded"] = True
  204. strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
  205. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  206. def fake_sync_open_orders_state():
  207. live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
  208. strategy.state["orders"] = live
  209. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  210. strategy.state["open_order_count"] = 5
  211. return live
  212. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  213. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: None)
  214. monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
  215. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  216. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  217. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  218. result = strategy.on_tick({})
  219. assert result["action"] in {"seed", "reseed"}
  220. assert ctx.cancelled_all == 1
  221. assert len(ctx.placed_orders) > 0
  222. def test_grid_recenters_exactly_on_live_price():
  223. class FakeContext:
  224. base_currency = "XRP"
  225. counter_currency = "USD"
  226. market_symbol = "xrpusd"
  227. minimum_order_value = 10.0
  228. mode = "active"
  229. def cancel_all_orders(self):
  230. return {"ok": True}
  231. def get_fee_rates(self, market):
  232. return {"maker": 0.0, "taker": 0.0}
  233. def suggest_order_amount(self, **kwargs):
  234. return 0.1
  235. def place_order(self, **kwargs):
  236. return {"status": "ok", "id": "oid-1"}
  237. strategy = GridStrategy(FakeContext(), {})
  238. strategy.state["center_price"] = 100.0
  239. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  240. assert strategy.state["center_price"] == 160.0
  241. def test_grid_stop_cancels_all_open_orders():
  242. class FakeContext:
  243. base_currency = "XRP"
  244. counter_currency = "USD"
  245. market_symbol = "xrpusd"
  246. minimum_order_value = 10.0
  247. mode = "active"
  248. def __init__(self):
  249. self.cancelled = False
  250. def cancel_all_orders(self):
  251. self.cancelled = True
  252. return {"ok": True}
  253. def get_fee_rates(self, market):
  254. return {"maker": 0.0, "taker": 0.0}
  255. strategy = GridStrategy(FakeContext(), {})
  256. strategy.state["orders"] = [{"id": "o1"}]
  257. strategy.state["order_ids"] = ["o1"]
  258. strategy.state["open_order_count"] = 1
  259. strategy.on_stop()
  260. assert strategy.context.cancelled is True
  261. assert strategy.state["open_order_count"] == 0
  262. assert strategy.state["last_action"] == "stopped"
  263. def test_base_strategy_report_uses_context_snapshot():
  264. class FakeContext:
  265. id = "s-1"
  266. account_id = "acct-1"
  267. market_symbol = "xrpusd"
  268. base_currency = "XRP"
  269. counter_currency = "USD"
  270. mode = "active"
  271. def get_strategy_snapshot(self):
  272. return {
  273. "identity": {"strategy_id": "s-1", "strategy_name": "Demo", "account_id": "acct-1", "market": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"},
  274. "control": {"enabled_state": "on", "mode": "active"},
  275. "position": {"balances": [{"asset_code": "XRP", "available": 1.0}]},
  276. "orders": {"open_orders": [{"id": "o1"}]},
  277. "execution": {"execution_quality": "good"},
  278. }
  279. class DemoStrategy(BaseStrategy):
  280. LABEL = "Demo"
  281. report = DemoStrategy(FakeContext(), {}).report()
  282. assert report["identity"]["strategy_id"] == "s-1"
  283. assert report["control"]["mode"] == "active"
  284. assert report["position"]["open_orders"][0]["id"] == "o1"
  285. def test_trend_follower_uses_policy_and_reports_fit():
  286. class FakeContext:
  287. id = "s-2"
  288. account_id = "acct-2"
  289. client_id = "cid-2"
  290. mode = "active"
  291. market_symbol = "xrpusd"
  292. base_currency = "XRP"
  293. counter_currency = "USD"
  294. def get_price(self, symbol):
  295. return {"price": 1.2}
  296. def get_regime(self, symbol, timeframe="1h"):
  297. return {"trend": {"state": "bull", "strength": 0.9}}
  298. def place_order(self, **kwargs):
  299. return {"ok": True, "order": kwargs}
  300. def get_strategy_snapshot(self):
  301. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  302. strat = TrendStrategy(FakeContext(), {"order_size": 1.5})
  303. strat.apply_policy()
  304. report = strat.report()
  305. assert report["fit"]["risk_profile"] == "growth"
  306. assert strat.state["policy_derived"]["order_size"] > 0