services_orders.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. from __future__ import annotations
  2. import json
  3. import os
  4. import time
  5. import threading
  6. from datetime import datetime, timezone, timedelta
  7. from decimal import Decimal, ROUND_DOWN
  8. from uuid import uuid4
  9. from fastapi import HTTPException
  10. from .bitstamp import BitstampClient, BitstampError
  11. from .services_bitstamp import capture_account_balance_snapshot, get_bitstamp_client, clear_bitstamp_trading_client, invalidate_account_cache
  12. from .bitstamp_metadata import load_market_by_symbol
  13. from .storage import get_connection
  14. OPEN_ORDER_STATUSES = {"open", "new", "partially_filled"}
  15. FINISHED_ORDER_STATUS = "finished"
  16. _CANCEL_BREAKER_LOCK = threading.Lock()
  17. _CANCEL_BREAKER_NEXT_ALLOWED: dict[str, float] = {}
  18. _CANCEL_BREAKER_SECONDS = 3.0
  19. def _cancel_breaker_is_open(account_id: str) -> bool:
  20. with _CANCEL_BREAKER_LOCK:
  21. return _CANCEL_BREAKER_NEXT_ALLOWED.get(account_id, 0.0) > time.monotonic()
  22. def _cancel_breaker_trip(account_id: str) -> None:
  23. with _CANCEL_BREAKER_LOCK:
  24. _CANCEL_BREAKER_NEXT_ALLOWED[account_id] = time.monotonic() + _CANCEL_BREAKER_SECONDS
  25. def _looks_like_auth_failure(detail: str) -> bool:
  26. text = str(detail or "").lower()
  27. return "403" in text and ("authentication failed" in text or "nonce" in text or "timestamp" in text)
  28. def _bitstamp_call_delay_seconds() -> float:
  29. try:
  30. return max(int(os.getenv("BITSTAMP_CALL_DELAY_MS", "250")) / 1000.0, 0.0)
  31. except Exception:
  32. return 0.25
  33. def _utc_now() -> str:
  34. return datetime.now(timezone.utc).isoformat()
  35. def _get_client(account_id: str) -> BitstampClient:
  36. return BitstampClientWrapper(account_id)
  37. class BitstampClientWrapper:
  38. def __init__(self, account_id: str):
  39. self.trading = get_bitstamp_client(account_id).trading
  40. def _invalidate_client(account_id: str) -> None:
  41. clear_bitstamp_trading_client(account_id)
  42. def _format_decimal(value, decimals: int) -> str:
  43. quant = Decimal("1").scaleb(-decimals)
  44. return str(Decimal(str(value)).quantize(quant, rounding=ROUND_DOWN))
  45. def _normalize_client_id(client_id: str | None) -> str | None:
  46. if client_id is None:
  47. return None
  48. if not isinstance(client_id, str):
  49. raise HTTPException(status_code=400, detail="client_id must be a string")
  50. client_id = client_id.strip()
  51. if not client_id:
  52. raise HTTPException(status_code=400, detail="client_id must not be empty")
  53. return client_id
  54. def _normalize_status(status) -> str:
  55. value = str(status or "unknown").strip().lower().replace(" ", "_")
  56. if value in OPEN_ORDER_STATUSES or value in {"cancelled", "cancel_failed", "missing", FINISHED_ORDER_STATUS}:
  57. return value
  58. return FINISHED_ORDER_STATUS
  59. def _load_order_record(*, bitstamp_order_id: str) -> dict | None:
  60. with get_connection() as conn:
  61. row = conn.execute(
  62. "SELECT id, account_id, status FROM order_records WHERE bitstamp_order_id = ?",
  63. (bitstamp_order_id,),
  64. ).fetchone()
  65. return dict(row) if row is not None else None
  66. def _encode_raw_json(raw_json) -> str:
  67. if isinstance(raw_json, str):
  68. return raw_json
  69. return json.dumps(raw_json)
  70. def _record_order_status_update(*, bitstamp_order_id: str, status: str, raw_json, account_id: str | None = None) -> dict:
  71. record = _load_order_record(bitstamp_order_id=bitstamp_order_id)
  72. if record is None:
  73. raise HTTPException(status_code=404, detail="order not found")
  74. if account_id is None:
  75. account_id = str(record["account_id"])
  76. previous_status = str(record["status"]) if record is not None else None
  77. normalized_status = _normalize_status(status)
  78. captured_at = _utc_now()
  79. with get_connection() as conn:
  80. conn.execute(
  81. "UPDATE order_records SET status = ?, raw_json = ?, updated_at = ? WHERE bitstamp_order_id = ?",
  82. (normalized_status, _encode_raw_json(raw_json), captured_at, bitstamp_order_id),
  83. )
  84. conn.commit()
  85. if previous_status != FINISHED_ORDER_STATUS and normalized_status == FINISHED_ORDER_STATUS:
  86. try:
  87. capture_account_balance_snapshot(account_id, source_kind="order_finished", source_ref=bitstamp_order_id)
  88. except Exception:
  89. pass
  90. invalidate_account_cache(account_id)
  91. return {
  92. "ok": True,
  93. "account_id": account_id,
  94. "bitstamp_order_id": bitstamp_order_id,
  95. "status": normalized_status,
  96. "previous_status": previous_status,
  97. }
  98. def _set_local_order_status(*, bitstamp_order_id: str, status: str) -> None:
  99. record = _load_order_record(bitstamp_order_id=bitstamp_order_id)
  100. if record is None:
  101. return
  102. with get_connection() as conn:
  103. conn.execute(
  104. "UPDATE order_records SET status = ?, updated_at = ? WHERE bitstamp_order_id = ?",
  105. (_normalize_status(status), _utc_now(), bitstamp_order_id),
  106. )
  107. conn.commit()
  108. def _validate_order_shape(market: str, side: str, order_type: str, amount, price) -> tuple[str, str | None, dict]:
  109. meta = load_market_by_symbol(market)
  110. if meta is None:
  111. raise HTTPException(status_code=400, detail=f"unknown market {market}")
  112. base_decimals = int(meta.get("base_decimals", 8))
  113. counter_decimals = int(meta.get("counter_decimals", 2))
  114. minimum_order_value = Decimal(str(meta.get("minimum_order_value", "0")))
  115. amount_dec = Decimal(str(amount))
  116. amount_fmt = _format_decimal(amount, base_decimals)
  117. if side == "buy" and order_type == "market":
  118. if Decimal(amount_fmt) < minimum_order_value:
  119. raise HTTPException(status_code=400, detail=f"Minimum order size is {minimum_order_value} {meta.get('counter_currency', 'USD')}.")
  120. if price is not None:
  121. price_fmt = _format_decimal(price, counter_decimals)
  122. if Decimal(price_fmt) <= 0:
  123. raise HTTPException(status_code=400, detail="price must be positive")
  124. else:
  125. price_fmt = None
  126. if amount_dec <= 0:
  127. raise HTTPException(status_code=400, detail="amount must be positive")
  128. return amount_fmt, price_fmt, meta
  129. 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:
  130. client = _get_client(account_id)
  131. client_id = _normalize_client_id(client_id)
  132. side = side.lower()
  133. order_type = order_type.lower()
  134. market = market.lower()
  135. if len(market) < 6:
  136. raise HTTPException(status_code=400, detail="market must look like xrpusd")
  137. base = market[:-3]
  138. quote = market[-3:]
  139. expire_timestamp = None
  140. if expire_time is not None:
  141. expire_timestamp = int((datetime.now(timezone.utc) + timedelta(seconds=expire_time)).timestamp() * 1000)
  142. amount, price, _meta = _validate_order_shape(market, side, order_type, amount, price)
  143. try:
  144. if order_type not in {"market", "limit"}:
  145. raise HTTPException(status_code=400, detail="invalid order_type")
  146. if side == "buy":
  147. if expire_timestamp is None:
  148. result = client.trading.buy_order(amount=amount, price=price or "0", base=base, quote=quote)
  149. else:
  150. result = client.trading.buy_gtd_order(amount=amount, price=price or "0", base=base, quote=quote, expire_time=expire_timestamp)
  151. elif side == "sell":
  152. if expire_timestamp is None:
  153. result = client.trading.sell_order(amount=amount, price=price or "0", base=base, quote=quote)
  154. else:
  155. result = client.trading.sell_gtd_order(amount=amount, price=price or "0", base=base, quote=quote, expire_time=expire_timestamp)
  156. else:
  157. raise HTTPException(status_code=400, detail="invalid side")
  158. except BitstampError as exc:
  159. detail = str(exc)
  160. if _looks_like_auth_failure(detail):
  161. _invalidate_client(account_id)
  162. raise HTTPException(status_code=400, detail=detail) from exc
  163. bitstamp_order_id = str(result.get("id") or result.get("order_id") or "")
  164. if not bitstamp_order_id:
  165. return {"ok": False, "error": "missing Bitstamp order id", "details": {"raw": result}}
  166. record_id = str(uuid4())
  167. now = _utc_now()
  168. with get_connection() as conn:
  169. conn.execute(
  170. """
  171. INSERT INTO order_records
  172. (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)
  173. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  174. """,
  175. (record_id, account_id, market, side, order_type, amount, price, expire_time, _normalize_status(result.get("status", "open")), bitstamp_order_id, client_id, client_order_id or result.get("client_order_id"), json.dumps(result), now, now),
  176. )
  177. conn.commit()
  178. invalidate_account_cache(account_id)
  179. return {"ok": True, "bitstamp_order_id": bitstamp_order_id, "record_id": record_id, "status": str(result.get("status", "open")), "raw": result}
  180. def get_open_orders(*, account_id: str, client_id: str | None = None) -> dict:
  181. client_id = _normalize_client_id(client_id)
  182. client = _get_client(account_id)
  183. with get_connection() as conn:
  184. if client_id is None:
  185. rows = conn.execute(
  186. """
  187. SELECT 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
  188. FROM order_records
  189. WHERE account_id = ? AND lower(status) IN ('open', 'new', 'partially_filled')
  190. ORDER BY created_at DESC
  191. """,
  192. (account_id,),
  193. ).fetchall()
  194. else:
  195. rows = conn.execute(
  196. """
  197. SELECT 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
  198. FROM order_records
  199. WHERE account_id = ? AND client_id = ? AND lower(status) IN ('open', 'new', 'partially_filled')
  200. ORDER BY created_at DESC
  201. """,
  202. (account_id, client_id),
  203. ).fetchall()
  204. order_status_v2 = getattr(getattr(client, "trading", None), "order_status_v2", None)
  205. if order_status_v2 is None:
  206. return {"ok": True, "client_id": client_id, "orders": [dict(row) for row in rows]}
  207. orders = []
  208. delay = _bitstamp_call_delay_seconds()
  209. for row in rows:
  210. order = dict(row)
  211. bitstamp_order_id = order.get("bitstamp_order_id")
  212. if not bitstamp_order_id:
  213. continue
  214. try:
  215. result = order_status_v2(order_id=str(bitstamp_order_id), omit_transactions=True)
  216. status = _normalize_status(result.get("status", "unknown"))
  217. if status not in OPEN_ORDER_STATUSES:
  218. _record_order_status_update(
  219. bitstamp_order_id=str(bitstamp_order_id),
  220. status=status,
  221. raw_json=result,
  222. account_id=account_id,
  223. )
  224. continue
  225. order["status"] = status
  226. order["raw_json"] = json.dumps(result)
  227. orders.append(order)
  228. except Exception as exc:
  229. msg = str(exc)
  230. if "not found" in msg.lower():
  231. _set_local_order_status(bitstamp_order_id=str(bitstamp_order_id), status="missing")
  232. invalidate_account_cache(account_id)
  233. continue
  234. # Best effort: keep the local record when the exchange cannot be queried.
  235. orders.append(order)
  236. if delay > 0:
  237. time.sleep(delay)
  238. return {"ok": True, "client_id": client_id, "orders": orders}
  239. def cancel_all_orders(*, account_id: str, client_id: str | None = None) -> dict:
  240. client_id = _normalize_client_id(client_id)
  241. orders = get_open_orders(account_id=account_id, client_id=client_id)["orders"]
  242. results = []
  243. delay = _bitstamp_call_delay_seconds()
  244. for order in orders:
  245. if _cancel_breaker_is_open(account_id):
  246. results.append({"ok": False, "order_id": order.get("bitstamp_order_id"), "error": "cancel breaker active", "status": "deferred"})
  247. continue
  248. bitstamp_order_id = order.get("bitstamp_order_id")
  249. if not bitstamp_order_id:
  250. results.append({"order_id": None, "ok": False, "error": "missing bitstamp_order_id"})
  251. continue
  252. try:
  253. results.append(cancel_order(account_id=account_id, order_id=bitstamp_order_id))
  254. except HTTPException as exc:
  255. detail = str(exc.detail)
  256. if _looks_like_auth_failure(detail):
  257. _cancel_breaker_trip(account_id)
  258. results.append({"ok": False, "order_id": bitstamp_order_id, "error": detail, "status": "deferred"})
  259. break
  260. if "not found" in detail.lower():
  261. _set_local_order_status(bitstamp_order_id=bitstamp_order_id, status="missing")
  262. invalidate_account_cache(account_id)
  263. results.append({"ok": False, "order_id": bitstamp_order_id, "error": detail, "status": "missing"})
  264. continue
  265. raise
  266. if delay > 0:
  267. time.sleep(delay)
  268. invalidate_account_cache(account_id)
  269. return {"ok": True, "client_id": client_id, "cancelled": results, "count": len(results)}
  270. def query_order(*, account_id: str, order_id, client_order_id: str | None = None, omit_transactions: bool | None = None) -> dict:
  271. order_id = str(order_id)
  272. client = _get_client(account_id)
  273. try:
  274. result = client.trading.order_status_v2(order_id=order_id, client_order_id=client_order_id, omit_transactions=omit_transactions)
  275. except BitstampError as exc:
  276. detail = str(exc)
  277. if _looks_like_auth_failure(detail):
  278. _invalidate_client(account_id)
  279. raise HTTPException(status_code=400, detail=detail) from exc
  280. _record_order_status_update(
  281. bitstamp_order_id=order_id,
  282. status=result.get("status", "unknown"),
  283. raw_json=result,
  284. account_id=account_id,
  285. )
  286. return {"ok": True, "order_id": order_id, "raw": result}
  287. def cancel_order(*, account_id: str, order_id) -> dict:
  288. order_id = str(order_id)
  289. if _cancel_breaker_is_open(account_id):
  290. raise HTTPException(status_code=503, detail="cancel breaker active, retry later")
  291. client = _get_client(account_id)
  292. try:
  293. result = client.trading.cancel_order(order_id=order_id, version=2)
  294. except BitstampError as exc:
  295. detail = str(exc)
  296. if _looks_like_auth_failure(detail):
  297. _cancel_breaker_trip(account_id)
  298. _invalidate_client(account_id)
  299. raise HTTPException(status_code=400, detail=detail) from exc
  300. status = "cancelled" if result else "cancel_failed"
  301. _record_order_status_update(
  302. bitstamp_order_id=order_id,
  303. status=status,
  304. raw_json=result,
  305. account_id=account_id,
  306. )
  307. return {"ok": bool(result), "order_id": order_id, "raw": result}