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,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from datetime import date, datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
SOSO_BASE = "https://openapi.sosovalue.com"
|
|
13
|
+
SOSO_HISTORICAL = "/openapi/v2/etf/historicalInflowChart"
|
|
14
|
+
SOSO_METRICS = "/openapi/v2/etf/currentEtfDataMetrics"
|
|
15
|
+
|
|
16
|
+
ETF_PRODUCTS = {
|
|
17
|
+
"BTC": "us-btc-spot",
|
|
18
|
+
"ETH": "us-eth-spot",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _post_json(url: str, body: dict[str, Any], headers: dict[str, str], timeout: float) -> dict:
|
|
23
|
+
payload = json.dumps(body).encode("utf-8")
|
|
24
|
+
request = urllib.request.Request(
|
|
25
|
+
url,
|
|
26
|
+
data=payload,
|
|
27
|
+
headers={**headers, "Content-Type": "application/json"},
|
|
28
|
+
method="POST",
|
|
29
|
+
)
|
|
30
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
31
|
+
parsed = json.loads(response.read().decode("utf-8"))
|
|
32
|
+
if not isinstance(parsed, dict):
|
|
33
|
+
raise ValueError("invalid JSON object response")
|
|
34
|
+
if parsed.get("code", 0) != 0:
|
|
35
|
+
raise RuntimeError(str(parsed.get("msg") or "sosovalue_api_error"))
|
|
36
|
+
return parsed
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch_sosovalue_historical(
|
|
40
|
+
*,
|
|
41
|
+
product: str,
|
|
42
|
+
api_key: str,
|
|
43
|
+
timeout: float,
|
|
44
|
+
) -> list[dict[str, Any]]:
|
|
45
|
+
payload = _post_json(
|
|
46
|
+
f"{SOSO_BASE}{SOSO_HISTORICAL}",
|
|
47
|
+
{"type": product},
|
|
48
|
+
{"x-soso-api-key": api_key, "User-Agent": "alloc-context/0.1"},
|
|
49
|
+
timeout,
|
|
50
|
+
)
|
|
51
|
+
rows = payload.get("data") or []
|
|
52
|
+
if not isinstance(rows, list):
|
|
53
|
+
raise ValueError("invalid sosovalue historical payload")
|
|
54
|
+
return [row for row in rows if isinstance(row, dict)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fetch_sosovalue_ticker_metrics(
|
|
58
|
+
*,
|
|
59
|
+
product: str,
|
|
60
|
+
api_key: str,
|
|
61
|
+
timeout: float,
|
|
62
|
+
) -> list[dict[str, Any]]:
|
|
63
|
+
payload = _post_json(
|
|
64
|
+
f"{SOSO_BASE}{SOSO_METRICS}",
|
|
65
|
+
{"type": product},
|
|
66
|
+
{"x-soso-api-key": api_key, "User-Agent": "alloc-context/0.1"},
|
|
67
|
+
timeout,
|
|
68
|
+
)
|
|
69
|
+
data = payload.get("data") or {}
|
|
70
|
+
rows = data.get("list") if isinstance(data, dict) else None
|
|
71
|
+
if not isinstance(rows, list):
|
|
72
|
+
raise ValueError("invalid sosovalue metrics payload")
|
|
73
|
+
return [row for row in rows if isinstance(row, dict)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _metric_value(block: Any) -> float | None:
|
|
77
|
+
if not isinstance(block, dict):
|
|
78
|
+
return None
|
|
79
|
+
value = block.get("value")
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return float(value)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _metric_date(block: Any) -> str | None:
|
|
89
|
+
if not isinstance(block, dict):
|
|
90
|
+
return None
|
|
91
|
+
raw = block.get("lastUpdateDate") or block.get("date")
|
|
92
|
+
return str(raw)[:10] if raw else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_fallback_snapshot(path: Path) -> dict[str, Any]:
|
|
96
|
+
if not path.exists():
|
|
97
|
+
return {}
|
|
98
|
+
raw = json.loads(path.read_text())
|
|
99
|
+
return raw if isinstance(raw, dict) else {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def upsert_etf_flow_days(
|
|
103
|
+
conn: sqlite3.Connection,
|
|
104
|
+
*,
|
|
105
|
+
asset: str,
|
|
106
|
+
rows: list[dict[str, Any]],
|
|
107
|
+
source: str,
|
|
108
|
+
) -> int:
|
|
109
|
+
fetched_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
110
|
+
count = 0
|
|
111
|
+
for row in rows:
|
|
112
|
+
flow_date = str(row.get("date") or row.get("flow_date") or "")[:10]
|
|
113
|
+
if not flow_date:
|
|
114
|
+
continue
|
|
115
|
+
net_flow = row.get("totalNetInflow", row.get("net_flow_usd"))
|
|
116
|
+
conn.execute(
|
|
117
|
+
"""
|
|
118
|
+
INSERT INTO etf_flow_days(
|
|
119
|
+
asset, flow_date, net_flow_usd, total_value_traded_usd,
|
|
120
|
+
total_net_assets_usd, cum_net_inflow_usd, source, fetched_at
|
|
121
|
+
)
|
|
122
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
123
|
+
ON CONFLICT(asset, flow_date) DO UPDATE SET
|
|
124
|
+
net_flow_usd = excluded.net_flow_usd,
|
|
125
|
+
total_value_traded_usd = excluded.total_value_traded_usd,
|
|
126
|
+
total_net_assets_usd = excluded.total_net_assets_usd,
|
|
127
|
+
cum_net_inflow_usd = excluded.cum_net_inflow_usd,
|
|
128
|
+
source = excluded.source,
|
|
129
|
+
fetched_at = excluded.fetched_at
|
|
130
|
+
""",
|
|
131
|
+
(
|
|
132
|
+
asset,
|
|
133
|
+
flow_date,
|
|
134
|
+
_float_or_none(net_flow),
|
|
135
|
+
_float_or_none(row.get("totalValueTraded", row.get("total_value_traded_usd"))),
|
|
136
|
+
_float_or_none(row.get("totalNetAssets", row.get("total_net_assets_usd"))),
|
|
137
|
+
_float_or_none(row.get("cumNetInflow", row.get("cum_net_inflow_usd"))),
|
|
138
|
+
source,
|
|
139
|
+
fetched_at,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
count += 1
|
|
143
|
+
return count
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def upsert_etf_ticker_flows(
|
|
147
|
+
conn: sqlite3.Connection,
|
|
148
|
+
*,
|
|
149
|
+
asset: str,
|
|
150
|
+
rows: list[dict[str, Any]],
|
|
151
|
+
source: str,
|
|
152
|
+
) -> int:
|
|
153
|
+
fetched_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
154
|
+
count = 0
|
|
155
|
+
for row in rows:
|
|
156
|
+
ticker = str(row.get("ticker") or "").upper()
|
|
157
|
+
if not ticker:
|
|
158
|
+
continue
|
|
159
|
+
inflow = row.get("dailyNetInflow") if "dailyNetInflow" in row else row.get("net_flow_usd")
|
|
160
|
+
flow_date = _metric_date(inflow) or _metric_date(row.get("netAssets"))
|
|
161
|
+
if not flow_date and isinstance(row.get("flow_date"), str):
|
|
162
|
+
flow_date = row["flow_date"][:10]
|
|
163
|
+
if not flow_date:
|
|
164
|
+
flow_date = datetime.now(timezone.utc).date().isoformat()
|
|
165
|
+
conn.execute(
|
|
166
|
+
"""
|
|
167
|
+
INSERT INTO etf_ticker_flows(
|
|
168
|
+
asset, ticker, flow_date, net_flow_usd, net_assets_usd,
|
|
169
|
+
institute, source, fetched_at
|
|
170
|
+
)
|
|
171
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
172
|
+
ON CONFLICT(asset, ticker, flow_date) DO UPDATE SET
|
|
173
|
+
net_flow_usd = excluded.net_flow_usd,
|
|
174
|
+
net_assets_usd = excluded.net_assets_usd,
|
|
175
|
+
institute = excluded.institute,
|
|
176
|
+
source = excluded.source,
|
|
177
|
+
fetched_at = excluded.fetched_at
|
|
178
|
+
""",
|
|
179
|
+
(
|
|
180
|
+
asset,
|
|
181
|
+
ticker,
|
|
182
|
+
flow_date,
|
|
183
|
+
_metric_value(inflow) if isinstance(inflow, dict) else _float_or_none(inflow),
|
|
184
|
+
_metric_value(row.get("netAssets")),
|
|
185
|
+
str(row.get("institute") or "") or None,
|
|
186
|
+
source,
|
|
187
|
+
fetched_at,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
count += 1
|
|
191
|
+
return count
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _float_or_none(value: Any) -> float | None:
|
|
195
|
+
if value is None:
|
|
196
|
+
return None
|
|
197
|
+
try:
|
|
198
|
+
return float(value)
|
|
199
|
+
except (TypeError, ValueError):
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _ingest_asset_from_fallback(
|
|
204
|
+
conn: sqlite3.Connection,
|
|
205
|
+
*,
|
|
206
|
+
asset: str,
|
|
207
|
+
snapshot: dict[str, Any],
|
|
208
|
+
) -> int:
|
|
209
|
+
daily = snapshot.get("daily") or snapshot.get("history") or []
|
|
210
|
+
tickers = snapshot.get("by_ticker") or snapshot.get("tickers") or []
|
|
211
|
+
count = upsert_etf_flow_days(conn, asset=asset, rows=list(daily), source="fallback")
|
|
212
|
+
count += upsert_etf_ticker_flows(conn, asset=asset, rows=list(tickers), source="fallback")
|
|
213
|
+
return count
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def refresh_etf_flows(conn: sqlite3.Connection, config) -> dict[str, Any]:
|
|
217
|
+
etf = config.etf
|
|
218
|
+
assets = [str(a).upper() for a in etf.assets]
|
|
219
|
+
api_key = os.environ.get("SOSOVALUE_API_KEY")
|
|
220
|
+
rows_total = 0
|
|
221
|
+
sources: set[str] = set()
|
|
222
|
+
feed_errors: dict[str, str] = {}
|
|
223
|
+
|
|
224
|
+
if api_key and etf.sosovalue_enabled:
|
|
225
|
+
for asset in assets:
|
|
226
|
+
product = ETF_PRODUCTS.get(asset)
|
|
227
|
+
if not product:
|
|
228
|
+
continue
|
|
229
|
+
try:
|
|
230
|
+
history = fetch_sosovalue_historical(
|
|
231
|
+
product=product,
|
|
232
|
+
api_key=api_key,
|
|
233
|
+
timeout=etf.timeout_seconds,
|
|
234
|
+
)
|
|
235
|
+
rows_total += upsert_etf_flow_days(
|
|
236
|
+
conn, asset=asset, rows=history, source="sosovalue"
|
|
237
|
+
)
|
|
238
|
+
metrics = fetch_sosovalue_ticker_metrics(
|
|
239
|
+
product=product,
|
|
240
|
+
api_key=api_key,
|
|
241
|
+
timeout=etf.timeout_seconds,
|
|
242
|
+
)
|
|
243
|
+
rows_total += upsert_etf_ticker_flows(
|
|
244
|
+
conn, asset=asset, rows=metrics, source="sosovalue"
|
|
245
|
+
)
|
|
246
|
+
sources.add("sosovalue")
|
|
247
|
+
except (urllib.error.URLError, TimeoutError, ValueError, RuntimeError) as exc:
|
|
248
|
+
feed_errors[f"sosovalue_{asset.lower()}"] = str(exc)
|
|
249
|
+
|
|
250
|
+
fallback_path = etf.fallback_snapshot
|
|
251
|
+
if fallback_path and fallback_path.exists():
|
|
252
|
+
snapshot = load_fallback_snapshot(fallback_path)
|
|
253
|
+
for asset in assets:
|
|
254
|
+
block = snapshot.get(asset.lower()) or snapshot.get(asset)
|
|
255
|
+
if isinstance(block, dict):
|
|
256
|
+
rows_total += _ingest_asset_from_fallback(conn, asset=asset, snapshot=block)
|
|
257
|
+
sources.add("fallback")
|
|
258
|
+
|
|
259
|
+
if rows_total == 0:
|
|
260
|
+
conn.rollback()
|
|
261
|
+
if not api_key and not (fallback_path and fallback_path.exists()):
|
|
262
|
+
return {
|
|
263
|
+
"ok": True,
|
|
264
|
+
"rows": 0,
|
|
265
|
+
"skipped": True,
|
|
266
|
+
"reason": "no_etf_data_source",
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
"ok": False,
|
|
270
|
+
"rows": 0,
|
|
271
|
+
"sources": sorted(sources),
|
|
272
|
+
"feed_errors": feed_errors,
|
|
273
|
+
"error": "etf_ingest_failed",
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
conn.commit()
|
|
277
|
+
return {
|
|
278
|
+
"ok": True,
|
|
279
|
+
"rows": rows_total,
|
|
280
|
+
"sources": sorted(sources),
|
|
281
|
+
"feed_errors": feed_errors,
|
|
282
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from alloccontext.horizon import bars_within_horizon, horizon_days
|
|
7
|
+
from alloccontext.ingest.exchange.portfolio import writes_portfolio_snapshot
|
|
8
|
+
from alloccontext.ingest.coinbase_client import CoinbaseError
|
|
9
|
+
from alloccontext.ingest.coinbase_portfolio import (
|
|
10
|
+
build_coinbase_client,
|
|
11
|
+
fetch_portfolio_snapshot,
|
|
12
|
+
load_coinbase_credentials,
|
|
13
|
+
)
|
|
14
|
+
from alloccontext.ingest.kraken_portfolio import upsert_market_bars, upsert_portfolio_snapshot
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def refresh_coinbase_exchange(conn: sqlite3.Connection, config) -> dict[str, Any]:
|
|
18
|
+
spot = config.exchanges.coinbase
|
|
19
|
+
if not spot.enabled:
|
|
20
|
+
return {"ok": True, "rows": 0, "skipped": True, "reason": "exchange_disabled"}
|
|
21
|
+
|
|
22
|
+
if not load_coinbase_credentials():
|
|
23
|
+
return {
|
|
24
|
+
"ok": True,
|
|
25
|
+
"rows": 0,
|
|
26
|
+
"skipped": True,
|
|
27
|
+
"reason": "missing_coinbase_credentials",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
client = build_coinbase_client(spot)
|
|
31
|
+
try:
|
|
32
|
+
snap = None
|
|
33
|
+
portfolio_rows = 0
|
|
34
|
+
if writes_portfolio_snapshot(config, "coinbase"):
|
|
35
|
+
snap = fetch_portfolio_snapshot(client, spot)
|
|
36
|
+
upsert_portfolio_snapshot(conn, snap)
|
|
37
|
+
portfolio_rows = 1
|
|
38
|
+
bar_rows = 0
|
|
39
|
+
for product_id in spot.pairs:
|
|
40
|
+
bars = client.get_ohlc(product_id, spot.ohlc_interval_minutes)
|
|
41
|
+
bars = bars_within_horizon(bars, days=horizon_days(config))
|
|
42
|
+
bar_rows += upsert_market_bars(
|
|
43
|
+
conn,
|
|
44
|
+
pair=product_id,
|
|
45
|
+
interval_minutes=spot.ohlc_interval_minutes,
|
|
46
|
+
bars=bars,
|
|
47
|
+
)
|
|
48
|
+
conn.commit()
|
|
49
|
+
except CoinbaseError as exc:
|
|
50
|
+
conn.rollback()
|
|
51
|
+
return {"ok": False, "error": str(exc), "rows": 0}
|
|
52
|
+
|
|
53
|
+
result: dict[str, Any] = {
|
|
54
|
+
"ok": True,
|
|
55
|
+
"rows": portfolio_rows + bar_rows,
|
|
56
|
+
"market_bars": bar_rows,
|
|
57
|
+
}
|
|
58
|
+
if snap is not None:
|
|
59
|
+
result["portfolio"] = {
|
|
60
|
+
"ts": snap.ts,
|
|
61
|
+
"nav_usd": snap.nav_usd,
|
|
62
|
+
"cash_usd": snap.cash_usd,
|
|
63
|
+
}
|
|
64
|
+
return result
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from alloccontext.horizon import bars_within_horizon, horizon_days
|
|
7
|
+
from alloccontext.ingest.exchange.portfolio import writes_portfolio_snapshot
|
|
8
|
+
from alloccontext.ingest.kraken_client import KrakenError
|
|
9
|
+
from alloccontext.ingest.kraken_portfolio import (
|
|
10
|
+
build_kraken_client,
|
|
11
|
+
fetch_portfolio_snapshot,
|
|
12
|
+
load_kraken_credentials,
|
|
13
|
+
upsert_market_bars,
|
|
14
|
+
upsert_portfolio_snapshot,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def refresh_kraken_exchange(conn: sqlite3.Connection, config) -> dict[str, Any]:
|
|
19
|
+
spot = config.exchanges.kraken
|
|
20
|
+
if not spot.enabled:
|
|
21
|
+
return {"ok": True, "rows": 0, "skipped": True, "reason": "exchange_disabled"}
|
|
22
|
+
|
|
23
|
+
creds = load_kraken_credentials()
|
|
24
|
+
if writes_portfolio_snapshot(config, "kraken") and not creds:
|
|
25
|
+
return {
|
|
26
|
+
"ok": True,
|
|
27
|
+
"rows": 0,
|
|
28
|
+
"skipped": True,
|
|
29
|
+
"reason": "missing_kraken_credentials",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
client = build_kraken_client(spot)
|
|
33
|
+
try:
|
|
34
|
+
snap = None
|
|
35
|
+
portfolio_rows = 0
|
|
36
|
+
if writes_portfolio_snapshot(config, "kraken"):
|
|
37
|
+
snap = fetch_portfolio_snapshot(client, spot)
|
|
38
|
+
upsert_portfolio_snapshot(conn, snap)
|
|
39
|
+
portfolio_rows = 1
|
|
40
|
+
bar_rows = 0
|
|
41
|
+
for pair in spot.pairs:
|
|
42
|
+
bars = client.get_ohlc(pair, spot.ohlc_interval_minutes)
|
|
43
|
+
bars = bars_within_horizon(bars, days=horizon_days(config))
|
|
44
|
+
bar_rows += upsert_market_bars(
|
|
45
|
+
conn,
|
|
46
|
+
pair=pair,
|
|
47
|
+
interval_minutes=spot.ohlc_interval_minutes,
|
|
48
|
+
bars=bars,
|
|
49
|
+
)
|
|
50
|
+
conn.commit()
|
|
51
|
+
except KrakenError as exc:
|
|
52
|
+
conn.rollback()
|
|
53
|
+
return {"ok": False, "error": str(exc), "rows": 0}
|
|
54
|
+
|
|
55
|
+
result: dict[str, Any] = {
|
|
56
|
+
"ok": True,
|
|
57
|
+
"rows": portfolio_rows + bar_rows,
|
|
58
|
+
"market_bars": bar_rows,
|
|
59
|
+
}
|
|
60
|
+
if snap is not None:
|
|
61
|
+
result["portfolio"] = {
|
|
62
|
+
"ts": snap.ts,
|
|
63
|
+
"nav_usd": snap.nav_usd,
|
|
64
|
+
"cash_usd": snap.cash_usd,
|
|
65
|
+
}
|
|
66
|
+
return result
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from alloccontext.ingest.coinbase_client import CoinbaseClient, CoinbaseError
|
|
6
|
+
from alloccontext.ingest.coinbase_portfolio import fetch_portfolio_snapshot as fetch_coinbase_snapshot
|
|
7
|
+
from alloccontext.ingest.exchange.types import ExchangeId
|
|
8
|
+
from alloccontext.ingest.kraken_client import KrakenClient, KrakenError
|
|
9
|
+
from alloccontext.ingest.kraken_portfolio import (
|
|
10
|
+
PortfolioSnapshot,
|
|
11
|
+
fetch_portfolio_snapshot as fetch_kraken_snapshot,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
SUPPORTED_EXCHANGES = frozenset({"kraken", "coinbase"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LivePortfolioError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_exchange_id(exchange: str) -> ExchangeId:
|
|
22
|
+
key = exchange.strip().lower()
|
|
23
|
+
if key not in SUPPORTED_EXCHANGES:
|
|
24
|
+
raise ValueError(f"unsupported exchange: {exchange}")
|
|
25
|
+
return key # type: ignore[return-value]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _spot_config(config, exchange_id: ExchangeId):
|
|
29
|
+
if exchange_id == "kraken":
|
|
30
|
+
return config.exchanges.kraken
|
|
31
|
+
return config.exchanges.coinbase
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch_live_portfolio_snapshot(
|
|
35
|
+
exchange_id: ExchangeId,
|
|
36
|
+
api_key: str,
|
|
37
|
+
api_secret: str,
|
|
38
|
+
config,
|
|
39
|
+
) -> PortfolioSnapshot:
|
|
40
|
+
spot = _spot_config(config, exchange_id)
|
|
41
|
+
key = api_key.strip()
|
|
42
|
+
secret = api_secret.strip()
|
|
43
|
+
if not key or not secret:
|
|
44
|
+
raise LivePortfolioError("api_key and api_secret are required")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
if exchange_id == "kraken":
|
|
48
|
+
client = KrakenClient(
|
|
49
|
+
api_key=key,
|
|
50
|
+
api_secret=secret,
|
|
51
|
+
retry_backoff=spot.retry_backoff_seconds,
|
|
52
|
+
max_retries=spot.max_retries,
|
|
53
|
+
)
|
|
54
|
+
return fetch_kraken_snapshot(client, spot)
|
|
55
|
+
client = CoinbaseClient(
|
|
56
|
+
api_key=key,
|
|
57
|
+
api_secret=secret,
|
|
58
|
+
retry_backoff=spot.retry_backoff_seconds,
|
|
59
|
+
max_retries=spot.max_retries,
|
|
60
|
+
)
|
|
61
|
+
return fetch_coinbase_snapshot(client, spot)
|
|
62
|
+
except (KrakenError, CoinbaseError) as exc:
|
|
63
|
+
raise LivePortfolioError(str(exc)) from exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def portfolio_state_from_snapshot(
|
|
67
|
+
snap: PortfolioSnapshot,
|
|
68
|
+
*,
|
|
69
|
+
exchange_id: ExchangeId,
|
|
70
|
+
target_pct: dict[str, float],
|
|
71
|
+
band: float,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
from alloccontext.rollup.band import check_allocation_band
|
|
74
|
+
|
|
75
|
+
allocation_pct = {
|
|
76
|
+
"BTC": snap.btc_pct,
|
|
77
|
+
"ETH": snap.eth_pct,
|
|
78
|
+
"CASH": snap.cash_pct,
|
|
79
|
+
}
|
|
80
|
+
band_result = check_allocation_band(allocation_pct, target_pct, float(band))
|
|
81
|
+
return {
|
|
82
|
+
"available": True,
|
|
83
|
+
"exchange": exchange_id,
|
|
84
|
+
"source": "live",
|
|
85
|
+
"nav_usd": round(float(snap.nav_usd), 2),
|
|
86
|
+
"cash_usd": round(float(snap.cash_usd), 2),
|
|
87
|
+
"allocation_pct": band_result["allocation_pct"],
|
|
88
|
+
"target_allocation_pct": target_pct,
|
|
89
|
+
"drift": band_result["drift"],
|
|
90
|
+
"rebalance_hint": band_result["hint"],
|
|
91
|
+
"outside_band": band_result["outside_band"],
|
|
92
|
+
"prices": dict(snap.prices),
|
|
93
|
+
"cash_breakdown": dict(snap.cash_breakdown),
|
|
94
|
+
"snapshot_ts": snap.ts,
|
|
95
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from alloccontext.ingest.exchange.types import ExchangeId
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def writes_portfolio_snapshot(config, exchange_id: ExchangeId) -> bool:
|
|
7
|
+
"""Only the configured primary exchange may upsert portfolio_snapshots."""
|
|
8
|
+
return config.exchanges.primary == exchange_id
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from alloccontext.ingest.exchange.coinbase_adapter import refresh_coinbase_exchange
|
|
8
|
+
from alloccontext.ingest.exchange.kraken_adapter import refresh_kraken_exchange
|
|
9
|
+
from alloccontext.ingest.exchange.types import ExchangeId
|
|
10
|
+
|
|
11
|
+
ExchangeRefreshFn = Callable[[sqlite3.Connection, Any], dict[str, Any]]
|
|
12
|
+
|
|
13
|
+
_ADAPTERS: dict[ExchangeId, ExchangeRefreshFn] = {
|
|
14
|
+
"kraken": refresh_kraken_exchange,
|
|
15
|
+
"coinbase": refresh_coinbase_exchange,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def refresh_exchange(
|
|
20
|
+
conn: sqlite3.Connection,
|
|
21
|
+
config,
|
|
22
|
+
exchange_id: ExchangeId,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
adapter = _ADAPTERS.get(exchange_id)
|
|
25
|
+
if adapter is None:
|
|
26
|
+
return {"ok": False, "rows": 0, "error": f"unknown_exchange:{exchange_id}"}
|
|
27
|
+
return adapter(conn, config)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""HTTP retry classification for exchange API clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
TRANSIENT_HTTP_STATUSES = frozenset({429, 502, 503, 504})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_transient_http_status(status_code: int) -> bool:
|
|
13
|
+
return status_code in TRANSIENT_HTTP_STATUSES
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_retryable_exchange_request_error(exc: Exception) -> bool:
|
|
17
|
+
if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
|
|
18
|
+
return True
|
|
19
|
+
if isinstance(exc, requests.HTTPError) and exc.response is not None:
|
|
20
|
+
return is_transient_http_status(exc.response.status_code)
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def should_retry_exchange_attempt(exc: Exception) -> bool:
|
|
25
|
+
"""Return True only for transient transport/HTTP errors worth retrying."""
|
|
26
|
+
if isinstance(exc, (json.JSONDecodeError, ValueError, TypeError, KeyError)):
|
|
27
|
+
return False
|
|
28
|
+
return is_retryable_exchange_request_error(exc)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
FNG_API = "https://api.alternative.me/fng/"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def classify_fear_greed(value: int) -> str:
|
|
14
|
+
if value <= 24:
|
|
15
|
+
return "Extreme Fear"
|
|
16
|
+
if value <= 44:
|
|
17
|
+
return "Fear"
|
|
18
|
+
if value <= 55:
|
|
19
|
+
return "Neutral"
|
|
20
|
+
if value <= 74:
|
|
21
|
+
return "Greed"
|
|
22
|
+
return "Extreme Greed"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
value = int(row["value"])
|
|
27
|
+
ts = int(row["timestamp"])
|
|
28
|
+
return {
|
|
29
|
+
"timestamp": ts,
|
|
30
|
+
"value": value,
|
|
31
|
+
"classification": classify_fear_greed(value),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fetch_fear_greed(*, limit: int = 1, timeout: float = 15.0) -> list[dict[str, Any]]:
|
|
36
|
+
"""Fetch Crypto Fear & Greed Index rows from alternative.me."""
|
|
37
|
+
url = f"{FNG_API}?limit={max(1, limit)}"
|
|
38
|
+
req = urllib.request.Request(url, headers={"User-Agent": "alloc-context/0.1"})
|
|
39
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
40
|
+
payload = json.loads(resp.read().decode())
|
|
41
|
+
if not isinstance(payload, dict):
|
|
42
|
+
raise ValueError("invalid fear_greed payload")
|
|
43
|
+
rows = payload.get("data") or []
|
|
44
|
+
if not isinstance(rows, list):
|
|
45
|
+
raise ValueError("invalid fear_greed data")
|
|
46
|
+
return [_parse_row(row) for row in rows if isinstance(row, dict)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def upsert_fear_greed_rows(conn: sqlite3.Connection, rows: list[dict[str, Any]]) -> int:
|
|
50
|
+
fetched_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
51
|
+
count = 0
|
|
52
|
+
for row in rows:
|
|
53
|
+
conn.execute(
|
|
54
|
+
"""
|
|
55
|
+
INSERT INTO fear_greed(ts, value, classification, fetched_at)
|
|
56
|
+
VALUES (?, ?, ?, ?)
|
|
57
|
+
ON CONFLICT(ts) DO UPDATE SET
|
|
58
|
+
value=excluded.value,
|
|
59
|
+
classification=excluded.classification,
|
|
60
|
+
fetched_at=excluded.fetched_at
|
|
61
|
+
""",
|
|
62
|
+
(
|
|
63
|
+
str(int(row["timestamp"])),
|
|
64
|
+
int(row["value"]),
|
|
65
|
+
str(row["classification"]),
|
|
66
|
+
fetched_at,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
count += 1
|
|
70
|
+
return count
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def refresh_fear_greed(
|
|
74
|
+
conn: sqlite3.Connection,
|
|
75
|
+
*,
|
|
76
|
+
history_limit: int = 90,
|
|
77
|
+
timeout: float = 15.0,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Refresh recent F&G history into SQLite."""
|
|
80
|
+
try:
|
|
81
|
+
rows = fetch_fear_greed(limit=history_limit, timeout=timeout)
|
|
82
|
+
except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
|
|
83
|
+
conn.rollback()
|
|
84
|
+
return {"ok": False, "error": str(exc), "rows": 0}
|
|
85
|
+
if not rows:
|
|
86
|
+
return {"ok": False, "error": "empty_response", "rows": 0}
|
|
87
|
+
upserted = upsert_fear_greed_rows(conn, rows)
|
|
88
|
+
conn.commit()
|
|
89
|
+
return {"ok": True, "rows": upserted, "latest": rows[0]}
|