test_engine.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  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, strategy_engine
  6. from src.trader_mcp import server as trader_server
  7. from src.trader_mcp.server import app, apply_control_decision
  8. STRATEGY_CODE = '''
  9. from src.trader_mcp.strategy_sdk import Strategy
  10. class Strategy(Strategy):
  11. CONFIG_SCHEMA = {"label": {"type": "string", "default": "hello world"}}
  12. def init(self):
  13. return {"started": True, "config_copy": dict(self.config)}
  14. def on_tick(self, tick):
  15. self.state["ticks"] = self.state.get("ticks", 0) + 1
  16. return self.state["ticks"]
  17. def render(self):
  18. return {"widgets": [{"type": "metric", "label": "ticks", "value": self.state.get("ticks", 0)}]}
  19. '''
  20. COUNTER_STRATEGY_CODE = '''
  21. from src.trader_mcp.strategy_sdk import Strategy
  22. class Strategy(Strategy):
  23. def init(self):
  24. return {"counter": 0}
  25. def on_tick(self, tick):
  26. self.state["counter"] += 1
  27. return self.state["counter"]
  28. def render(self):
  29. return {"widgets": [{"type": "metric", "label": "ticks", "value": self.state["counter"]}]}
  30. '''
  31. def test_mode_off_does_not_instantiate_and_active_does(tmp_path):
  32. original_db = strategy_store.DB_PATH
  33. original_dir = strategy_registry.STRATEGIES_DIR
  34. try:
  35. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  36. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  37. strategy_registry.STRATEGIES_DIR.mkdir()
  38. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  39. strategy_store.add_strategy_instance(id="s1", strategy_type="demo", account_id="acct-1", client_id="cid-1", mode="off", config={"x": 1})
  40. result = strategy_engine.reconcile_all()
  41. assert result["running"] == []
  42. assert strategy_engine.get_running_strategy("s1") is None
  43. strategy_store.update_strategy_mode("s1", "active")
  44. result = strategy_engine.reconcile_all()
  45. assert "s1" in result["running"]
  46. runtime = strategy_engine.get_running_strategy("s1")
  47. assert runtime is not None
  48. assert runtime.instance.state["started"] is True
  49. assert runtime.instance.context.account_id == "acct-1"
  50. assert runtime.instance.context.client_id == "cid-1"
  51. tick_result = strategy_engine.tick_strategy("s1", {"price": 1})
  52. assert tick_result["ok"] is True
  53. assert tick_result["result"] == 1
  54. render_result = strategy_engine.render_strategy("s1")
  55. assert render_result["ok"] is True
  56. assert render_result["render"]["widgets"][0]["value"] == 1
  57. finally:
  58. strategy_store.DB_PATH = original_db
  59. strategy_registry.STRATEGIES_DIR = original_dir
  60. strategy_engine._running.clear()
  61. def test_mode_change_route_reconciles(tmp_path):
  62. original_db = strategy_store.DB_PATH
  63. original_dir = strategy_registry.STRATEGIES_DIR
  64. try:
  65. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  66. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  67. strategy_registry.STRATEGIES_DIR.mkdir()
  68. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  69. client = TestClient(app)
  70. client.post(
  71. "/strategies",
  72. json={"id": "s2", "strategy_type": "demo", "account_id": "acct-1", "client_id": "cid-1", "mode": "off", "config": {"x": 1}},
  73. )
  74. r = client.post("/strategies/s2/mode", json={"mode": "active"})
  75. assert r.status_code == 200
  76. assert "s2" in r.json()["running"]
  77. finally:
  78. strategy_store.DB_PATH = original_db
  79. strategy_registry.STRATEGIES_DIR = original_dir
  80. strategy_engine._running.clear()
  81. def test_dashboard_add_strategy_synthesizes_client_and_defaults(tmp_path):
  82. original_db = strategy_store.DB_PATH
  83. original_dir = strategy_registry.STRATEGIES_DIR
  84. try:
  85. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  86. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  87. strategy_registry.STRATEGIES_DIR.mkdir()
  88. (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
  89. client = TestClient(app)
  90. response = client.post(
  91. "/dashboard/strategies/add",
  92. data={"name": "My super Grid 0.5", "strategy_type": "hello_world", "account_id": "acct-1"},
  93. follow_redirects=False,
  94. )
  95. assert response.status_code in {302, 303}
  96. record = strategy_store.list_strategy_instances()[0]
  97. assert record.name == "My super Grid 0.5"
  98. assert record.client_id.startswith("hello_world:")
  99. assert record.config == {"label": "hello world"}
  100. assert record.mode == "off"
  101. finally:
  102. strategy_store.DB_PATH = original_db
  103. strategy_registry.STRATEGIES_DIR = original_dir
  104. strategy_engine._running.clear()
  105. def test_runtime_pause_suppresses_tick_and_render(tmp_path):
  106. original_db = strategy_store.DB_PATH
  107. original_dir = strategy_registry.STRATEGIES_DIR
  108. try:
  109. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  110. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  111. strategy_registry.STRATEGIES_DIR.mkdir()
  112. (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
  113. strategy_store.add_strategy_instance(id="s3", strategy_type="hello_world", account_id="acct-1", client_id="cid-1", mode="active", config={})
  114. strategy_engine.reconcile_all()
  115. assert strategy_engine.pause_strategy("s3")["ok"] is True
  116. tick_result = strategy_engine.tick_strategy("s3", {"price": 1})
  117. assert tick_result["paused"] is True
  118. assert tick_result["skipped"] is True
  119. render_result = strategy_engine.render_strategy("s3")
  120. assert render_result["paused"] is True
  121. assert render_result["render"] is None
  122. assert strategy_engine.resume_strategy("s3")["ok"] is True
  123. tick_result = strategy_engine.tick_strategy("s3", {"price": 1})
  124. assert tick_result["result"] == 1
  125. render_result = strategy_engine.render_strategy("s3")
  126. assert render_result["render"]["widgets"][0]["value"] == 1
  127. finally:
  128. strategy_store.DB_PATH = original_db
  129. strategy_registry.STRATEGIES_DIR = original_dir
  130. strategy_engine._running.clear()
  131. def test_run_due_ticks_triggers_tick_when_due(tmp_path):
  132. original_db = strategy_store.DB_PATH
  133. original_dir = strategy_registry.STRATEGIES_DIR
  134. try:
  135. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  136. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  137. strategy_registry.STRATEGIES_DIR.mkdir()
  138. (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(COUNTER_STRATEGY_CODE)
  139. strategy_store.add_strategy_instance(id="s4", strategy_type="hello_world", account_id="acct-1", client_id="cid-1", mode="active", config={})
  140. strategy_engine.reconcile_all()
  141. runtime = strategy_engine.get_running_strategy("s4")
  142. assert runtime is not None
  143. runtime.next_tick_at = 0.0
  144. result = strategy_engine.run_due_ticks(now=1.0)
  145. assert "s4" in result["ticked"]
  146. assert strategy_engine.get_running_strategy("s4").instance.state["counter"] == 1
  147. finally:
  148. strategy_store.DB_PATH = original_db
  149. strategy_registry.STRATEGIES_DIR = original_dir
  150. strategy_engine._running.clear()
  151. def test_dashboard_pause_toggle(tmp_path):
  152. original_db = strategy_store.DB_PATH
  153. original_dir = strategy_registry.STRATEGIES_DIR
  154. try:
  155. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  156. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  157. strategy_registry.STRATEGIES_DIR.mkdir()
  158. (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
  159. client = TestClient(app)
  160. client.post(
  161. "/dashboard/strategies/add",
  162. data={"name": "Pause test", "strategy_type": "hello_world", "account_id": "acct-1"},
  163. follow_redirects=False,
  164. )
  165. strategy_id = strategy_store.list_strategy_instances()[0].id
  166. strategy_store.update_strategy_mode(strategy_id, "active")
  167. strategy_engine.reconcile_instance(strategy_id)
  168. assert strategy_engine.get_running_strategy(strategy_id).paused is False
  169. client.post(f"/dashboard/strategies/{strategy_id}/pause", follow_redirects=False)
  170. assert strategy_engine.get_running_strategy(strategy_id).paused is True
  171. client.post(f"/dashboard/strategies/{strategy_id}/pause", follow_redirects=False)
  172. assert strategy_engine.get_running_strategy(strategy_id).paused is False
  173. finally:
  174. strategy_store.DB_PATH = original_db
  175. strategy_registry.STRATEGIES_DIR = original_dir
  176. strategy_engine._running.clear()
  177. def test_dashboard_detail_panel_shows_render_and_saves_config(tmp_path):
  178. original_db = strategy_store.DB_PATH
  179. original_dir = strategy_registry.STRATEGIES_DIR
  180. try:
  181. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  182. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  183. strategy_registry.STRATEGIES_DIR.mkdir()
  184. (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
  185. client = TestClient(app)
  186. client.post(
  187. "/dashboard/strategies/add",
  188. data={"name": "Render test", "strategy_type": "hello_world", "account_id": "acct-1"},
  189. follow_redirects=False,
  190. )
  191. strategy_id = strategy_store.list_strategy_instances()[0].id
  192. strategy_store.update_strategy_mode(strategy_id, "active")
  193. strategy_engine.reconcile_instance(strategy_id)
  194. page = client.get("/dashboard/")
  195. assert page.status_code == 200
  196. render_response = client.get(f"/dashboard/strategies/{strategy_id}/render")
  197. assert render_response.status_code == 200
  198. assert render_response.json()["render"]["widgets"][0]["label"] == "ticks"
  199. client.post(
  200. f"/dashboard/strategies/{strategy_id}/config",
  201. data={"config_json": '{"label": "changed label"}'},
  202. follow_redirects=False,
  203. )
  204. record = strategy_store.get_strategy_instance(strategy_id)
  205. assert record is not None
  206. assert record.config == {"label": "changed label"}
  207. finally:
  208. strategy_store.DB_PATH = original_db
  209. strategy_registry.STRATEGIES_DIR = original_dir
  210. strategy_engine._running.clear()
  211. def test_apply_control_decision_switches_active_strategy_and_records_audit(tmp_path):
  212. original_db = strategy_store.DB_PATH
  213. original_dir = strategy_registry.STRATEGIES_DIR
  214. try:
  215. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  216. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  217. strategy_registry.STRATEGIES_DIR.mkdir()
  218. (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
  219. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  220. strategy_store.add_strategy_instance(id="old", strategy_type="hello_world", account_id="acct-1", client_id="cid-old", mode="active", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
  221. strategy_store.add_strategy_instance(id="new", strategy_type="demo", account_id="acct-1", client_id="cid-new", mode="off", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
  222. strategy_engine.reconcile_all()
  223. result = apply_control_decision(
  224. {
  225. "decision_id": "dec-switch-1",
  226. "concern_id": "acct-1:xrpusd",
  227. "account_id": "acct-1",
  228. "market_symbol": "xrpusd",
  229. "action": "switch",
  230. "target_strategy_id": "new",
  231. "expected_active_strategy_id": "old",
  232. "reason": "trend fits better",
  233. "confidence": 0.9,
  234. }
  235. )
  236. assert result["ok"] is True
  237. assert result["status"] == "applied"
  238. assert strategy_store.get_strategy_instance("old").mode == "off"
  239. assert strategy_store.get_strategy_instance("new").mode == "active"
  240. audit = strategy_store.get_control_action_by_decision_id("dec-switch-1")
  241. assert audit is not None
  242. assert audit.status == "applied"
  243. assert audit.result["to_strategy_id"] == "new"
  244. finally:
  245. strategy_store.DB_PATH = original_db
  246. strategy_registry.STRATEGIES_DIR = original_dir
  247. strategy_engine._running.clear()
  248. def test_apply_control_decision_is_idempotent_by_decision_id(tmp_path):
  249. original_db = strategy_store.DB_PATH
  250. original_dir = strategy_registry.STRATEGIES_DIR
  251. try:
  252. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  253. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  254. strategy_registry.STRATEGIES_DIR.mkdir()
  255. (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
  256. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  257. strategy_store.add_strategy_instance(id="old", strategy_type="hello_world", account_id="acct-1", client_id="cid-old", mode="active", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
  258. strategy_store.add_strategy_instance(id="new", strategy_type="demo", account_id="acct-1", client_id="cid-new", mode="off", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
  259. strategy_engine.reconcile_all()
  260. payload = {
  261. "decision_id": "dec-switch-2",
  262. "concern_id": "acct-1:xrpusd",
  263. "account_id": "acct-1",
  264. "market_symbol": "xrpusd",
  265. "action": "switch",
  266. "target_strategy_id": "new",
  267. "expected_active_strategy_id": "old",
  268. "reason": "trend fits better",
  269. "confidence": 0.9,
  270. }
  271. first = apply_control_decision(payload)
  272. second = apply_control_decision(payload)
  273. assert first == second
  274. finally:
  275. strategy_store.DB_PATH = original_db
  276. strategy_registry.STRATEGIES_DIR = original_dir
  277. strategy_engine._running.clear()
  278. def test_apply_control_decision_rejects_expected_active_mismatch(tmp_path):
  279. original_db = strategy_store.DB_PATH
  280. original_dir = strategy_registry.STRATEGIES_DIR
  281. try:
  282. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  283. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  284. strategy_registry.STRATEGIES_DIR.mkdir()
  285. (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
  286. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  287. strategy_store.add_strategy_instance(id="old", strategy_type="hello_world", account_id="acct-1", client_id="cid-old", mode="active", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
  288. strategy_store.add_strategy_instance(id="new", strategy_type="demo", account_id="acct-1", client_id="cid-new", mode="off", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
  289. strategy_engine.reconcile_all()
  290. result = apply_control_decision(
  291. {
  292. "decision_id": "dec-switch-3",
  293. "concern_id": "acct-1:xrpusd",
  294. "account_id": "acct-1",
  295. "market_symbol": "xrpusd",
  296. "action": "switch",
  297. "target_strategy_id": "new",
  298. "expected_active_strategy_id": "something-else",
  299. "reason": "trend fits better",
  300. "confidence": 0.9,
  301. }
  302. )
  303. assert result["ok"] is False
  304. assert result["status"] == "rejected"
  305. assert "expected active strategy mismatch" in result["errors"]
  306. finally:
  307. strategy_store.DB_PATH = original_db
  308. strategy_registry.STRATEGIES_DIR = original_dir
  309. strategy_engine._running.clear()
  310. def test_apply_control_decision_switches_even_when_current_is_degraded(tmp_path, monkeypatch):
  311. original_db = strategy_store.DB_PATH
  312. original_dir = strategy_registry.STRATEGIES_DIR
  313. try:
  314. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  315. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  316. strategy_registry.STRATEGIES_DIR.mkdir()
  317. (strategy_registry.STRATEGIES_DIR / "hello_world.py").write_text(STRATEGY_CODE)
  318. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  319. strategy_store.add_strategy_instance(id="old", strategy_type="hello_world", account_id="acct-1", client_id="cid-old", mode="active", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
  320. strategy_store.add_strategy_instance(id="new", strategy_type="demo", account_id="acct-1", client_id="cid-new", mode="off", market_symbol="xrpusd", base_currency="XRP", counter_currency="USD", config={})
  321. strategy_engine.reconcile_all()
  322. monkeypatch.setattr(trader_server, "_is_degraded", lambda instance_id: True)
  323. result = apply_control_decision(
  324. {
  325. "decision_id": "dec-switch-degraded-1",
  326. "concern_id": "acct-1:xrpusd",
  327. "account_id": "acct-1",
  328. "market_symbol": "xrpusd",
  329. "action": "switch",
  330. "target_strategy_id": "new",
  331. "expected_active_strategy_id": "old",
  332. "reason": "trend fits better",
  333. "confidence": 0.9,
  334. }
  335. )
  336. assert result["ok"] is True
  337. assert result["status"] == "applied"
  338. assert result["validation"]["current_degraded"] is True
  339. assert result["result"].get("current_degraded") is True
  340. assert result["result"].get("warning")
  341. assert strategy_store.get_strategy_instance("old").mode == "off"
  342. assert strategy_store.get_strategy_instance("new").mode == "active"
  343. finally:
  344. strategy_store.DB_PATH = original_db
  345. strategy_registry.STRATEGIES_DIR = original_dir
  346. strategy_engine._running.clear()