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,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
+ }