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
alloccontext/config.py ADDED
@@ -0,0 +1,415 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ import yaml
10
+
11
+ from alloccontext.rollup.cluster_config import RollupConfig, load_rollup_config
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class PathsConfig:
16
+ db: Path
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PortfolioConfig:
21
+ target_allocations: dict[str, float]
22
+ rebalance_band: float
23
+ max_cash_risk_off: float
24
+ notes: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class HorizonConfig:
29
+ """Quarterly scope for stored history (default 90 days)."""
30
+
31
+ days: int
32
+
33
+
34
+ _KALSHI_ALLOWED_HOSTS = frozenset(
35
+ {
36
+ "api.elections.kalshi.com",
37
+ "trading-api.kalshi.com",
38
+ }
39
+ )
40
+
41
+
42
+ def _validate_kalshi_base_url(url: str) -> str:
43
+ normalized = url.strip().rstrip("/")
44
+ parsed = urlparse(normalized)
45
+ if parsed.scheme != "https":
46
+ raise ValueError(f"kalshi.base_url must use https, got {parsed.scheme!r}")
47
+ host = (parsed.hostname or "").lower()
48
+ if host not in _KALSHI_ALLOWED_HOSTS:
49
+ allowed = ", ".join(sorted(_KALSHI_ALLOWED_HOSTS))
50
+ raise ValueError(f"kalshi.base_url host {host!r} not allowed ({allowed})")
51
+ return normalized
52
+
53
+
54
+ DEFAULT_OPTIONAL_INGEST_SOURCES = frozenset(
55
+ {
56
+ "fred",
57
+ "finnhub",
58
+ "fmp",
59
+ "coingecko",
60
+ "coinmarketcap",
61
+ "sosovalue",
62
+ }
63
+ )
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class IngestConfig:
68
+ interval_minutes: int
69
+ sources: dict[str, bool]
70
+ optional_sources: frozenset[str]
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class KrakenConfig:
75
+ ohlc_interval_minutes: int
76
+ pairs: list[str]
77
+ retry_backoff_seconds: float
78
+ max_retries: int
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class SpotExchangeConfig:
83
+ enabled: bool
84
+ ohlc_interval_minutes: int
85
+ pairs: list[str]
86
+ retry_backoff_seconds: float
87
+ max_retries: int
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class ExchangesConfig:
92
+ primary: str
93
+ kraken: SpotExchangeConfig
94
+ coinbase: SpotExchangeConfig
95
+
96
+ def primary_spot(self) -> SpotExchangeConfig:
97
+ if self.primary == "kraken":
98
+ return self.kraken
99
+ if self.primary == "coinbase":
100
+ return self.coinbase
101
+ raise ValueError(f"unsupported primary exchange: {self.primary}")
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class KalshiSeriesConfig:
106
+ asset: str
107
+ series: str
108
+ cf_index: str
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class KalshiConfig:
113
+ use_api: bool
114
+ base_url: str
115
+ timeout_seconds: float
116
+ cf_history_max_age_minutes: int
117
+ series: list[KalshiSeriesConfig]
118
+ fallback_tactical_snapshot: Path | None
119
+ fallback_state: Path | None
120
+ fallback_daily_archive: Path | None
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class MacroConfig:
125
+ static_calendar: Path
126
+ countries: list[str]
127
+ min_impact: str
128
+ fetch_past_days: int
129
+ fetch_future_days: int
130
+ finnhub_enabled: bool
131
+ fmp_enabled: bool
132
+ timeout_seconds: float
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class EtfConfig:
137
+ assets: list[str]
138
+ sosovalue_enabled: bool
139
+ fallback_snapshot: Path | None
140
+ timeout_seconds: float
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class CoingeckoConfig:
145
+ coin_ids: list[str]
146
+ use_demo_key: bool
147
+ timeout_seconds: float
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class FredSeriesSpec:
152
+ id: str
153
+ label: str
154
+ category: str
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class FredConfig:
159
+ series: list[FredSeriesSpec]
160
+ lookback_days: int
161
+ timeout_seconds: float
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class CoinmarketcapConfig:
166
+ symbols: list[str]
167
+ timeout_seconds: float
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class AppConfig:
172
+ paths: PathsConfig
173
+ horizon: HorizonConfig
174
+ portfolio: PortfolioConfig
175
+ ingest: IngestConfig
176
+ kraken: KrakenConfig
177
+ exchanges: ExchangesConfig
178
+ kalshi: KalshiConfig
179
+ rollup: RollupConfig
180
+ macro: MacroConfig
181
+ etf: EtfConfig
182
+ coingecko: CoingeckoConfig
183
+ coinmarketcap: CoinmarketcapConfig
184
+ fred: FredConfig
185
+
186
+
187
+ def _path(value: str | None, fallback: str) -> Path:
188
+ return Path(value or fallback)
189
+
190
+
191
+ def _optional_ingest_sources(raw: Any) -> frozenset[str]:
192
+ if raw is None:
193
+ return DEFAULT_OPTIONAL_INGEST_SOURCES
194
+ if not isinstance(raw, (list, tuple)):
195
+ return DEFAULT_OPTIONAL_INGEST_SOURCES
196
+ return frozenset(str(item).strip() for item in raw if str(item).strip())
197
+
198
+
199
+ def _load_fred_series(catalog_path: Path) -> list[FredSeriesSpec]:
200
+ if not catalog_path.exists():
201
+ return []
202
+ raw = yaml.safe_load(catalog_path.read_text()) or {}
203
+ rows = raw.get("series") or []
204
+ specs: list[FredSeriesSpec] = []
205
+ for row in rows:
206
+ if not isinstance(row, dict):
207
+ continue
208
+ series_id = str(row.get("id") or "").strip()
209
+ if not series_id:
210
+ continue
211
+ specs.append(
212
+ FredSeriesSpec(
213
+ id=series_id,
214
+ label=str(row.get("label") or series_id),
215
+ category=str(row.get("category") or "macro"),
216
+ )
217
+ )
218
+ return specs
219
+
220
+
221
+ def _spot_fields(
222
+ raw: dict[str, Any],
223
+ *,
224
+ default_pairs: list[str],
225
+ ) -> dict[str, Any]:
226
+ return {
227
+ "ohlc_interval_minutes": int(raw.get("ohlc_interval_minutes") or 1440),
228
+ "pairs": [str(p) for p in (raw.get("pairs") or default_pairs)],
229
+ "retry_backoff_seconds": float(raw.get("retry_backoff_seconds") or 2.0),
230
+ "max_retries": int(raw.get("max_retries") or 3),
231
+ }
232
+
233
+
234
+ def _kraken_config_from_spot(spot: SpotExchangeConfig) -> KrakenConfig:
235
+ return KrakenConfig(
236
+ ohlc_interval_minutes=spot.ohlc_interval_minutes,
237
+ pairs=list(spot.pairs),
238
+ retry_backoff_seconds=spot.retry_backoff_seconds,
239
+ max_retries=spot.max_retries,
240
+ )
241
+
242
+
243
+ def _load_exchanges_config(
244
+ raw: dict[str, Any],
245
+ *,
246
+ kraken_raw: dict[str, Any],
247
+ ingest_sources: dict[str, bool],
248
+ ) -> ExchangesConfig:
249
+ exchanges_raw = raw.get("exchanges") or {}
250
+ if exchanges_raw:
251
+ kr_raw = exchanges_raw.get("kraken") or {}
252
+ kr_enabled = bool(kr_raw.get("enabled", ingest_sources.get("kraken", True)))
253
+ cb_raw = exchanges_raw.get("coinbase") or {}
254
+ cb_enabled = bool(cb_raw.get("enabled", ingest_sources.get("coinbase", False)))
255
+ primary = str(exchanges_raw.get("primary") or "kraken")
256
+ else:
257
+ kr_raw = kraken_raw
258
+ kr_enabled = bool(ingest_sources.get("kraken", True))
259
+ cb_raw = {}
260
+ cb_enabled = bool(ingest_sources.get("coinbase", False))
261
+ primary = "kraken"
262
+
263
+ kraken = SpotExchangeConfig(
264
+ enabled=kr_enabled,
265
+ **_spot_fields(kr_raw, default_pairs=["XBTUSD", "ETHUSD"]),
266
+ )
267
+ coinbase = SpotExchangeConfig(
268
+ enabled=cb_enabled,
269
+ **_spot_fields(cb_raw, default_pairs=["BTC-USD", "ETH-USD"]),
270
+ )
271
+ return ExchangesConfig(primary=primary, kraken=kraken, coinbase=coinbase)
272
+
273
+
274
+ def _resolve_config_path(path: str | Path | None) -> Path:
275
+ if path is not None:
276
+ return Path(path)
277
+ env_path = os.environ.get("ALLOC_CONTEXT_CONFIG", "").strip()
278
+ if env_path:
279
+ return Path(env_path)
280
+ local = Path("config/config.yaml")
281
+ if local.exists():
282
+ return local
283
+ return Path("config/config.example.yaml")
284
+
285
+
286
+ def load_config(path: str | Path | None = None) -> AppConfig:
287
+ config_path = _resolve_config_path(path)
288
+ raw: dict[str, Any] = {}
289
+ if config_path.exists():
290
+ raw = yaml.safe_load(config_path.read_text()) or {}
291
+
292
+ paths_raw = raw.get("paths") or {}
293
+ horizon_raw = raw.get("horizon") or {}
294
+ portfolio_raw = raw.get("portfolio") or {}
295
+ ingest_raw = raw.get("ingest") or {}
296
+ kraken_raw = raw.get("kraken") or {}
297
+ kalshi_raw = raw.get("kalshi") or {}
298
+ macro_raw = raw.get("macro") or {}
299
+ etf_raw = raw.get("etf") or {}
300
+ coingecko_raw = raw.get("coingecko") or {}
301
+ coinmarketcap_raw = raw.get("coinmarketcap") or {}
302
+ fred_raw = raw.get("fred") or {}
303
+
304
+ db_env = os.environ.get("ALLOC_CONTEXT_DB", "").strip()
305
+ db = _path(db_env or None, str(paths_raw.get("db") or "state/alloccontext.db"))
306
+
307
+ kalshi_fallback_tactical = kalshi_raw.get("fallback_tactical_snapshot")
308
+ kalshi_fallback_state = kalshi_raw.get("fallback_state")
309
+ kalshi_fallback_daily = kalshi_raw.get("fallback_daily_archive")
310
+ etf_fallback = etf_raw.get("fallback_snapshot")
311
+
312
+ ingest_sources = {
313
+ str(k): bool(v)
314
+ for k, v in (ingest_raw.get("sources") or {}).items()
315
+ }
316
+ exchanges = _load_exchanges_config(
317
+ raw,
318
+ kraken_raw=kraken_raw,
319
+ ingest_sources=ingest_sources,
320
+ )
321
+ kraken = _kraken_config_from_spot(exchanges.kraken)
322
+
323
+ return AppConfig(
324
+ paths=PathsConfig(
325
+ db=db,
326
+ ),
327
+ horizon=HorizonConfig(
328
+ days=int(horizon_raw.get("days") or 90),
329
+ ),
330
+ portfolio=PortfolioConfig(
331
+ target_allocations=dict(portfolio_raw.get("target_allocations") or {}),
332
+ rebalance_band=float(portfolio_raw.get("rebalance_band") or 0.15),
333
+ max_cash_risk_off=float(portfolio_raw.get("max_cash_risk_off") or 0.50),
334
+ notes=str(portfolio_raw.get("notes") or "").strip(),
335
+ ),
336
+ ingest=IngestConfig(
337
+ interval_minutes=int(ingest_raw.get("interval_minutes") or 60),
338
+ sources=ingest_sources,
339
+ optional_sources=_optional_ingest_sources(ingest_raw.get("optional_sources")),
340
+ ),
341
+ kraken=kraken,
342
+ exchanges=exchanges,
343
+ kalshi=KalshiConfig(
344
+ use_api=bool(kalshi_raw.get("use_api", True)),
345
+ base_url=_validate_kalshi_base_url(
346
+ str(
347
+ kalshi_raw.get("base_url")
348
+ or "https://api.elections.kalshi.com/trade-api/v2"
349
+ )
350
+ ),
351
+ timeout_seconds=float(kalshi_raw.get("timeout_seconds") or 20.0),
352
+ cf_history_max_age_minutes=int(kalshi_raw.get("cf_history_max_age_minutes") or 90),
353
+ series=[
354
+ KalshiSeriesConfig(
355
+ asset=str(row.get("asset") or ""),
356
+ series=str(row.get("series") or ""),
357
+ cf_index=str(row.get("cf_index") or ""),
358
+ )
359
+ for row in (kalshi_raw.get("series") or [])
360
+ if isinstance(row, dict) and row.get("series")
361
+ ]
362
+ or [
363
+ KalshiSeriesConfig(asset="BTC", series="KXBTCD", cf_index="BRTI"),
364
+ KalshiSeriesConfig(asset="ETH", series="KXETHD", cf_index="ETHUSD_RTI"),
365
+ ],
366
+ fallback_tactical_snapshot=(
367
+ Path(kalshi_fallback_tactical) if kalshi_fallback_tactical else None
368
+ ),
369
+ fallback_state=(
370
+ Path(kalshi_fallback_state) if kalshi_fallback_state else None
371
+ ),
372
+ fallback_daily_archive=(
373
+ Path(kalshi_fallback_daily) if kalshi_fallback_daily else None
374
+ ),
375
+ ),
376
+ rollup=load_rollup_config(raw),
377
+ macro=MacroConfig(
378
+ static_calendar=_path(
379
+ str(macro_raw.get("static_calendar") or "config/macro-calendar.yaml"),
380
+ "config/macro-calendar.yaml",
381
+ ),
382
+ countries=[str(c).upper() for c in (macro_raw.get("countries") or ["US"])],
383
+ min_impact=str(macro_raw.get("min_impact") or "medium").lower(),
384
+ fetch_past_days=int(macro_raw.get("fetch_past_days") or 7),
385
+ fetch_future_days=int(macro_raw.get("fetch_future_days") or 28),
386
+ finnhub_enabled=bool(macro_raw.get("finnhub_enabled", True)),
387
+ fmp_enabled=bool(macro_raw.get("fmp_enabled", False)),
388
+ timeout_seconds=float(macro_raw.get("timeout_seconds") or 20.0),
389
+ ),
390
+ etf=EtfConfig(
391
+ assets=[str(a).upper() for a in (etf_raw.get("assets") or ["BTC", "ETH"])],
392
+ sosovalue_enabled=bool(etf_raw.get("sosovalue_enabled", True)),
393
+ fallback_snapshot=Path(etf_fallback) if etf_fallback else None,
394
+ timeout_seconds=float(etf_raw.get("timeout_seconds") or 20.0),
395
+ ),
396
+ coingecko=CoingeckoConfig(
397
+ coin_ids=[str(c) for c in (coingecko_raw.get("coin_ids") or ["bitcoin", "ethereum"])],
398
+ use_demo_key=bool(coingecko_raw.get("use_demo_key", True)),
399
+ timeout_seconds=float(coingecko_raw.get("timeout_seconds") or 20.0),
400
+ ),
401
+ coinmarketcap=CoinmarketcapConfig(
402
+ symbols=[str(s).upper() for s in (coinmarketcap_raw.get("symbols") or ["BTC", "ETH"])],
403
+ timeout_seconds=float(coinmarketcap_raw.get("timeout_seconds") or 20.0),
404
+ ),
405
+ fred=FredConfig(
406
+ series=_load_fred_series(
407
+ _path(
408
+ str(fred_raw.get("series_catalog") or "config/fred-series.yaml"),
409
+ "config/fred-series.yaml",
410
+ )
411
+ ),
412
+ lookback_days=int(fred_raw.get("lookback_days") or 120),
413
+ timeout_seconds=float(fred_raw.get("timeout_seconds") or 20.0),
414
+ ),
415
+ )
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ QUARTERLY_DAYS = 90
6
+
7
+
8
+ def horizon_days(config) -> int:
9
+ return int(getattr(config.horizon, "days", QUARTERLY_DAYS))
10
+
11
+
12
+ def cutoff_unix(*, days: int, now: datetime | None = None) -> int:
13
+ now = now or datetime.now(timezone.utc)
14
+ return int((now - timedelta(days=max(1, days))).timestamp())
15
+
16
+
17
+ def cutoff_iso(*, days: int, now: datetime | None = None) -> str:
18
+ now = now or datetime.now(timezone.utc)
19
+ at = (now - timedelta(days=max(1, days))).replace(microsecond=0)
20
+ return at.isoformat()
21
+
22
+
23
+ def bars_within_horizon(
24
+ bars: list[dict[str, float]],
25
+ *,
26
+ days: int,
27
+ now: datetime | None = None,
28
+ ) -> list[dict[str, float]]:
29
+ floor = cutoff_unix(days=days, now=now)
30
+ return [bar for bar in bars if int(bar["time"]) >= floor]
@@ -0,0 +1 @@
1
+ """Data source ingestors. See docs/data-sources.md."""
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import urllib.error
5
+ import urllib.request
6
+
7
+ CF_BENCHMARKS_BASE = "https://www.cfbenchmarks.com/data/indices"
8
+ _USER_AGENT = "Mozilla/5.0 (compatible; alloc-context/0.1)"
9
+ _VALUE_PATTERN = re.compile(r'"value"\s*:\s*"?([0-9.]+)')
10
+
11
+
12
+ class CFBenchmarksPriceError(RuntimeError):
13
+ pass
14
+
15
+
16
+ def parse_index_value(html: str) -> float:
17
+ match = _VALUE_PATTERN.search(html)
18
+ if not match:
19
+ raise CFBenchmarksPriceError("Could not parse CF Benchmarks index value")
20
+ return float(match.group(1))
21
+
22
+
23
+ def fetch_index_price(index: str, *, timeout: float = 20.0) -> float:
24
+ url = f"{CF_BENCHMARKS_BASE}/{index}"
25
+ request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
26
+ try:
27
+ with urllib.request.urlopen(request, timeout=timeout) as response:
28
+ html = response.read().decode("utf-8", errors="replace")
29
+ except (urllib.error.URLError, TimeoutError) as exc:
30
+ raise CFBenchmarksPriceError(f"CF Benchmarks fetch failed for {index}: {exc}") from exc
31
+ return parse_index_value(html)
32
+
33
+
34
+ def fetch_prices(indices: list[str], *, timeout: float = 20.0) -> dict[str, float]:
35
+ out: dict[str, float] = {}
36
+ for index in indices:
37
+ out[index] = fetch_index_price(index, timeout=timeout)
38
+ return out
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import json
5
+ import sqlite3
6
+ from typing import Any
7
+
8
+ from alloccontext.store.meta import get_meta, set_meta
9
+
10
+ CF_HISTORY_META_KEY = "cf_price_history"
11
+
12
+
13
+ def _parse_ts(raw: str | None) -> dt.datetime | None:
14
+ if not raw:
15
+ return None
16
+ try:
17
+ parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
18
+ except ValueError:
19
+ return None
20
+ if parsed.tzinfo is None:
21
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
22
+ return parsed.astimezone(dt.timezone.utc)
23
+
24
+
25
+ def load_cf_history(conn: sqlite3.Connection) -> dict[str, list[dict[str, Any]]]:
26
+ raw = get_meta(conn, CF_HISTORY_META_KEY)
27
+ if not raw:
28
+ return {}
29
+ try:
30
+ parsed = json.loads(raw)
31
+ except json.JSONDecodeError:
32
+ return {}
33
+ if not isinstance(parsed, dict):
34
+ return {}
35
+ return {
36
+ str(key): [row for row in rows if isinstance(row, dict)]
37
+ for key, rows in parsed.items()
38
+ if isinstance(rows, list)
39
+ }
40
+
41
+
42
+ def save_cf_history(conn: sqlite3.Connection, history: dict[str, list[dict[str, Any]]]) -> None:
43
+ set_meta(conn, CF_HISTORY_META_KEY, json.dumps(history))
44
+
45
+
46
+ def record_cf_price_samples(
47
+ history: dict[str, list[dict[str, Any]]] | None,
48
+ prices: dict[str, float],
49
+ now: dt.datetime,
50
+ *,
51
+ max_age_minutes: float,
52
+ ) -> dict[str, list[dict[str, Any]]]:
53
+ out = {key: list(rows) for key, rows in (history or {}).items()}
54
+ cutoff = now.astimezone(dt.timezone.utc) - dt.timedelta(minutes=max_age_minutes)
55
+ stamp = now.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat()
56
+ for index, price in prices.items():
57
+ rows = list(out.get(index) or [])
58
+ rows.append({"at": stamp, "price": float(price)})
59
+ kept: list[dict[str, Any]] = []
60
+ for row in rows:
61
+ ts = _parse_ts(row.get("at"))
62
+ if ts is not None and ts >= cutoff:
63
+ kept.append(row)
64
+ out[index] = kept
65
+ return out