alloc-context 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. alloc_context-0.1.0.dist-info/METADATA +154 -0
  2. alloc_context-0.1.0.dist-info/RECORD +85 -0
  3. alloc_context-0.1.0.dist-info/WHEEL +5 -0
  4. alloc_context-0.1.0.dist-info/entry_points.txt +4 -0
  5. alloc_context-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. alloc_context-0.1.0.dist-info/top_level.txt +1 -0
  7. alloccontext/__init__.py +3 -0
  8. alloccontext/__main__.py +149 -0
  9. alloccontext/config.py +415 -0
  10. alloccontext/horizon.py +30 -0
  11. alloccontext/ingest/__init__.py +1 -0
  12. alloccontext/ingest/cf_benchmarks.py +38 -0
  13. alloccontext/ingest/cf_history.py +65 -0
  14. alloccontext/ingest/coinbase_client.py +234 -0
  15. alloccontext/ingest/coinbase_portfolio.py +53 -0
  16. alloccontext/ingest/coingecko.py +148 -0
  17. alloccontext/ingest/coinmarketcap.py +135 -0
  18. alloccontext/ingest/env_keys.py +12 -0
  19. alloccontext/ingest/etf_flows.py +282 -0
  20. alloccontext/ingest/exchange/__init__.py +4 -0
  21. alloccontext/ingest/exchange/coinbase_adapter.py +64 -0
  22. alloccontext/ingest/exchange/kraken_adapter.py +66 -0
  23. alloccontext/ingest/exchange/live.py +95 -0
  24. alloccontext/ingest/exchange/portfolio.py +8 -0
  25. alloccontext/ingest/exchange/registry.py +27 -0
  26. alloccontext/ingest/exchange/types.py +5 -0
  27. alloccontext/ingest/exchange_http.py +28 -0
  28. alloccontext/ingest/fear_greed.py +89 -0
  29. alloccontext/ingest/fred.py +138 -0
  30. alloccontext/ingest/http_errors.py +29 -0
  31. alloccontext/ingest/kalshi.py +84 -0
  32. alloccontext/ingest/kalshi_api.py +199 -0
  33. alloccontext/ingest/kalshi_client.py +95 -0
  34. alloccontext/ingest/kalshi_files.py +44 -0
  35. alloccontext/ingest/kalshi_state.py +67 -0
  36. alloccontext/ingest/kraken_client.py +177 -0
  37. alloccontext/ingest/kraken_portfolio.py +161 -0
  38. alloccontext/ingest/macro_calendar.py +310 -0
  39. alloccontext/ingest/macro_normalize.py +98 -0
  40. alloccontext/ingest/market_snapshots.py +113 -0
  41. alloccontext/ingest/outcome.py +110 -0
  42. alloccontext/ingest/parse_helpers.py +23 -0
  43. alloccontext/ingest/runner.py +148 -0
  44. alloccontext/mcp/__init__.py +1 -0
  45. alloccontext/mcp/assets.py +153 -0
  46. alloccontext/mcp/bazaar.py +630 -0
  47. alloccontext/mcp/contracts.py +286 -0
  48. alloccontext/mcp/handlers.py +487 -0
  49. alloccontext/mcp/http.py +250 -0
  50. alloccontext/mcp/payment_middleware.py +211 -0
  51. alloccontext/mcp/server.py +319 -0
  52. alloccontext/mcp/staleness.py +30 -0
  53. alloccontext/mcp/validation.py +56 -0
  54. alloccontext/mcp/x402_bazaar_dynamic.py +104 -0
  55. alloccontext/mcp/x402_config.py +131 -0
  56. alloccontext/mcp/x402_pricing.py +55 -0
  57. alloccontext/mcp/x402_stables.py +179 -0
  58. alloccontext/rollup/__init__.py +1 -0
  59. alloccontext/rollup/band.py +50 -0
  60. alloccontext/rollup/breadth.py +45 -0
  61. alloccontext/rollup/cf_math.py +103 -0
  62. alloccontext/rollup/cluster.py +149 -0
  63. alloccontext/rollup/cluster_config.py +86 -0
  64. alloccontext/rollup/comparison.py +67 -0
  65. alloccontext/rollup/context.py +118 -0
  66. alloccontext/rollup/delta.py +109 -0
  67. alloccontext/rollup/etf.py +113 -0
  68. alloccontext/rollup/fear_greed.py +61 -0
  69. alloccontext/rollup/macro.py +185 -0
  70. alloccontext/rollup/portfolio.py +137 -0
  71. alloccontext/rollup/rebalance.py +125 -0
  72. alloccontext/rollup/regime.py +188 -0
  73. alloccontext/rollup/sentiment.py +118 -0
  74. alloccontext/rollup/snapshots.py +64 -0
  75. alloccontext/rollup/tape.py +176 -0
  76. alloccontext/status_report.py +321 -0
  77. alloccontext/store/__init__.py +0 -0
  78. alloccontext/store/db.py +216 -0
  79. alloccontext/store/jsonutil.py +10 -0
  80. alloccontext/store/meta.py +20 -0
  81. alloccontext/store/retention.py +63 -0
  82. alloccontext/store/status.py +89 -0
  83. alloccontext/timeutil.py +11 -0
  84. alloccontext/x402_production_check.py +193 -0
  85. alloccontext/x402_smoke_redact.py +41 -0
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import time
5
+ from typing import Any
6
+
7
+ import jwt
8
+ import requests
9
+
10
+ from alloccontext.ingest.exchange_http import should_retry_exchange_attempt
11
+ from cryptography.hazmat.primitives import serialization
12
+
13
+ COINBASE_API = "https://api.coinbase.com"
14
+ BROKERAGE_PREFIX = "/api/v3/brokerage"
15
+
16
+ STABLE_CURRENCIES = frozenset(
17
+ {"USD", "USDC", "USDT", "DAI", "PYUSD", "USDE", "GUSD"}
18
+ )
19
+
20
+ PRODUCT_TO_SYMBOL = {
21
+ "BTC-USD": "BTC",
22
+ "ETH-USD": "ETH",
23
+ }
24
+
25
+ _INTERVAL_TO_GRANULARITY = {
26
+ 1: "ONE_MINUTE",
27
+ 5: "FIVE_MINUTE",
28
+ 15: "FIFTEEN_MINUTE",
29
+ 30: "THIRTY_MINUTE",
30
+ 60: "ONE_HOUR",
31
+ 120: "TWO_HOUR",
32
+ 360: "SIX_HOUR",
33
+ 1440: "ONE_DAY",
34
+ }
35
+
36
+
37
+ def product_to_symbol(product_id: str) -> str:
38
+ product = product_id.upper()
39
+ if product in PRODUCT_TO_SYMBOL:
40
+ return PRODUCT_TO_SYMBOL[product]
41
+ base = product.split("-", 1)[0]
42
+ if base in {"BTC", "ETH"}:
43
+ return base
44
+ return base
45
+
46
+
47
+ def interval_to_granularity(interval_minutes: int) -> str:
48
+ granularity = _INTERVAL_TO_GRANULARITY.get(interval_minutes)
49
+ if granularity is None:
50
+ raise CoinbaseError(f"unsupported_ohlc_interval_minutes:{interval_minutes}")
51
+ return granularity
52
+
53
+
54
+ def normalize_pem_secret(raw: str) -> str:
55
+ secret = raw.strip()
56
+ if "\\n" in secret:
57
+ secret = secret.replace("\\n", "\n")
58
+ return secret
59
+
60
+
61
+ def normalize_coinbase_balances(
62
+ accounts: list[dict[str, Any]],
63
+ ) -> tuple[dict[str, float], dict[str, float]]:
64
+ balances: dict[str, float] = {"BTC": 0.0, "ETH": 0.0, "USD": 0.0}
65
+ cash_breakdown: dict[str, float] = {}
66
+ for account in accounts:
67
+ currency = str(account.get("currency") or "").upper()
68
+ if not currency:
69
+ continue
70
+ available = float((account.get("available_balance") or {}).get("value") or 0)
71
+ hold = float((account.get("hold") or {}).get("value") or 0)
72
+ total = available + hold
73
+ if total <= 0:
74
+ continue
75
+ if currency == "BTC":
76
+ balances["BTC"] += total
77
+ elif currency == "ETH":
78
+ balances["ETH"] += total
79
+ elif currency in STABLE_CURRENCIES:
80
+ balances["USD"] += total
81
+ cash_breakdown[currency] = cash_breakdown.get(currency, 0.0) + total
82
+ return balances, cash_breakdown
83
+
84
+
85
+ class CoinbaseError(Exception):
86
+ pass
87
+
88
+
89
+ class CoinbaseClient:
90
+ """Read-only Coinbase Advanced Trade REST client (accounts, product, candles)."""
91
+
92
+ def __init__(
93
+ self,
94
+ api_key: str = "",
95
+ api_secret: str = "",
96
+ *,
97
+ retry_backoff: float = 2.0,
98
+ max_retries: int = 3,
99
+ session: requests.Session | None = None,
100
+ ) -> None:
101
+ self.api_key = api_key.strip()
102
+ self.api_secret = normalize_pem_secret(api_secret)
103
+ self.retry_backoff = retry_backoff
104
+ self.max_retries = max_retries
105
+ self.session = session or requests.Session()
106
+
107
+ def get_ticker(self, product_id: str) -> dict[str, float]:
108
+ data = self._private("GET", f"/market/products/{product_id}")
109
+ price = data.get("price")
110
+ if price is None:
111
+ raise CoinbaseError(f"missing_price:{product_id}")
112
+ last = float(price)
113
+ return {"last": last, "bid": last, "ask": last}
114
+
115
+ def get_ohlc(self, product_id: str, interval_minutes: int = 1440) -> list[dict[str, float]]:
116
+ granularity = interval_to_granularity(interval_minutes)
117
+ end = int(time.time())
118
+ start = end - 86400 * 120
119
+ data = self._private(
120
+ "GET",
121
+ f"/market/products/{product_id}/candles",
122
+ params={
123
+ "start": str(start),
124
+ "end": str(end),
125
+ "granularity": granularity,
126
+ },
127
+ )
128
+ candles: list[dict[str, float]] = []
129
+ for row in data.get("candles") or []:
130
+ candles.append(
131
+ {
132
+ "time": float(row["start"]),
133
+ "open": float(row["open"]),
134
+ "high": float(row["high"]),
135
+ "low": float(row["low"]),
136
+ "close": float(row["close"]),
137
+ }
138
+ )
139
+ candles.sort(key=lambda bar: bar["time"])
140
+ return candles
141
+
142
+ def get_balances_with_breakdown(
143
+ self,
144
+ ) -> tuple[dict[str, float], dict[str, float]]:
145
+ if not self.api_key or not self.api_secret:
146
+ raise CoinbaseError(
147
+ "COINBASE_API_KEY and COINBASE_API_SECRET required for balances"
148
+ )
149
+ accounts = self._list_accounts()
150
+ return normalize_coinbase_balances(accounts)
151
+
152
+ def _list_accounts(self) -> list[dict[str, Any]]:
153
+ accounts: list[dict[str, Any]] = []
154
+ cursor = ""
155
+ while True:
156
+ params: dict[str, str] = {"limit": "250"}
157
+ if cursor:
158
+ params["cursor"] = cursor
159
+ data = self._private("GET", "/accounts", params=params)
160
+ accounts.extend(data.get("accounts") or [])
161
+ if not data.get("has_next"):
162
+ break
163
+ cursor = str(data.get("cursor") or "")
164
+ if not cursor:
165
+ break
166
+ return accounts
167
+
168
+ def _build_jwt(self, method: str, path: str) -> str:
169
+ uri = f"{method} api.coinbase.com{path}"
170
+ private_key = serialization.load_pem_private_key(
171
+ self.api_secret.encode("utf-8"),
172
+ password=None,
173
+ )
174
+ payload = {
175
+ "sub": self.api_key,
176
+ "iss": "cdp",
177
+ "nbf": int(time.time()),
178
+ "exp": int(time.time()) + 120,
179
+ "uri": uri,
180
+ }
181
+ token = jwt.encode(
182
+ payload,
183
+ private_key,
184
+ algorithm="ES256",
185
+ headers={"kid": self.api_key, "nonce": secrets.token_hex()},
186
+ )
187
+ if isinstance(token, bytes):
188
+ return token.decode("utf-8")
189
+ return token
190
+
191
+ def _private(
192
+ self,
193
+ method: str,
194
+ subpath: str,
195
+ *,
196
+ params: dict[str, str] | None = None,
197
+ ) -> dict[str, Any]:
198
+ path = f"{BROKERAGE_PREFIX}{subpath}"
199
+ return self._request(method, path, params=params, auth=True)
200
+
201
+ def _request(
202
+ self,
203
+ method: str,
204
+ path: str,
205
+ *,
206
+ params: dict[str, str] | None = None,
207
+ auth: bool = False,
208
+ ) -> dict[str, Any]:
209
+ url = COINBASE_API + path
210
+ last_error: Exception | None = None
211
+ for attempt in range(self.max_retries):
212
+ try:
213
+ headers: dict[str, str] = {}
214
+ if auth:
215
+ headers["Authorization"] = f"Bearer {self._build_jwt(method, path)}"
216
+ resp = self.session.request(
217
+ method,
218
+ url,
219
+ params=params,
220
+ headers=headers,
221
+ timeout=30,
222
+ )
223
+ resp.raise_for_status()
224
+ body = resp.json()
225
+ if not isinstance(body, dict):
226
+ raise CoinbaseError("invalid_response")
227
+ return body
228
+ except Exception as exc: # noqa: BLE001
229
+ last_error = exc
230
+ if attempt + 1 < self.max_retries and should_retry_exchange_attempt(exc):
231
+ time.sleep(self.retry_backoff * (attempt + 1))
232
+ continue
233
+ break
234
+ raise CoinbaseError(str(last_error)) from last_error
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sqlite3
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from alloccontext.ingest.coinbase_client import (
9
+ CoinbaseClient,
10
+ normalize_pem_secret,
11
+ product_to_symbol,
12
+ )
13
+ from alloccontext.ingest.kraken_portfolio import (
14
+ PortfolioSnapshot,
15
+ portfolio_from_balances,
16
+ upsert_market_bars,
17
+ upsert_portfolio_snapshot,
18
+ )
19
+
20
+
21
+ def load_coinbase_credentials() -> tuple[str, str] | None:
22
+ api_key = os.environ.get("COINBASE_API_KEY", "").strip()
23
+ api_secret = normalize_pem_secret(os.environ.get("COINBASE_API_SECRET", ""))
24
+ if api_key and api_secret:
25
+ return api_key, api_secret
26
+ return None
27
+
28
+
29
+ def build_coinbase_client(spot) -> CoinbaseClient:
30
+ creds = load_coinbase_credentials()
31
+ return CoinbaseClient(
32
+ api_key=creds[0] if creds else "",
33
+ api_secret=creds[1] if creds else "",
34
+ retry_backoff=spot.retry_backoff_seconds,
35
+ max_retries=spot.max_retries,
36
+ )
37
+
38
+
39
+ def fetch_portfolio_snapshot(client: CoinbaseClient, spot) -> PortfolioSnapshot:
40
+ prices: dict[str, float] = {}
41
+ for product_id in spot.pairs:
42
+ symbol = product_to_symbol(product_id)
43
+ prices[symbol] = client.get_ticker(product_id)["last"]
44
+ balances, cash_breakdown = client.get_balances_with_breakdown()
45
+ snap = portfolio_from_balances(balances, prices, cash_breakdown=cash_breakdown)
46
+ snap.ts = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
47
+ return snap
48
+
49
+
50
+ def refresh_coinbase(conn: sqlite3.Connection, config) -> dict[str, Any]:
51
+ from alloccontext.ingest.exchange.registry import refresh_exchange
52
+
53
+ return refresh_exchange(conn, config, "coinbase")
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from alloccontext.ingest.env_keys import optional_env_key
11
+ from alloccontext.ingest.parse_helpers import parse_float, parse_int
12
+
13
+ COINGECKO_BASE = "https://api.coingecko.com/api/v3"
14
+
15
+
16
+ def _fetch_json(url: str, *, headers: dict[str, str] | None = None, timeout: float = 20.0) -> Any:
17
+ request = urllib.request.Request(
18
+ url,
19
+ headers={"User-Agent": "alloc-context/0.1", **(headers or {})},
20
+ method="GET",
21
+ )
22
+ with urllib.request.urlopen(request, timeout=timeout) as response:
23
+ return json.loads(response.read().decode("utf-8"))
24
+
25
+
26
+ def _headers(api_key: str | None) -> dict[str, str]:
27
+ if not api_key:
28
+ return {}
29
+ return {"x-cg-demo-api-key": api_key}
30
+
31
+
32
+ def fetch_coingecko_global(*, api_key: str | None, timeout: float) -> dict[str, Any]:
33
+ url = f"{COINGECKO_BASE}/global"
34
+ payload = _fetch_json(url, headers=_headers(api_key), timeout=timeout)
35
+ data = payload.get("data") if isinstance(payload, dict) else None
36
+ if not isinstance(data, dict):
37
+ raise ValueError("invalid coingecko global payload")
38
+ return data
39
+
40
+
41
+ def fetch_coingecko_markets(
42
+ *,
43
+ coin_ids: list[str],
44
+ api_key: str | None,
45
+ timeout: float,
46
+ ) -> list[dict[str, Any]]:
47
+ if not coin_ids:
48
+ return []
49
+ query = urllib.parse.urlencode(
50
+ {
51
+ "vs_currency": "usd",
52
+ "ids": ",".join(coin_ids),
53
+ "order": "market_cap_desc",
54
+ "sparkline": "false",
55
+ }
56
+ )
57
+ url = f"{COINGECKO_BASE}/coins/markets?{query}"
58
+ payload = _fetch_json(url, headers=_headers(api_key), timeout=timeout)
59
+ if not isinstance(payload, list):
60
+ raise ValueError("invalid coingecko markets payload")
61
+ return [row for row in payload if isinstance(row, dict)]
62
+
63
+
64
+ def normalize_coingecko_snapshot(
65
+ *,
66
+ global_data: dict[str, Any],
67
+ markets: list[dict[str, Any]],
68
+ ) -> dict[str, Any]:
69
+ market_caps = global_data.get("market_cap_percentage") or {}
70
+ total_cap = (global_data.get("total_market_cap") or {}).get("usd")
71
+
72
+ by_id = {str(row.get("id")): row for row in markets}
73
+ btc = by_id.get("bitcoin") or {}
74
+ eth = by_id.get("ethereum") or {}
75
+
76
+ return {
77
+ "total_market_cap_usd": _rounded(parse_float(total_cap)),
78
+ "btc_dominance_pct": _rounded(parse_float(market_caps.get("btc"))),
79
+ "eth_dominance_pct": _rounded(parse_float(market_caps.get("eth"))),
80
+ "btc_rank": parse_int(btc.get("market_cap_rank")),
81
+ "eth_rank": parse_int(eth.get("market_cap_rank")),
82
+ "btc_price_usd": _rounded(parse_float(btc.get("current_price"))),
83
+ "eth_price_usd": _rounded(parse_float(eth.get("current_price"))),
84
+ "btc_market_cap_usd": _rounded(parse_float(btc.get("market_cap"))),
85
+ "eth_market_cap_usd": _rounded(parse_float(eth.get("market_cap"))),
86
+ "btc_change_pct_24h": _rounded(parse_float(btc.get("price_change_percentage_24h"))),
87
+ "eth_change_pct_24h": _rounded(parse_float(eth.get("price_change_percentage_24h"))),
88
+ }
89
+
90
+
91
+ def _rounded(value: float | None) -> float | None:
92
+ return round(value, 4) if value is not None else None
93
+
94
+
95
+ def refresh_coingecko(conn, config) -> dict[str, Any]:
96
+ api_key = optional_env_key("COINGECKO_API_KEY") if config.coingecko.use_demo_key else None
97
+
98
+ def _fetch_snapshot(*, key: str | None) -> dict[str, Any]:
99
+ global_data = fetch_coingecko_global(api_key=key, timeout=config.coingecko.timeout_seconds)
100
+ markets = fetch_coingecko_markets(
101
+ coin_ids=list(config.coingecko.coin_ids),
102
+ api_key=key,
103
+ timeout=config.coingecko.timeout_seconds,
104
+ )
105
+ return normalize_coingecko_snapshot(global_data=global_data, markets=markets)
106
+
107
+ try:
108
+ snapshot = _fetch_snapshot(key=api_key)
109
+ except urllib.error.HTTPError as exc:
110
+ if exc.code == 429:
111
+ return {
112
+ "ok": True,
113
+ "rows": 0,
114
+ "skipped": True,
115
+ "reason": "coingecko_rate_limited",
116
+ }
117
+ if exc.code in (401, 403) and api_key:
118
+ try:
119
+ snapshot = _fetch_snapshot(key=None)
120
+ except urllib.error.HTTPError as retry_exc:
121
+ if retry_exc.code in (401, 403):
122
+ return {
123
+ "ok": True,
124
+ "rows": 0,
125
+ "skipped": True,
126
+ "reason": "coingecko_auth_failed",
127
+ }
128
+ return {"ok": False, "error": str(retry_exc), "rows": 0}
129
+ except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError, RuntimeError) as retry_exc:
130
+ return {"ok": False, "error": str(retry_exc), "rows": 0}
131
+ elif exc.code in (401, 403):
132
+ return {
133
+ "ok": True,
134
+ "rows": 0,
135
+ "skipped": True,
136
+ "reason": "coingecko_auth_failed",
137
+ }
138
+ else:
139
+ return {"ok": False, "error": str(exc), "rows": 0}
140
+ except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError, RuntimeError) as exc:
141
+ return {"ok": False, "error": str(exc), "rows": 0}
142
+
143
+ from alloccontext.ingest.market_snapshots import upsert_crypto_market_snapshot
144
+
145
+ ts = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
146
+ upsert_crypto_market_snapshot(conn, source="coingecko", snapshot_ts=ts, snapshot=snapshot)
147
+ conn.commit()
148
+ return {"ok": True, "rows": 1, "snapshot_ts": ts, **snapshot}
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from alloccontext.ingest.env_keys import optional_env_key
11
+ from alloccontext.ingest.parse_helpers import parse_float, parse_int
12
+
13
+ CMC_BASE = "https://pro-api.coinmarketcap.com/v1"
14
+
15
+
16
+ def _fetch_json(url: str, *, api_key: str, timeout: float) -> Any:
17
+ request = urllib.request.Request(
18
+ url,
19
+ headers={
20
+ "User-Agent": "alloc-context/0.1",
21
+ "X-CMC_PRO_API_KEY": api_key,
22
+ "Accept": "application/json",
23
+ },
24
+ method="GET",
25
+ )
26
+ with urllib.request.urlopen(request, timeout=timeout) as response:
27
+ return json.loads(response.read().decode("utf-8"))
28
+
29
+
30
+ def fetch_cmc_global(*, api_key: str, timeout: float) -> dict[str, Any]:
31
+ payload = _fetch_json(f"{CMC_BASE}/global-metrics/quotes/latest", api_key=api_key, timeout=timeout)
32
+ data = payload.get("data") if isinstance(payload, dict) else None
33
+ if not isinstance(data, dict):
34
+ raise ValueError("invalid cmc global payload")
35
+ return data
36
+
37
+
38
+ def fetch_cmc_quotes(
39
+ *,
40
+ symbols: list[str],
41
+ api_key: str,
42
+ timeout: float,
43
+ ) -> dict[str, Any]:
44
+ if not symbols:
45
+ return {}
46
+ query = urllib.parse.urlencode({"symbol": ",".join(symbols), "convert": "USD"})
47
+ payload = _fetch_json(
48
+ f"{CMC_BASE}/cryptocurrency/quotes/latest?{query}",
49
+ api_key=api_key,
50
+ timeout=timeout,
51
+ )
52
+ data = payload.get("data") if isinstance(payload, dict) else None
53
+ if not isinstance(data, dict):
54
+ raise ValueError("invalid cmc quotes payload")
55
+ return data
56
+
57
+
58
+ def _quote_usd(asset: dict[str, Any]) -> dict[str, Any]:
59
+ quote = (asset.get("quote") or {}).get("USD") or {}
60
+ return quote if isinstance(quote, dict) else {}
61
+
62
+
63
+ def normalize_cmc_snapshot(
64
+ *,
65
+ global_data: dict[str, Any],
66
+ quotes: dict[str, Any],
67
+ ) -> dict[str, Any]:
68
+ usd = _quote_usd(global_data)
69
+ btc = quotes.get("BTC") or {}
70
+ eth = quotes.get("ETH") or {}
71
+ btc_q = _quote_usd(btc)
72
+ eth_q = _quote_usd(eth)
73
+
74
+ return {
75
+ "total_market_cap_usd": _rounded(parse_float(usd.get("total_market_cap"))),
76
+ "btc_dominance_pct": _rounded(parse_float(global_data.get("btc_dominance"))),
77
+ "eth_dominance_pct": _rounded(parse_float(global_data.get("eth_dominance"))),
78
+ "btc_rank": parse_int(btc.get("cmc_rank")),
79
+ "eth_rank": parse_int(eth.get("cmc_rank")),
80
+ "btc_price_usd": _rounded(parse_float(btc_q.get("price"))),
81
+ "eth_price_usd": _rounded(parse_float(eth_q.get("price"))),
82
+ "btc_market_cap_usd": _rounded(parse_float(btc_q.get("market_cap"))),
83
+ "eth_market_cap_usd": _rounded(parse_float(eth_q.get("market_cap"))),
84
+ "btc_change_pct_24h": _rounded(parse_float(btc_q.get("percent_change_24h"))),
85
+ "eth_change_pct_24h": _rounded(parse_float(eth_q.get("percent_change_24h"))),
86
+ }
87
+
88
+
89
+ def _rounded(value: float | None) -> float | None:
90
+ return round(value, 4) if value is not None else None
91
+
92
+
93
+ def refresh_coinmarketcap(conn, config) -> dict[str, Any]:
94
+ api_key = optional_env_key("COINMARKETCAP_API_KEY")
95
+ if not api_key:
96
+ return {
97
+ "ok": True,
98
+ "rows": 0,
99
+ "skipped": True,
100
+ "reason": "COINMARKETCAP_API_KEY not set",
101
+ }
102
+
103
+ try:
104
+ global_data = fetch_cmc_global(api_key=api_key, timeout=config.coinmarketcap.timeout_seconds)
105
+ quotes = fetch_cmc_quotes(
106
+ symbols=list(config.coinmarketcap.symbols),
107
+ api_key=api_key,
108
+ timeout=config.coinmarketcap.timeout_seconds,
109
+ )
110
+ snapshot = normalize_cmc_snapshot(global_data=global_data, quotes=quotes)
111
+ except urllib.error.HTTPError as exc:
112
+ if exc.code in (401, 403):
113
+ return {
114
+ "ok": True,
115
+ "rows": 0,
116
+ "skipped": True,
117
+ "reason": "coinmarketcap_auth_failed",
118
+ }
119
+ if exc.code == 429:
120
+ return {
121
+ "ok": True,
122
+ "rows": 0,
123
+ "skipped": True,
124
+ "reason": "coinmarketcap_rate_limited",
125
+ }
126
+ return {"ok": False, "error": str(exc), "rows": 0}
127
+ except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError, RuntimeError) as exc:
128
+ return {"ok": False, "error": str(exc), "rows": 0}
129
+
130
+ from alloccontext.ingest.market_snapshots import upsert_crypto_market_snapshot
131
+
132
+ ts = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
133
+ upsert_crypto_market_snapshot(conn, source="coinmarketcap", snapshot_ts=ts, snapshot=snapshot)
134
+ conn.commit()
135
+ return {"ok": True, "rows": 1, "snapshot_ts": ts, **snapshot}
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ def optional_env_key(name: str) -> str | None:
7
+ """Return env value when non-empty after strip; else None."""
8
+ raw = os.environ.get(name)
9
+ if raw is None:
10
+ return None
11
+ stripped = raw.strip()
12
+ return stripped or None