services_bitstamp.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. from __future__ import annotations
  2. import os
  3. import threading
  4. try:
  5. import bitstamp.client
  6. except ModuleNotFoundError: # allows test runs without the optional dependency
  7. bitstamp = None # type: ignore
  8. from . import repo
  9. from .bitstamp import BitstampClient
  10. from .bitstamp_fx import load_eur_usd
  11. BITSTAMP_BASE_URL = "https://www.bitstamp.net"
  12. # Live trading reads should refresh quickly, but not so often that Bitstamp trips a breaker.
  13. BALANCE_CACHE_TTL_MS = 1200
  14. ACCOUNT_INFO_CACHE_TTL_MS = 1200
  15. FEES_CACHE_TTL_SECONDS = 10 * 60
  16. STALE_CACHE_TTL_SECONDS = 2
  17. _CACHE_LOCKS: dict[str, threading.Lock] = {}
  18. _CACHE_LOCKS_GUARD = threading.Lock()
  19. _BITSTAMP_CLIENTS: dict[str, BitstampClient] = {}
  20. _BITSTAMP_CLIENTS_GUARD = threading.Lock()
  21. def _ttl_from_env_ms(name: str, default_ms: int) -> float:
  22. try:
  23. raw = os.getenv(name)
  24. if raw is not None:
  25. return max(float(raw) / 1000.0, 0.0)
  26. return max(float(default_ms) / 1000.0, 0.0)
  27. except Exception:
  28. return max(float(default_ms) / 1000.0, 0.0)
  29. def _cache_lock(cache_key: str) -> threading.Lock:
  30. with _CACHE_LOCKS_GUARD:
  31. lock = _CACHE_LOCKS.get(cache_key)
  32. if lock is None:
  33. lock = threading.Lock()
  34. _CACHE_LOCKS[cache_key] = lock
  35. return lock
  36. def _cache_error(cache_key: str, detail: str, ttl_seconds: int = 15) -> None:
  37. repo.cache_put(cache_key, {"_cached_error": detail}, ttl_seconds)
  38. def _raise_cached_error(payload: dict) -> None:
  39. detail = str(payload.get("_cached_error") or "Bitstamp request failed")
  40. raise RuntimeError(detail)
  41. def _stale_key(cache_key: str) -> str:
  42. return f"{cache_key}:stale"
  43. def invalidate_account_cache(account_id: str) -> None:
  44. for key in (
  45. f"bitstamp:account_balance:{account_id}",
  46. f"bitstamp:account_balance:{account_id}:stale",
  47. f"bitstamp:account_info:{account_id}",
  48. f"bitstamp:account_info:{account_id}:stale",
  49. ):
  50. repo.cache_delete(key)
  51. def invalidate_balance_cache(account_id: str) -> None:
  52. for key in (
  53. f"bitstamp:account_balance:{account_id}",
  54. f"bitstamp:account_balance:{account_id}:stale",
  55. ):
  56. repo.cache_delete(key)
  57. def invalidate_account_info_cache(account_id: str) -> None:
  58. for key in (
  59. f"bitstamp:account_info:{account_id}",
  60. f"bitstamp:account_info:{account_id}:stale",
  61. ):
  62. repo.cache_delete(key)
  63. def _fee_cache_key(market_symbol: str | None = None) -> str:
  64. return "bitstamp:fees:all" if market_symbol is None else f"bitstamp:fees:{market_symbol.lower()}"
  65. def _normalize_fee_entry(item: dict) -> dict:
  66. fees = item.get("fees") or {}
  67. return {
  68. "currency_pair": item.get("currency_pair"),
  69. "market": item.get("market"),
  70. "fees": {
  71. "maker": fees.get("maker"),
  72. "taker": fees.get("taker"),
  73. },
  74. }
  75. def fetch_trading_fees(account_id: str, market_symbol: str | None = None) -> dict:
  76. cache_key = _fee_cache_key(market_symbol)
  77. cached = repo.cache_get(cache_key)
  78. if cached is not None:
  79. return cached
  80. with _cache_lock(cache_key):
  81. cached = repo.cache_get(cache_key)
  82. if cached is not None:
  83. return cached
  84. try:
  85. client = get_bitstamp_client(account_id)
  86. if market_symbol:
  87. payload = client.trading.fees_trading_market(market_symbol)
  88. else:
  89. payload = client.trading.fees_trading()
  90. except Exception as exc:
  91. _cache_error(cache_key, str(exc))
  92. raise
  93. if isinstance(payload, list):
  94. result = {"source": "bitstamp", "market": market_symbol, "fees": [_normalize_fee_entry(item) for item in payload]}
  95. elif isinstance(payload, dict):
  96. result = {"source": "bitstamp", "market": market_symbol, **_normalize_fee_entry(payload)}
  97. else:
  98. result = {"source": "bitstamp", "market": market_symbol, "raw": payload}
  99. repo.cache_put(cache_key, result, FEES_CACHE_TTL_SECONDS)
  100. return result
  101. def _require_client() -> None:
  102. if bitstamp is None:
  103. raise RuntimeError("bitstamp-python-client dependency is not installed")
  104. def _build_trading_client(account_id: str):
  105. _require_client()
  106. account = repo.get_account(account_id)
  107. secrets = repo.get_account_secrets(account_id)
  108. return bitstamp.client.Trading(
  109. username=account["venue_account_ref"],
  110. key=secrets["api_key"],
  111. secret=secrets["api_secret"],
  112. )
  113. def get_bitstamp_client(account_id: str):
  114. with _BITSTAMP_CLIENTS_GUARD:
  115. client = _BITSTAMP_CLIENTS.get(account_id)
  116. if client is None:
  117. account = repo.get_account(account_id)
  118. secrets = repo.get_account_secrets(account_id)
  119. client = BitstampClient(
  120. username=account["venue_account_ref"],
  121. api_key=secrets["api_key"],
  122. api_secret=secrets["api_secret"],
  123. )
  124. _BITSTAMP_CLIENTS[account_id] = client
  125. return client
  126. def clear_bitstamp_trading_client(account_id: str) -> None:
  127. with _BITSTAMP_CLIENTS_GUARD:
  128. _BITSTAMP_CLIENTS.pop(account_id, None)
  129. def _normalize_account_balances_payload(payload: list[dict], account_id: str) -> list[dict]:
  130. balances: list[dict] = []
  131. for item in payload:
  132. currency = item.get("currency")
  133. if not currency:
  134. continue
  135. try:
  136. total = float(item.get("total", 0) or 0)
  137. available = float(item.get("available", 0) or 0)
  138. reserved = float(item.get("reserved", 0) or 0)
  139. except (TypeError, ValueError):
  140. continue
  141. balances.append(
  142. {
  143. "account_id": account_id,
  144. "asset_code": str(currency).upper(),
  145. "available": available,
  146. "reserved": reserved,
  147. "total": total,
  148. }
  149. )
  150. return balances
  151. def _price_for_asset(asset_code: str) -> float | None:
  152. asset = str(asset_code or "").lower()
  153. if not asset:
  154. return None
  155. if asset == "usd":
  156. return 1.0
  157. if asset == "eur":
  158. fx = load_eur_usd()
  159. if fx and fx.get("sell") is not None:
  160. try:
  161. return float(fx["sell"])
  162. except (TypeError, ValueError):
  163. return None
  164. return None
  165. return repo.get_latest_price(f"{asset}usd")
  166. def _value_account_balances(balance: dict) -> tuple[list[dict], float]:
  167. valued_balances: list[dict] = []
  168. total_value_usd = 0.0
  169. for item in balance.get("balances", []):
  170. asset_code = str(item.get("asset_code") or "").upper()
  171. total = float(item.get("total") or 0)
  172. price_usd = _price_for_asset(asset_code)
  173. value_usd = total * price_usd if price_usd is not None else None
  174. if value_usd is not None:
  175. total_value_usd += value_usd
  176. valued_balances.append(
  177. {
  178. **item,
  179. "asset_code": asset_code,
  180. "price_usd": price_usd,
  181. "value_currency": "USD",
  182. "value_usd": value_usd,
  183. }
  184. )
  185. return valued_balances, total_value_usd
  186. def fetch_account_balance(account_id: str) -> dict:
  187. cache_key = f"bitstamp:account_balance:{account_id}"
  188. cached = repo.cache_get(cache_key)
  189. if cached is not None:
  190. if isinstance(cached, dict) and cached.get("_cached_error"):
  191. stale = repo.cache_get(_stale_key(cache_key))
  192. if stale is not None:
  193. return stale
  194. _raise_cached_error(cached)
  195. return cached
  196. with _cache_lock(cache_key):
  197. cached = repo.cache_get(cache_key)
  198. if cached is not None:
  199. if isinstance(cached, dict) and cached.get("_cached_error"):
  200. stale = repo.cache_get(_stale_key(cache_key))
  201. if stale is not None:
  202. return stale
  203. _raise_cached_error(cached)
  204. return cached
  205. try:
  206. client = get_bitstamp_client(account_id)
  207. payload = client.trading._post("account_balances/", return_json=True, version=2)
  208. except Exception as exc:
  209. _cache_error(cache_key, str(exc))
  210. raise
  211. normalized = _normalize_account_balances_payload(payload, account_id)
  212. result = {"source": "bitstamp", "cached": False, "balances": normalized, "payload": payload}
  213. repo.cache_put(
  214. cache_key,
  215. result,
  216. _ttl_from_env_ms("BITSTAMP_BALANCE_CACHE_TTL_MS", BALANCE_CACHE_TTL_MS),
  217. )
  218. repo.cache_put(_stale_key(cache_key), result, STALE_CACHE_TTL_SECONDS)
  219. return result
  220. def capture_account_balance_snapshot(account_id: str, *, source_kind: str, source_ref: str = "") -> dict:
  221. if source_ref and repo.has_account_balance_snapshot(account_id=account_id, source_kind=source_kind, source_ref=source_ref):
  222. return {"ok": True, "account_id": account_id, "source_kind": source_kind, "source_ref": source_ref, "snapshot_id": None, "snapshot": None}
  223. account = repo.get_account(account_id)
  224. invalidate_balance_cache(account_id)
  225. balance = fetch_account_balance(account_id)
  226. valued_balances, total_value_usd = _value_account_balances(balance)
  227. snapshot = {
  228. "account_id": account["id"],
  229. "display_name": account["display_name"],
  230. "venue": account["venue"],
  231. "venue_account_ref": account["venue_account_ref"],
  232. "balances": valued_balances,
  233. "total_value_usd": total_value_usd,
  234. "raw_balance": balance["payload"],
  235. }
  236. snapshot_id = repo.save_account_balance_snapshot(
  237. account_id=account_id,
  238. source_kind=source_kind,
  239. source_ref=source_ref,
  240. snapshot=snapshot,
  241. )
  242. return {
  243. "ok": True,
  244. "account_id": account_id,
  245. "source_kind": source_kind,
  246. "source_ref": source_ref,
  247. "snapshot_id": snapshot_id,
  248. "snapshot": snapshot,
  249. }
  250. def fetch_account_info(account_id: str) -> dict:
  251. cache_key = f"bitstamp:account_info:{account_id}"
  252. cached = repo.cache_get(cache_key)
  253. if cached is not None:
  254. if isinstance(cached, dict) and cached.get("_cached_error"):
  255. stale = repo.cache_get(_stale_key(cache_key))
  256. if stale is not None:
  257. return stale
  258. _raise_cached_error(cached)
  259. return cached
  260. with _cache_lock(cache_key):
  261. cached = repo.cache_get(cache_key)
  262. if cached is not None:
  263. if isinstance(cached, dict) and cached.get("_cached_error"):
  264. stale = repo.cache_get(_stale_key(cache_key))
  265. if stale is not None:
  266. return stale
  267. _raise_cached_error(cached)
  268. return cached
  269. try:
  270. account = repo.get_account(account_id)
  271. balance = fetch_account_balance(account_id)
  272. except Exception as exc:
  273. _cache_error(cache_key, str(exc))
  274. raise
  275. valued_balances, total_value_usd = _value_account_balances(balance)
  276. result = {
  277. "id": account["id"],
  278. "display_name": account["display_name"],
  279. "venue": account["venue"],
  280. "venue_account_ref": account["venue_account_ref"],
  281. "description": account["description"],
  282. "enabled": account["enabled"],
  283. "metadata": account["metadata"],
  284. "balances": valued_balances,
  285. "total_value_usd": total_value_usd,
  286. "raw_balance": balance["payload"],
  287. }
  288. repo.cache_put(
  289. cache_key,
  290. result,
  291. _ttl_from_env_ms("BITSTAMP_ACCOUNT_INFO_CACHE_TTL_MS", ACCOUNT_INFO_CACHE_TTL_MS),
  292. )
  293. repo.cache_put(_stale_key(cache_key), result, STALE_CACHE_TTL_SECONDS)
  294. return result
  295. def fetch_account_fees(account_id: str, market_symbol: str | None = None) -> dict:
  296. return fetch_trading_fees(account_id, market_symbol)