test_strategies.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  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"] == 180
  86. assert stop_defaults["min_price_move_pct"] == 0.005
  87. finally:
  88. strategy_store.DB_PATH = original_db
  89. strategy_registry.STRATEGIES_DIR = original_dir
  90. def test_grid_supervision_only_reports_ready_for_handoff_on_true_depletion():
  91. class FakeContext:
  92. account_id = "acct-1"
  93. market_symbol = "xrpusd"
  94. base_currency = "XRP"
  95. counter_currency = "USD"
  96. mode = "active"
  97. strategy = GridStrategy(FakeContext(), {})
  98. strategy.state.update({
  99. "last_price": 1.45,
  100. "base_available": 50.0,
  101. "counter_available": 38.7,
  102. "regimes": {"1h": {"trend": {"state": "bull"}}},
  103. })
  104. supervision = strategy._supervision()
  105. assert supervision["inventory_pressure"] == "base_heavy"
  106. assert supervision["switch_readiness"] == "watch_handoff"
  107. assert supervision["desired_companion"] is None
  108. strategy.state.update({
  109. "base_available": 88.0,
  110. "counter_available": 4.0,
  111. })
  112. supervision = strategy._supervision()
  113. assert supervision["inventory_pressure"] == "base_side_depleted"
  114. assert supervision["switch_readiness"] == "ready_for_handoff"
  115. assert supervision["desired_companion"] == "exposure_protector"
  116. def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
  117. class FakeContext:
  118. base_currency = "XRP"
  119. counter_currency = "USD"
  120. market_symbol = "xrpusd"
  121. minimum_order_value = 10.0
  122. mode = "active"
  123. def __init__(self):
  124. self.cancelled_all = 0
  125. self.placed_orders = []
  126. def get_fee_rates(self, market):
  127. return {"maker": 0.0, "taker": 0.004}
  128. def get_account_info(self):
  129. return {
  130. "balances": [
  131. {"asset_code": "USD", "available": 13.55},
  132. {"asset_code": "XRP", "available": 22.0103},
  133. ]
  134. }
  135. def suggest_order_amount(
  136. self,
  137. *,
  138. side,
  139. price,
  140. levels,
  141. min_notional,
  142. fee_rate,
  143. max_notional_per_order=0.0,
  144. dust_collect=False,
  145. order_size=0.0,
  146. safety=0.995,
  147. ):
  148. if side == "buy":
  149. quote_available = 13.55
  150. spendable_quote = quote_available * safety
  151. quote_cap = min(spendable_quote, max_notional_per_order) if max_notional_per_order > 0 else spendable_quote
  152. if quote_cap < min_notional * (1 + fee_rate):
  153. return 0.0
  154. return quote_cap / (price * (1 + fee_rate))
  155. return 0.0
  156. def cancel_all_orders(self):
  157. self.cancelled_all += 1
  158. return {"ok": True}
  159. def place_order(self, **kwargs):
  160. self.placed_orders.append(kwargs)
  161. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  162. ctx = FakeContext()
  163. strategy = GridStrategy(
  164. ctx,
  165. {
  166. "grid_levels": 2,
  167. "grid_step_pct": 0.0062,
  168. "grid_step_min_pct": 0.0033,
  169. "grid_step_max_pct": 0.012,
  170. "max_notional_per_order": 12,
  171. "order_call_delay_ms": 0,
  172. "trade_sides": "both",
  173. "debug_orders": True,
  174. "dust_collect": True,
  175. "enable_trend_guard": False,
  176. "fee_rate": 0.004,
  177. },
  178. )
  179. strategy.state["center_price"] = 1.3285
  180. strategy.state["seeded"] = True
  181. strategy.state["base_available"] = 22.0103
  182. strategy.state["counter_available"] = 13.55
  183. strategy.state["orders"] = [
  184. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  185. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  186. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  187. ]
  188. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  189. def fake_sync_open_orders_state():
  190. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  191. strategy.state["orders"] = live
  192. strategy.state["order_ids"] = ["sell-1"]
  193. strategy.state["open_order_count"] = 1
  194. return live
  195. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  196. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  197. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  198. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  199. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  200. result = strategy.on_tick({})
  201. assert result["action"] in {"seed", "reseed"}
  202. assert ctx.cancelled_all == 1
  203. assert len(ctx.placed_orders) > 0
  204. assert strategy.state["last_action"] == "reseeded"
  205. def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
  206. class FakeContext:
  207. base_currency = "XRP"
  208. counter_currency = "USD"
  209. market_symbol = "xrpusd"
  210. minimum_order_value = 10.0
  211. mode = "active"
  212. def __init__(self):
  213. self.cancelled_all = 0
  214. self.placed_orders = []
  215. def get_fee_rates(self, market):
  216. return {"maker": 0.0, "taker": 0.004}
  217. def get_account_info(self):
  218. return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
  219. def cancel_all_orders(self):
  220. self.cancelled_all += 1
  221. return {"ok": True}
  222. def suggest_order_amount(self, **kwargs):
  223. return 10.0
  224. def place_order(self, **kwargs):
  225. self.placed_orders.append(kwargs)
  226. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  227. ctx = FakeContext()
  228. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  229. strategy.state["center_price"] = 1.3907
  230. strategy.state["seeded"] = True
  231. strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
  232. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  233. def fake_sync_open_orders_state():
  234. live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
  235. strategy.state["orders"] = live
  236. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  237. strategy.state["open_order_count"] = 5
  238. return live
  239. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  240. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: None)
  241. monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
  242. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  243. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  244. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  245. result = strategy.on_tick({})
  246. assert result["action"] in {"seed", "reseed"}
  247. assert ctx.cancelled_all == 1
  248. assert len(ctx.placed_orders) > 0
  249. def test_grid_recenters_exactly_on_live_price():
  250. class FakeContext:
  251. base_currency = "XRP"
  252. counter_currency = "USD"
  253. market_symbol = "xrpusd"
  254. minimum_order_value = 10.0
  255. mode = "active"
  256. def cancel_all_orders(self):
  257. return {"ok": True}
  258. def get_fee_rates(self, market):
  259. return {"maker": 0.0, "taker": 0.0}
  260. def suggest_order_amount(self, **kwargs):
  261. return 0.1
  262. def place_order(self, **kwargs):
  263. return {"status": "ok", "id": "oid-1"}
  264. strategy = GridStrategy(FakeContext(), {})
  265. strategy.state["center_price"] = 100.0
  266. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  267. assert strategy.state["center_price"] == 160.0
  268. def test_grid_stop_cancels_all_open_orders():
  269. class FakeContext:
  270. base_currency = "XRP"
  271. counter_currency = "USD"
  272. market_symbol = "xrpusd"
  273. minimum_order_value = 10.0
  274. mode = "active"
  275. def __init__(self):
  276. self.cancelled = False
  277. def cancel_all_orders(self):
  278. self.cancelled = True
  279. return {"ok": True}
  280. def get_fee_rates(self, market):
  281. return {"maker": 0.0, "taker": 0.0}
  282. strategy = GridStrategy(FakeContext(), {})
  283. strategy.state["orders"] = [{"id": "o1"}]
  284. strategy.state["order_ids"] = ["o1"]
  285. strategy.state["open_order_count"] = 1
  286. strategy.on_stop()
  287. assert strategy.context.cancelled is True
  288. assert strategy.state["open_order_count"] == 0
  289. assert strategy.state["last_action"] == "stopped"
  290. def test_base_strategy_report_uses_context_snapshot():
  291. class FakeContext:
  292. id = "s-1"
  293. account_id = "acct-1"
  294. market_symbol = "xrpusd"
  295. base_currency = "XRP"
  296. counter_currency = "USD"
  297. mode = "active"
  298. def get_strategy_snapshot(self):
  299. return {
  300. "identity": {"strategy_id": "s-1", "strategy_name": "Demo", "account_id": "acct-1", "market": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"},
  301. "control": {"enabled_state": "on", "mode": "active"},
  302. "position": {"balances": [{"asset_code": "XRP", "available": 1.0}]},
  303. "orders": {"open_orders": [{"id": "o1"}]},
  304. "execution": {"execution_quality": "good"},
  305. }
  306. class DemoStrategy(BaseStrategy):
  307. LABEL = "Demo"
  308. report = DemoStrategy(FakeContext(), {}).report()
  309. assert report["identity"]["strategy_id"] == "s-1"
  310. assert report["control"]["mode"] == "active"
  311. assert report["position"]["open_orders"][0]["id"] == "o1"
  312. def test_trend_follower_uses_policy_and_reports_fit():
  313. class FakeContext:
  314. id = "s-2"
  315. account_id = "acct-2"
  316. client_id = "cid-2"
  317. mode = "active"
  318. market_symbol = "xrpusd"
  319. base_currency = "XRP"
  320. counter_currency = "USD"
  321. def get_price(self, symbol):
  322. return {"price": 1.2}
  323. def get_regime(self, symbol, timeframe="1h"):
  324. return {"trend": {"state": "bull", "strength": 0.9}}
  325. def place_order(self, **kwargs):
  326. return {"ok": True, "order": kwargs}
  327. def get_strategy_snapshot(self):
  328. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  329. strat = TrendStrategy(FakeContext(), {"order_size": 1.5})
  330. strat.apply_policy()
  331. report = strat.report()
  332. assert report["fit"]["risk_profile"] == "growth"
  333. assert strat.state["policy_derived"]["order_size"] > 0
  334. def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
  335. class FakeContext:
  336. id = "s-bull"
  337. account_id = "acct-1"
  338. client_id = "cid-1"
  339. mode = "active"
  340. market_symbol = "xrpusd"
  341. base_currency = "XRP"
  342. counter_currency = "USD"
  343. def __init__(self):
  344. self.orders = []
  345. def get_price(self, symbol):
  346. return {"price": 1.2}
  347. def get_regime(self, symbol, timeframe="1h"):
  348. return {
  349. "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
  350. "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
  351. }
  352. def place_order(self, **kwargs):
  353. self.orders.append(kwargs)
  354. return {"ok": True, "order": kwargs}
  355. def get_account_info(self):
  356. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  357. minimum_order_value = 10.0
  358. def suggest_order_amount(self, **kwargs):
  359. return 10.0
  360. def get_strategy_snapshot(self):
  361. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  362. ctx = FakeContext()
  363. strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
  364. result = strat.on_tick({})
  365. assert result["action"] == "buy"
  366. assert ctx.orders[-1]["side"] == "buy"
  367. assert ctx.orders[-1]["amount"] == 10.0
  368. assert strat.state["last_action"] == "buy_trend"
  369. assert strat.state["last_strength"] >= 0.65
  370. def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
  371. class FakeContext:
  372. id = "s-bear"
  373. account_id = "acct-1"
  374. client_id = "cid-1"
  375. mode = "active"
  376. market_symbol = "xrpusd"
  377. base_currency = "XRP"
  378. counter_currency = "USD"
  379. def __init__(self):
  380. self.orders = []
  381. def get_price(self, symbol):
  382. return {"price": 1.2}
  383. def get_regime(self, symbol, timeframe="1h"):
  384. return {
  385. "trend": {"state": "bear", "ema_fast": 1.17, "ema_slow": 1.2},
  386. "momentum": {"state": "bear", "rsi": 36, "macd_histogram": -0.002},
  387. }
  388. def place_order(self, **kwargs):
  389. self.orders.append(kwargs)
  390. return {"ok": True, "order": kwargs}
  391. def get_account_info(self):
  392. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  393. minimum_order_value = 10.0
  394. def suggest_order_amount(self, **kwargs):
  395. return 6.0
  396. def get_strategy_snapshot(self):
  397. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  398. ctx = FakeContext()
  399. strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
  400. result = strat.on_tick({})
  401. assert result["action"] == "sell"
  402. assert ctx.orders[-1]["side"] == "sell"
  403. assert ctx.orders[-1]["amount"] == 6.0
  404. assert strat.state["last_action"] == "sell_trend"
  405. assert strat.state["last_strength"] >= 0.65
  406. def test_trend_follower_policy_does_not_override_explicit_order_size():
  407. class FakeContext:
  408. id = "s-explicit"
  409. account_id = "acct-1"
  410. client_id = "cid-1"
  411. mode = "active"
  412. market_symbol = "xrpusd"
  413. base_currency = "XRP"
  414. counter_currency = "USD"
  415. def get_price(self, symbol):
  416. return {"price": 1.2}
  417. def get_regime(self, symbol, timeframe="1h"):
  418. return {"trend": {"state": "bull", "strength": 0.9}}
  419. def get_strategy_snapshot(self):
  420. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  421. strat = TrendStrategy(FakeContext(), {"order_size": 10.5})
  422. strat.apply_policy()
  423. assert strat.config["order_size"] == 10.5
  424. assert strat.state["policy_derived"]["order_size"] == 10.5
  425. def test_trend_follower_passes_live_fee_rate_into_sizing_helper():
  426. class FakeContext:
  427. id = "s-fee"
  428. account_id = "acct-1"
  429. client_id = "cid-1"
  430. mode = "active"
  431. market_symbol = "xrpusd"
  432. base_currency = "XRP"
  433. counter_currency = "USD"
  434. minimum_order_value = 10.0
  435. def __init__(self):
  436. self.fee_calls = []
  437. self.suggest_calls = []
  438. def get_price(self, symbol):
  439. return {"price": 1.2}
  440. def get_regime(self, symbol, timeframe="1h"):
  441. return {"trend": {"state": "bull", "strength": 0.9}}
  442. def get_fee_rates(self, market_symbol=None):
  443. self.fee_calls.append(market_symbol)
  444. return {"maker": 0.0025, "taker": 0.004}
  445. def suggest_order_amount(self, **kwargs):
  446. self.suggest_calls.append(kwargs)
  447. return 8.0
  448. def place_order(self, **kwargs):
  449. return {"ok": True, "order": kwargs}
  450. def get_account_info(self):
  451. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  452. def get_strategy_snapshot(self):
  453. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  454. ctx = FakeContext()
  455. strat = TrendStrategy(ctx, {"order_size": 10.5, "trend_timeframe": "15m"})
  456. strat.on_tick({})
  457. assert ctx.fee_calls == ["xrpusd"]
  458. assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025