test_reporting_tools.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. from __future__ import annotations
  2. import json
  3. import pytest
  4. from exec_mcp import repo, storage
  5. from exec_mcp import services_bitstamp, services_orders
  6. from exec_mcp.server import report_balance_snapshots, report_recent_trades
  7. @pytest.fixture()
  8. def temp_db(tmp_path, monkeypatch):
  9. db_path = tmp_path / "exec.sqlite3"
  10. monkeypatch.setattr(storage, "DB_PATH", db_path)
  11. storage.init_db()
  12. return db_path
  13. def _create_account() -> str:
  14. repo.create_account(display_name="strategy", venue="bitstamp", venue_account_ref="ref-1", api_key="k", api_secret="s")
  15. return repo.list_accounts(enabled_only=False)[0]["id"]
  16. def test_report_recent_trades_defaults_to_finished_and_sorts_newest_first(temp_db):
  17. account_id = _create_account()
  18. with storage.get_connection() as conn:
  19. rows = [
  20. ("rec-1", "finished", "client-a", "1994292738961401", "2026-04-09T08:20:50+00:00"),
  21. ("rec-2", "cancelled", "client-a", "1994292738961402", "2026-04-09T09:20:50+00:00"),
  22. ("rec-3", "finished", "client-b", "1994292738961403", "2026-04-09T10:20:50+00:00"),
  23. ("rec-4", "open", "client-a", "1994292738961404", "2026-04-09T11:20:50+00:00"),
  24. ("rec-5", "new", "client-a", "1994292738961405", "2026-04-09T12:20:50+00:00"),
  25. ]
  26. for record_id, status, client_id, order_id, stamp in rows:
  27. conn.execute(
  28. """
  29. INSERT INTO order_records
  30. (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
  31. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  32. """,
  33. (
  34. record_id,
  35. account_id,
  36. "xrpusd",
  37. "buy",
  38. "limit",
  39. "10",
  40. "2.00",
  41. None,
  42. status,
  43. order_id,
  44. client_id,
  45. None,
  46. json.dumps({"id": order_id, "status": status}),
  47. stamp,
  48. stamp,
  49. ),
  50. )
  51. conn.commit()
  52. result = report_recent_trades(account_id=account_id)
  53. assert result["ok"] is True
  54. assert result["status"] == "finished"
  55. assert result["count"] == 2
  56. assert [row["bitstamp_order_id"] for row in result["orders"]] == ["1994292738961403", "1994292738961401"]
  57. def test_report_recent_trades_supports_open_status_and_client_filter(temp_db):
  58. account_id = _create_account()
  59. with storage.get_connection() as conn:
  60. for idx, (status, client_id, stamp) in enumerate(
  61. [
  62. ("open", "client-a", "2026-04-09T08:20:50+00:00"),
  63. ("new", "client-a", "2026-04-09T09:20:50+00:00"),
  64. ("partially_filled", "client-b", "2026-04-09T10:20:50+00:00"),
  65. ("cancelled", "client-a", "2026-04-09T11:20:50+00:00"),
  66. ],
  67. start=1,
  68. ):
  69. order_id = f"19942927389614{idx}"
  70. conn.execute(
  71. """
  72. INSERT INTO order_records
  73. (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
  74. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  75. """,
  76. (
  77. f"rec-{idx}",
  78. account_id,
  79. "xrpusd",
  80. "sell",
  81. "limit",
  82. "10",
  83. "2.00",
  84. None,
  85. status,
  86. order_id,
  87. client_id,
  88. None,
  89. json.dumps({"id": order_id, "status": status}),
  90. stamp,
  91. stamp,
  92. ),
  93. )
  94. conn.commit()
  95. result = report_recent_trades(account_id=account_id, status="open", client_id="client-a", limit=10)
  96. assert result["ok"] is True
  97. assert result["status"] == "open"
  98. assert result["client_id"] == "client-a"
  99. assert [row["status"] for row in result["orders"]] == ["new", "open"]
  100. def test_report_recent_trades_supports_all_statuses_and_client_filter(temp_db):
  101. account_id = _create_account()
  102. with storage.get_connection() as conn:
  103. for idx, (status, client_id, stamp) in enumerate(
  104. [
  105. ("finished", "client-a", "2026-04-09T08:20:50+00:00"),
  106. ("cancelled", "client-a", "2026-04-09T09:20:50+00:00"),
  107. ("finished", "client-b", "2026-04-09T10:20:50+00:00"),
  108. ],
  109. start=1,
  110. ):
  111. order_id = f"19942927389614{idx}"
  112. conn.execute(
  113. """
  114. INSERT INTO order_records
  115. (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
  116. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  117. """,
  118. (
  119. f"rec-all-{idx}",
  120. account_id,
  121. "xrpusd",
  122. "sell",
  123. "limit",
  124. "10",
  125. "2.00",
  126. None,
  127. status,
  128. order_id,
  129. client_id,
  130. None,
  131. json.dumps({"id": order_id, "status": status}),
  132. stamp,
  133. stamp,
  134. ),
  135. )
  136. conn.commit()
  137. result = report_recent_trades(account_id=account_id, status="all", client_id="client-a", limit=10)
  138. assert result["ok"] is True
  139. assert result["status"] == "all"
  140. assert result["client_id"] == "client-a"
  141. assert [row["status"] for row in result["orders"]] == ["cancelled", "finished"]
  142. def test_report_balance_snapshots_returns_history_newest_first(temp_db):
  143. account_id = _create_account()
  144. repo.save_account_balance_snapshot(
  145. account_id=account_id,
  146. source_kind="order_finished",
  147. source_ref="order-1",
  148. snapshot={"balances": [{"asset_code": "BTC"}], "total_value_usd": 11250.0},
  149. )
  150. repo.save_account_balance_snapshot(
  151. account_id=account_id,
  152. source_kind="order_finished",
  153. source_ref="order-2",
  154. snapshot={"balances": [{"asset_code": "USD"}], "total_value_usd": 500.0},
  155. )
  156. result = report_balance_snapshots(account_id=account_id)
  157. assert len(result) == 2
  158. assert [row["source_ref"] for row in result] == ["order-2", "order-1"]
  159. assert [row["snapshot"]["balances"][0]["asset_code"] for row in result] == ["USD", "BTC"]
  160. def test_record_order_status_update_persists_raw_json_and_triggers_snapshot_once(temp_db, monkeypatch):
  161. account_id = _create_account()
  162. with storage.get_connection() as conn:
  163. conn.execute(
  164. """
  165. INSERT INTO order_records
  166. (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
  167. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  168. """,
  169. (
  170. "rec-1",
  171. account_id,
  172. "xrpusd",
  173. "buy",
  174. "limit",
  175. "10",
  176. "2.00",
  177. None,
  178. "open",
  179. "1994292738961401",
  180. "client-a",
  181. None,
  182. json.dumps({"status": "open"}),
  183. "2026-04-09T08:20:50+00:00",
  184. "2026-04-09T08:20:50+00:00",
  185. ),
  186. )
  187. conn.commit()
  188. snapshot_calls: list[dict] = []
  189. monkeypatch.setattr(
  190. services_orders,
  191. "capture_account_balance_snapshot",
  192. lambda account_id, *, source_kind, source_ref="": snapshot_calls.append(
  193. {"account_id": account_id, "source_kind": source_kind, "source_ref": source_ref}
  194. ),
  195. )
  196. services_orders._record_order_status_update(
  197. bitstamp_order_id="1994292738961401",
  198. status="finished",
  199. raw_json={"id": "1994292738961401", "status": "finished"},
  200. account_id=account_id,
  201. )
  202. with storage.get_connection() as conn:
  203. row = conn.execute(
  204. "SELECT status, raw_json FROM order_records WHERE bitstamp_order_id = ?",
  205. ("1994292738961401",),
  206. ).fetchone()
  207. assert row["status"] == "finished"
  208. assert json.loads(row["raw_json"]) == {"id": "1994292738961401", "status": "finished"}
  209. assert snapshot_calls == [
  210. {"account_id": account_id, "source_kind": "order_finished", "source_ref": "1994292738961401"}
  211. ]
  212. services_orders._record_order_status_update(
  213. bitstamp_order_id="1994292738961401",
  214. status="finished",
  215. raw_json={"id": "1994292738961401", "status": "finished"},
  216. account_id=account_id,
  217. )
  218. assert len(snapshot_calls) == 1
  219. def test_record_order_status_update_ws_path_defers_snapshot(temp_db, monkeypatch):
  220. """When account_id is not passed (WS-driven call), the balance snapshot
  221. is deferred to the background pool instead of running synchronously."""
  222. account_id = _create_account()
  223. with storage.get_connection() as conn:
  224. conn.execute(
  225. """
  226. INSERT INTO order_records
  227. (id, account_id, market, side, order_type, amount, price, expire_time, status, bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at)
  228. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  229. """,
  230. (
  231. "rec-ws-1",
  232. account_id,
  233. "xrpusd",
  234. "buy",
  235. "limit",
  236. "10",
  237. "2.00",
  238. None,
  239. "open",
  240. "1994292738961402",
  241. "client-a",
  242. None,
  243. json.dumps({"status": "open"}),
  244. "2026-04-09T08:20:50+00:00",
  245. "2026-04-09T08:20:50+00:00",
  246. ),
  247. )
  248. conn.commit()
  249. submitted: list[tuple] = []
  250. class _FakePool:
  251. def submit(self, fn, *a):
  252. submitted.append((fn, a))
  253. monkeypatch.setattr(services_orders, "_SNAPSHOT_POOL", _FakePool())
  254. # No account_id — simulates the WS handler call path
  255. result = services_orders._record_order_status_update(
  256. bitstamp_order_id="1994292738961402",
  257. status="finished",
  258. raw_json={"id": "1994292738961402", "status": "finished"},
  259. )
  260. assert result["ok"] is True
  261. assert result["status"] == "finished"
  262. assert len(submitted) == 1
  263. assert submitted[0][0] is services_orders._capture_snapshot_task
  264. assert submitted[0][1] == (account_id, "1994292738961402")
  265. def test_capture_account_balance_snapshot_uses_crypto_price(temp_db, monkeypatch):
  266. account_id = _create_account()
  267. monkeypatch.setattr(
  268. services_bitstamp,
  269. "fetch_account_balance",
  270. lambda _account_id: {
  271. "source": "bitstamp",
  272. "cached": False,
  273. "balances": [
  274. {
  275. "account_id": _account_id,
  276. "asset_code": "XRP",
  277. "available": 1.0,
  278. "reserved": 0.0,
  279. "total": 2.0,
  280. }
  281. ],
  282. "payload": [{"currency": "xrp", "total": "2.0", "available": "1.0", "reserved": "0.0"}],
  283. },
  284. )
  285. monkeypatch.setattr(
  286. services_bitstamp,
  287. "get_crypto_price",
  288. lambda symbol: {"symbol": symbol.upper(), "price": 1.4109, "timestamp": 1779039021},
  289. )
  290. result = services_bitstamp.capture_account_balance_snapshot(
  291. account_id,
  292. source_kind="order_finished",
  293. source_ref="order-crypto-price",
  294. )
  295. assert result["ok"] is True
  296. snapshot = result["snapshot"]
  297. assert snapshot["balances"][0]["price_usd"] == 1.4109
  298. with storage.get_connection() as conn:
  299. row = conn.execute(
  300. "SELECT snapshot_json FROM account_balance_snapshots WHERE account_id = ?",
  301. (account_id,),
  302. ).fetchone()
  303. stored = json.loads(row["snapshot_json"])
  304. assert stored["balances"][0]["price_usd"] == 1.4109
  305. def test_get_account_info_stays_available_when_crypto_price_is_unavailable(temp_db, monkeypatch):
  306. account_id = _create_account()
  307. monkeypatch.setattr(
  308. services_bitstamp,
  309. "fetch_account_balance",
  310. lambda _account_id: {
  311. "source": "bitstamp",
  312. "cached": False,
  313. "balances": [
  314. {
  315. "account_id": _account_id,
  316. "asset_code": "XRP",
  317. "available": 1.5,
  318. "reserved": 0.5,
  319. "total": 2.0,
  320. },
  321. {
  322. "account_id": _account_id,
  323. "asset_code": "USD",
  324. "available": 10.0,
  325. "reserved": 5.0,
  326. "total": 15.0,
  327. },
  328. ],
  329. "payload": [],
  330. },
  331. )
  332. monkeypatch.setattr(services_bitstamp, "get_crypto_price", lambda symbol: {"error": "TIMEOUT", "symbol": symbol})
  333. result = services_bitstamp.fetch_account_info(account_id)
  334. assert result["id"] == account_id
  335. assert result["total_value_usd"] == 15.0
  336. assert result["balances"][0]["asset_code"] == "XRP"
  337. assert result["balances"][0]["price_usd"] is None
  338. assert result["balances"][0]["value_usd"] is None
  339. assert result["balances"][1]["asset_code"] == "USD"
  340. assert result["balances"][1]["price_usd"] == 1.0
  341. assert result["balances"][1]["value_usd"] == 15.0
  342. def test_get_account_info_falls_back_when_balance_fetch_fails(temp_db, monkeypatch):
  343. account_id = _create_account()
  344. monkeypatch.setattr(services_bitstamp, "fetch_account_balance", lambda _account_id: (_ for _ in ()).throw(RuntimeError("bitstamp unavailable")))
  345. monkeypatch.setattr(services_bitstamp, "get_crypto_price", lambda symbol: {"symbol": symbol.upper(), "price": 1.4109, "timestamp": 1779039021})
  346. result = services_bitstamp.fetch_account_info(account_id)
  347. assert result["id"] == account_id
  348. assert result["balances"] == []
  349. assert result["total_value_usd"] == 0.0
  350. assert result["raw_balance"] is None
  351. assert "balance_error" in result