repo.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. from __future__ import annotations
  2. import json
  3. import secrets
  4. from datetime import datetime, timezone
  5. from sqlite3 import IntegrityError
  6. from uuid import uuid4
  7. from fastapi import HTTPException
  8. from .storage import get_connection
  9. def utc_now_iso() -> str:
  10. return datetime.now(timezone.utc).isoformat()
  11. def list_accounts(*, venue: str | None = None, enabled_only: bool = True) -> list[dict]:
  12. query = "SELECT id, display_name, venue, venue_account_ref, description, enabled, metadata_json, created_at, updated_at FROM accounts"
  13. clauses: list[str] = []
  14. params: list[str] = []
  15. if venue:
  16. clauses.append("venue = ?")
  17. params.append(venue)
  18. if enabled_only:
  19. clauses.append("enabled = 1")
  20. if clauses:
  21. query += " WHERE " + " AND ".join(clauses)
  22. query += " ORDER BY created_at ASC"
  23. with get_connection() as conn:
  24. rows = conn.execute(query, tuple(params)).fetchall()
  25. return [
  26. {
  27. "id": row["id"],
  28. "display_name": row["display_name"],
  29. "venue": row["venue"],
  30. "venue_account_ref": row["venue_account_ref"],
  31. "description": row["description"],
  32. "enabled": bool(row["enabled"]),
  33. "metadata": row["metadata_json"],
  34. "created_at": row["created_at"],
  35. "updated_at": row["updated_at"],
  36. }
  37. for row in rows
  38. ]
  39. def get_account(account_id: str) -> dict:
  40. with get_connection() as conn:
  41. row = conn.execute(
  42. "SELECT id, display_name, venue, venue_account_ref, description, enabled, metadata_json, created_at, updated_at FROM accounts WHERE id = ?",
  43. (account_id,),
  44. ).fetchone()
  45. if row is None:
  46. raise HTTPException(status_code=404, detail="account not found")
  47. return {
  48. "id": row["id"],
  49. "display_name": row["display_name"],
  50. "venue": row["venue"],
  51. "venue_account_ref": row["venue_account_ref"],
  52. "description": row["description"],
  53. "enabled": bool(row["enabled"]),
  54. "metadata": row["metadata_json"],
  55. "created_at": row["created_at"],
  56. "updated_at": row["updated_at"],
  57. }
  58. def get_account_secrets(account_id: str) -> dict:
  59. with get_connection() as conn:
  60. row = conn.execute(
  61. "SELECT api_key, api_secret FROM account_secrets WHERE account_id = ?",
  62. (account_id,),
  63. ).fetchone()
  64. if row is None:
  65. raise HTTPException(status_code=404, detail="account secrets not found")
  66. return {"api_key": row["api_key"], "api_secret": row["api_secret"]}
  67. def _generate_account_id(length: int = 12) -> str:
  68. alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
  69. while True:
  70. candidate = "".join(secrets.choice(alphabet) for _ in range(length))
  71. with get_connection() as conn:
  72. exists = conn.execute("SELECT 1 FROM accounts WHERE id = ?", (candidate,)).fetchone()
  73. if exists is None:
  74. return candidate
  75. def create_account(*, display_name: str, venue: str, venue_account_ref: str, api_key: str, api_secret: str, description: str | None = None, enabled: bool = True) -> dict:
  76. account_id = _generate_account_id()
  77. now = utc_now_iso()
  78. with get_connection() as conn:
  79. try:
  80. conn.execute(
  81. """
  82. INSERT INTO accounts (id, display_name, venue, venue_account_ref, description, enabled, created_at, updated_at)
  83. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  84. """,
  85. (account_id, display_name, venue, venue_account_ref, description, int(enabled), now, now),
  86. )
  87. conn.execute(
  88. """
  89. INSERT INTO account_secrets (account_id, api_key, api_secret, created_at, updated_at)
  90. VALUES (?, ?, ?, ?, ?)
  91. """,
  92. (account_id, api_key, api_secret, now, now),
  93. )
  94. conn.commit()
  95. except IntegrityError as exc:
  96. conn.rollback()
  97. if "api_key" in str(exc).lower():
  98. raise HTTPException(status_code=409, detail="api key already exists") from exc
  99. raise
  100. return {"id": account_id, "display_name": display_name, "venue": venue}
  101. def update_account(*, account_id: str, display_name: str | None = None, description: str | None = None, enabled: bool | None = None) -> dict:
  102. now = utc_now_iso()
  103. with get_connection() as conn:
  104. row = conn.execute("SELECT id FROM accounts WHERE id = ?", (account_id,)).fetchone()
  105. if row is None:
  106. raise HTTPException(status_code=404, detail="account not found")
  107. if display_name is not None:
  108. conn.execute("UPDATE accounts SET display_name = ?, updated_at = ? WHERE id = ?", (display_name, now, account_id))
  109. if description is not None:
  110. conn.execute("UPDATE accounts SET description = ?, updated_at = ? WHERE id = ?", (description, now, account_id))
  111. if enabled is not None:
  112. conn.execute("UPDATE accounts SET enabled = ?, updated_at = ? WHERE id = ?", (int(enabled), now, account_id))
  113. conn.commit()
  114. return {"id": account_id, "updated": True}
  115. def delete_account(*, account_id: str) -> dict:
  116. with get_connection() as conn:
  117. deleted = conn.execute("DELETE FROM accounts WHERE id = ?", (account_id,)).rowcount
  118. conn.commit()
  119. if not deleted:
  120. raise HTTPException(status_code=404, detail="account not found")
  121. return {"id": account_id, "deleted": True}
  122. def cache_get(cache_key: str) -> dict | None:
  123. now = utc_now_iso()
  124. with get_connection() as conn:
  125. row = conn.execute(
  126. "SELECT payload_json FROM api_cache WHERE cache_key = ? AND expires_at > ?",
  127. (cache_key, now),
  128. ).fetchone()
  129. if row is None:
  130. return None
  131. return json.loads(row["payload_json"])
  132. def cache_put(cache_key: str, payload: dict, ttl_seconds: int) -> None:
  133. now_dt = datetime.now(timezone.utc)
  134. fetched_at = now_dt.isoformat()
  135. expires_at = datetime.fromtimestamp(now_dt.timestamp() + ttl_seconds, tz=timezone.utc).isoformat()
  136. with get_connection() as conn:
  137. conn.execute(
  138. """
  139. INSERT INTO api_cache (cache_key, payload_json, fetched_at, expires_at)
  140. VALUES (?, ?, ?, ?)
  141. ON CONFLICT(cache_key) DO UPDATE SET
  142. payload_json=excluded.payload_json,
  143. fetched_at=excluded.fetched_at,
  144. expires_at=excluded.expires_at
  145. """,
  146. (cache_key, json.dumps(payload), fetched_at, expires_at),
  147. )
  148. conn.commit()
  149. def cache_delete(cache_key: str) -> None:
  150. with get_connection() as conn:
  151. conn.execute("DELETE FROM api_cache WHERE cache_key = ?", (cache_key,))
  152. conn.commit()
  153. def has_account_balance_snapshot(*, account_id: str, source_kind: str, source_ref: str) -> bool:
  154. with get_connection() as conn:
  155. row = conn.execute(
  156. """
  157. SELECT 1
  158. FROM account_balance_snapshots
  159. WHERE account_id = ? AND source_kind = ? AND source_ref = ?
  160. """,
  161. (account_id, source_kind, source_ref),
  162. ).fetchone()
  163. return row is not None
  164. def save_account_balance_snapshot(*, account_id: str, source_kind: str, source_ref: str = "", snapshot: dict) -> str:
  165. snapshot_id = str(uuid4())
  166. captured_at = utc_now_iso()
  167. with get_connection() as conn:
  168. conn.execute(
  169. """
  170. INSERT OR IGNORE INTO account_balance_snapshots
  171. (id, account_id, source_kind, source_ref, snapshot_json, captured_at)
  172. VALUES (?, ?, ?, ?, ?, ?)
  173. """,
  174. (snapshot_id, account_id, source_kind, source_ref, json.dumps(snapshot), captured_at),
  175. )
  176. row = conn.execute(
  177. """
  178. SELECT id
  179. FROM account_balance_snapshots
  180. WHERE account_id = ? AND source_kind = ? AND source_ref = ?
  181. """,
  182. (account_id, source_kind, source_ref),
  183. ).fetchone()
  184. conn.commit()
  185. if row is None:
  186. return snapshot_id
  187. return str(row["id"])
  188. def list_recent_orders(*, account_id: str, client_id: str | None = None, status: str = "finished", limit: int = 50) -> list[dict]:
  189. query = """
  190. SELECT id, account_id, market, side, order_type, amount, price, expire_time, status,
  191. bitstamp_order_id, client_id, client_order_id, raw_json, created_at, updated_at
  192. FROM order_records
  193. WHERE account_id = ?
  194. """
  195. params: list[object] = [account_id]
  196. if client_id is not None:
  197. query += " AND client_id = ?"
  198. params.append(client_id)
  199. if status == "open":
  200. query += " AND lower(status) IN ('open', 'new', 'partially_filled')"
  201. elif status == "cancelled":
  202. query += " AND lower(status) = 'cancelled'"
  203. elif status == "finished":
  204. query += " AND lower(status) = 'finished'"
  205. elif status != "all":
  206. query += " AND lower(status) = ?"
  207. params.append(status)
  208. query += " ORDER BY created_at DESC, id DESC LIMIT ?"
  209. params.append(limit)
  210. with get_connection() as conn:
  211. rows = conn.execute(query, tuple(params)).fetchall()
  212. return [dict(row) for row in rows]
  213. def list_account_balance_snapshots(*, account_id: str, limit: int = 50) -> list[dict]:
  214. with get_connection() as conn:
  215. rows = conn.execute(
  216. """
  217. SELECT id, account_id, source_kind, source_ref, snapshot_json, captured_at
  218. FROM account_balance_snapshots
  219. WHERE account_id = ?
  220. ORDER BY captured_at DESC, id DESC
  221. LIMIT ?
  222. """,
  223. (account_id, limit),
  224. ).fetchall()
  225. snapshots = []
  226. for row in rows:
  227. item = dict(row)
  228. item["snapshot"] = json.loads(item.pop("snapshot_json"))
  229. snapshots.append(item)
  230. return snapshots