server.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. from __future__ import annotations
  2. from contextlib import asynccontextmanager
  3. import asyncio
  4. from fastapi import FastAPI, Form, HTTPException
  5. from fastapi.responses import HTMLResponse, RedirectResponse
  6. from fastmcp import FastMCP
  7. from .models import AccountView
  8. from . import repo
  9. from .services_bitstamp import fetch_account_info as fetch_remote_account_info, fetch_account_fees as fetch_remote_account_fees
  10. from .bitstamp_metadata import METADATA_REFRESH_SECONDS, refresh_metadata, list_markets as bitstamp_list_markets, list_currencies as bitstamp_list_currencies
  11. from .bitstamp_fx import FX_REFRESH_SECONDS, refresh_eur_usd
  12. from .bitstamp_private_ws import private_ws_main
  13. from .services_orders import place_order as service_place_order, query_order as service_query_order, cancel_order as service_cancel_order, get_open_orders as service_get_open_orders, cancel_all_orders as service_cancel_all_orders
  14. from .storage import init_db
  15. mcp = FastMCP("exec-mcp")
  16. async def _metadata_refresh_loop() -> None:
  17. while True:
  18. try:
  19. refresh_metadata()
  20. except Exception:
  21. pass
  22. await asyncio.sleep(METADATA_REFRESH_SECONDS)
  23. async def _fx_refresh_loop() -> None:
  24. while True:
  25. try:
  26. refresh_eur_usd()
  27. except Exception:
  28. pass
  29. await asyncio.sleep(FX_REFRESH_SECONDS)
  30. @asynccontextmanager
  31. async def lifespan(_: FastAPI):
  32. init_db()
  33. try:
  34. refresh_metadata()
  35. except Exception:
  36. pass
  37. stop_event = asyncio.Event()
  38. metadata_task = asyncio.create_task(_metadata_refresh_loop())
  39. fx_task = asyncio.create_task(_fx_refresh_loop())
  40. private_ws_task = asyncio.create_task(private_ws_main(stop_event))
  41. try:
  42. yield
  43. finally:
  44. stop_event.set()
  45. metadata_task.cancel()
  46. fx_task.cancel()
  47. private_ws_task.cancel()
  48. app = FastAPI(title="exec-mcp", lifespan=lifespan)
  49. app.mount("/mcp", mcp.http_app(path="/sse", transport="sse"))
  50. SUPPORTED_VENUES = {"bitstamp"}
  51. REPORT_ORDER_STATUSES = {"all", "open", "cancelled", "finished"}
  52. def _normalize_report_status(status: str) -> str:
  53. value = str(status or "").strip().lower()
  54. if value not in REPORT_ORDER_STATUSES:
  55. raise HTTPException(status_code=400, detail="status must be one of all, open, cancelled, finished")
  56. return value
  57. def _normalize_report_limit(limit: int) -> int:
  58. try:
  59. value = int(limit)
  60. except Exception as exc:
  61. raise HTTPException(status_code=400, detail="limit must be an integer") from exc
  62. if value < 1:
  63. raise HTTPException(status_code=400, detail="limit must be at least 1")
  64. if value > 500:
  65. raise HTTPException(status_code=400, detail="limit must be at most 500")
  66. return value
  67. @app.get("/", response_class=HTMLResponse)
  68. def http_root() -> str:
  69. return """
  70. <html><body style="font-family: system-ui, sans-serif; max-width: 900px; margin: 32px auto; padding: 0 16px;">
  71. <header style="margin-bottom: 24px;">
  72. <h1>exec-mcp</h1>
  73. </header>
  74. <main>
  75. <p>Execution layer for Trader27, with a dashboard for account management.</p>
  76. <ul>
  77. <li><a href="/dashboard">Dashboard</a></li>
  78. <li><a href="/health">/health</a></li>
  79. </ul>
  80. </main>
  81. <footer style="margin-top: 32px; padding-top: 12px; border-top: 1px solid #e5e7eb; color: #6b7280;">
  82. exec-mcp
  83. </footer>
  84. </body></html>
  85. """
  86. @app.get("/health")
  87. def http_health() -> dict:
  88. return {"ok": True, "server": "exec-mcp"}
  89. @app.get("/dashboard", response_class=HTMLResponse)
  90. def http_dashboard() -> str:
  91. rows = list_accounts(enabled_only=False)
  92. options = "".join(f'<option value="{v}">{v}</option>' for v in sorted(SUPPORTED_VENUES))
  93. table_rows = "".join(
  94. f"""
  95. <tr>
  96. <td>{row['display_name'] or ''}</td>
  97. <td>{row['venue'] or ''}</td>
  98. <td>{row['venue_account_ref'] or ''}</td>
  99. <td>{row['description'] or ''}</td>
  100. <td>{'yes' if row['enabled'] else 'no'}</td>
  101. <td class="actions">
  102. <form method="get" action="/dashboard/accounts/{row['id']}/edit">
  103. <button type="submit">Edit</button>
  104. </form>
  105. <form method="post" action="/dashboard/accounts/{row['id']}/delete">
  106. <button type="submit" class="danger">Delete</button>
  107. </form>
  108. </td>
  109. </tr>
  110. """
  111. for row in rows
  112. )
  113. return f"""
  114. <html>
  115. <head>
  116. <style>
  117. body {{ font-family: system-ui, sans-serif; max-width: 1100px; margin: 32px auto; padding: 0 16px; color: #1f2937; }}
  118. header, footer {{ padding: 12px 0; }}
  119. header {{ border-bottom: 1px solid #e5e7eb; margin-bottom: 20px; }}
  120. footer {{ border-top: 1px solid #e5e7eb; margin-top: 32px; color: #6b7280; }}
  121. .panel {{ background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; margin-bottom: 20px; }}
  122. summary {{ list-style: none; }}
  123. summary::-webkit-details-marker {{ display: none; }}
  124. form {{ display: grid; gap: 10px; max-width: 520px; }}
  125. input, select, button {{ padding: 10px 12px; border-radius: 8px; border: 1px solid #cbd5e1; }}
  126. button {{ background: #111827; color: white; cursor: pointer; }}
  127. button.danger {{ background: #991b1b; }}
  128. table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
  129. th, td {{ border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align: left; vertical-align: top; }}
  130. .actions {{ display: flex; gap: 8px; align-items: center; }}
  131. .actions form {{ display: inline; }}
  132. </style>
  133. </head>
  134. <body>
  135. <header>
  136. <h1>exec-mcp dashboard</h1>
  137. </header>
  138. <details class="panel">
  139. <summary style="cursor:pointer; font-size: 1.1rem; font-weight: 600;">Create account</summary>
  140. <form method="post" action="/dashboard/accounts/create" style="margin-top: 12px;">
  141. <input name="display_name" placeholder="name" />
  142. <select name="venue">{options}</select>
  143. <input name="venue_account_ref" placeholder="exchange account ref" />
  144. <input name="api_key" placeholder="api key" required />
  145. <input name="api_secret" placeholder="api secret" required />
  146. <input name="description" placeholder="description" />
  147. <label><input type="checkbox" name="enabled" checked /> enabled</label>
  148. <button type="submit">Create</button>
  149. </form>
  150. </details>
  151. <div class="panel">
  152. <h2>Accounts</h2>
  153. <table>
  154. <tr>
  155. <th>name</th><th>venue</th><th>exchange account ref</th><th>description</th><th>enabled</th><th>actions</th>
  156. </tr>
  157. {table_rows}
  158. </table>
  159. </div>
  160. <footer>
  161. exec-mcp
  162. </footer>
  163. </body>
  164. </html>
  165. """
  166. @app.post("/dashboard/accounts/create")
  167. def http_dashboard_create_account(
  168. display_name: str = Form(""),
  169. venue: str = Form(...),
  170. venue_account_ref: str = Form(""),
  171. api_key: str = Form(...),
  172. api_secret: str = Form(...),
  173. description: str | None = Form(None),
  174. enabled: bool = Form(False),
  175. ) -> RedirectResponse:
  176. repo.create_account(
  177. display_name=display_name,
  178. venue=venue,
  179. venue_account_ref=venue_account_ref,
  180. api_key=api_key,
  181. api_secret=api_secret,
  182. description=description,
  183. enabled=enabled,
  184. )
  185. return RedirectResponse(url="/dashboard", status_code=303)
  186. @app.get("/dashboard/accounts/{account_id}/edit", response_class=HTMLResponse)
  187. def http_dashboard_edit_account(account_id: str) -> str:
  188. row = repo.get_account(account_id)
  189. checked = "checked" if row["enabled"] else ""
  190. return f"""
  191. <html>
  192. <body style="font-family: system-ui, sans-serif; max-width: 700px; margin: 32px auto; padding: 0 16px;">
  193. <header style="margin-bottom: 24px; border-bottom: 1px solid #e5e7eb; padding-bottom: 12px;"><h1>Edit account</h1></header>
  194. <p><a href="/dashboard">Back to dashboard</a></p>
  195. <form method="post" action="/dashboard/accounts/{row['id']}/update" style="display:grid; gap:10px; max-width: 520px;">
  196. <input name="display_name" value="{row['display_name'] or ''}" placeholder="name" />
  197. <input value="{row['venue'] or ''}" placeholder="venue" readonly />
  198. <input value="{row['venue_account_ref'] or ''}" placeholder="exchange account ref" readonly />
  199. <input name="description" value="{row['description'] or ''}" placeholder="description" />
  200. <label><input type="checkbox" name="enabled" {checked} /> enabled</label>
  201. <button type="submit">Save</button>
  202. </form>
  203. <footer style="margin-top: 32px; padding-top: 12px; border-top: 1px solid #e5e7eb; color: #6b7280;">exec-mcp</footer>
  204. </body>
  205. </html>
  206. """
  207. @app.post("/dashboard/accounts/{account_id}/update")
  208. def http_dashboard_update_account(
  209. account_id: str,
  210. display_name: str = Form(""),
  211. description: str = Form(""),
  212. enabled: bool = Form(False),
  213. ) -> RedirectResponse:
  214. repo.update_account(account_id=account_id, display_name=display_name, description=description or None, enabled=enabled)
  215. return RedirectResponse(url="/dashboard", status_code=303)
  216. @app.post("/dashboard/accounts/{account_id}/delete")
  217. def http_dashboard_delete_account(account_id: str) -> RedirectResponse:
  218. repo.delete_account(account_id=account_id)
  219. return RedirectResponse(url="/dashboard", status_code=303)
  220. @mcp.tool()
  221. def list_accounts(enabled_only: bool = True, venue: str | None = None) -> list[dict]:
  222. """list_accounts(enabled_only=True, venue=None)
  223. Return account metadata only, never secrets. If `venue` is set, filter to
  224. that exchange. `enabled_only` defaults to true.
  225. """
  226. return repo.list_accounts(venue=venue, enabled_only=enabled_only)
  227. @mcp.tool()
  228. def list_markets() -> list[dict]:
  229. """list_markets()
  230. Return cached Bitstamp market metadata from the local SQLite cache.
  231. """
  232. return bitstamp_list_markets()
  233. @mcp.tool()
  234. def list_currencies() -> list[dict]:
  235. """list_currencies()
  236. Return cached Bitstamp currency metadata from the local SQLite cache.
  237. """
  238. return bitstamp_list_currencies()
  239. @mcp.tool()
  240. def get_account_info(account_id: str) -> dict:
  241. """get_account_info(account_id)
  242. Return account metadata plus venue-specific live account details when
  243. available.
  244. """
  245. account = repo.get_account(account_id)
  246. if account["venue"] == "bitstamp":
  247. return fetch_remote_account_info(account_id)
  248. raise HTTPException(status_code=400, detail="unsupported venue")
  249. @mcp.tool()
  250. def get_account_fees(account_id: str, market_symbol: str | None = None) -> dict:
  251. """get_account_fees(account_id, market_symbol=None)
  252. Return Bitstamp fee information for one account, optionally scoped to a
  253. market symbol.
  254. """
  255. account = repo.get_account(account_id)
  256. if account["venue"] == "bitstamp":
  257. return fetch_remote_account_fees(account_id, market_symbol)
  258. raise HTTPException(status_code=400, detail="unsupported venue")
  259. @mcp.tool()
  260. def place_order(account_id: str, market: str, side: str, order_type: str, amount, price=None, expire_time: int | None = None, client_id: str | None = None, client_order_id: str | None = None) -> dict:
  261. """place_order(account_id, market, side, order_type, amount, price=None, expire_time=None, client_id=None, client_order_id=None)
  262. Place a Bitstamp order. `market` is a Bitstamp symbol like `xrpusd`,
  263. `amount` is base units, and `expire_time` is relative seconds from now.
  264. `client_id` is optional and must be a string when provided.
  265. """
  266. return service_place_order(account_id=account_id, market=market, side=side, order_type=order_type, amount=amount, price=price, expire_time=expire_time, client_id=client_id, client_order_id=client_order_id)
  267. @mcp.tool()
  268. def query_order(account_id: str, order_id, client_order_id: str | None = None, omit_transactions: bool | None = None) -> dict:
  269. """query_order(account_id, order_id, client_order_id=None, omit_transactions=None)
  270. Query a Bitstamp order by exchange order id. `client_order_id` and
  271. `omit_transactions` are optional Bitstamp query parameters.
  272. """
  273. return service_query_order(account_id=account_id, order_id=order_id, client_order_id=client_order_id, omit_transactions=omit_transactions)
  274. @mcp.tool()
  275. def cancel_order(account_id: str, order_id) -> dict:
  276. """cancel_order(account_id, order_id)
  277. Cancel one Bitstamp order by exchange order id.
  278. """
  279. return service_cancel_order(account_id=account_id, order_id=order_id)
  280. @mcp.tool()
  281. def get_open_orders(account_id: str, client_id: str | None = None) -> dict:
  282. """get_open_orders(account_id, client_id=None)
  283. List open orders for one account. If `client_id` is provided, only return
  284. orders recorded with that client identifier.
  285. """
  286. return service_get_open_orders(account_id=account_id, client_id=client_id)
  287. @mcp.tool()
  288. def cancel_all_orders(account_id: str, client_id: str | None = None) -> dict:
  289. """cancel_all_orders(account_id, client_id=None)
  290. Cancel all open orders for one account. If `client_id` is provided, only
  291. cancel orders recorded with that client identifier.
  292. """
  293. return service_cancel_all_orders(account_id=account_id, client_id=client_id)
  294. @mcp.tool()
  295. def report_recent_trades(account_id: str, status: str = "finished", client_id: str | None = None, limit: int = 50) -> dict:
  296. """report_recent_trades(account_id, status="finished", client_id=None, limit=50)
  297. Return recent order snapshots for reporting, ordered newest first. Status
  298. may be `all`, `open`, `cancelled`, or `finished`. `client_id` is optional
  299. and filters to one reporting stream when provided.
  300. """
  301. repo.get_account(account_id)
  302. status = _normalize_report_status(status)
  303. limit = _normalize_report_limit(limit)
  304. orders = repo.list_recent_orders(account_id=account_id, client_id=client_id, status=status, limit=limit)
  305. return {
  306. "ok": True,
  307. "account_id": account_id,
  308. "client_id": client_id,
  309. "status": status,
  310. "limit": limit,
  311. "count": len(orders),
  312. "orders": orders,
  313. }
  314. @mcp.tool()
  315. def report_balance_snapshots(account_id: str, limit: int = 50) -> list[dict]:
  316. """report_balance_snapshots(account_id, limit=50)
  317. Return stored wallet balance snapshots for the account, ordered newest first.
  318. """
  319. repo.get_account(account_id)
  320. limit = _normalize_report_limit(limit)
  321. return repo.list_account_balance_snapshots(account_id=account_id, limit=limit)
  322. def main() -> None:
  323. init_db()