test_engine.py 16 KB

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