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,98 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+ from zoneinfo import ZoneInfo
7
+
8
+ _IMPACT_RANK = {"low": 1, "medium": 2, "high": 3}
9
+
10
+
11
+ def normalize_impact(value: str | None) -> str:
12
+ if not value:
13
+ return "medium"
14
+ cleaned = str(value).strip().lower()
15
+ if cleaned in _IMPACT_RANK:
16
+ return cleaned
17
+ if cleaned in {"1", "2", "3"}:
18
+ return {1: "low", 2: "medium", 3: "high"}[int(cleaned)]
19
+ return "medium"
20
+
21
+
22
+ def impact_meets_minimum(impact: str, minimum: str) -> bool:
23
+ return _IMPACT_RANK.get(impact, 2) >= _IMPACT_RANK.get(minimum, 2)
24
+
25
+
26
+ def slug_event_name(name: str) -> str:
27
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
28
+ return slug[:80] or "event"
29
+
30
+
31
+ def calendar_row_date_time(
32
+ item: dict[str, Any],
33
+ *,
34
+ date_key: str = "date",
35
+ time_key: str = "time",
36
+ ) -> tuple[str, str] | None:
37
+ """Extract (YYYY-MM-DD, HH:MM:SS) from calendar API rows.
38
+
39
+ Finnhub live payloads often use a combined ``time`` field
40
+ (``2026-05-21 12:30:00``) without a separate ``date`` key.
41
+ """
42
+ raw_date = str(item.get(date_key) or "").strip()
43
+ raw_time = str(item.get(time_key) or "").strip()
44
+ if raw_date:
45
+ return raw_date[:10], raw_time or "00:00:00"
46
+ if not raw_time:
47
+ return None
48
+ if " " in raw_time:
49
+ date_part, clock = raw_time.split(" ", 1)
50
+ return date_part.strip()[:10], clock.strip()
51
+ return None
52
+
53
+
54
+ def parse_event_ts(
55
+ *,
56
+ date: str,
57
+ time: str | None,
58
+ tz_name: str,
59
+ ) -> str:
60
+ """Return UTC ISO timestamp for a calendar date + local time."""
61
+ local_tz = ZoneInfo(tz_name)
62
+ time_part = (time or "00:00:00").strip()
63
+ if len(time_part) == 5:
64
+ time_part = f"{time_part}:00"
65
+ local = datetime.fromisoformat(f"{date}T{time_part}").replace(tzinfo=local_tz)
66
+ return local.astimezone(timezone.utc).replace(microsecond=0).isoformat()
67
+
68
+
69
+ def normalized_event(
70
+ *,
71
+ source: str,
72
+ country: str,
73
+ name: str,
74
+ event_ts: str,
75
+ impact: str,
76
+ category: str | None = None,
77
+ actual: Any = None,
78
+ estimate: Any = None,
79
+ previous: Any = None,
80
+ unit: str | None = None,
81
+ raw: dict[str, Any] | None = None,
82
+ ) -> dict[str, Any]:
83
+ impact_norm = normalize_impact(impact)
84
+ event_id = f"{source}:{event_ts[:10]}:{slug_event_name(name)}"
85
+ return {
86
+ "event_id": event_id,
87
+ "event_ts": event_ts,
88
+ "country": country.upper(),
89
+ "name": name.strip(),
90
+ "impact": impact_norm,
91
+ "category": category,
92
+ "actual": actual,
93
+ "estimate": estimate,
94
+ "previous": previous,
95
+ "unit": unit,
96
+ "source": source,
97
+ "raw": raw or {},
98
+ }
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+
8
+ def upsert_crypto_market_snapshot(
9
+ conn: sqlite3.Connection,
10
+ *,
11
+ source: str,
12
+ snapshot_ts: str,
13
+ snapshot: dict[str, Any],
14
+ ) -> None:
15
+ fetched_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
16
+ conn.execute(
17
+ """
18
+ INSERT INTO crypto_market_snapshots(
19
+ snapshot_ts, source, total_market_cap_usd, btc_dominance_pct,
20
+ eth_dominance_pct, btc_rank, eth_rank, btc_price_usd, eth_price_usd,
21
+ btc_market_cap_usd, eth_market_cap_usd, btc_change_pct_24h,
22
+ eth_change_pct_24h, fetched_at
23
+ )
24
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
25
+ ON CONFLICT(source, snapshot_ts) DO UPDATE SET
26
+ total_market_cap_usd = excluded.total_market_cap_usd,
27
+ btc_dominance_pct = excluded.btc_dominance_pct,
28
+ eth_dominance_pct = excluded.eth_dominance_pct,
29
+ btc_rank = excluded.btc_rank,
30
+ eth_rank = excluded.eth_rank,
31
+ btc_price_usd = excluded.btc_price_usd,
32
+ eth_price_usd = excluded.eth_price_usd,
33
+ btc_market_cap_usd = excluded.btc_market_cap_usd,
34
+ eth_market_cap_usd = excluded.eth_market_cap_usd,
35
+ btc_change_pct_24h = excluded.btc_change_pct_24h,
36
+ eth_change_pct_24h = excluded.eth_change_pct_24h,
37
+ fetched_at = excluded.fetched_at
38
+ """,
39
+ (
40
+ snapshot_ts,
41
+ source,
42
+ snapshot.get("total_market_cap_usd"),
43
+ snapshot.get("btc_dominance_pct"),
44
+ snapshot.get("eth_dominance_pct"),
45
+ snapshot.get("btc_rank"),
46
+ snapshot.get("eth_rank"),
47
+ snapshot.get("btc_price_usd"),
48
+ snapshot.get("eth_price_usd"),
49
+ snapshot.get("btc_market_cap_usd"),
50
+ snapshot.get("eth_market_cap_usd"),
51
+ snapshot.get("btc_change_pct_24h"),
52
+ snapshot.get("eth_change_pct_24h"),
53
+ fetched_at,
54
+ ),
55
+ )
56
+
57
+
58
+ def latest_snapshot(conn: sqlite3.Connection, source: str):
59
+ return conn.execute(
60
+ """
61
+ SELECT *
62
+ FROM crypto_market_snapshots
63
+ WHERE source = ?
64
+ ORDER BY snapshot_ts DESC
65
+ LIMIT 1
66
+ """,
67
+ (source,),
68
+ ).fetchone()
69
+
70
+
71
+ def prior_snapshot(conn: sqlite3.Connection, source: str, before_ts: str):
72
+ return conn.execute(
73
+ """
74
+ SELECT *
75
+ FROM crypto_market_snapshots
76
+ WHERE source = ? AND snapshot_ts < ?
77
+ ORDER BY snapshot_ts DESC
78
+ LIMIT 1
79
+ """,
80
+ (source, before_ts),
81
+ ).fetchone()
82
+
83
+
84
+ def row_to_dict(row) -> dict[str, Any]:
85
+ if row is None:
86
+ return {}
87
+ return {
88
+ "snapshot_ts": row["snapshot_ts"],
89
+ "source": row["source"],
90
+ "total_market_cap_usd": row["total_market_cap_usd"],
91
+ "btc_dominance_pct": row["btc_dominance_pct"],
92
+ "eth_dominance_pct": row["eth_dominance_pct"],
93
+ "btc_rank": row["btc_rank"],
94
+ "eth_rank": row["eth_rank"],
95
+ "btc_price_usd": row["btc_price_usd"],
96
+ "eth_price_usd": row["eth_price_usd"],
97
+ "btc_market_cap_usd": row["btc_market_cap_usd"],
98
+ "eth_market_cap_usd": row["eth_market_cap_usd"],
99
+ "btc_change_pct_24h": row["btc_change_pct_24h"],
100
+ "eth_change_pct_24h": row["eth_change_pct_24h"],
101
+ }
102
+
103
+
104
+ def dominance_delta(current: dict[str, Any], prior: dict[str, Any]) -> dict[str, float | None]:
105
+ out: dict[str, float | None] = {}
106
+ for key in ("btc_dominance_pct", "eth_dominance_pct"):
107
+ cur = current.get(key)
108
+ prev = prior.get(key)
109
+ if cur is None or prev is None:
110
+ out[key.replace("_pct", "_change")] = None
111
+ else:
112
+ out[key.replace("_pct", "_change")] = round(float(cur) - float(prev), 4)
113
+ return out
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def normalize_optional_feed_name(feed: str) -> str:
7
+ """Map per-asset SoSoValue feed keys to the configured optional source name."""
8
+ if feed.startswith("sosovalue_"):
9
+ return "sosovalue"
10
+ return feed
11
+
12
+
13
+ def skipped_source_error(
14
+ source: str,
15
+ result: dict[str, Any],
16
+ optional_sources: frozenset[str],
17
+ ) -> str | None:
18
+ """Map required-source skips to ingest errors."""
19
+ if not result.get("skipped"):
20
+ return None
21
+ if source in optional_sources:
22
+ return None
23
+ reason = str(result.get("reason") or "skipped")
24
+ if reason in {"exchange_disabled", "not_implemented"}:
25
+ return None
26
+ return reason
27
+
28
+
29
+ def optional_feed_errors(
30
+ result: dict[str, Any],
31
+ optional_sources: frozenset[str],
32
+ ) -> dict[str, str]:
33
+ errors: dict[str, str] = {}
34
+ for feed, message in (result.get("feed_errors") or {}).items():
35
+ name = normalize_optional_feed_name(str(feed))
36
+ if name in optional_sources:
37
+ errors[name] = str(message)
38
+ return errors
39
+
40
+
41
+ def ingest_errors_from_source(
42
+ source: str,
43
+ result: dict[str, Any],
44
+ optional_sources: frozenset[str],
45
+ ) -> dict[str, str]:
46
+ """Collect per-source and optional-feed errors for ingest outcome classification."""
47
+ skip_error = skipped_source_error(source, result, optional_sources)
48
+ if skip_error:
49
+ return {source: skip_error}
50
+
51
+ feed_optional = optional_feed_errors(result, optional_sources)
52
+
53
+ if result.get("ok") or result.get("skipped"):
54
+ return feed_optional
55
+
56
+ if source in optional_sources:
57
+ errors = {source: str(result.get("error") or "failed")}
58
+ errors.update(feed_optional)
59
+ return errors
60
+
61
+ # Parent failed: only optional feeds failed — do not fail ingest on the parent.
62
+ if feed_optional and not any(
63
+ normalize_optional_feed_name(str(feed)) not in optional_sources
64
+ for feed in (result.get("feed_errors") or {})
65
+ ):
66
+ return feed_optional
67
+
68
+ errors = {source: str(result.get("error") or "failed")}
69
+ errors.update(feed_optional)
70
+ return errors
71
+
72
+
73
+ def classify_ingest_errors(
74
+ errors: dict[str, str],
75
+ optional_sources: frozenset[str],
76
+ ) -> tuple[dict[str, str], dict[str, str]]:
77
+ """Split failures into required (fatal) vs optional (non-fatal)."""
78
+ fatal: dict[str, str] = {}
79
+ optional: dict[str, str] = {}
80
+ for source, message in errors.items():
81
+ if source in optional_sources:
82
+ optional[source] = message
83
+ else:
84
+ fatal[source] = message
85
+ return fatal, optional
86
+
87
+
88
+ def ingest_ok(
89
+ errors: dict[str, str],
90
+ optional_sources: frozenset[str],
91
+ ) -> tuple[bool, bool]:
92
+ fatal, optional = classify_ingest_errors(errors, optional_sources)
93
+ ok = not fatal
94
+ partial = ok and bool(optional)
95
+ return ok, partial
96
+
97
+
98
+ def summarize_ingest_outcome(
99
+ errors: dict[str, str],
100
+ optional_sources: frozenset[str],
101
+ ) -> dict[str, Any]:
102
+ fatal, optional = classify_ingest_errors(errors, optional_sources)
103
+ ok, partial = ingest_ok(errors, optional_sources)
104
+ return {
105
+ "ok": ok,
106
+ "partial": partial,
107
+ "errors": errors,
108
+ "fatal_errors": fatal,
109
+ "optional_errors": optional,
110
+ }
@@ -0,0 +1,23 @@
1
+ """Shared parsing helpers for ingest API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def parse_float(value: Any) -> float | None:
9
+ if value is None:
10
+ return None
11
+ try:
12
+ return float(value)
13
+ except (TypeError, ValueError):
14
+ return None
15
+
16
+
17
+ def parse_int(value: Any) -> int | None:
18
+ if value is None:
19
+ return None
20
+ try:
21
+ return int(value)
22
+ except (TypeError, ValueError):
23
+ return None
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from alloccontext.horizon import horizon_days
8
+ from alloccontext.ingest.outcome import (
9
+ ingest_errors_from_source,
10
+ optional_feed_errors,
11
+ summarize_ingest_outcome,
12
+ )
13
+ from alloccontext.ingest.coingecko import refresh_coingecko
14
+ from alloccontext.ingest.coinmarketcap import refresh_coinmarketcap
15
+ from alloccontext.ingest.etf_flows import refresh_etf_flows
16
+ from alloccontext.ingest.fred import refresh_fred
17
+ from alloccontext.ingest.fear_greed import refresh_fear_greed
18
+ from alloccontext.ingest.kalshi import refresh_kalshi
19
+ from alloccontext.ingest.coinbase_portfolio import refresh_coinbase
20
+ from alloccontext.ingest.kraken_portfolio import refresh_kraken
21
+ from alloccontext.ingest.macro_calendar import refresh_macro_calendar
22
+ from alloccontext.store.db import record_ingest_run
23
+ from alloccontext.store.retention import prune_to_horizon
24
+
25
+
26
+ def _now_iso() -> str:
27
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
28
+
29
+
30
+ def _run_source(
31
+ conn: sqlite3.Connection,
32
+ config,
33
+ source: str,
34
+ ) -> dict[str, Any]:
35
+ started = _now_iso()
36
+ if source == "fear_greed":
37
+ result = refresh_fear_greed(conn, history_limit=horizon_days(config))
38
+ elif source == "kraken":
39
+ result = refresh_kraken(conn, config)
40
+ elif source == "coinbase":
41
+ result = refresh_coinbase(conn, config)
42
+ elif source == "kalshi":
43
+ result = refresh_kalshi(conn, config)
44
+ elif source == "macro_calendar":
45
+ result = refresh_macro_calendar(conn, config)
46
+ elif source == "etf_flows":
47
+ result = refresh_etf_flows(conn, config)
48
+ elif source == "coingecko":
49
+ result = refresh_coingecko(conn, config)
50
+ elif source == "coinmarketcap":
51
+ result = refresh_coinmarketcap(conn, config)
52
+ elif source == "fred":
53
+ result = refresh_fred(conn, config)
54
+ else:
55
+ result = {"ok": False, "rows": 0, "error": f"unknown_source:{source}"}
56
+
57
+ finished = _now_iso()
58
+ rows = int(result.get("rows") or 0)
59
+ source_errors = ingest_errors_from_source(
60
+ source,
61
+ result,
62
+ config.ingest.optional_sources,
63
+ )
64
+ parent_error = source_errors.get(source)
65
+ record_ingest_run(
66
+ conn,
67
+ source=source,
68
+ started_at=started,
69
+ finished_at=finished,
70
+ rows_upserted=rows,
71
+ error=parent_error,
72
+ )
73
+ for feed_name, message in optional_feed_errors(
74
+ result, config.ingest.optional_sources
75
+ ).items():
76
+ record_ingest_run(
77
+ conn,
78
+ source=feed_name,
79
+ started_at=started,
80
+ finished_at=finished,
81
+ rows_upserted=0,
82
+ error=message,
83
+ )
84
+ return result
85
+
86
+
87
+ def run_ingest(
88
+ conn: sqlite3.Connection,
89
+ config,
90
+ *,
91
+ dry_run: bool = False,
92
+ ) -> dict[str, Any]:
93
+ """Pull enabled sources into SQLite."""
94
+ counts: dict[str, int] = {}
95
+ results: dict[str, Any] = {}
96
+ errors: dict[str, str] = {}
97
+
98
+ for source, enabled in config.ingest.sources.items():
99
+ if not enabled:
100
+ counts[source] = 0
101
+ continue
102
+ if dry_run:
103
+ counts[source] = 0
104
+ results[source] = {"ok": True, "dry_run": True}
105
+ continue
106
+ result = _run_source(conn, config, source)
107
+ results[source] = result
108
+ counts[source] = int(result.get("rows") or 0)
109
+ errors.update(
110
+ ingest_errors_from_source(
111
+ source,
112
+ result,
113
+ config.ingest.optional_sources,
114
+ )
115
+ )
116
+
117
+ outcome = summarize_ingest_outcome(errors, config.ingest.optional_sources)
118
+ pruned: dict[str, int] = {}
119
+ snapshots: dict[str, str] = {}
120
+ if not dry_run:
121
+ pruned = prune_to_horizon(conn, config)
122
+ if not outcome["fatal_errors"]:
123
+ from alloccontext.rollup.context import Scope, build_context_bundle
124
+
125
+ for scope in ("daily", "weekly"):
126
+ scope_lit: Scope = scope # type: ignore[assignment]
127
+ bundle = build_context_bundle(
128
+ conn,
129
+ config,
130
+ scope=scope_lit,
131
+ rollup=config.rollup,
132
+ save_snapshot=True,
133
+ )
134
+ snapshots[scope] = bundle["as_of"]
135
+
136
+ return {
137
+ "counts": counts,
138
+ "results": results,
139
+ "errors": outcome["errors"],
140
+ "fatal_errors": outcome["fatal_errors"],
141
+ "optional_errors": outcome["optional_errors"],
142
+ "pruned": pruned,
143
+ "snapshots": snapshots,
144
+ "dry_run": dry_run,
145
+ "ok": outcome["ok"],
146
+ "partial": outcome["partial"],
147
+ "horizon_days": horizon_days(config),
148
+ }
@@ -0,0 +1 @@
1
+ """AllocContext MCP server (stdio)."""
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ DEFAULT_VIEW_ASSETS: tuple[str, ...] = ("BTC", "ETH")
6
+ ALLOCATION_ASSETS: tuple[str, ...] = ("BTC", "ETH", "CASH")
7
+ _SYMBOL_BY_ASSET = {"BTC": "btc", "ETH": "eth", "CASH": "cash"}
8
+
9
+
10
+ def validate_view_assets(assets: list[str] | None) -> tuple[str, ...]:
11
+ if assets is None or len(assets) == 0:
12
+ return DEFAULT_VIEW_ASSETS
13
+ normalized: list[str] = []
14
+ seen: set[str] = set()
15
+ for raw in assets:
16
+ key = str(raw).strip().upper()
17
+ if not key:
18
+ continue
19
+ if key not in ALLOCATION_ASSETS:
20
+ raise ValueError(
21
+ f"unsupported asset {raw!r}; allowed: {', '.join(ALLOCATION_ASSETS)}"
22
+ )
23
+ if key not in seen:
24
+ normalized.append(key)
25
+ seen.add(key)
26
+ if not normalized:
27
+ return DEFAULT_VIEW_ASSETS
28
+ return tuple(normalized)
29
+
30
+
31
+ def _asset_symbols(assets: tuple[str, ...]) -> set[str]:
32
+ return {_SYMBOL_BY_ASSET[asset] for asset in assets if asset in _SYMBOL_BY_ASSET}
33
+
34
+
35
+ def filter_market_assets(market: dict[str, Any], assets: tuple[str, ...]) -> dict[str, Any]:
36
+ if not market.get("available"):
37
+ return market
38
+ symbols = _asset_symbols(assets)
39
+ block = market.get("assets")
40
+ if not isinstance(block, dict) or not symbols:
41
+ return market
42
+ filtered = {
43
+ key: value for key, value in block.items() if key.lower() in symbols
44
+ }
45
+ result = dict(market)
46
+ if filtered:
47
+ result["assets"] = filtered
48
+ else:
49
+ result["available"] = False
50
+ result["reason"] = "no_market_data_for_requested_assets"
51
+ result.pop("assets", None)
52
+ return result
53
+
54
+
55
+ def filter_etf_block(etf: dict[str, Any], assets: tuple[str, ...]) -> dict[str, Any]:
56
+ if not etf.get("available"):
57
+ return etf
58
+ block = etf.get("assets")
59
+ if not isinstance(block, dict):
60
+ return etf
61
+ wanted = {asset for asset in assets if asset in ("BTC", "ETH")}
62
+ if not wanted:
63
+ return etf
64
+ filtered = {key: value for key, value in block.items() if key.upper() in wanted}
65
+ result = dict(etf)
66
+ if filtered:
67
+ result["assets"] = filtered
68
+ else:
69
+ result["available"] = False
70
+ result["reason"] = "no_etf_data_for_requested_assets"
71
+ result.pop("assets", None)
72
+ return result
73
+
74
+
75
+ def filter_delta_market(delta: dict[str, Any], assets: tuple[str, ...]) -> dict[str, Any]:
76
+ if not delta.get("available"):
77
+ return delta
78
+ symbols = _asset_symbols(assets)
79
+ shifts = [
80
+ line
81
+ for line in delta.get("notable_shifts") or []
82
+ if any(symbol.upper() in line for symbol in symbols)
83
+ or "Portfolio" in line
84
+ or "F&G" in line
85
+ ]
86
+ result = dict(delta)
87
+ result["notable_shifts"] = shifts
88
+ market = delta.get("market")
89
+ if not isinstance(market, dict):
90
+ return result
91
+ filtered_market = {
92
+ key: value
93
+ for key, value in market.items()
94
+ if any(symbol in key for symbol in symbols)
95
+ }
96
+ if filtered_market:
97
+ result["market"] = filtered_market
98
+ else:
99
+ result.pop("market", None)
100
+ return result
101
+
102
+
103
+ def filter_macro_etf(macro: dict[str, Any], assets: tuple[str, ...]) -> dict[str, Any]:
104
+ etf = macro.get("etf")
105
+ if not isinstance(etf, dict):
106
+ return macro
107
+ wanted = {asset for asset in assets if asset in ("BTC", "ETH")}
108
+ if not wanted:
109
+ return macro
110
+ filtered = {key: value for key, value in etf.items() if key.upper() in wanted}
111
+ result = dict(macro)
112
+ if filtered:
113
+ result["etf"] = filtered
114
+ else:
115
+ result.pop("etf", None)
116
+ return result
117
+
118
+
119
+ def apply_assets_filter_to_bundle(
120
+ bundle: dict[str, Any],
121
+ assets: tuple[str, ...],
122
+ ) -> dict[str, Any]:
123
+ result = dict(bundle)
124
+ result["assets"] = list(assets)
125
+ if "market" in result:
126
+ result["market"] = filter_market_assets(result["market"], assets)
127
+ if "macro" in result and isinstance(result["macro"], dict):
128
+ result["macro"] = filter_macro_etf(result["macro"], assets)
129
+ if "delta" in result:
130
+ result["delta"] = filter_delta_market(result["delta"], assets)
131
+ return result
132
+
133
+
134
+ def apply_assets_filter_to_market_payload(
135
+ payload: dict[str, Any],
136
+ assets: tuple[str, ...],
137
+ ) -> dict[str, Any]:
138
+ result = dict(payload)
139
+ result["assets"] = list(assets)
140
+ if isinstance(result.get("etf"), dict):
141
+ result["etf"] = filter_etf_block(result["etf"], assets)
142
+ if isinstance(result.get("breadth"), dict) and isinstance(
143
+ result["breadth"].get("assets"), dict
144
+ ):
145
+ symbols = _asset_symbols(assets)
146
+ breadth = dict(result["breadth"])
147
+ breadth["assets"] = {
148
+ key: value
149
+ for key, value in breadth["assets"].items()
150
+ if key.lower() in symbols
151
+ }
152
+ result["breadth"] = breadth
153
+ return result