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,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from alloccontext.ingest.exchange_http import should_retry_exchange_attempt
|
|
13
|
+
|
|
14
|
+
KRAKEN_API = "https://api.kraken.com"
|
|
15
|
+
|
|
16
|
+
ASSET_TO_SYMBOL = {
|
|
17
|
+
"XXBT": "BTC",
|
|
18
|
+
"XBT": "BTC",
|
|
19
|
+
"XETH": "ETH",
|
|
20
|
+
"ETH": "ETH",
|
|
21
|
+
"ZUSD": "USD",
|
|
22
|
+
"USD": "USD",
|
|
23
|
+
"USDT": "USD",
|
|
24
|
+
"USDC": "USD",
|
|
25
|
+
"DAI": "USD",
|
|
26
|
+
"PYUSD": "USD",
|
|
27
|
+
"USDE": "USD",
|
|
28
|
+
"TUSD": "USD",
|
|
29
|
+
"USDD": "USD",
|
|
30
|
+
"GUSD": "USD",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
PAIR_TO_SYMBOL = {
|
|
34
|
+
"XBTUSD": "BTC",
|
|
35
|
+
"XXBTZUSD": "BTC",
|
|
36
|
+
"ETHUSD": "ETH",
|
|
37
|
+
"XETHZUSD": "ETH",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def pair_to_symbol(pair: str) -> str:
|
|
42
|
+
return PAIR_TO_SYMBOL.get(pair.upper(), pair.replace("USD", "").replace("XBT", "BTC"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _kraken_asset_base(asset: str) -> str:
|
|
46
|
+
return asset.split(".", 1)[0]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_kraken_balances(raw: dict[str, Any]) -> dict[str, float]:
|
|
50
|
+
balances: dict[str, float] = {"BTC": 0.0, "ETH": 0.0, "USD": 0.0}
|
|
51
|
+
for asset, amount in raw.items():
|
|
52
|
+
symbol = ASSET_TO_SYMBOL.get(_kraken_asset_base(asset))
|
|
53
|
+
if symbol:
|
|
54
|
+
balances[symbol] = balances.get(symbol, 0.0) + float(amount)
|
|
55
|
+
return balances
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cash_breakdown_from_raw(raw: dict[str, Any]) -> dict[str, float]:
|
|
59
|
+
breakdown: dict[str, float] = {}
|
|
60
|
+
for asset, amount in raw.items():
|
|
61
|
+
base = _kraken_asset_base(asset)
|
|
62
|
+
if ASSET_TO_SYMBOL.get(base) != "USD":
|
|
63
|
+
continue
|
|
64
|
+
value = float(amount)
|
|
65
|
+
if value <= 0:
|
|
66
|
+
continue
|
|
67
|
+
breakdown[base] = breakdown.get(base, 0.0) + value
|
|
68
|
+
return breakdown
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class KrakenError(Exception):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class KrakenClient:
|
|
76
|
+
"""Read-only Kraken REST client (balances, ticker, OHLC)."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
api_key: str = "",
|
|
81
|
+
api_secret: str = "",
|
|
82
|
+
*,
|
|
83
|
+
retry_backoff: float = 2.0,
|
|
84
|
+
max_retries: int = 3,
|
|
85
|
+
session: requests.Session | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.api_key = api_key.strip()
|
|
88
|
+
self.api_secret = api_secret.strip()
|
|
89
|
+
self.retry_backoff = retry_backoff
|
|
90
|
+
self.max_retries = max_retries
|
|
91
|
+
self.session = session or requests.Session()
|
|
92
|
+
|
|
93
|
+
def get_ticker(self, pair: str) -> dict[str, float]:
|
|
94
|
+
data = self._public("Ticker", {"pair": pair})
|
|
95
|
+
key = next(iter(data))
|
|
96
|
+
ticker = data[key]
|
|
97
|
+
return {
|
|
98
|
+
"last": float(ticker["c"][0]),
|
|
99
|
+
"bid": float(ticker["b"][0]),
|
|
100
|
+
"ask": float(ticker["a"][0]),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def get_ohlc(self, pair: str, interval: int = 1440) -> list[dict[str, float]]:
|
|
104
|
+
data = self._public("OHLC", {"pair": pair, "interval": interval})
|
|
105
|
+
key = next(k for k in data if k != "last")
|
|
106
|
+
candles: list[dict[str, float]] = []
|
|
107
|
+
for row in data[key]:
|
|
108
|
+
candles.append(
|
|
109
|
+
{
|
|
110
|
+
"time": float(row[0]),
|
|
111
|
+
"open": float(row[1]),
|
|
112
|
+
"high": float(row[2]),
|
|
113
|
+
"low": float(row[3]),
|
|
114
|
+
"close": float(row[4]),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
return candles
|
|
118
|
+
|
|
119
|
+
def get_balances_with_breakdown(
|
|
120
|
+
self,
|
|
121
|
+
) -> tuple[dict[str, float], dict[str, float]]:
|
|
122
|
+
if not self.api_key or not self.api_secret:
|
|
123
|
+
raise KrakenError("KRAKEN_API_KEY and KRAKEN_API_SECRET required for balances")
|
|
124
|
+
raw = self._private("Balance")
|
|
125
|
+
return normalize_kraken_balances(raw), cash_breakdown_from_raw(raw)
|
|
126
|
+
|
|
127
|
+
def _public(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
128
|
+
return self._request("GET", f"/0/public/{path}", params=params)
|
|
129
|
+
|
|
130
|
+
def _private(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
131
|
+
return self._request("POST", f"/0/private/{path}", data=params or {})
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _sign(private_path: str, payload: dict[str, Any], api_secret: str) -> str:
|
|
135
|
+
post_data = urllib.parse.urlencode(payload)
|
|
136
|
+
nonce = str(payload["nonce"])
|
|
137
|
+
encoded = (nonce + post_data).encode()
|
|
138
|
+
message = private_path.encode() + hashlib.sha256(encoded).digest()
|
|
139
|
+
secret = base64.b64decode(api_secret)
|
|
140
|
+
digest = hmac.new(secret, message, hashlib.sha512).digest()
|
|
141
|
+
return base64.b64encode(digest).decode()
|
|
142
|
+
|
|
143
|
+
def _request(
|
|
144
|
+
self,
|
|
145
|
+
method: str,
|
|
146
|
+
path: str,
|
|
147
|
+
*,
|
|
148
|
+
params: dict[str, Any] | None = None,
|
|
149
|
+
data: dict[str, Any] | None = None,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
url = KRAKEN_API + path
|
|
152
|
+
last_error: Exception | None = None
|
|
153
|
+
for attempt in range(self.max_retries):
|
|
154
|
+
try:
|
|
155
|
+
headers: dict[str, str] = {}
|
|
156
|
+
if path.startswith("/0/private"):
|
|
157
|
+
nonce = str(int(time.time() * 1000))
|
|
158
|
+
payload = {"nonce": nonce, **(data or {})}
|
|
159
|
+
headers = {
|
|
160
|
+
"API-Key": self.api_key,
|
|
161
|
+
"API-Sign": self._sign(path, payload, self.api_secret),
|
|
162
|
+
}
|
|
163
|
+
resp = self.session.post(url, data=payload, headers=headers, timeout=30)
|
|
164
|
+
else:
|
|
165
|
+
resp = self.session.get(url, params=params, timeout=30)
|
|
166
|
+
resp.raise_for_status()
|
|
167
|
+
body = resp.json()
|
|
168
|
+
if body.get("error"):
|
|
169
|
+
raise KrakenError("; ".join(body["error"]))
|
|
170
|
+
return body["result"]
|
|
171
|
+
except Exception as exc: # noqa: BLE001
|
|
172
|
+
last_error = exc
|
|
173
|
+
if attempt + 1 < self.max_retries and should_retry_exchange_attempt(exc):
|
|
174
|
+
time.sleep(self.retry_backoff * (attempt + 1))
|
|
175
|
+
continue
|
|
176
|
+
break
|
|
177
|
+
raise KrakenError(str(last_error)) from last_error
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from alloccontext.ingest.kraken_client import KrakenClient, pair_to_symbol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PortfolioSnapshot:
|
|
15
|
+
ts: str
|
|
16
|
+
nav_usd: float
|
|
17
|
+
cash_usd: float
|
|
18
|
+
btc_usd: float
|
|
19
|
+
eth_usd: float
|
|
20
|
+
btc_pct: float
|
|
21
|
+
eth_pct: float
|
|
22
|
+
cash_pct: float
|
|
23
|
+
prices: dict[str, float]
|
|
24
|
+
cash_breakdown: dict[str, float] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_kraken_credentials() -> tuple[str, str] | None:
|
|
28
|
+
api_key = os.environ.get("KRAKEN_API_KEY", "").strip()
|
|
29
|
+
api_secret = os.environ.get("KRAKEN_API_SECRET", "").strip()
|
|
30
|
+
if api_key and api_secret:
|
|
31
|
+
return api_key, api_secret
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_kraken_client(spot) -> KrakenClient:
|
|
36
|
+
creds = load_kraken_credentials()
|
|
37
|
+
return KrakenClient(
|
|
38
|
+
api_key=creds[0] if creds else "",
|
|
39
|
+
api_secret=creds[1] if creds else "",
|
|
40
|
+
retry_backoff=spot.retry_backoff_seconds,
|
|
41
|
+
max_retries=spot.max_retries,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def portfolio_from_balances(
|
|
46
|
+
balances: dict[str, float],
|
|
47
|
+
prices: dict[str, float],
|
|
48
|
+
*,
|
|
49
|
+
cash_breakdown: dict[str, float] | None = None,
|
|
50
|
+
) -> PortfolioSnapshot:
|
|
51
|
+
btc_usd = balances.get("BTC", 0.0) * prices.get("BTC", 0.0)
|
|
52
|
+
eth_usd = balances.get("ETH", 0.0) * prices.get("ETH", 0.0)
|
|
53
|
+
cash_usd = balances.get("USD", 0.0)
|
|
54
|
+
total = btc_usd + eth_usd + cash_usd
|
|
55
|
+
if total <= 0:
|
|
56
|
+
return PortfolioSnapshot(
|
|
57
|
+
ts="",
|
|
58
|
+
nav_usd=0.0,
|
|
59
|
+
cash_usd=0.0,
|
|
60
|
+
btc_usd=0.0,
|
|
61
|
+
eth_usd=0.0,
|
|
62
|
+
btc_pct=0.0,
|
|
63
|
+
eth_pct=0.0,
|
|
64
|
+
cash_pct=0.0,
|
|
65
|
+
prices=dict(prices),
|
|
66
|
+
cash_breakdown=dict(cash_breakdown or {}),
|
|
67
|
+
)
|
|
68
|
+
return PortfolioSnapshot(
|
|
69
|
+
ts="",
|
|
70
|
+
nav_usd=total,
|
|
71
|
+
cash_usd=cash_usd,
|
|
72
|
+
btc_usd=btc_usd,
|
|
73
|
+
eth_usd=eth_usd,
|
|
74
|
+
btc_pct=btc_usd / total,
|
|
75
|
+
eth_pct=eth_usd / total,
|
|
76
|
+
cash_pct=cash_usd / total,
|
|
77
|
+
prices=dict(prices),
|
|
78
|
+
cash_breakdown=dict(cash_breakdown or {}),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fetch_portfolio_snapshot(client: KrakenClient, spot) -> PortfolioSnapshot:
|
|
83
|
+
prices: dict[str, float] = {}
|
|
84
|
+
for pair in spot.pairs:
|
|
85
|
+
symbol = pair_to_symbol(pair)
|
|
86
|
+
prices[symbol] = client.get_ticker(pair)["last"]
|
|
87
|
+
balances, cash_breakdown = client.get_balances_with_breakdown()
|
|
88
|
+
snap = portfolio_from_balances(balances, prices, cash_breakdown=cash_breakdown)
|
|
89
|
+
snap.ts = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
90
|
+
return snap
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def upsert_portfolio_snapshot(conn: sqlite3.Connection, snap: PortfolioSnapshot) -> None:
|
|
94
|
+
allocation = {
|
|
95
|
+
"BTC": snap.btc_pct,
|
|
96
|
+
"ETH": snap.eth_pct,
|
|
97
|
+
"CASH": snap.cash_pct,
|
|
98
|
+
"btc_usd": snap.btc_usd,
|
|
99
|
+
"eth_usd": snap.eth_usd,
|
|
100
|
+
"prices": snap.prices,
|
|
101
|
+
"cash_breakdown": snap.cash_breakdown,
|
|
102
|
+
}
|
|
103
|
+
raw = {**asdict(snap), "allocation": allocation}
|
|
104
|
+
conn.execute(
|
|
105
|
+
"""
|
|
106
|
+
INSERT INTO portfolio_snapshots(ts, nav_usd, cash_usd, allocation_json, raw_json)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?)
|
|
108
|
+
ON CONFLICT(ts) DO UPDATE SET
|
|
109
|
+
nav_usd=excluded.nav_usd,
|
|
110
|
+
cash_usd=excluded.cash_usd,
|
|
111
|
+
allocation_json=excluded.allocation_json,
|
|
112
|
+
raw_json=excluded.raw_json
|
|
113
|
+
""",
|
|
114
|
+
(
|
|
115
|
+
snap.ts,
|
|
116
|
+
snap.nav_usd,
|
|
117
|
+
snap.cash_usd,
|
|
118
|
+
json.dumps(allocation, sort_keys=True),
|
|
119
|
+
json.dumps(raw, sort_keys=True),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def upsert_market_bars(
|
|
125
|
+
conn: sqlite3.Connection,
|
|
126
|
+
*,
|
|
127
|
+
pair: str,
|
|
128
|
+
interval_minutes: int,
|
|
129
|
+
bars: list[dict[str, float]],
|
|
130
|
+
) -> int:
|
|
131
|
+
count = 0
|
|
132
|
+
for bar in bars:
|
|
133
|
+
conn.execute(
|
|
134
|
+
"""
|
|
135
|
+
INSERT INTO market_bars(
|
|
136
|
+
pair, interval_minutes, bar_ts, open, high, low, close
|
|
137
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
138
|
+
ON CONFLICT(pair, interval_minutes, bar_ts) DO UPDATE SET
|
|
139
|
+
open=excluded.open,
|
|
140
|
+
high=excluded.high,
|
|
141
|
+
low=excluded.low,
|
|
142
|
+
close=excluded.close
|
|
143
|
+
""",
|
|
144
|
+
(
|
|
145
|
+
pair,
|
|
146
|
+
interval_minutes,
|
|
147
|
+
int(bar["time"]),
|
|
148
|
+
float(bar["open"]),
|
|
149
|
+
float(bar["high"]),
|
|
150
|
+
float(bar["low"]),
|
|
151
|
+
float(bar["close"]),
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
count += 1
|
|
155
|
+
return count
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def refresh_kraken(conn: sqlite3.Connection, config) -> dict[str, Any]:
|
|
159
|
+
from alloccontext.ingest.exchange.registry import refresh_exchange
|
|
160
|
+
|
|
161
|
+
return refresh_exchange(conn, config, "kraken")
|
|
@@ -0,0 +1,310 @@
|
|
|
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
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from alloccontext.ingest.macro_normalize import (
|
|
15
|
+
calendar_row_date_time,
|
|
16
|
+
impact_meets_minimum,
|
|
17
|
+
normalize_impact,
|
|
18
|
+
normalized_event,
|
|
19
|
+
parse_event_ts,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
FINNHUB_URL = "https://finnhub.io/api/v1/calendar/economic"
|
|
23
|
+
FMP_URL = "https://financialmodelingprep.com/stable/economic-calendar"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_static_events(path: Path, *, countries: set[str], min_impact: str) -> list[dict[str, Any]]:
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return []
|
|
29
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
30
|
+
tz_name = str(raw.get("timezone") or "America/New_York")
|
|
31
|
+
rows: list[dict[str, Any]] = []
|
|
32
|
+
for item in raw.get("events") or []:
|
|
33
|
+
if not isinstance(item, dict):
|
|
34
|
+
continue
|
|
35
|
+
country = str(item.get("country") or "US").upper()
|
|
36
|
+
if countries and country not in countries:
|
|
37
|
+
continue
|
|
38
|
+
impact = normalize_impact(str(item.get("impact") or "high"))
|
|
39
|
+
if not impact_meets_minimum(impact, min_impact):
|
|
40
|
+
continue
|
|
41
|
+
event_date = str(item.get("date") or "").strip()
|
|
42
|
+
if not event_date:
|
|
43
|
+
continue
|
|
44
|
+
event_ts = parse_event_ts(
|
|
45
|
+
date=event_date,
|
|
46
|
+
time=str(item.get("time") or "00:00"),
|
|
47
|
+
tz_name=tz_name,
|
|
48
|
+
)
|
|
49
|
+
rows.append(
|
|
50
|
+
normalized_event(
|
|
51
|
+
source="static",
|
|
52
|
+
country=country,
|
|
53
|
+
name=str(item.get("name") or "Macro event"),
|
|
54
|
+
event_ts=event_ts,
|
|
55
|
+
impact=impact,
|
|
56
|
+
category=str(item.get("category") or "macro"),
|
|
57
|
+
raw=dict(item),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fetch_finnhub_events(
|
|
64
|
+
*,
|
|
65
|
+
start: date,
|
|
66
|
+
end: date,
|
|
67
|
+
api_key: str,
|
|
68
|
+
countries: set[str],
|
|
69
|
+
min_impact: str,
|
|
70
|
+
timeout: float = 20.0,
|
|
71
|
+
) -> list[dict[str, Any]]:
|
|
72
|
+
url = (
|
|
73
|
+
f"{FINNHUB_URL}?from={start.isoformat()}&to={end.isoformat()}"
|
|
74
|
+
f"&token={api_key}"
|
|
75
|
+
)
|
|
76
|
+
req = urllib.request.Request(url, headers={"User-Agent": "alloc-context/0.1"})
|
|
77
|
+
try:
|
|
78
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
79
|
+
payload = json.loads(resp.read().decode())
|
|
80
|
+
except urllib.error.HTTPError as exc:
|
|
81
|
+
from alloccontext.ingest.http_errors import http_error_message
|
|
82
|
+
|
|
83
|
+
raise ValueError(
|
|
84
|
+
http_error_message(exc, context="finnhub economic calendar")
|
|
85
|
+
) from exc
|
|
86
|
+
calendar = payload.get("economicCalendar") if isinstance(payload, dict) else payload
|
|
87
|
+
if not isinstance(calendar, list):
|
|
88
|
+
raise ValueError("invalid finnhub economic calendar payload")
|
|
89
|
+
|
|
90
|
+
rows: list[dict[str, Any]] = []
|
|
91
|
+
for item in calendar:
|
|
92
|
+
if not isinstance(item, dict):
|
|
93
|
+
continue
|
|
94
|
+
country = str(item.get("country") or "").upper()
|
|
95
|
+
if countries and country not in countries:
|
|
96
|
+
continue
|
|
97
|
+
impact = normalize_impact(str(item.get("impact") or "medium"))
|
|
98
|
+
if not impact_meets_minimum(impact, min_impact):
|
|
99
|
+
continue
|
|
100
|
+
when = calendar_row_date_time(item)
|
|
101
|
+
if when is None:
|
|
102
|
+
continue
|
|
103
|
+
event_date, event_time = when
|
|
104
|
+
event_ts = parse_event_ts(
|
|
105
|
+
date=event_date,
|
|
106
|
+
time=event_time,
|
|
107
|
+
tz_name="America/New_York",
|
|
108
|
+
)
|
|
109
|
+
rows.append(
|
|
110
|
+
normalized_event(
|
|
111
|
+
source="finnhub",
|
|
112
|
+
country=country or "US",
|
|
113
|
+
name=str(item.get("event") or "Economic release"),
|
|
114
|
+
event_ts=event_ts,
|
|
115
|
+
impact=impact,
|
|
116
|
+
category="economic",
|
|
117
|
+
actual=item.get("actual"),
|
|
118
|
+
estimate=item.get("estimate"),
|
|
119
|
+
previous=item.get("prev"),
|
|
120
|
+
unit=item.get("unit"),
|
|
121
|
+
raw=item,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return rows
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fetch_fmp_events(
|
|
128
|
+
*,
|
|
129
|
+
start: date,
|
|
130
|
+
end: date,
|
|
131
|
+
api_key: str,
|
|
132
|
+
countries: set[str],
|
|
133
|
+
min_impact: str,
|
|
134
|
+
timeout: float = 20.0,
|
|
135
|
+
) -> list[dict[str, Any]]:
|
|
136
|
+
url = (
|
|
137
|
+
f"{FMP_URL}?from={start.isoformat()}&to={end.isoformat()}"
|
|
138
|
+
f"&apikey={api_key}"
|
|
139
|
+
)
|
|
140
|
+
req = urllib.request.Request(url, headers={"User-Agent": "alloc-context/0.1"})
|
|
141
|
+
try:
|
|
142
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
143
|
+
payload = json.loads(resp.read().decode())
|
|
144
|
+
except urllib.error.HTTPError as exc:
|
|
145
|
+
from alloccontext.ingest.http_errors import http_error_message
|
|
146
|
+
|
|
147
|
+
raise ValueError(http_error_message(exc, context="fmp economic calendar")) from exc
|
|
148
|
+
if not isinstance(payload, list):
|
|
149
|
+
raise ValueError("invalid fmp economic calendar payload")
|
|
150
|
+
|
|
151
|
+
rows: list[dict[str, Any]] = []
|
|
152
|
+
for item in payload:
|
|
153
|
+
if not isinstance(item, dict):
|
|
154
|
+
continue
|
|
155
|
+
country = str(item.get("country") or item.get("currency") or "US").upper()
|
|
156
|
+
if len(country) == 3 and country in {"USD", "EUR", "GBP"}:
|
|
157
|
+
country = {"USD": "US", "EUR": "EU", "GBP": "UK"}.get(country, country)
|
|
158
|
+
if countries and country not in countries:
|
|
159
|
+
continue
|
|
160
|
+
impact = normalize_impact(str(item.get("impact") or item.get("importance") or "medium"))
|
|
161
|
+
if not impact_meets_minimum(impact, min_impact):
|
|
162
|
+
continue
|
|
163
|
+
event_date = str(item.get("date") or "").strip()[:10]
|
|
164
|
+
if not event_date:
|
|
165
|
+
continue
|
|
166
|
+
event_ts = parse_event_ts(
|
|
167
|
+
date=event_date,
|
|
168
|
+
time=str(item.get("time") or item.get("releaseTime") or "00:00:00"),
|
|
169
|
+
tz_name="America/New_York",
|
|
170
|
+
)
|
|
171
|
+
rows.append(
|
|
172
|
+
normalized_event(
|
|
173
|
+
source="fmp",
|
|
174
|
+
country=country or "US",
|
|
175
|
+
name=str(item.get("event") or item.get("name") or "Economic release"),
|
|
176
|
+
event_ts=event_ts,
|
|
177
|
+
impact=impact,
|
|
178
|
+
category="economic",
|
|
179
|
+
actual=item.get("actual"),
|
|
180
|
+
estimate=item.get("estimate") or item.get("forecast"),
|
|
181
|
+
previous=item.get("previous") or item.get("prior"),
|
|
182
|
+
unit=item.get("unit"),
|
|
183
|
+
raw=item,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return rows
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def merge_events(*feeds: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
190
|
+
"""Prefer earlier feeds on duplicate day+name (static wins over API)."""
|
|
191
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
192
|
+
for feed in feeds:
|
|
193
|
+
for event in feed:
|
|
194
|
+
key = f"{event['event_ts'][:10]}:{event['name'].lower()}"
|
|
195
|
+
if key not in merged:
|
|
196
|
+
merged[key] = event
|
|
197
|
+
return sorted(merged.values(), key=lambda row: row["event_ts"])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def upsert_macro_events(conn: sqlite3.Connection, events: list[dict[str, Any]]) -> int:
|
|
201
|
+
fetched_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
202
|
+
count = 0
|
|
203
|
+
for event in events:
|
|
204
|
+
conn.execute(
|
|
205
|
+
"""
|
|
206
|
+
INSERT INTO macro_events(
|
|
207
|
+
event_id, event_ts, country, name, impact, category,
|
|
208
|
+
actual, estimate, previous, unit, source, raw_json, fetched_at
|
|
209
|
+
)
|
|
210
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
211
|
+
ON CONFLICT(event_id) DO UPDATE SET
|
|
212
|
+
event_ts = excluded.event_ts,
|
|
213
|
+
country = excluded.country,
|
|
214
|
+
name = excluded.name,
|
|
215
|
+
impact = excluded.impact,
|
|
216
|
+
category = excluded.category,
|
|
217
|
+
actual = excluded.actual,
|
|
218
|
+
estimate = excluded.estimate,
|
|
219
|
+
previous = excluded.previous,
|
|
220
|
+
unit = excluded.unit,
|
|
221
|
+
source = excluded.source,
|
|
222
|
+
raw_json = excluded.raw_json,
|
|
223
|
+
fetched_at = excluded.fetched_at
|
|
224
|
+
""",
|
|
225
|
+
(
|
|
226
|
+
event["event_id"],
|
|
227
|
+
event["event_ts"],
|
|
228
|
+
event["country"],
|
|
229
|
+
event["name"],
|
|
230
|
+
event["impact"],
|
|
231
|
+
event.get("category"),
|
|
232
|
+
_json_scalar(event.get("actual")),
|
|
233
|
+
_json_scalar(event.get("estimate")),
|
|
234
|
+
_json_scalar(event.get("previous")),
|
|
235
|
+
event.get("unit"),
|
|
236
|
+
event["source"],
|
|
237
|
+
json.dumps(event.get("raw") or {}),
|
|
238
|
+
fetched_at,
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
count += 1
|
|
242
|
+
return count
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _json_scalar(value: Any) -> str | None:
|
|
246
|
+
if value is None:
|
|
247
|
+
return None
|
|
248
|
+
return str(value)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def refresh_macro_calendar(conn: sqlite3.Connection, config) -> dict[str, Any]:
|
|
252
|
+
macro = config.macro
|
|
253
|
+
countries = {c.upper() for c in macro.countries}
|
|
254
|
+
today = datetime.now(timezone.utc).date()
|
|
255
|
+
start = today - timedelta(days=macro.fetch_past_days)
|
|
256
|
+
end = today + timedelta(days=macro.fetch_future_days)
|
|
257
|
+
|
|
258
|
+
feeds: list[list[dict[str, Any]]] = []
|
|
259
|
+
feed_errors: dict[str, str] = {}
|
|
260
|
+
|
|
261
|
+
static_path = Path(macro.static_calendar)
|
|
262
|
+
static_rows = load_static_events(
|
|
263
|
+
static_path, countries=countries, min_impact=macro.min_impact
|
|
264
|
+
)
|
|
265
|
+
feeds.append(static_rows)
|
|
266
|
+
|
|
267
|
+
finnhub_key = os.environ.get("FINNHUB_API_KEY")
|
|
268
|
+
if macro.finnhub_enabled and finnhub_key:
|
|
269
|
+
try:
|
|
270
|
+
feeds.append(
|
|
271
|
+
fetch_finnhub_events(
|
|
272
|
+
start=start,
|
|
273
|
+
end=end,
|
|
274
|
+
api_key=finnhub_key,
|
|
275
|
+
countries=countries,
|
|
276
|
+
min_impact=macro.min_impact,
|
|
277
|
+
timeout=macro.timeout_seconds,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
|
|
281
|
+
feed_errors["finnhub"] = str(exc)
|
|
282
|
+
|
|
283
|
+
fmp_key = os.environ.get("FMP_API_KEY")
|
|
284
|
+
if macro.fmp_enabled and fmp_key:
|
|
285
|
+
try:
|
|
286
|
+
feeds.append(
|
|
287
|
+
fetch_fmp_events(
|
|
288
|
+
start=start,
|
|
289
|
+
end=end,
|
|
290
|
+
api_key=fmp_key,
|
|
291
|
+
countries=countries,
|
|
292
|
+
min_impact=macro.min_impact,
|
|
293
|
+
timeout=macro.timeout_seconds,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
|
|
297
|
+
feed_errors["fmp"] = str(exc)
|
|
298
|
+
|
|
299
|
+
merged = merge_events(*feeds)
|
|
300
|
+
upserted = upsert_macro_events(conn, merged)
|
|
301
|
+
conn.commit()
|
|
302
|
+
|
|
303
|
+
ok = upserted > 0 or bool(static_rows)
|
|
304
|
+
return {
|
|
305
|
+
"ok": ok,
|
|
306
|
+
"rows": upserted,
|
|
307
|
+
"static_rows": len(static_rows),
|
|
308
|
+
"feed_errors": feed_errors,
|
|
309
|
+
"sources": sorted({row["source"] for row in merged}),
|
|
310
|
+
}
|