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,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"])
|