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