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,118 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Literal
7
+
8
+ from alloccontext.store.jsonutil import canonical_json
9
+
10
+ from alloccontext.rollup.cluster_config import RollupConfig, load_rollup_config
11
+ from alloccontext.rollup.delta import build_delta_context
12
+ from alloccontext.rollup.macro import build_macro_context
13
+ from alloccontext.rollup.portfolio import build_market_context, build_portfolio_context
14
+ from alloccontext.rollup.regime import build_regime_context
15
+ from alloccontext.rollup.sentiment import build_sentiment_context
16
+
17
+ Scope = Literal["daily", "weekly"]
18
+
19
+
20
+ def _load_prior_context(
21
+ conn: sqlite3.Connection,
22
+ *,
23
+ scope: Scope,
24
+ prior_as_of: str | None,
25
+ ) -> dict[str, Any] | None:
26
+ if not prior_as_of:
27
+ return None
28
+ row = conn.execute(
29
+ """
30
+ SELECT context_json FROM context_snapshots
31
+ WHERE scope = ? AND as_of = ?
32
+ """,
33
+ (scope, prior_as_of),
34
+ ).fetchone()
35
+ if row is None:
36
+ return None
37
+ try:
38
+ return json.loads(row["context_json"])
39
+ except (TypeError, json.JSONDecodeError):
40
+ return None
41
+
42
+
43
+ def _save_context_snapshot(
44
+ conn: sqlite3.Connection,
45
+ *,
46
+ scope: Scope,
47
+ as_of: str,
48
+ context: dict[str, Any],
49
+ ) -> None:
50
+ conn.execute(
51
+ """
52
+ INSERT INTO context_snapshots(scope, as_of, context_json)
53
+ VALUES (?, ?, ?)
54
+ ON CONFLICT(scope, as_of) DO UPDATE SET
55
+ context_json = excluded.context_json
56
+ """,
57
+ (scope, as_of, canonical_json(context)),
58
+ )
59
+ conn.commit()
60
+
61
+
62
+ def build_context_bundle(
63
+ conn,
64
+ config,
65
+ *,
66
+ scope: Scope,
67
+ rollup: RollupConfig,
68
+ as_of: datetime | None = None,
69
+ save_snapshot: bool = False,
70
+ ) -> dict[str, Any]:
71
+ now = (as_of or datetime.now(timezone.utc)).replace(microsecond=0)
72
+
73
+ prior_row = conn.execute(
74
+ """
75
+ SELECT as_of FROM context_snapshots
76
+ WHERE scope = ? AND as_of < ?
77
+ ORDER BY as_of DESC LIMIT 1
78
+ """,
79
+ (scope, now.isoformat()),
80
+ ).fetchone()
81
+ prior_as_of = prior_row["as_of"] if prior_row else None
82
+ prior_context = _load_prior_context(conn, scope=scope, prior_as_of=prior_as_of)
83
+
84
+ portfolio = build_portfolio_context(conn, config)
85
+ market = build_market_context(conn, config)
86
+ sentiment = build_sentiment_context(conn, config, rollup, now=now)
87
+ macro = build_macro_context(conn, config, now=now, scope=scope)
88
+ delta = build_delta_context(
89
+ conn,
90
+ now=now,
91
+ portfolio=portfolio,
92
+ sentiment=sentiment,
93
+ market=market,
94
+ prior_context=prior_context,
95
+ )
96
+
97
+ bundle = {
98
+ "bundle_id": f"{scope}:{now.isoformat()}",
99
+ "scope": scope,
100
+ "as_of": now.isoformat(),
101
+ "prior_as_of": prior_as_of,
102
+ "horizon_days": config.horizon.days,
103
+ "portfolio": portfolio,
104
+ "market": market,
105
+ "sentiment": sentiment,
106
+ "macro": macro,
107
+ "delta": delta,
108
+ "regime": build_regime_context(
109
+ portfolio=portfolio,
110
+ sentiment=sentiment,
111
+ delta=delta,
112
+ prior_as_of=prior_as_of,
113
+ max_cash_risk_off=config.portfolio.max_cash_risk_off,
114
+ ),
115
+ }
116
+ if save_snapshot:
117
+ _save_context_snapshot(conn, scope=scope, as_of=bundle["as_of"], context=bundle)
118
+ return bundle
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any
6
+
7
+
8
+ def _asset_price(context: dict[str, Any] | None, symbol: str) -> float | None:
9
+ if not context:
10
+ return None
11
+ market = context.get("market") or {}
12
+ if not market.get("available"):
13
+ return None
14
+ assets = market.get("assets") or {}
15
+ block = assets.get(symbol.lower()) or assets.get(symbol)
16
+ if not isinstance(block, dict):
17
+ return None
18
+ price = block.get("price_usd")
19
+ return float(price) if price is not None else None
20
+
21
+
22
+ def _pct_change(current: float | None, prior: float | None) -> float | None:
23
+ if current is None or prior is None or prior == 0:
24
+ return None
25
+ return round((current - prior) / prior * 100, 2)
26
+
27
+
28
+ def build_delta_context(
29
+ conn: sqlite3.Connection,
30
+ *,
31
+ now: datetime,
32
+ portfolio: dict[str, Any],
33
+ sentiment: dict[str, Any],
34
+ market: dict[str, Any],
35
+ prior_context: dict[str, Any] | None = None,
36
+ ) -> dict[str, Any]:
37
+ delta: dict[str, Any] = {"available": True, "notable_shifts": []}
38
+
39
+ fg = sentiment.get("fear_greed") if sentiment.get("available") else None
40
+ if fg:
41
+ prior_fg_value = None
42
+ if prior_context:
43
+ prior_sentiment = prior_context.get("sentiment") or {}
44
+ prior_fg = prior_sentiment.get("fear_greed") or {}
45
+ if prior_fg.get("value") is not None:
46
+ prior_fg_value = int(prior_fg["value"])
47
+ if prior_fg_value is None:
48
+ from alloccontext.rollup.fear_greed import fear_greed_at_or_before
49
+
50
+ day_ago = int((now - timedelta(days=1)).timestamp())
51
+ prior_row = fear_greed_at_or_before(conn, at_ts=day_ago)
52
+ if prior_row:
53
+ prior_fg_value = int(prior_row["value"])
54
+
55
+ if prior_fg_value is not None:
56
+ change = int(fg["value"]) - prior_fg_value
57
+ delta["fear_greed_change"] = change
58
+ if abs(change) >= 5:
59
+ delta["notable_shifts"].append(
60
+ f"F&G {prior_fg_value} → {fg['value']} ({change:+d})"
61
+ )
62
+
63
+ if portfolio.get("available"):
64
+ nav = portfolio.get("nav_usd")
65
+ prior_nav = None
66
+ if prior_context:
67
+ prior_portfolio = prior_context.get("portfolio") or {}
68
+ if prior_portfolio.get("nav_usd") is not None:
69
+ prior_nav = float(prior_portfolio["nav_usd"])
70
+ if prior_nav is not None and nav is not None:
71
+ pnl = round(float(nav) - prior_nav, 2)
72
+ delta["portfolio_nav_change_usd"] = pnl
73
+ if abs(pnl) >= 100:
74
+ delta["notable_shifts"].append(
75
+ f"Portfolio Δ ${pnl:+.2f} since prior snapshot"
76
+ )
77
+ else:
78
+ pnl = (portfolio.get("pnl_usd") or {}).get("since_prior_snapshot")
79
+ if pnl is not None:
80
+ delta["portfolio_nav_change_usd"] = pnl
81
+ if abs(float(pnl)) >= 100:
82
+ delta["notable_shifts"].append(
83
+ f"Portfolio Δ ${pnl:+.2f} vs prior snapshot"
84
+ )
85
+
86
+ market_changes: dict[str, float | None] = {}
87
+ for symbol in ("btc", "eth"):
88
+ current = _asset_price(market, symbol)
89
+ prior = _asset_price(prior_context, symbol)
90
+ change = _pct_change(current, prior)
91
+ if change is not None:
92
+ market_changes[f"{symbol}_change_pct_since_prior"] = change
93
+ if abs(change) >= 2:
94
+ delta["notable_shifts"].append(
95
+ f"{symbol.upper()} {change:+.2f}% since prior snapshot"
96
+ )
97
+ if market_changes:
98
+ delta["market"] = market_changes
99
+
100
+ if not delta.get("notable_shifts"):
101
+ has_metrics = (
102
+ delta.get("fear_greed_change") is not None
103
+ or delta.get("portfolio_nav_change_usd") is not None
104
+ or bool(market_changes)
105
+ )
106
+ if not has_metrics:
107
+ delta["available"] = False
108
+
109
+ return delta
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Any, Literal
5
+
6
+ Scope = Literal["daily", "weekly"]
7
+
8
+
9
+ def _sum_flows(rows) -> float | None:
10
+ values = [float(row["net_flow_usd"]) for row in rows if row["net_flow_usd"] is not None]
11
+ if not values:
12
+ return None
13
+ return round(sum(values), 2)
14
+
15
+
16
+ def _latest_flow_row(conn, asset: str):
17
+ return conn.execute(
18
+ """
19
+ SELECT flow_date, net_flow_usd, total_net_assets_usd, cum_net_inflow_usd, source
20
+ FROM etf_flow_days
21
+ WHERE asset = ?
22
+ ORDER BY flow_date DESC
23
+ LIMIT 1
24
+ """,
25
+ (asset,),
26
+ ).fetchone()
27
+
28
+
29
+ def _flows_since(conn, asset: str, since_date: str) -> list:
30
+ return conn.execute(
31
+ """
32
+ SELECT flow_date, net_flow_usd
33
+ FROM etf_flow_days
34
+ WHERE asset = ? AND flow_date >= ?
35
+ ORDER BY flow_date ASC
36
+ """,
37
+ (asset, since_date),
38
+ ).fetchall()
39
+
40
+
41
+ def _ticker_breakdown(conn, asset: str, flow_date: str | None) -> dict[str, float]:
42
+ if not flow_date:
43
+ return {}
44
+ rows = conn.execute(
45
+ """
46
+ SELECT ticker, net_flow_usd
47
+ FROM etf_ticker_flows
48
+ WHERE asset = ? AND flow_date = ?
49
+ ORDER BY ABS(COALESCE(net_flow_usd, 0)) DESC
50
+ """,
51
+ (asset, flow_date),
52
+ ).fetchall()
53
+ out: dict[str, float] = {}
54
+ for row in rows:
55
+ if row["net_flow_usd"] is None:
56
+ continue
57
+ out[str(row["ticker"])] = round(float(row["net_flow_usd"]), 2)
58
+ return out
59
+
60
+
61
+ def build_etf_asset_context(conn, *, asset: str, now: datetime, scope: Scope) -> dict[str, Any]:
62
+ latest = _latest_flow_row(conn, asset)
63
+ if latest is None:
64
+ return {"available": False, "reason": "no_data"}
65
+
66
+ window_days = 1 if scope == "daily" else 7
67
+ since = (now.date() - timedelta(days=window_days - 1)).isoformat()
68
+ window_rows = _flows_since(conn, asset, since)
69
+ net_window = _sum_flows(window_rows)
70
+
71
+ result: dict[str, Any] = {
72
+ "available": True,
73
+ "as_of_date": latest["flow_date"],
74
+ "source": latest["source"],
75
+ "net_flow_usd_1d": (
76
+ round(float(latest["net_flow_usd"]), 2)
77
+ if latest["net_flow_usd"] is not None
78
+ else None
79
+ ),
80
+ "net_flow_usd_7d": net_window if scope == "weekly" else _sum_flows(_flows_since(
81
+ conn,
82
+ asset,
83
+ (now.date() - timedelta(days=6)).isoformat(),
84
+ )),
85
+ "total_net_assets_usd": (
86
+ round(float(latest["total_net_assets_usd"]), 2)
87
+ if latest["total_net_assets_usd"] is not None
88
+ else None
89
+ ),
90
+ "by_ticker": _ticker_breakdown(conn, asset, latest["flow_date"]),
91
+ }
92
+ if scope == "daily":
93
+ result["net_flow_usd_24h"] = result["net_flow_usd_1d"]
94
+ return result
95
+
96
+
97
+ def build_etf_context(conn, *, now: datetime, scope: Scope, assets: list[str]) -> dict[str, Any]:
98
+ blocks: dict[str, Any] = {}
99
+ sources: set[str] = set()
100
+ for asset in assets:
101
+ block = build_etf_asset_context(conn, asset=asset, now=now, scope=scope)
102
+ blocks[asset.lower()] = block
103
+ if block.get("available") and block.get("source"):
104
+ sources.add(str(block["source"]))
105
+
106
+ if not any(block.get("available") for block in blocks.values()):
107
+ return {"available": False, "reason": "no_etf_data"}
108
+
109
+ return {
110
+ "available": True,
111
+ "assets": blocks,
112
+ "sources": sorted(sources),
113
+ }
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+
9
+ def _row_to_context(row: sqlite3.Row, *, now: datetime) -> dict[str, Any]:
10
+ ts = int(row["ts"])
11
+ computed_at = datetime.fromtimestamp(ts, tz=timezone.utc)
12
+ age = max(0, int((now - computed_at).total_seconds()))
13
+ return {
14
+ "value": int(row["value"]),
15
+ "classification": str(row["classification"]),
16
+ "timestamp": ts,
17
+ "computed_at": computed_at.replace(microsecond=0).isoformat(),
18
+ "age_seconds": age,
19
+ }
20
+
21
+
22
+ def build_fear_greed_context(
23
+ conn: sqlite3.Connection,
24
+ *,
25
+ now: datetime | None = None,
26
+ max_age_seconds: int = 86_400 * 2,
27
+ ) -> dict[str, Any] | None:
28
+ now = now or datetime.now(timezone.utc)
29
+ row = conn.execute(
30
+ """
31
+ SELECT ts, value, classification, fetched_at
32
+ FROM fear_greed ORDER BY CAST(ts AS INTEGER) DESC LIMIT 1
33
+ """
34
+ ).fetchone()
35
+ if row is None:
36
+ return None
37
+ ctx = _row_to_context(row, now=now)
38
+ if ctx["age_seconds"] > max_age_seconds:
39
+ ctx["stale"] = True
40
+ return ctx
41
+
42
+
43
+ def fear_greed_at_or_before(
44
+ conn: sqlite3.Connection,
45
+ *,
46
+ at_ts: int,
47
+ ) -> dict[str, Any] | None:
48
+ row = conn.execute(
49
+ """
50
+ SELECT ts, value, classification, fetched_at
51
+ FROM fear_greed
52
+ WHERE CAST(ts AS INTEGER) <= ?
53
+ ORDER BY CAST(ts AS INTEGER) DESC
54
+ LIMIT 1
55
+ """,
56
+ (at_ts,),
57
+ ).fetchone()
58
+ if row is None:
59
+ return None
60
+ at = datetime.fromtimestamp(at_ts, tz=timezone.utc)
61
+ return _row_to_context(row, now=at)
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime, timedelta, timezone
4
+ from typing import Any, Literal
5
+
6
+ from alloccontext.rollup.etf import build_etf_context
7
+
8
+ Scope = Literal["daily", "weekly"]
9
+
10
+
11
+ def _row_to_event(row) -> dict[str, Any]:
12
+ return {
13
+ "event_id": row["event_id"],
14
+ "event_ts": row["event_ts"],
15
+ "country": row["country"],
16
+ "name": row["name"],
17
+ "impact": row["impact"],
18
+ "category": row["category"],
19
+ "actual": row["actual"],
20
+ "estimate": row["estimate"],
21
+ "previous": row["previous"],
22
+ "unit": row["unit"],
23
+ "source": row["source"],
24
+ }
25
+
26
+
27
+ def _value_on_or_before(conn, *, series_id: str, target_date: date) -> tuple[str, float] | None:
28
+ row = conn.execute(
29
+ """
30
+ SELECT obs_date, value
31
+ FROM fred_observations
32
+ WHERE series_id = ? AND obs_date <= ? AND value IS NOT NULL
33
+ ORDER BY obs_date DESC
34
+ LIMIT 1
35
+ """,
36
+ (series_id, target_date.isoformat()),
37
+ ).fetchone()
38
+ if row is None:
39
+ return None
40
+ return str(row["obs_date"]), float(row["value"])
41
+
42
+
43
+ def _latest_value(conn, *, series_id: str) -> tuple[str, float] | None:
44
+ row = conn.execute(
45
+ """
46
+ SELECT obs_date, value
47
+ FROM fred_observations
48
+ WHERE series_id = ? AND value IS NOT NULL
49
+ ORDER BY obs_date DESC
50
+ LIMIT 1
51
+ """,
52
+ (series_id,),
53
+ ).fetchone()
54
+ if row is None:
55
+ return None
56
+ return str(row["obs_date"]), float(row["value"])
57
+
58
+
59
+ def _pct_change(current: float, prior: float) -> float | None:
60
+ if prior == 0:
61
+ return None
62
+ return round((current - prior) / prior * 100.0, 2)
63
+
64
+
65
+ def _build_indicators(conn, config, *, now: datetime) -> dict[str, Any]:
66
+ specs = config.fred.series
67
+ if not specs:
68
+ return {"available": False, "reason": "no_series_configured"}
69
+
70
+ as_of_date = now.date()
71
+ indicators: dict[str, Any] = {}
72
+ for spec in specs:
73
+ latest = _latest_value(conn, series_id=spec.id)
74
+ if latest is None:
75
+ continue
76
+ obs_date, value = latest
77
+ entry: dict[str, Any] = {
78
+ "label": spec.label,
79
+ "category": spec.category,
80
+ "value": round(value, 4),
81
+ "obs_date": obs_date,
82
+ }
83
+ for days, key in ((7, "change_7d"), (30, "change_30d")):
84
+ prior = _value_on_or_before(
85
+ conn,
86
+ series_id=spec.id,
87
+ target_date=as_of_date - timedelta(days=days),
88
+ )
89
+ if prior is None:
90
+ continue
91
+ prior_date, prior_value = prior
92
+ delta = round(value - prior_value, 4)
93
+ entry[key] = delta
94
+ entry[f"{key}_from_date"] = prior_date
95
+ pct = _pct_change(value, prior_value)
96
+ if pct is not None:
97
+ entry[key.replace("change", "change_pct")] = pct
98
+ indicators[spec.id] = entry
99
+
100
+ if not indicators:
101
+ return {"available": False, "reason": "no_fred_data"}
102
+
103
+ return {"available": True, "indicators": indicators}
104
+
105
+
106
+ def _build_events(conn, *, now: datetime, scope: Scope) -> dict[str, Any]:
107
+ past_hours = 24 if scope == "daily" else 24 * 7
108
+ future_days = 7
109
+ past_start = (now - timedelta(hours=past_hours)).isoformat()
110
+ future_end = (now + timedelta(days=future_days)).isoformat()
111
+ now_iso = now.isoformat()
112
+
113
+ rows = conn.execute(
114
+ """
115
+ SELECT event_id, event_ts, country, name, impact, category,
116
+ actual, estimate, previous, unit, source
117
+ FROM macro_events
118
+ WHERE event_ts >= ? AND event_ts <= ?
119
+ ORDER BY event_ts ASC
120
+ """,
121
+ (past_start, future_end),
122
+ ).fetchall()
123
+
124
+ if not rows:
125
+ return {"available": False, "reason": "no_events"}
126
+
127
+ past_key = "past_24h" if scope == "daily" else "past_7d"
128
+ past_events = [_row_to_event(row) for row in rows if row["event_ts"] < now_iso]
129
+ upcoming = [_row_to_event(row) for row in rows if row["event_ts"] >= now_iso]
130
+ sources = sorted({row["source"] for row in rows})
131
+
132
+ return {
133
+ "available": True,
134
+ "events": {
135
+ past_key: past_events,
136
+ "next_7d": upcoming,
137
+ },
138
+ "sources": sources,
139
+ "counts": {
140
+ past_key: len(past_events),
141
+ "next_7d": len(upcoming),
142
+ },
143
+ }
144
+
145
+
146
+ def build_macro_context(
147
+ conn,
148
+ config,
149
+ *,
150
+ now: datetime,
151
+ scope: Scope,
152
+ ) -> dict[str, Any]:
153
+ if now.tzinfo is None:
154
+ now = now.replace(tzinfo=timezone.utc)
155
+
156
+ events_block = _build_events(conn, now=now, scope=scope)
157
+ etf_block = build_etf_context(
158
+ conn,
159
+ now=now,
160
+ scope=scope,
161
+ assets=list(config.etf.assets),
162
+ )
163
+ indicators_block = _build_indicators(conn, config, now=now)
164
+
165
+ if (
166
+ not events_block.get("available")
167
+ and not etf_block.get("available")
168
+ and not indicators_block.get("available")
169
+ ):
170
+ return {"available": False, "reason": "no_macro_data"}
171
+
172
+ sources = sorted(
173
+ set(events_block.get("sources") or [])
174
+ | set(etf_block.get("sources") or [])
175
+ | ({"fred"} if indicators_block.get("available") else set())
176
+ )
177
+ result: dict[str, Any] = {"available": True, "sources": sources}
178
+ if events_block.get("available"):
179
+ result["events"] = events_block["events"]
180
+ result["counts"] = events_block.get("counts", {})
181
+ if etf_block.get("available"):
182
+ result["etf"] = etf_block["assets"]
183
+ if indicators_block.get("available"):
184
+ result["indicators"] = indicators_block["indicators"]
185
+ return result