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,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,4 @@
1
+ from alloccontext.ingest.exchange.registry import refresh_exchange
2
+ from alloccontext.ingest.exchange.types import ExchangeId
3
+
4
+ __all__ = ["ExchangeId", "refresh_exchange"]
@@ -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,5 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ ExchangeId = Literal["kraken", "coinbase"]
@@ -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]}