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,179 @@
|
|
|
1
|
+
"""Base stablecoin options for x402 exact payments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
8
|
+
|
|
9
|
+
from alloccontext.mcp.x402_pricing import (
|
|
10
|
+
build_mcp_dynamic_price,
|
|
11
|
+
mcp_call_is_heavy,
|
|
12
|
+
read_mcp_request_json,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
BASE_MAINNET = "eip155:8453"
|
|
16
|
+
BASE_SEPOLIA = "eip155:84532"
|
|
17
|
+
DEFAULT_ACCEPTED_STABLES = "USDC,EURC"
|
|
18
|
+
# When env parses to no symbols (e.g. X402_ACCEPTED_STABLES=""), discovery and
|
|
19
|
+
# payment routes both use this list — not dynamic $ pricing.
|
|
20
|
+
FALLBACK_ACCEPTED_STABLES = ("USDC",)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class StableCoin:
|
|
25
|
+
"""ERC-20 stable accepted for x402 exact settlement on Base."""
|
|
26
|
+
|
|
27
|
+
symbol: str
|
|
28
|
+
address: str
|
|
29
|
+
decimals: int
|
|
30
|
+
eip712_name: str
|
|
31
|
+
eip712_version: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Circle USDC on Base mainnet matches x402 NETWORK_CONFIGS default_asset.
|
|
35
|
+
_BASE_MAINNET_STABLES: dict[str, StableCoin] = {
|
|
36
|
+
"USDC": StableCoin(
|
|
37
|
+
symbol="USDC",
|
|
38
|
+
address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
39
|
+
decimals=6,
|
|
40
|
+
eip712_name="USD Coin",
|
|
41
|
+
eip712_version="2",
|
|
42
|
+
),
|
|
43
|
+
"EURC": StableCoin(
|
|
44
|
+
symbol="EURC",
|
|
45
|
+
address="0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
|
|
46
|
+
decimals=6,
|
|
47
|
+
eip712_name="EURC",
|
|
48
|
+
eip712_version="2",
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_BASE_SEPOLIA_STABLES: dict[str, StableCoin] = {
|
|
53
|
+
"USDC": StableCoin(
|
|
54
|
+
symbol="USDC",
|
|
55
|
+
address="0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
56
|
+
decimals=6,
|
|
57
|
+
eip712_name="USDC",
|
|
58
|
+
eip712_version="2",
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_STABLES_BY_NETWORK: dict[str, dict[str, StableCoin]] = {
|
|
63
|
+
BASE_MAINNET: _BASE_MAINNET_STABLES,
|
|
64
|
+
BASE_SEPOLIA: _BASE_SEPOLIA_STABLES,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_accepted_stable_symbols(raw: str | None) -> tuple[str, ...]:
|
|
69
|
+
"""Parse comma-separated stable symbols (e.g. USDC,EURC)."""
|
|
70
|
+
value = (raw if raw is not None else DEFAULT_ACCEPTED_STABLES).strip()
|
|
71
|
+
if not value:
|
|
72
|
+
return ()
|
|
73
|
+
return tuple(part.strip().upper() for part in value.split(",") if part.strip())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def effective_accepted_stable_symbols(symbols: tuple[str, ...]) -> tuple[str, ...]:
|
|
77
|
+
"""Non-empty symbol list for routes and discovery; USDC when parse yields none."""
|
|
78
|
+
return symbols if symbols else FALLBACK_ACCEPTED_STABLES
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_accepted_stable_symbols() -> tuple[str, ...]:
|
|
82
|
+
raw = os.environ.get("X402_ACCEPTED_STABLES")
|
|
83
|
+
return effective_accepted_stable_symbols(parse_accepted_stable_symbols(raw))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def stables_for_network(
|
|
87
|
+
network: str,
|
|
88
|
+
symbols: tuple[str, ...] | None = None,
|
|
89
|
+
) -> tuple[StableCoin, ...]:
|
|
90
|
+
"""Return configured stablecoins for a CAIP-2 network."""
|
|
91
|
+
catalog = _STABLES_BY_NETWORK.get(network)
|
|
92
|
+
if catalog is None:
|
|
93
|
+
return ()
|
|
94
|
+
|
|
95
|
+
chosen = symbols if symbols is not None else load_accepted_stable_symbols()
|
|
96
|
+
if not chosen:
|
|
97
|
+
return ()
|
|
98
|
+
|
|
99
|
+
resolved: list[StableCoin] = []
|
|
100
|
+
for symbol in chosen:
|
|
101
|
+
stable = catalog.get(symbol)
|
|
102
|
+
if stable is not None:
|
|
103
|
+
resolved.append(stable)
|
|
104
|
+
if chosen and not resolved:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"no accepted stables from {chosen!r} are listed on network {network}"
|
|
107
|
+
)
|
|
108
|
+
return tuple(resolved)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def dollar_to_atomic(amount: str, decimals: int) -> str:
|
|
112
|
+
"""Convert a $ price string to ERC-20 atomic units (USD-pegged stables)."""
|
|
113
|
+
dollars = Decimal(amount.strip().lstrip("$"))
|
|
114
|
+
scale = Decimal(10) ** decimals
|
|
115
|
+
atomic = (dollars * scale).to_integral_value(rounding=ROUND_HALF_UP)
|
|
116
|
+
return str(int(atomic))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_stable_dynamic_price(
|
|
120
|
+
*,
|
|
121
|
+
stable: StableCoin,
|
|
122
|
+
light_price: str,
|
|
123
|
+
heavy_price: str,
|
|
124
|
+
):
|
|
125
|
+
"""Build async DynamicPrice returning AssetAmount for one stable."""
|
|
126
|
+
|
|
127
|
+
async def resolve_price(context):
|
|
128
|
+
body = await read_mcp_request_json(context)
|
|
129
|
+
price_str = heavy_price if mcp_call_is_heavy(body) else light_price
|
|
130
|
+
return {
|
|
131
|
+
"amount": dollar_to_atomic(price_str, stable.decimals),
|
|
132
|
+
"asset": stable.address,
|
|
133
|
+
"extra": {
|
|
134
|
+
"name": stable.eip712_name,
|
|
135
|
+
"version": stable.eip712_version,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return resolve_price
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_payment_options_for_stables(
|
|
143
|
+
*,
|
|
144
|
+
pay_to: str,
|
|
145
|
+
network: str,
|
|
146
|
+
light_price: str,
|
|
147
|
+
heavy_price: str,
|
|
148
|
+
symbols: tuple[str, ...] | None = None,
|
|
149
|
+
):
|
|
150
|
+
"""PaymentOption list: one per accepted stable; fallback to $ price on unknown nets."""
|
|
151
|
+
from x402.http.types import PaymentOption
|
|
152
|
+
|
|
153
|
+
stables = stables_for_network(network, symbols)
|
|
154
|
+
if not stables:
|
|
155
|
+
return [
|
|
156
|
+
PaymentOption(
|
|
157
|
+
scheme="exact",
|
|
158
|
+
pay_to=pay_to,
|
|
159
|
+
price=build_mcp_dynamic_price(
|
|
160
|
+
light_price=light_price,
|
|
161
|
+
heavy_price=heavy_price,
|
|
162
|
+
),
|
|
163
|
+
network=network,
|
|
164
|
+
)
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
PaymentOption(
|
|
169
|
+
scheme="exact",
|
|
170
|
+
pay_to=pay_to,
|
|
171
|
+
price=build_stable_dynamic_price(
|
|
172
|
+
stable=stable,
|
|
173
|
+
light_price=light_price,
|
|
174
|
+
heavy_price=heavy_price,
|
|
175
|
+
),
|
|
176
|
+
network=network,
|
|
177
|
+
)
|
|
178
|
+
for stable in stables
|
|
179
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deterministic ContextBundle builders."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
_ASSETS = ("BTC", "ETH", "CASH")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _pct(allocation: dict[str, float], key: str) -> float:
|
|
9
|
+
return float(allocation.get(key) or 0)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_allocation_band(
|
|
13
|
+
allocation_pct: dict[str, float],
|
|
14
|
+
target_pct: dict[str, float],
|
|
15
|
+
band: float,
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""Drift vs target and rebalance hint for BTC/ETH/CASH."""
|
|
18
|
+
btc_pct = _pct(allocation_pct, "BTC")
|
|
19
|
+
eth_pct = _pct(allocation_pct, "ETH")
|
|
20
|
+
cash_pct = _pct(allocation_pct, "CASH")
|
|
21
|
+
drift = {
|
|
22
|
+
"BTC": round(btc_pct - _pct(target_pct, "BTC"), 4),
|
|
23
|
+
"ETH": round(eth_pct - _pct(target_pct, "ETH"), 4),
|
|
24
|
+
"CASH": round(cash_pct - _pct(target_pct, "CASH"), 4),
|
|
25
|
+
}
|
|
26
|
+
max_drift = max(abs(v) for v in drift.values()) if drift else 0.0
|
|
27
|
+
outside_band = max_drift > band
|
|
28
|
+
if not outside_band:
|
|
29
|
+
hint = "within_band"
|
|
30
|
+
elif cash_pct > _pct(target_pct, "CASH") + band:
|
|
31
|
+
hint = "consider_deploy_cash"
|
|
32
|
+
elif cash_pct < _pct(target_pct, "CASH") - band:
|
|
33
|
+
hint = "consider_trim_to_cash"
|
|
34
|
+
else:
|
|
35
|
+
hint = "consider_rebalance"
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"available": True,
|
|
39
|
+
"allocation_pct": {
|
|
40
|
+
"BTC": round(btc_pct, 4),
|
|
41
|
+
"ETH": round(eth_pct, 4),
|
|
42
|
+
"CASH": round(cash_pct, 4),
|
|
43
|
+
},
|
|
44
|
+
"target_pct": {a: _pct(target_pct, a) for a in _ASSETS},
|
|
45
|
+
"drift": drift,
|
|
46
|
+
"max_drift": round(max_drift, 4),
|
|
47
|
+
"band": band,
|
|
48
|
+
"outside_band": outside_band,
|
|
49
|
+
"hint": hint,
|
|
50
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from alloccontext.ingest.market_snapshots import (
|
|
7
|
+
dominance_delta,
|
|
8
|
+
latest_snapshot,
|
|
9
|
+
prior_snapshot,
|
|
10
|
+
row_to_dict,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_SOURCES = ("coingecko", "coinmarketcap")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_market_breadth_context(conn: sqlite3.Connection) -> dict[str, Any]:
|
|
17
|
+
feeds: dict[str, Any] = {}
|
|
18
|
+
for source in _SOURCES:
|
|
19
|
+
row = latest_snapshot(conn, source)
|
|
20
|
+
if row is None:
|
|
21
|
+
feeds[source] = {"available": False, "reason": "no_snapshot"}
|
|
22
|
+
continue
|
|
23
|
+
current = row_to_dict(row)
|
|
24
|
+
prior = row_to_dict(prior_snapshot(conn, source, current["snapshot_ts"]))
|
|
25
|
+
block: dict[str, Any] = {
|
|
26
|
+
"available": True,
|
|
27
|
+
"as_of": current["snapshot_ts"],
|
|
28
|
+
**{k: v for k, v in current.items() if k not in {"snapshot_ts", "source"}},
|
|
29
|
+
}
|
|
30
|
+
if prior:
|
|
31
|
+
block["delta_since_prior"] = dominance_delta(current, prior)
|
|
32
|
+
if prior.get("btc_rank") is not None and current.get("btc_rank") is not None:
|
|
33
|
+
block["delta_since_prior"]["btc_rank_change"] = int(current["btc_rank"]) - int(
|
|
34
|
+
prior["btc_rank"]
|
|
35
|
+
)
|
|
36
|
+
if prior.get("eth_rank") is not None and current.get("eth_rank") is not None:
|
|
37
|
+
block["delta_since_prior"]["eth_rank_change"] = int(current["eth_rank"]) - int(
|
|
38
|
+
prior["eth_rank"]
|
|
39
|
+
)
|
|
40
|
+
feeds[source] = block
|
|
41
|
+
|
|
42
|
+
if not any(feed.get("available") for feed in feeds.values()):
|
|
43
|
+
return {"available": False, "reason": "no_market_breadth_data"}
|
|
44
|
+
|
|
45
|
+
return {"available": True, "feeds": feeds}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_ts(raw: str | None) -> dt.datetime | None:
|
|
8
|
+
if not raw:
|
|
9
|
+
return None
|
|
10
|
+
try:
|
|
11
|
+
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
12
|
+
except ValueError:
|
|
13
|
+
return None
|
|
14
|
+
if parsed.tzinfo is None:
|
|
15
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
16
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pct_change_over_minutes(
|
|
20
|
+
history: dict[str, list[dict[str, Any]]] | None,
|
|
21
|
+
index: str,
|
|
22
|
+
now: dt.datetime,
|
|
23
|
+
*,
|
|
24
|
+
lookback_minutes: float,
|
|
25
|
+
min_samples: int,
|
|
26
|
+
) -> float | None:
|
|
27
|
+
rows = history.get(index) if history else None
|
|
28
|
+
if not rows:
|
|
29
|
+
return None
|
|
30
|
+
now = now.astimezone(dt.timezone.utc)
|
|
31
|
+
cutoff = now - dt.timedelta(minutes=lookback_minutes)
|
|
32
|
+
samples: list[tuple[dt.datetime, float]] = []
|
|
33
|
+
for row in rows:
|
|
34
|
+
ts = parse_ts(row.get("at"))
|
|
35
|
+
price = row.get("price")
|
|
36
|
+
if ts is None or price is None or ts < cutoff:
|
|
37
|
+
continue
|
|
38
|
+
samples.append((ts, float(price)))
|
|
39
|
+
if len(samples) < min_samples:
|
|
40
|
+
return None
|
|
41
|
+
samples.sort(key=lambda item: item[0])
|
|
42
|
+
oldest = samples[0][1]
|
|
43
|
+
newest = samples[-1][1]
|
|
44
|
+
if oldest <= 0:
|
|
45
|
+
return None
|
|
46
|
+
return (newest - oldest) / oldest
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def range_pct_over_minutes(
|
|
50
|
+
history: dict[str, list[dict[str, Any]]] | None,
|
|
51
|
+
index: str,
|
|
52
|
+
now: dt.datetime,
|
|
53
|
+
*,
|
|
54
|
+
lookback_minutes: float,
|
|
55
|
+
min_samples: int,
|
|
56
|
+
) -> float | None:
|
|
57
|
+
rows = history.get(index) if history else None
|
|
58
|
+
if not rows:
|
|
59
|
+
return None
|
|
60
|
+
now = now.astimezone(dt.timezone.utc)
|
|
61
|
+
cutoff = now - dt.timedelta(minutes=lookback_minutes)
|
|
62
|
+
prices: list[float] = []
|
|
63
|
+
for row in rows:
|
|
64
|
+
ts = parse_ts(row.get("at"))
|
|
65
|
+
price = row.get("price")
|
|
66
|
+
if ts is None or price is None or ts < cutoff:
|
|
67
|
+
continue
|
|
68
|
+
prices.append(float(price))
|
|
69
|
+
if len(prices) < min_samples:
|
|
70
|
+
return None
|
|
71
|
+
low = min(prices)
|
|
72
|
+
high = max(prices)
|
|
73
|
+
mid = (low + high) / 2.0
|
|
74
|
+
if mid <= 0:
|
|
75
|
+
return None
|
|
76
|
+
return (high - low) / mid
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def trend_pct_for_index(
|
|
80
|
+
history: dict[str, list[dict[str, Any]]] | None,
|
|
81
|
+
index: str,
|
|
82
|
+
now: dt.datetime,
|
|
83
|
+
*,
|
|
84
|
+
lookback_minutes: float,
|
|
85
|
+
min_samples: int,
|
|
86
|
+
enabled: bool = True,
|
|
87
|
+
) -> float | None:
|
|
88
|
+
if not enabled:
|
|
89
|
+
return None
|
|
90
|
+
return pct_change_over_minutes(
|
|
91
|
+
history,
|
|
92
|
+
index,
|
|
93
|
+
now,
|
|
94
|
+
lookback_minutes=lookback_minutes,
|
|
95
|
+
min_samples=min_samples,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def scale_pct_map(values: dict[str, float | None]) -> dict[str, float | None]:
|
|
100
|
+
return {
|
|
101
|
+
asset: None if pct is None else round(pct * 100, 4)
|
|
102
|
+
for asset, pct in values.items()
|
|
103
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from alloccontext.rollup.cf_math import pct_change_over_minutes
|
|
8
|
+
from alloccontext.rollup.cluster_config import ClusterLogConfig, CryptoAssetConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MarketQuote:
|
|
13
|
+
ticker: str
|
|
14
|
+
yes_ask_cents: int | None
|
|
15
|
+
no_ask_cents: int | None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ClusterSnapshot:
|
|
20
|
+
assets_with_drift: int
|
|
21
|
+
drift_5m_by_asset: dict[str, float]
|
|
22
|
+
weighted_drift_5m: float | None
|
|
23
|
+
btc_drift_5m: float | None
|
|
24
|
+
eth_drift_5m: float | None
|
|
25
|
+
sentiment_up_frac: float | None
|
|
26
|
+
sentiment_sample_size: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _alt_weight(cfg: ClusterLogConfig, alt_count: int) -> float:
|
|
30
|
+
if alt_count <= 0:
|
|
31
|
+
return 0.0
|
|
32
|
+
remainder = max(0.0, 1.0 - cfg.btc_weight - cfg.eth_weight)
|
|
33
|
+
return remainder / alt_count
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sentiment_up_fraction(markets: list[MarketQuote]) -> tuple[float | None, int]:
|
|
37
|
+
samples = 0
|
|
38
|
+
up_votes = 0
|
|
39
|
+
for market in markets:
|
|
40
|
+
yes_ask = market.yes_ask_cents
|
|
41
|
+
no_ask = market.no_ask_cents
|
|
42
|
+
if yes_ask is None or no_ask is None:
|
|
43
|
+
continue
|
|
44
|
+
samples += 1
|
|
45
|
+
if yes_ask >= no_ask:
|
|
46
|
+
up_votes += 1
|
|
47
|
+
if samples == 0:
|
|
48
|
+
return None, 0
|
|
49
|
+
return up_votes / samples, samples
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_cluster_snapshot(
|
|
53
|
+
cfg: ClusterLogConfig,
|
|
54
|
+
*,
|
|
55
|
+
crypto_assets: tuple[CryptoAssetConfig, ...],
|
|
56
|
+
cf_history: dict[str, list[dict[str, Any]]] | None,
|
|
57
|
+
markets: list[MarketQuote],
|
|
58
|
+
now: dt.datetime,
|
|
59
|
+
min_samples_short: int,
|
|
60
|
+
) -> ClusterSnapshot | None:
|
|
61
|
+
if not cfg.enabled or not cf_history:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
drift_5m_by_asset: dict[str, float] = {}
|
|
65
|
+
for asset_row in crypto_assets:
|
|
66
|
+
index = asset_row.cf_index
|
|
67
|
+
if not index:
|
|
68
|
+
continue
|
|
69
|
+
drift = pct_change_over_minutes(
|
|
70
|
+
cf_history,
|
|
71
|
+
index,
|
|
72
|
+
now,
|
|
73
|
+
lookback_minutes=cfg.drift_lookback_minutes,
|
|
74
|
+
min_samples=min_samples_short,
|
|
75
|
+
)
|
|
76
|
+
if drift is not None:
|
|
77
|
+
drift_5m_by_asset[asset_row.asset] = drift
|
|
78
|
+
|
|
79
|
+
if not drift_5m_by_asset:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
alt_assets = [
|
|
83
|
+
asset
|
|
84
|
+
for asset in crypto_assets
|
|
85
|
+
if asset.asset not in cfg.leader_assets and asset.asset in drift_5m_by_asset
|
|
86
|
+
]
|
|
87
|
+
alt_weight = _alt_weight(cfg, len(alt_assets))
|
|
88
|
+
weighted = 0.0
|
|
89
|
+
weight_used = 0.0
|
|
90
|
+
for asset_row in crypto_assets:
|
|
91
|
+
drift = drift_5m_by_asset.get(asset_row.asset)
|
|
92
|
+
if drift is None:
|
|
93
|
+
continue
|
|
94
|
+
if asset_row.asset == "BTC":
|
|
95
|
+
weight = cfg.btc_weight
|
|
96
|
+
elif asset_row.asset == "ETH":
|
|
97
|
+
weight = cfg.eth_weight
|
|
98
|
+
else:
|
|
99
|
+
weight = alt_weight
|
|
100
|
+
weighted += drift * weight
|
|
101
|
+
weight_used += weight
|
|
102
|
+
weighted_drift = weighted / weight_used if weight_used > 0 else None
|
|
103
|
+
|
|
104
|
+
sentiment_up_frac, sentiment_n = sentiment_up_fraction(markets)
|
|
105
|
+
|
|
106
|
+
return ClusterSnapshot(
|
|
107
|
+
assets_with_drift=len(drift_5m_by_asset),
|
|
108
|
+
drift_5m_by_asset=drift_5m_by_asset,
|
|
109
|
+
weighted_drift_5m=weighted_drift,
|
|
110
|
+
btc_drift_5m=drift_5m_by_asset.get("BTC"),
|
|
111
|
+
eth_drift_5m=drift_5m_by_asset.get("ETH"),
|
|
112
|
+
sentiment_up_frac=sentiment_up_frac,
|
|
113
|
+
sentiment_sample_size=sentiment_n,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cluster_advisory_fields(cluster: ClusterSnapshot | None) -> dict[str, Any]:
|
|
118
|
+
if cluster is None:
|
|
119
|
+
return {}
|
|
120
|
+
|
|
121
|
+
drifts = list(cluster.drift_5m_by_asset.values())
|
|
122
|
+
breadth = sum(1 for drift in drifts if drift > 0) / len(drifts) if drifts else None
|
|
123
|
+
leaders_agree = None
|
|
124
|
+
if cluster.btc_drift_5m is not None and cluster.eth_drift_5m is not None:
|
|
125
|
+
leaders_agree = (cluster.btc_drift_5m > 0) == (cluster.eth_drift_5m > 0)
|
|
126
|
+
|
|
127
|
+
fields: dict[str, Any] = {
|
|
128
|
+
"assets_with_drift": cluster.assets_with_drift,
|
|
129
|
+
"sentiment_sample": cluster.sentiment_sample_size,
|
|
130
|
+
}
|
|
131
|
+
if cluster.sentiment_up_frac is not None:
|
|
132
|
+
fields["sentiment_up_frac"] = round(cluster.sentiment_up_frac, 4)
|
|
133
|
+
if cluster.weighted_drift_5m is not None:
|
|
134
|
+
fields["weighted_drift_5m_pct"] = round(cluster.weighted_drift_5m * 100, 4)
|
|
135
|
+
if cluster.btc_drift_5m is not None:
|
|
136
|
+
fields["btc_drift_5m_pct"] = round(cluster.btc_drift_5m * 100, 4)
|
|
137
|
+
if cluster.eth_drift_5m is not None:
|
|
138
|
+
fields["eth_drift_5m_pct"] = round(cluster.eth_drift_5m * 100, 4)
|
|
139
|
+
drift_scaled = {
|
|
140
|
+
asset: round(value * 100, 4)
|
|
141
|
+
for asset, value in cluster.drift_5m_by_asset.items()
|
|
142
|
+
}
|
|
143
|
+
if drift_scaled:
|
|
144
|
+
fields["drift_5m_by_asset"] = drift_scaled
|
|
145
|
+
if breadth is not None:
|
|
146
|
+
fields["breadth_up_frac"] = round(breadth, 4)
|
|
147
|
+
if leaders_agree is not None:
|
|
148
|
+
fields["leaders_agree"] = leaders_agree
|
|
149
|
+
return fields
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class CryptoAssetConfig:
|
|
9
|
+
asset: str
|
|
10
|
+
cf_index: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TrendFilterConfig:
|
|
15
|
+
enabled: bool
|
|
16
|
+
lookback_minutes: float
|
|
17
|
+
min_samples: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ContextFilterConfig:
|
|
22
|
+
min_samples_short: int
|
|
23
|
+
short_drift_5m_minutes: float
|
|
24
|
+
short_drift_15m_minutes: float
|
|
25
|
+
volatility_lookback_minutes: float
|
|
26
|
+
medium_volatility_pct: float
|
|
27
|
+
high_volatility_pct: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ClusterLogConfig:
|
|
32
|
+
enabled: bool
|
|
33
|
+
drift_lookback_minutes: float
|
|
34
|
+
btc_weight: float
|
|
35
|
+
eth_weight: float
|
|
36
|
+
leader_assets: tuple[str, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class RollupConfig:
|
|
41
|
+
crypto: tuple[CryptoAssetConfig, ...]
|
|
42
|
+
trend_filter: TrendFilterConfig
|
|
43
|
+
context_filter: ContextFilterConfig
|
|
44
|
+
cluster_log: ClusterLogConfig
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_rollup_config(raw: dict[str, Any]) -> RollupConfig:
|
|
48
|
+
rollup = raw.get("rollup") or {}
|
|
49
|
+
trend = rollup.get("trend_filter") or {}
|
|
50
|
+
ctx = rollup.get("context_filter") or {}
|
|
51
|
+
cluster = rollup.get("cluster") or {}
|
|
52
|
+
crypto_rows = rollup.get("crypto") or [
|
|
53
|
+
{"asset": "BTC", "cf_index": "BRTI"},
|
|
54
|
+
{"asset": "ETH", "cf_index": "ETHUSD_RTI"},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
return RollupConfig(
|
|
58
|
+
crypto=tuple(
|
|
59
|
+
CryptoAssetConfig(asset=str(row["asset"]), cf_index=str(row["cf_index"]))
|
|
60
|
+
for row in crypto_rows
|
|
61
|
+
),
|
|
62
|
+
trend_filter=TrendFilterConfig(
|
|
63
|
+
enabled=bool(trend.get("enabled", True)),
|
|
64
|
+
lookback_minutes=float(trend.get("lookback_minutes", 60)),
|
|
65
|
+
min_samples=int(trend.get("min_samples", 4)),
|
|
66
|
+
),
|
|
67
|
+
context_filter=ContextFilterConfig(
|
|
68
|
+
min_samples_short=int(ctx.get("min_samples_short", 2)),
|
|
69
|
+
short_drift_5m_minutes=float(ctx.get("short_drift_5m_minutes", 60)),
|
|
70
|
+
short_drift_15m_minutes=float(ctx.get("short_drift_15m_minutes", 60)),
|
|
71
|
+
volatility_lookback_minutes=float(ctx.get("volatility_lookback_minutes", 60)),
|
|
72
|
+
medium_volatility_pct=float(ctx.get("medium_volatility_pct", 0.15)) / 100.0
|
|
73
|
+
if float(ctx.get("medium_volatility_pct", 0.15)) > 1
|
|
74
|
+
else float(ctx.get("medium_volatility_pct", 0.15)),
|
|
75
|
+
high_volatility_pct=float(ctx.get("high_volatility_pct", 0.25)) / 100.0
|
|
76
|
+
if float(ctx.get("high_volatility_pct", 0.25)) > 1
|
|
77
|
+
else float(ctx.get("high_volatility_pct", 0.25)),
|
|
78
|
+
),
|
|
79
|
+
cluster_log=ClusterLogConfig(
|
|
80
|
+
enabled=bool(cluster.get("enabled", True)),
|
|
81
|
+
drift_lookback_minutes=float(cluster.get("drift_lookback_minutes", 60)),
|
|
82
|
+
btc_weight=float(cluster.get("btc_weight", 0.45)),
|
|
83
|
+
eth_weight=float(cluster.get("eth_weight", 0.30)),
|
|
84
|
+
leader_assets=tuple(str(a) for a in cluster.get("leader_assets") or ["BTC", "ETH"]),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _allocation_drift_lines(
|
|
7
|
+
prior: dict[str, Any],
|
|
8
|
+
current: dict[str, Any],
|
|
9
|
+
) -> list[str]:
|
|
10
|
+
lines: list[str] = []
|
|
11
|
+
prior_alloc = (prior.get("portfolio") or {}).get("allocation_pct") or {}
|
|
12
|
+
current_alloc = (current.get("portfolio") or {}).get("allocation_pct") or {}
|
|
13
|
+
for asset in ("BTC", "ETH", "CASH"):
|
|
14
|
+
before = prior_alloc.get(asset)
|
|
15
|
+
after = current_alloc.get(asset)
|
|
16
|
+
if before is None or after is None:
|
|
17
|
+
continue
|
|
18
|
+
delta_pp = round((float(after) - float(before)) * 100, 1)
|
|
19
|
+
if abs(delta_pp) >= 1.0:
|
|
20
|
+
lines.append(f"{asset} allocation {delta_pp:+.1f} pp")
|
|
21
|
+
return lines
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compare_context_bundles(
|
|
25
|
+
prior: dict[str, Any],
|
|
26
|
+
current: dict[str, Any],
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Structured diff between two archived or live ContextBundles."""
|
|
29
|
+
prior_as_of = prior.get("as_of")
|
|
30
|
+
current_as_of = current.get("as_of")
|
|
31
|
+
notable: list[str] = []
|
|
32
|
+
|
|
33
|
+
prior_delta = prior.get("delta") or {}
|
|
34
|
+
current_delta = current.get("delta") or {}
|
|
35
|
+
for block in (prior_delta, current_delta):
|
|
36
|
+
for line in block.get("notable_shifts") or []:
|
|
37
|
+
if line not in notable:
|
|
38
|
+
notable.append(str(line))
|
|
39
|
+
|
|
40
|
+
notable.extend(_allocation_drift_lines(prior, current))
|
|
41
|
+
|
|
42
|
+
prior_fg = ((prior.get("sentiment") or {}).get("fear_greed") or {}).get("value")
|
|
43
|
+
current_fg = ((current.get("sentiment") or {}).get("fear_greed") or {}).get("value")
|
|
44
|
+
if prior_fg is not None and current_fg is not None:
|
|
45
|
+
change = int(current_fg) - int(prior_fg)
|
|
46
|
+
if abs(change) >= 5 and not any("F&G" in line for line in notable):
|
|
47
|
+
notable.append(f"F&G {prior_fg} → {current_fg} ({change:+d})")
|
|
48
|
+
|
|
49
|
+
prior_nav = (prior.get("portfolio") or {}).get("nav_usd")
|
|
50
|
+
current_nav = (current.get("portfolio") or {}).get("nav_usd")
|
|
51
|
+
nav_change = None
|
|
52
|
+
if prior_nav is not None and current_nav is not None:
|
|
53
|
+
nav_change = round(float(current_nav) - float(prior_nav), 2)
|
|
54
|
+
if abs(nav_change) >= 100 and not any("Portfolio Δ" in line for line in notable):
|
|
55
|
+
notable.append(f"Portfolio Δ ${nav_change:+.2f} since prior snapshot")
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"prior_as_of": prior_as_of,
|
|
59
|
+
"current_as_of": current_as_of,
|
|
60
|
+
"notable_shifts": notable,
|
|
61
|
+
"portfolio_nav_change_usd": nav_change,
|
|
62
|
+
"fear_greed_change": (
|
|
63
|
+
int(current_fg) - int(prior_fg)
|
|
64
|
+
if prior_fg is not None and current_fg is not None
|
|
65
|
+
else None
|
|
66
|
+
),
|
|
67
|
+
}
|