|
@@ -1,5 +1,7 @@
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
+import threading
|
|
|
|
|
+
|
|
|
try:
|
|
try:
|
|
|
import bitstamp.client
|
|
import bitstamp.client
|
|
|
except ModuleNotFoundError: # allows test runs without the optional dependency
|
|
except ModuleNotFoundError: # allows test runs without the optional dependency
|
|
@@ -10,6 +12,9 @@ from .bitstamp_fx import load_eur_usd
|
|
|
|
|
|
|
|
BALANCE_CACHE_TTL_SECONDS = 20
|
|
BALANCE_CACHE_TTL_SECONDS = 20
|
|
|
ACCOUNT_INFO_CACHE_TTL_SECONDS = 30
|
|
ACCOUNT_INFO_CACHE_TTL_SECONDS = 30
|
|
|
|
|
+STALE_CACHE_TTL_SECONDS = 10 * 60
|
|
|
|
|
+_CACHE_LOCKS: dict[str, threading.Lock] = {}
|
|
|
|
|
+_CACHE_LOCKS_GUARD = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ttl_from_env(name: str, default_seconds: int) -> int:
|
|
def _ttl_from_env(name: str, default_seconds: int) -> int:
|
|
@@ -19,6 +24,28 @@ def _ttl_from_env(name: str, default_seconds: int) -> int:
|
|
|
return default_seconds
|
|
return default_seconds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _cache_lock(cache_key: str) -> threading.Lock:
|
|
|
|
|
+ with _CACHE_LOCKS_GUARD:
|
|
|
|
|
+ lock = _CACHE_LOCKS.get(cache_key)
|
|
|
|
|
+ if lock is None:
|
|
|
|
|
+ lock = threading.Lock()
|
|
|
|
|
+ _CACHE_LOCKS[cache_key] = lock
|
|
|
|
|
+ return lock
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _cache_error(cache_key: str, detail: str, ttl_seconds: int = 15) -> None:
|
|
|
|
|
+ repo.cache_put(cache_key, {"_cached_error": detail}, ttl_seconds)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _raise_cached_error(payload: dict) -> None:
|
|
|
|
|
+ detail = str(payload.get("_cached_error") or "Bitstamp request failed")
|
|
|
|
|
+ raise RuntimeError(detail)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _stale_key(cache_key: str) -> str:
|
|
|
|
|
+ return f"{cache_key}:stale"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _require_client() -> None:
|
|
def _require_client() -> None:
|
|
|
if bitstamp is None:
|
|
if bitstamp is None:
|
|
|
raise RuntimeError("bitstamp-python-client dependency is not installed")
|
|
raise RuntimeError("bitstamp-python-client dependency is not installed")
|
|
@@ -63,59 +90,100 @@ def fetch_account_balance(account_id: str) -> dict:
|
|
|
cache_key = f"bitstamp:account_balance:{account_id}"
|
|
cache_key = f"bitstamp:account_balance:{account_id}"
|
|
|
cached = repo.cache_get(cache_key)
|
|
cached = repo.cache_get(cache_key)
|
|
|
if cached is not None:
|
|
if cached is not None:
|
|
|
|
|
+ if isinstance(cached, dict) and cached.get("_cached_error"):
|
|
|
|
|
+ stale = repo.cache_get(_stale_key(cache_key))
|
|
|
|
|
+ if stale is not None:
|
|
|
|
|
+ return stale
|
|
|
|
|
+ _raise_cached_error(cached)
|
|
|
return cached
|
|
return cached
|
|
|
|
|
|
|
|
- client = _build_trading_client(account_id)
|
|
|
|
|
- payload = client._post("account_balances/", return_json=True, version=2)
|
|
|
|
|
- normalized = _normalize_account_balances_payload(payload, account_id)
|
|
|
|
|
|
|
+ with _cache_lock(cache_key):
|
|
|
|
|
+ cached = repo.cache_get(cache_key)
|
|
|
|
|
+ if cached is not None:
|
|
|
|
|
+ if isinstance(cached, dict) and cached.get("_cached_error"):
|
|
|
|
|
+ stale = repo.cache_get(_stale_key(cache_key))
|
|
|
|
|
+ if stale is not None:
|
|
|
|
|
+ return stale
|
|
|
|
|
+ _raise_cached_error(cached)
|
|
|
|
|
+ return cached
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ client = _build_trading_client(account_id)
|
|
|
|
|
+ payload = client._post("account_balances/", return_json=True, version=2)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ _cache_error(cache_key, str(exc))
|
|
|
|
|
+ raise
|
|
|
|
|
+
|
|
|
|
|
+ normalized = _normalize_account_balances_payload(payload, account_id)
|
|
|
|
|
|
|
|
- result = {"source": "bitstamp", "cached": False, "balances": normalized, "payload": payload}
|
|
|
|
|
- repo.cache_put(cache_key, result, _ttl_from_env("BITSTAMP_BALANCE_CACHE_TTL_SECONDS", BALANCE_CACHE_TTL_SECONDS))
|
|
|
|
|
- return result
|
|
|
|
|
|
|
+ result = {"source": "bitstamp", "cached": False, "balances": normalized, "payload": payload}
|
|
|
|
|
+ repo.cache_put(cache_key, result, _ttl_from_env("BITSTAMP_BALANCE_CACHE_TTL_SECONDS", BALANCE_CACHE_TTL_SECONDS))
|
|
|
|
|
+ repo.cache_put(_stale_key(cache_key), result, STALE_CACHE_TTL_SECONDS)
|
|
|
|
|
+ return result
|
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_account_info(account_id: str) -> dict:
|
|
def fetch_account_info(account_id: str) -> dict:
|
|
|
cache_key = f"bitstamp:account_info:{account_id}"
|
|
cache_key = f"bitstamp:account_info:{account_id}"
|
|
|
cached = repo.cache_get(cache_key)
|
|
cached = repo.cache_get(cache_key)
|
|
|
if cached is not None:
|
|
if cached is not None:
|
|
|
|
|
+ if isinstance(cached, dict) and cached.get("_cached_error"):
|
|
|
|
|
+ stale = repo.cache_get(_stale_key(cache_key))
|
|
|
|
|
+ if stale is not None:
|
|
|
|
|
+ return stale
|
|
|
|
|
+ _raise_cached_error(cached)
|
|
|
return cached
|
|
return cached
|
|
|
|
|
|
|
|
- account = repo.get_account(account_id)
|
|
|
|
|
- balance = fetch_account_balance(account_id)
|
|
|
|
|
-
|
|
|
|
|
- valued_balances = []
|
|
|
|
|
- total_value_usd = 0.0
|
|
|
|
|
- for item in balance["balances"]:
|
|
|
|
|
- asset = item["asset_code"].lower()
|
|
|
|
|
- total = float(item["total"])
|
|
|
|
|
- if asset == "usd":
|
|
|
|
|
- value_usd = total
|
|
|
|
|
- else:
|
|
|
|
|
- value_usd = None
|
|
|
|
|
- market = f"{asset}usd"
|
|
|
|
|
- price = repo.get_latest_price(market)
|
|
|
|
|
- if price is not None:
|
|
|
|
|
- value_usd = total * price
|
|
|
|
|
- elif asset == "eur":
|
|
|
|
|
- fx = load_eur_usd()
|
|
|
|
|
- if fx and fx.get("sell") is not None:
|
|
|
|
|
- value_usd = total * float(fx["sell"])
|
|
|
|
|
- if value_usd is not None:
|
|
|
|
|
- total_value_usd += value_usd
|
|
|
|
|
- valued_balances.append({**item, "value_currency": "USD", "value_usd": value_usd})
|
|
|
|
|
-
|
|
|
|
|
- result = {
|
|
|
|
|
- "id": account["id"],
|
|
|
|
|
- "display_name": account["display_name"],
|
|
|
|
|
- "venue": account["venue"],
|
|
|
|
|
- "venue_account_ref": account["venue_account_ref"],
|
|
|
|
|
- "description": account["description"],
|
|
|
|
|
- "enabled": account["enabled"],
|
|
|
|
|
- "metadata": account["metadata"],
|
|
|
|
|
- "balances": valued_balances,
|
|
|
|
|
- "total_value_usd": total_value_usd,
|
|
|
|
|
- "raw_balance": balance["payload"],
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- repo.cache_put(cache_key, result, _ttl_from_env("BITSTAMP_ACCOUNT_INFO_CACHE_TTL_SECONDS", ACCOUNT_INFO_CACHE_TTL_SECONDS))
|
|
|
|
|
- return result
|
|
|
|
|
|
|
+ with _cache_lock(cache_key):
|
|
|
|
|
+ cached = repo.cache_get(cache_key)
|
|
|
|
|
+ if cached is not None:
|
|
|
|
|
+ if isinstance(cached, dict) and cached.get("_cached_error"):
|
|
|
|
|
+ stale = repo.cache_get(_stale_key(cache_key))
|
|
|
|
|
+ if stale is not None:
|
|
|
|
|
+ return stale
|
|
|
|
|
+ _raise_cached_error(cached)
|
|
|
|
|
+ return cached
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ account = repo.get_account(account_id)
|
|
|
|
|
+ balance = fetch_account_balance(account_id)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ _cache_error(cache_key, str(exc))
|
|
|
|
|
+ raise
|
|
|
|
|
+
|
|
|
|
|
+ valued_balances = []
|
|
|
|
|
+ total_value_usd = 0.0
|
|
|
|
|
+ for item in balance["balances"]:
|
|
|
|
|
+ asset = item["asset_code"].lower()
|
|
|
|
|
+ total = float(item["total"])
|
|
|
|
|
+ if asset == "usd":
|
|
|
|
|
+ value_usd = total
|
|
|
|
|
+ else:
|
|
|
|
|
+ value_usd = None
|
|
|
|
|
+ market = f"{asset}usd"
|
|
|
|
|
+ price = repo.get_latest_price(market)
|
|
|
|
|
+ if price is not None:
|
|
|
|
|
+ value_usd = total * price
|
|
|
|
|
+ elif asset == "eur":
|
|
|
|
|
+ fx = load_eur_usd()
|
|
|
|
|
+ if fx and fx.get("sell") is not None:
|
|
|
|
|
+ value_usd = total * float(fx["sell"])
|
|
|
|
|
+ if value_usd is not None:
|
|
|
|
|
+ total_value_usd += value_usd
|
|
|
|
|
+ valued_balances.append({**item, "value_currency": "USD", "value_usd": value_usd})
|
|
|
|
|
+
|
|
|
|
|
+ result = {
|
|
|
|
|
+ "id": account["id"],
|
|
|
|
|
+ "display_name": account["display_name"],
|
|
|
|
|
+ "venue": account["venue"],
|
|
|
|
|
+ "venue_account_ref": account["venue_account_ref"],
|
|
|
|
|
+ "description": account["description"],
|
|
|
|
|
+ "enabled": account["enabled"],
|
|
|
|
|
+ "metadata": account["metadata"],
|
|
|
|
|
+ "balances": valued_balances,
|
|
|
|
|
+ "total_value_usd": total_value_usd,
|
|
|
|
|
+ "raw_balance": balance["payload"],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ repo.cache_put(cache_key, result, _ttl_from_env("BITSTAMP_ACCOUNT_INFO_CACHE_TTL_SECONDS", ACCOUNT_INFO_CACHE_TTL_SECONDS))
|
|
|
|
|
+ repo.cache_put(_stale_key(cache_key), result, STALE_CACHE_TTL_SECONDS)
|
|
|
|
|
+ return result
|