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