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
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
|
+
)
|
alloccontext/horizon.py
ADDED
|
@@ -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
|