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,137 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from typing import Any
6
+
7
+ from alloccontext.rollup.band import check_allocation_band
8
+ from alloccontext.rollup.breadth import build_market_breadth_context
9
+
10
+
11
+ def _allocation_pct(allocation: dict[str, Any], key: str) -> float:
12
+ if key in allocation:
13
+ return float(allocation[key])
14
+ return 0.0
15
+
16
+
17
+ def build_portfolio_context(conn: sqlite3.Connection, config) -> dict[str, Any]:
18
+ row = conn.execute(
19
+ """
20
+ SELECT ts, nav_usd, cash_usd, allocation_json
21
+ FROM portfolio_snapshots ORDER BY ts DESC LIMIT 1
22
+ """
23
+ ).fetchone()
24
+ if row is None:
25
+ return {"available": False, "reason": "no_portfolio_snapshot"}
26
+
27
+ allocation = json.loads(row["allocation_json"] or "{}")
28
+ target = dict(config.portfolio.target_allocations)
29
+ band_result = check_allocation_band(
30
+ {
31
+ "BTC": _allocation_pct(allocation, "BTC"),
32
+ "ETH": _allocation_pct(allocation, "ETH"),
33
+ "CASH": _allocation_pct(allocation, "CASH"),
34
+ },
35
+ target,
36
+ float(config.portfolio.rebalance_band),
37
+ )
38
+ btc_pct = band_result["allocation_pct"]["BTC"]
39
+ eth_pct = band_result["allocation_pct"]["ETH"]
40
+ cash_pct = band_result["allocation_pct"]["CASH"]
41
+ drift = band_result["drift"]
42
+ rebalance_hint = band_result["hint"]
43
+
44
+ prior = conn.execute(
45
+ """
46
+ SELECT nav_usd FROM portfolio_snapshots
47
+ WHERE ts < ?
48
+ ORDER BY ts DESC LIMIT 1
49
+ """,
50
+ (row["ts"],),
51
+ ).fetchone()
52
+
53
+ pnl_24h = None
54
+ if prior and prior["nav_usd"] is not None and row["nav_usd"] is not None:
55
+ pnl_24h = round(float(row["nav_usd"]) - float(prior["nav_usd"]), 2)
56
+
57
+ return {
58
+ "available": True,
59
+ "as_of": row["ts"],
60
+ "nav_usd": round(float(row["nav_usd"] or 0), 2),
61
+ "cash_usd": round(float(row["cash_usd"] or 0), 2),
62
+ "allocation_pct": {
63
+ "BTC": round(btc_pct, 4),
64
+ "ETH": round(eth_pct, 4),
65
+ "CASH": round(cash_pct, 4),
66
+ },
67
+ "target_allocation_pct": target,
68
+ "drift": drift,
69
+ "rebalance_hint": rebalance_hint,
70
+ "outside_band": band_result["outside_band"],
71
+ "max_drift": band_result["max_drift"],
72
+ "band": band_result["band"],
73
+ "pnl_usd": {"since_prior_snapshot": pnl_24h},
74
+ "prices": allocation.get("prices") or {},
75
+ "cash_breakdown": allocation.get("cash_breakdown") or {},
76
+ }
77
+
78
+
79
+ def build_market_context(conn: sqlite3.Connection, config) -> dict[str, Any]:
80
+ spot = config.exchanges.primary_spot()
81
+ assets = build_spot_market_assets(conn, spot)
82
+ breadth = build_market_breadth_context(conn)
83
+
84
+ if not assets and not breadth.get("available"):
85
+ return {"available": False, "reason": "no_market_data"}
86
+
87
+ result: dict[str, Any] = {"available": True, "interval_minutes": spot.ohlc_interval_minutes}
88
+ if assets:
89
+ result["assets"] = assets
90
+ if breadth.get("available"):
91
+ result["breadth"] = breadth
92
+ if not assets:
93
+ result["reason"] = "no_market_bars"
94
+ return result
95
+
96
+
97
+ def _spot_pair_to_symbol(pair: str) -> str:
98
+ if "-" in pair:
99
+ from alloccontext.ingest.coinbase_client import product_to_symbol
100
+
101
+ return product_to_symbol(pair)
102
+ from alloccontext.ingest.kraken_client import pair_to_symbol
103
+
104
+ return pair_to_symbol(pair)
105
+
106
+
107
+ def build_spot_market_assets(conn: sqlite3.Connection, spot) -> dict[str, Any]:
108
+ assets: dict[str, Any] = {}
109
+ interval = spot.ohlc_interval_minutes
110
+ for pair in spot.pairs:
111
+ symbol = _spot_pair_to_symbol(pair)
112
+ rows = conn.execute(
113
+ """
114
+ SELECT bar_ts, close FROM market_bars
115
+ WHERE pair = ? AND interval_minutes = ?
116
+ ORDER BY bar_ts DESC LIMIT 2
117
+ """,
118
+ (pair, interval),
119
+ ).fetchall()
120
+ if not rows:
121
+ continue
122
+ latest = float(rows[0]["close"])
123
+ change_pct = None
124
+ if len(rows) >= 2 and float(rows[1]["close"]) > 0:
125
+ prior = float(rows[1]["close"])
126
+ change_pct = round((latest - prior) / prior * 100, 2)
127
+ assets[symbol.lower()] = {
128
+ "pair": pair,
129
+ "price_usd": round(latest, 2),
130
+ "change_pct": {"1_bar": change_pct},
131
+ }
132
+ return assets
133
+
134
+
135
+ def build_kraken_market_assets(conn: sqlite3.Connection, config) -> dict[str, Any]:
136
+ """Compatibility alias — use build_spot_market_assets with primary exchange config."""
137
+ return build_spot_market_assets(conn, config.exchanges.primary_spot())
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ _ASSETS = ("BTC", "ETH", "CASH")
6
+
7
+ _DEFAULT_PAIRS = {
8
+ "kraken": {"BTC": "XBTUSD", "ETH": "ETHUSD"},
9
+ "coinbase": {"BTC": "BTC-USD", "ETH": "ETH-USD"},
10
+ }
11
+
12
+
13
+ def _round_usd(value: float) -> float:
14
+ return round(value)
15
+
16
+
17
+ def _product_for_asset(exchange: str, asset: str, pairs: dict[str, str] | None) -> str:
18
+ if pairs and asset in pairs:
19
+ return pairs[asset]
20
+ return _DEFAULT_PAIRS.get(exchange, _DEFAULT_PAIRS["kraken"])[asset]
21
+
22
+
23
+ def _buy_move(exchange: str, asset: str, usd: float, pairs: dict[str, str] | None) -> str:
24
+ amount = _round_usd(usd)
25
+ if exchange == "coinbase":
26
+ product = _product_for_asset(exchange, asset, pairs)
27
+ return f"Buy ~${amount:,.0f} {asset} on {product}"
28
+ pair = _product_for_asset(exchange, asset, pairs)
29
+ symbol = "XBT" if asset == "BTC" else asset
30
+ return f"Buy ~${amount:,.0f} {symbol} ({pair})"
31
+
32
+
33
+ def _trim_move(exchange: str, asset: str, usd: float, pairs: dict[str, str] | None) -> str:
34
+ amount = _round_usd(usd)
35
+ if exchange == "coinbase":
36
+ product = _product_for_asset(exchange, asset, pairs)
37
+ return f"Sell ~${amount:,.0f} {asset} on {product}"
38
+ pair = _product_for_asset(exchange, asset, pairs)
39
+ symbol = "XBT" if asset == "BTC" else asset
40
+ return f"Sell ~${amount:,.0f} {symbol} ({pair})"
41
+
42
+
43
+ def _deploy_move(exchange: str, asset: str, usd: float, pairs: dict[str, str] | None) -> str:
44
+ amount = _round_usd(usd)
45
+ if exchange == "coinbase":
46
+ product = _product_for_asset(exchange, asset, pairs)
47
+ return f"Deploy ~${amount:,.0f} from USD → {asset} on {product}"
48
+ symbol = "XBT" if asset == "BTC" else asset
49
+ return f"Deploy ~${amount:,.0f} from cash → {symbol}"
50
+
51
+
52
+ def compute_rebalance_plan(
53
+ nav_usd: float,
54
+ current_pct: dict[str, float],
55
+ target_pct: dict[str, float],
56
+ *,
57
+ min_usd: float = 1.0,
58
+ exchange: str = "kraken",
59
+ pairs: dict[str, str] | None = None,
60
+ ) -> dict[str, Any]:
61
+ """USD deltas and exchange-style moves from current to target allocation."""
62
+ if nav_usd <= 0:
63
+ return {"available": False, "reason": "no_nav"}
64
+
65
+ exchange_key = exchange.strip().lower() if exchange else "kraken"
66
+ current_usd = {a: nav_usd * float(current_pct.get(a) or 0) for a in _ASSETS}
67
+ target_usd = {a: nav_usd * float(target_pct.get(a) or 0) for a in _ASSETS}
68
+ delta_usd = {a: target_usd[a] - current_usd[a] for a in _ASSETS}
69
+
70
+ moves: list[str] = []
71
+ cash_surplus = max(0.0, -delta_usd["CASH"])
72
+ crypto_need = {
73
+ "BTC": max(0.0, delta_usd["BTC"]),
74
+ "ETH": max(0.0, delta_usd["ETH"]),
75
+ }
76
+ total_crypto_need = crypto_need["BTC"] + crypto_need["ETH"]
77
+
78
+ deployed: dict[str, float] = {"BTC": 0.0, "ETH": 0.0}
79
+ if cash_surplus >= min_usd and total_crypto_need >= min_usd:
80
+ deploy_total = min(cash_surplus, total_crypto_need)
81
+ for asset in ("BTC", "ETH"):
82
+ share = deploy_total * crypto_need[asset] / total_crypto_need
83
+ if share >= min_usd:
84
+ deployed[asset] = share
85
+ moves.append(_deploy_move(exchange_key, asset, share, pairs))
86
+
87
+ for asset in ("BTC", "ETH"):
88
+ remaining = delta_usd[asset] - deployed[asset]
89
+ if remaining >= min_usd:
90
+ moves.append(_buy_move(exchange_key, asset, remaining, pairs))
91
+ elif remaining <= -min_usd:
92
+ moves.append(_trim_move(exchange_key, asset, -remaining, pairs))
93
+
94
+ if not moves and all(abs(delta_usd[a]) < min_usd for a in _ASSETS):
95
+ moves.append("Already at target within ~$1 rounding.")
96
+
97
+ return {
98
+ "available": True,
99
+ "exchange": exchange_key,
100
+ "nav_usd": round(nav_usd, 2),
101
+ "current_usd": {a: round(current_usd[a], 2) for a in _ASSETS},
102
+ "target_usd": {a: round(target_usd[a], 2) for a in _ASSETS},
103
+ "delta_usd": {a: round(delta_usd[a], 2) for a in _ASSETS},
104
+ "moves": moves,
105
+ }
106
+
107
+
108
+ def format_rebalance_plan(
109
+ plan: dict[str, Any],
110
+ *,
111
+ target_pct: dict[str, float],
112
+ ) -> str:
113
+ if not plan.get("available"):
114
+ return ""
115
+
116
+ btc = round(float(target_pct.get("BTC") or 0) * 100)
117
+ eth = round(float(target_pct.get("ETH") or 0) * 100)
118
+ cash = round(float(target_pct.get("CASH") or 0) * 100)
119
+ nav = plan.get("nav_usd", 0)
120
+ header = (
121
+ f"**Moves to reach BTC {btc}%, ETH {eth}%, Cash {cash}% "
122
+ f"(~${nav:,.0f} NAV):**"
123
+ )
124
+ bullets = "\n".join(f"- {line}" for line in plan.get("moves") or [])
125
+ return f"{header}\n{bullets}"
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def _kalshi_block(sentiment: dict[str, Any]) -> dict[str, Any]:
7
+ kalshi = sentiment.get("kalshi")
8
+ return kalshi if isinstance(kalshi, dict) else {}
9
+
10
+
11
+ def _fear_greed_block(sentiment: dict[str, Any]) -> dict[str, Any] | None:
12
+ fg = sentiment.get("fear_greed")
13
+ return fg if isinstance(fg, dict) else None
14
+
15
+
16
+ def build_regime_context(
17
+ *,
18
+ portfolio: dict[str, Any],
19
+ sentiment: dict[str, Any],
20
+ delta: dict[str, Any],
21
+ prior_as_of: str | None,
22
+ max_cash_risk_off: float = 0.50,
23
+ ) -> dict[str, Any]:
24
+ hints: list[dict[str, str]] = []
25
+ allocation: dict[str, Any] = {"available": False}
26
+ volatility: dict[str, Any] = {"available": False}
27
+ sentiment_block: dict[str, Any] = {"available": False}
28
+
29
+ if portfolio.get("available"):
30
+ allocation = {
31
+ "available": True,
32
+ "hint": portfolio.get("rebalance_hint"),
33
+ "outside_band": portfolio.get("outside_band"),
34
+ "max_drift": portfolio.get("max_drift"),
35
+ "band": portfolio.get("band"),
36
+ "target_allocation_pct": portfolio.get("target_allocation_pct"),
37
+ }
38
+ hint = portfolio.get("rebalance_hint")
39
+ if hint:
40
+ hints.append(
41
+ {
42
+ "kind": "allocation",
43
+ "code": str(hint),
44
+ "text": _allocation_hint_text(str(hint)),
45
+ }
46
+ )
47
+
48
+ kalshi = _kalshi_block(sentiment)
49
+ if kalshi.get("available"):
50
+ vol_regime = kalshi.get("volatility_regime")
51
+ vol_by_asset = kalshi.get("volatility_by_asset")
52
+ if vol_regime or vol_by_asset:
53
+ volatility = {
54
+ "available": True,
55
+ "regime": vol_regime,
56
+ "by_asset": vol_by_asset,
57
+ }
58
+ if vol_regime:
59
+ hints.append(
60
+ {
61
+ "kind": "volatility",
62
+ "code": str(vol_regime),
63
+ "text": f"Short-horizon volatility regime: {vol_regime}.",
64
+ }
65
+ )
66
+ tape_summary = kalshi.get("tape_summary")
67
+ leaders_agree = kalshi.get("leaders_agree")
68
+ sentiment_up_frac = kalshi.get("sentiment_up_frac")
69
+ sentiment_block = {
70
+ "available": True,
71
+ "tape_summary": tape_summary,
72
+ "leaders_agree": leaders_agree,
73
+ "sentiment_up_frac": sentiment_up_frac,
74
+ }
75
+ if leaders_agree is False:
76
+ hints.append(
77
+ {
78
+ "kind": "spot_prediction",
79
+ "code": "leaders_diverge",
80
+ "text": "BTC and ETH short-term Kalshi drift disagree.",
81
+ }
82
+ )
83
+
84
+ fg = _fear_greed_block(sentiment) if sentiment.get("available") else None
85
+ if fg and fg.get("value") is not None:
86
+ sentiment_block.setdefault("available", True)
87
+ sentiment_block["fear_greed_value"] = fg.get("value")
88
+ sentiment_block["fear_greed_classification"] = fg.get("classification")
89
+ classification = fg.get("classification")
90
+ if classification:
91
+ hints.append(
92
+ {
93
+ "kind": "sentiment",
94
+ "code": str(classification).lower().replace(" ", "_"),
95
+ "text": f"Fear & Greed index: {fg['value']} ({classification}).",
96
+ }
97
+ )
98
+
99
+ comparison: dict[str, Any] = {
100
+ "prior_as_of": prior_as_of,
101
+ "has_prior_snapshot": bool(prior_as_of),
102
+ }
103
+ if delta.get("available"):
104
+ comparison["notable_shifts"] = list(delta.get("notable_shifts") or [])
105
+ for line in comparison["notable_shifts"]:
106
+ hints.append({"kind": "delta", "code": "notable_shift", "text": str(line)})
107
+
108
+ available = (
109
+ allocation.get("available")
110
+ or volatility.get("available")
111
+ or sentiment_block.get("available")
112
+ or comparison["has_prior_snapshot"]
113
+ )
114
+ summary_parts = [hint["text"] for hint in hints[:3]]
115
+ summary = " ".join(summary_parts) if summary_parts else None
116
+ risk_off = _build_risk_off(
117
+ portfolio=portfolio,
118
+ sentiment=sentiment,
119
+ max_cash_risk_off=max_cash_risk_off,
120
+ )
121
+
122
+ return {
123
+ "available": available,
124
+ "allocation": allocation,
125
+ "volatility": volatility,
126
+ "sentiment": sentiment_block,
127
+ "comparison": comparison,
128
+ "hints": hints,
129
+ "summary": summary,
130
+ "risk_off": risk_off,
131
+ }
132
+
133
+
134
+ def _allocation_hint_text(code: str) -> str:
135
+ mapping = {
136
+ "within_band": "Portfolio allocation is within the configured drift band.",
137
+ "consider_deploy_cash": "Cash weight is above target — consider deploying idle cash.",
138
+ "consider_trim_to_cash": "Cash weight is below target — consider trimming to raise cash.",
139
+ "consider_rebalance": "Allocation drift exceeds the band — consider rebalancing.",
140
+ }
141
+ return mapping.get(code, f"Allocation hint: {code}.")
142
+
143
+
144
+ def _build_risk_off(
145
+ *,
146
+ portfolio: dict[str, Any],
147
+ sentiment: dict[str, Any],
148
+ max_cash_risk_off: float,
149
+ ) -> dict[str, Any]:
150
+ signals: list[str] = []
151
+ score = 0
152
+
153
+ if portfolio.get("available"):
154
+ cash = float((portfolio.get("allocation_pct") or {}).get("CASH") or 0)
155
+ if cash >= max_cash_risk_off:
156
+ score += 40
157
+ signals.append(f"cash {cash * 100:.1f}% at/above risk-off ceiling")
158
+ elif cash >= max_cash_risk_off * 0.75:
159
+ score += 20
160
+ signals.append(f"cash {cash * 100:.1f}% elevated vs risk-off ceiling")
161
+ hint = str(portfolio.get("rebalance_hint") or "")
162
+ if hint == "consider_deploy_cash":
163
+ score += 15
164
+ signals.append("rebalance hint favors deploying cash")
165
+
166
+ fg = _fear_greed_block(sentiment) if sentiment.get("available") else None
167
+ if fg and fg.get("value") is not None:
168
+ value = int(fg["value"])
169
+ if value <= 25:
170
+ score += 35
171
+ signals.append(f"Fear & Greed {value} (extreme fear)")
172
+ elif value <= 40:
173
+ score += 20
174
+ signals.append(f"Fear & Greed {value} (fear)")
175
+
176
+ score = min(100, score)
177
+ level = "low"
178
+ if score >= 70:
179
+ level = "high"
180
+ elif score >= 40:
181
+ level = "moderate"
182
+
183
+ return {
184
+ "available": bool(signals),
185
+ "score": score,
186
+ "level": level,
187
+ "signals": signals,
188
+ }
@@ -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
7
+
8
+ from alloccontext.rollup.cluster import MarketQuote
9
+ from alloccontext.rollup.cluster_config import RollupConfig
10
+ from alloccontext.rollup.tape import build_live_tape_context
11
+ from alloccontext.store.meta import get_meta
12
+
13
+
14
+ def _latest_snapshot(conn: sqlite3.Connection) -> sqlite3.Row | None:
15
+ return conn.execute(
16
+ """
17
+ SELECT ts, tape_summary, cluster_json, raw_json
18
+ FROM kalshi_snapshots ORDER BY id DESC LIMIT 1
19
+ """
20
+ ).fetchone()
21
+
22
+
23
+ def _markets_from_meta(conn: sqlite3.Connection) -> list[MarketQuote]:
24
+ raw = get_meta(conn, "kalshi_markets") or get_meta(conn, "kalshi_markets_15m")
25
+ if not raw:
26
+ return []
27
+ try:
28
+ rows = json.loads(raw)
29
+ except json.JSONDecodeError:
30
+ return []
31
+ markets: list[MarketQuote] = []
32
+ for row in rows:
33
+ if not isinstance(row, dict):
34
+ continue
35
+ markets.append(
36
+ MarketQuote(
37
+ ticker=str(row.get("ticker") or ""),
38
+ yes_ask_cents=row.get("yes_ask_cents"),
39
+ no_ask_cents=row.get("no_ask_cents"),
40
+ )
41
+ )
42
+ return markets
43
+
44
+
45
+ def _cf_history_from_meta(conn: sqlite3.Connection) -> dict[str, Any] | None:
46
+ raw = get_meta(conn, "cf_price_history")
47
+ if not raw:
48
+ return None
49
+ try:
50
+ parsed = json.loads(raw)
51
+ except json.JSONDecodeError:
52
+ return None
53
+ return parsed if isinstance(parsed, dict) else None
54
+
55
+
56
+ def build_kalshi_sentiment_context(
57
+ conn: sqlite3.Connection,
58
+ rollup: RollupConfig,
59
+ *,
60
+ now: datetime | None = None,
61
+ ) -> dict[str, Any]:
62
+ now = now or datetime.now(timezone.utc)
63
+ row = _latest_snapshot(conn)
64
+ if row is None:
65
+ return {"available": False, "reason": "no_kalshi_snapshot"}
66
+
67
+ cluster = json.loads(row["cluster_json"] or "{}")
68
+ tape_summary = row["tape_summary"]
69
+ source = "kalshi_snapshot"
70
+
71
+ if not cluster:
72
+ cf_history = _cf_history_from_meta(conn)
73
+ markets = _markets_from_meta(conn)
74
+ computed = build_live_tape_context(
75
+ rollup,
76
+ cf_history=cf_history,
77
+ markets=markets,
78
+ now=now,
79
+ )
80
+ if computed:
81
+ tape_ctx, cluster_ctx, _computed_at = computed
82
+ cluster = cluster_ctx
83
+ tape_summary = tape_ctx.get("summary") or tape_summary
84
+ source = "computed_cf_meta"
85
+
86
+ if not cluster and not tape_summary:
87
+ return {"available": False, "reason": "empty_kalshi_telemetry"}
88
+
89
+ return {
90
+ "available": True,
91
+ "source": source,
92
+ "as_of": row["ts"],
93
+ "tape_summary": tape_summary,
94
+ **cluster,
95
+ }
96
+
97
+
98
+ def build_sentiment_context(
99
+ conn: sqlite3.Connection,
100
+ config,
101
+ rollup: RollupConfig,
102
+ *,
103
+ now: datetime | None = None,
104
+ ) -> dict[str, Any]:
105
+ from alloccontext.rollup.fear_greed import build_fear_greed_context
106
+
107
+ now = now or datetime.now(timezone.utc)
108
+ fg = build_fear_greed_context(conn, now=now)
109
+ kalshi = build_kalshi_sentiment_context(conn, rollup, now=now)
110
+ available = fg is not None or kalshi.get("available")
111
+ body: dict[str, Any] = {
112
+ "available": available,
113
+ "fear_greed": fg,
114
+ "kalshi": kalshi,
115
+ }
116
+ if not available:
117
+ body["reason"] = "no_sentiment_sources"
118
+ return body
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from typing import Any, Literal
6
+
7
+ Scope = Literal["daily", "weekly"]
8
+
9
+
10
+ class SnapshotNotFoundError(LookupError):
11
+ pass
12
+
13
+
14
+ def load_context_bundle_snapshot(
15
+ conn: sqlite3.Connection,
16
+ *,
17
+ scope: Scope,
18
+ as_of: str,
19
+ ) -> dict[str, Any]:
20
+ row = conn.execute(
21
+ """
22
+ SELECT context_json FROM context_snapshots
23
+ WHERE scope = ? AND as_of = ?
24
+ """,
25
+ (scope, as_of),
26
+ ).fetchone()
27
+ if row is None:
28
+ raise SnapshotNotFoundError(f"no {scope} snapshot at {as_of}")
29
+ try:
30
+ return json.loads(row["context_json"])
31
+ except (TypeError, json.JSONDecodeError) as exc:
32
+ raise SnapshotNotFoundError(f"invalid snapshot JSON at {as_of}") from exc
33
+
34
+
35
+ def resolve_context_snapshot_as_of(
36
+ conn: sqlite3.Connection,
37
+ *,
38
+ scope: Scope,
39
+ as_of: str,
40
+ mode: Literal["exact", "at_or_before"] = "exact",
41
+ ) -> str:
42
+ if mode == "exact":
43
+ row = conn.execute(
44
+ """
45
+ SELECT as_of FROM context_snapshots
46
+ WHERE scope = ? AND as_of = ?
47
+ """,
48
+ (scope, as_of),
49
+ ).fetchone()
50
+ if row is None:
51
+ raise SnapshotNotFoundError(f"no {scope} snapshot at {as_of}")
52
+ return str(row["as_of"])
53
+
54
+ row = conn.execute(
55
+ """
56
+ SELECT as_of FROM context_snapshots
57
+ WHERE scope = ? AND as_of <= ?
58
+ ORDER BY as_of DESC LIMIT 1
59
+ """,
60
+ (scope, as_of),
61
+ ).fetchone()
62
+ if row is None:
63
+ raise SnapshotNotFoundError(f"no {scope} snapshot at or before {as_of}")
64
+ return str(row["as_of"])