test_strategies.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  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 strategies.grid_trader import Strategy as GridStrategy
  9. STRATEGY_CODE = '''
  10. from src.trader_mcp.strategy_sdk import Strategy
  11. class Strategy(Strategy):
  12. def init(self):
  13. return {"started": True, "config_copy": dict(self.config)}
  14. '''
  15. def test_strategies_endpoints_roundtrip():
  16. with TemporaryDirectory() as tmpdir:
  17. strategy_store.DB_PATH = Path(tmpdir) / "trader_mcp.sqlite3"
  18. from src.trader_mcp import strategy_registry
  19. strategy_registry.STRATEGIES_DIR = Path(tmpdir) / "strategies"
  20. strategy_registry.STRATEGIES_DIR.mkdir()
  21. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  22. client = TestClient(app)
  23. r = client.get("/strategies")
  24. assert r.status_code == 200
  25. body = r.json()
  26. assert "available" in body
  27. assert "configured" in body
  28. r = client.post(
  29. "/strategies",
  30. json={
  31. "id": "demo-1",
  32. "strategy_type": "demo",
  33. "account_id": "acct-1",
  34. "client_id": "strategy:test",
  35. "mode": "observe",
  36. "config": {"risk": 0.01},
  37. },
  38. )
  39. assert r.status_code == 200
  40. assert r.json()["id"] == "demo-1"
  41. r = client.get("/strategies")
  42. assert any(item["id"] == "demo-1" for item in r.json()["configured"])
  43. r = client.delete("/strategies/demo-1")
  44. assert r.status_code == 200
  45. assert r.json()["ok"] is True
  46. def test_strategy_context_binds_identity(monkeypatch):
  47. calls = {}
  48. def fake_place_order(arguments):
  49. calls["place_order"] = arguments
  50. return {"ok": True}
  51. def fake_open_orders(account_id, client_id=None):
  52. calls["open_orders"] = {"account_id": account_id, "client_id": client_id}
  53. return {"ok": True}
  54. def fake_cancel_all(account_id, client_id=None):
  55. calls["cancel_all"] = {"account_id": account_id, "client_id": client_id}
  56. return {"ok": True}
  57. monkeypatch.setattr("src.trader_mcp.strategy_context.place_order", fake_place_order)
  58. monkeypatch.setattr("src.trader_mcp.strategy_context.list_open_orders", fake_open_orders)
  59. monkeypatch.setattr("src.trader_mcp.strategy_context.cancel_all_orders", fake_cancel_all)
  60. ctx = StrategyContext(id="inst-1", account_id="acct-1", client_id="client-1", mode="active")
  61. ctx.place_order(side="sell", market="xrpusd", order_type="limit", amount="10", price="2")
  62. ctx.get_open_orders()
  63. ctx.cancel_all_orders()
  64. assert calls["place_order"]["account_id"] == "acct-1"
  65. assert calls["place_order"]["client_id"] == "client-1"
  66. assert calls["open_orders"] == {"account_id": "acct-1", "client_id": "client-1"}
  67. assert calls["cancel_all"] == {"account_id": "acct-1", "client_id": "client-1"}
  68. def test_stop_loss_strategy_loads_with_aligned_regime_config(tmp_path):
  69. original_db = strategy_store.DB_PATH
  70. original_dir = strategy_registry.STRATEGIES_DIR
  71. try:
  72. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  73. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  74. strategy_registry.STRATEGIES_DIR.mkdir()
  75. (strategy_registry.STRATEGIES_DIR / "grid_trader.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "grid_trader.py").read_text())
  76. (strategy_registry.STRATEGIES_DIR / "stop_loss_trader.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "stop_loss_trader.py").read_text())
  77. grid_defaults = strategy_registry.get_strategy_default_config("grid_trader")
  78. stop_defaults = strategy_registry.get_strategy_default_config("stop_loss_trader")
  79. assert grid_defaults["trade_sides"] == "both"
  80. assert grid_defaults["trend_guard_reversal_max"] == 0.25
  81. assert stop_defaults["regime_timeframes"] == ["1d", "4h", "1h", "15m"]
  82. assert stop_defaults["trend_enter_threshold"] == 0.7
  83. assert stop_defaults["trend_exit_threshold"] == 0.45
  84. finally:
  85. strategy_store.DB_PATH = original_db
  86. strategy_registry.STRATEGIES_DIR = original_dir
  87. def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
  88. class FakeContext:
  89. base_currency = "XRP"
  90. counter_currency = "USD"
  91. market_symbol = "xrpusd"
  92. minimum_order_value = 10.0
  93. mode = "active"
  94. def __init__(self):
  95. self.cancelled_all = 0
  96. self.placed_orders = []
  97. def get_fee_rates(self, market):
  98. return {"maker": 0.0, "taker": 0.004}
  99. def get_account_info(self):
  100. return {
  101. "balances": [
  102. {"asset_code": "USD", "available": 13.55},
  103. {"asset_code": "XRP", "available": 22.0103},
  104. ]
  105. }
  106. def suggest_order_amount(
  107. self,
  108. *,
  109. side,
  110. price,
  111. levels,
  112. min_notional,
  113. fee_rate,
  114. max_notional_per_order=0.0,
  115. dust_collect=False,
  116. order_size=0.0,
  117. safety=0.995,
  118. ):
  119. if side == "buy":
  120. quote_available = 13.55
  121. spendable_quote = quote_available * safety
  122. quote_cap = min(spendable_quote, max_notional_per_order) if max_notional_per_order > 0 else spendable_quote
  123. if quote_cap < min_notional * (1 + fee_rate):
  124. return 0.0
  125. return quote_cap / (price * (1 + fee_rate))
  126. return 0.0
  127. def cancel_all_orders(self):
  128. self.cancelled_all += 1
  129. return {"ok": True}
  130. def place_order(self, **kwargs):
  131. self.placed_orders.append(kwargs)
  132. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  133. ctx = FakeContext()
  134. strategy = GridStrategy(
  135. ctx,
  136. {
  137. "grid_levels": 2,
  138. "grid_step_pct": 0.0062,
  139. "grid_step_min_pct": 0.0033,
  140. "grid_step_max_pct": 0.012,
  141. "max_notional_per_order": 12,
  142. "order_call_delay_ms": 0,
  143. "trade_sides": "both",
  144. "debug_orders": True,
  145. "dust_collect": True,
  146. "enable_trend_guard": False,
  147. "fee_rate": 0.004,
  148. },
  149. )
  150. strategy.state["center_price"] = 1.3285
  151. strategy.state["seeded"] = True
  152. strategy.state["base_available"] = 22.0103
  153. strategy.state["counter_available"] = 13.55
  154. strategy.state["orders"] = [
  155. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  156. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  157. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  158. ]
  159. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  160. def fake_sync_open_orders_state():
  161. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  162. strategy.state["orders"] = live
  163. strategy.state["order_ids"] = ["sell-1"]
  164. strategy.state["open_order_count"] = 1
  165. return live
  166. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  167. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  168. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  169. monkeypatch.setattr(strategy, "_trend_guard_status", lambda: (False, "disabled"))
  170. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  171. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  172. result = strategy.on_tick({})
  173. assert result["action"] in {"seed", "reseed"}
  174. assert ctx.cancelled_all == 1
  175. assert len(ctx.placed_orders) > 0
  176. assert strategy.state["last_action"] == "reseeded"
  177. def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
  178. class FakeContext:
  179. base_currency = "XRP"
  180. counter_currency = "USD"
  181. market_symbol = "xrpusd"
  182. minimum_order_value = 10.0
  183. mode = "active"
  184. def __init__(self):
  185. self.cancelled_all = 0
  186. self.placed_orders = []
  187. def get_fee_rates(self, market):
  188. return {"maker": 0.0, "taker": 0.004}
  189. def get_account_info(self):
  190. return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
  191. def cancel_all_orders(self):
  192. self.cancelled_all += 1
  193. return {"ok": True}
  194. def suggest_order_amount(self, **kwargs):
  195. return 10.0
  196. def place_order(self, **kwargs):
  197. self.placed_orders.append(kwargs)
  198. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  199. ctx = FakeContext()
  200. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  201. strategy.state["center_price"] = 1.3907
  202. strategy.state["seeded"] = True
  203. strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
  204. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  205. def fake_sync_open_orders_state():
  206. live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
  207. strategy.state["orders"] = live
  208. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  209. strategy.state["open_order_count"] = 5
  210. return live
  211. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  212. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: None)
  213. monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
  214. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  215. monkeypatch.setattr(strategy, "_trend_guard_status", lambda: (False, "disabled"))
  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"