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.
- alloc_context-0.1.0.dist-info/METADATA +154 -0
- alloc_context-0.1.0.dist-info/RECORD +85 -0
- alloc_context-0.1.0.dist-info/WHEEL +5 -0
- alloc_context-0.1.0.dist-info/entry_points.txt +4 -0
- alloc_context-0.1.0.dist-info/licenses/LICENSE +21 -0
- alloc_context-0.1.0.dist-info/top_level.txt +1 -0
- alloccontext/__init__.py +3 -0
- alloccontext/__main__.py +149 -0
- alloccontext/config.py +415 -0
- alloccontext/horizon.py +30 -0
- alloccontext/ingest/__init__.py +1 -0
- alloccontext/ingest/cf_benchmarks.py +38 -0
- alloccontext/ingest/cf_history.py +65 -0
- alloccontext/ingest/coinbase_client.py +234 -0
- alloccontext/ingest/coinbase_portfolio.py +53 -0
- alloccontext/ingest/coingecko.py +148 -0
- alloccontext/ingest/coinmarketcap.py +135 -0
- alloccontext/ingest/env_keys.py +12 -0
- alloccontext/ingest/etf_flows.py +282 -0
- alloccontext/ingest/exchange/__init__.py +4 -0
- alloccontext/ingest/exchange/coinbase_adapter.py +64 -0
- alloccontext/ingest/exchange/kraken_adapter.py +66 -0
- alloccontext/ingest/exchange/live.py +95 -0
- alloccontext/ingest/exchange/portfolio.py +8 -0
- alloccontext/ingest/exchange/registry.py +27 -0
- alloccontext/ingest/exchange/types.py +5 -0
- alloccontext/ingest/exchange_http.py +28 -0
- alloccontext/ingest/fear_greed.py +89 -0
- alloccontext/ingest/fred.py +138 -0
- alloccontext/ingest/http_errors.py +29 -0
- alloccontext/ingest/kalshi.py +84 -0
- alloccontext/ingest/kalshi_api.py +199 -0
- alloccontext/ingest/kalshi_client.py +95 -0
- alloccontext/ingest/kalshi_files.py +44 -0
- alloccontext/ingest/kalshi_state.py +67 -0
- alloccontext/ingest/kraken_client.py +177 -0
- alloccontext/ingest/kraken_portfolio.py +161 -0
- alloccontext/ingest/macro_calendar.py +310 -0
- alloccontext/ingest/macro_normalize.py +98 -0
- alloccontext/ingest/market_snapshots.py +113 -0
- alloccontext/ingest/outcome.py +110 -0
- alloccontext/ingest/parse_helpers.py +23 -0
- alloccontext/ingest/runner.py +148 -0
- alloccontext/mcp/__init__.py +1 -0
- alloccontext/mcp/assets.py +153 -0
- alloccontext/mcp/bazaar.py +630 -0
- alloccontext/mcp/contracts.py +286 -0
- alloccontext/mcp/handlers.py +487 -0
- alloccontext/mcp/http.py +250 -0
- alloccontext/mcp/payment_middleware.py +211 -0
- alloccontext/mcp/server.py +319 -0
- alloccontext/mcp/staleness.py +30 -0
- alloccontext/mcp/validation.py +56 -0
- alloccontext/mcp/x402_bazaar_dynamic.py +104 -0
- alloccontext/mcp/x402_config.py +131 -0
- alloccontext/mcp/x402_pricing.py +55 -0
- alloccontext/mcp/x402_stables.py +179 -0
- alloccontext/rollup/__init__.py +1 -0
- alloccontext/rollup/band.py +50 -0
- alloccontext/rollup/breadth.py +45 -0
- alloccontext/rollup/cf_math.py +103 -0
- alloccontext/rollup/cluster.py +149 -0
- alloccontext/rollup/cluster_config.py +86 -0
- alloccontext/rollup/comparison.py +67 -0
- alloccontext/rollup/context.py +118 -0
- alloccontext/rollup/delta.py +109 -0
- alloccontext/rollup/etf.py +113 -0
- alloccontext/rollup/fear_greed.py +61 -0
- alloccontext/rollup/macro.py +185 -0
- alloccontext/rollup/portfolio.py +137 -0
- alloccontext/rollup/rebalance.py +125 -0
- alloccontext/rollup/regime.py +188 -0
- alloccontext/rollup/sentiment.py +118 -0
- alloccontext/rollup/snapshots.py +64 -0
- alloccontext/rollup/tape.py +176 -0
- alloccontext/status_report.py +321 -0
- alloccontext/store/__init__.py +0 -0
- alloccontext/store/db.py +216 -0
- alloccontext/store/jsonutil.py +10 -0
- alloccontext/store/meta.py +20 -0
- alloccontext/store/retention.py +63 -0
- alloccontext/store/status.py +89 -0
- alloccontext/timeutil.py +11 -0
- alloccontext/x402_production_check.py +193 -0
- 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
|