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,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from alloccontext.rollup.cf_math import (
|
|
7
|
+
pct_change_over_minutes,
|
|
8
|
+
range_pct_over_minutes,
|
|
9
|
+
scale_pct_map,
|
|
10
|
+
trend_pct_for_index,
|
|
11
|
+
)
|
|
12
|
+
from alloccontext.rollup.cluster import (
|
|
13
|
+
MarketQuote,
|
|
14
|
+
build_cluster_snapshot,
|
|
15
|
+
cluster_advisory_fields,
|
|
16
|
+
sentiment_up_fraction,
|
|
17
|
+
)
|
|
18
|
+
from alloccontext.rollup.cluster_config import RollupConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _volatility_regime(
|
|
22
|
+
range_pct: float | None,
|
|
23
|
+
*,
|
|
24
|
+
high_threshold: float,
|
|
25
|
+
medium_threshold: float,
|
|
26
|
+
) -> str | None:
|
|
27
|
+
if range_pct is None:
|
|
28
|
+
return None
|
|
29
|
+
if range_pct >= high_threshold:
|
|
30
|
+
return "high"
|
|
31
|
+
if range_pct >= medium_threshold:
|
|
32
|
+
return "medium"
|
|
33
|
+
return "low"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _dominant_vol_regime(regimes: dict[str, str]) -> str | None:
|
|
37
|
+
if not regimes:
|
|
38
|
+
return None
|
|
39
|
+
order = {"high": 3, "medium": 2, "low": 1}
|
|
40
|
+
return max(regimes.values(), key=lambda value: order.get(value, 0))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_tape_summary(
|
|
44
|
+
*,
|
|
45
|
+
trend_60m: dict[str, float | None],
|
|
46
|
+
vol_regime: str | None,
|
|
47
|
+
sentiment_up_frac: float | None,
|
|
48
|
+
) -> str:
|
|
49
|
+
scored = [(asset, pct) for asset, pct in trend_60m.items() if pct is not None]
|
|
50
|
+
if not scored:
|
|
51
|
+
direction = "mixed"
|
|
52
|
+
else:
|
|
53
|
+
down = sum(1 for _, pct in scored if pct < -0.05)
|
|
54
|
+
up = sum(1 for _, pct in scored if pct > 0.05)
|
|
55
|
+
if down >= max(3, len(scored) - 1):
|
|
56
|
+
direction = "broad down"
|
|
57
|
+
elif up >= max(3, len(scored) - 1):
|
|
58
|
+
direction = "broad up"
|
|
59
|
+
elif up > down:
|
|
60
|
+
direction = "mixed, leaning up"
|
|
61
|
+
elif down > up:
|
|
62
|
+
direction = "mixed, leaning down"
|
|
63
|
+
else:
|
|
64
|
+
direction = "mixed"
|
|
65
|
+
|
|
66
|
+
if vol_regime == "high":
|
|
67
|
+
vol_label = "high short vol"
|
|
68
|
+
elif vol_regime == "medium":
|
|
69
|
+
vol_label = "elevated short vol"
|
|
70
|
+
elif vol_regime == "low":
|
|
71
|
+
vol_label = "calm short vol"
|
|
72
|
+
else:
|
|
73
|
+
vol_label = "short vol n/a"
|
|
74
|
+
|
|
75
|
+
if sentiment_up_frac is None:
|
|
76
|
+
sentiment_label = "hourly sentiment n/a"
|
|
77
|
+
elif sentiment_up_frac >= 0.55:
|
|
78
|
+
sentiment_label = "crowd leaning YES on hourly"
|
|
79
|
+
elif sentiment_up_frac <= 0.45:
|
|
80
|
+
sentiment_label = "crowd leaning NO on hourly"
|
|
81
|
+
else:
|
|
82
|
+
sentiment_label = "mixed hourly sentiment"
|
|
83
|
+
|
|
84
|
+
return f"Tape today: {direction}, {vol_label}, {sentiment_label}."
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_live_tape_context(
|
|
88
|
+
config: RollupConfig,
|
|
89
|
+
*,
|
|
90
|
+
cf_history: dict[str, list[dict[str, Any]]] | None,
|
|
91
|
+
markets: list[MarketQuote],
|
|
92
|
+
now: dt.datetime | None = None,
|
|
93
|
+
) -> tuple[dict[str, Any], dict[str, Any], dt.datetime] | None:
|
|
94
|
+
if not cf_history:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
now = now or dt.datetime.now(dt.timezone.utc)
|
|
98
|
+
ctx = config.context_filter
|
|
99
|
+
trend_cfg = config.trend_filter
|
|
100
|
+
|
|
101
|
+
trend_60m: dict[str, float | None] = {}
|
|
102
|
+
trend_15m: dict[str, float | None] = {}
|
|
103
|
+
trend_5m: dict[str, float | None] = {}
|
|
104
|
+
vol_by_asset: dict[str, str | None] = {}
|
|
105
|
+
|
|
106
|
+
for asset_row in config.crypto:
|
|
107
|
+
index = asset_row.cf_index
|
|
108
|
+
if not index:
|
|
109
|
+
continue
|
|
110
|
+
trend_60m[asset_row.asset] = trend_pct_for_index(
|
|
111
|
+
cf_history,
|
|
112
|
+
index,
|
|
113
|
+
now,
|
|
114
|
+
lookback_minutes=trend_cfg.lookback_minutes,
|
|
115
|
+
min_samples=trend_cfg.min_samples,
|
|
116
|
+
enabled=trend_cfg.enabled,
|
|
117
|
+
)
|
|
118
|
+
trend_15m[asset_row.asset] = pct_change_over_minutes(
|
|
119
|
+
cf_history,
|
|
120
|
+
index,
|
|
121
|
+
now,
|
|
122
|
+
lookback_minutes=ctx.short_drift_15m_minutes,
|
|
123
|
+
min_samples=ctx.min_samples_short,
|
|
124
|
+
)
|
|
125
|
+
trend_5m[asset_row.asset] = pct_change_over_minutes(
|
|
126
|
+
cf_history,
|
|
127
|
+
index,
|
|
128
|
+
now,
|
|
129
|
+
lookback_minutes=ctx.short_drift_5m_minutes,
|
|
130
|
+
min_samples=ctx.min_samples_short,
|
|
131
|
+
)
|
|
132
|
+
range_pct = range_pct_over_minutes(
|
|
133
|
+
cf_history,
|
|
134
|
+
index,
|
|
135
|
+
now,
|
|
136
|
+
lookback_minutes=ctx.volatility_lookback_minutes,
|
|
137
|
+
min_samples=ctx.min_samples_short,
|
|
138
|
+
)
|
|
139
|
+
vol_by_asset[asset_row.asset] = _volatility_regime(
|
|
140
|
+
range_pct,
|
|
141
|
+
high_threshold=ctx.high_volatility_pct,
|
|
142
|
+
medium_threshold=ctx.medium_volatility_pct,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
cluster = build_cluster_snapshot(
|
|
146
|
+
config.cluster_log,
|
|
147
|
+
crypto_assets=config.crypto,
|
|
148
|
+
cf_history=cf_history,
|
|
149
|
+
markets=markets,
|
|
150
|
+
now=now,
|
|
151
|
+
min_samples_short=ctx.min_samples_short,
|
|
152
|
+
)
|
|
153
|
+
dominant_vol = _dominant_vol_regime(
|
|
154
|
+
{asset: regime for asset, regime in vol_by_asset.items() if regime}
|
|
155
|
+
)
|
|
156
|
+
sentiment_up_frac = cluster.sentiment_up_frac if cluster else None
|
|
157
|
+
if sentiment_up_frac is None and markets:
|
|
158
|
+
sentiment_up_frac, _ = sentiment_up_fraction(markets)
|
|
159
|
+
|
|
160
|
+
tape_context = {
|
|
161
|
+
"summary": format_tape_summary(
|
|
162
|
+
trend_60m=scale_pct_map(trend_60m),
|
|
163
|
+
vol_regime=dominant_vol,
|
|
164
|
+
sentiment_up_frac=sentiment_up_frac,
|
|
165
|
+
),
|
|
166
|
+
"trend_60m_pct": scale_pct_map(trend_60m),
|
|
167
|
+
"trend_15m_pct": scale_pct_map(trend_15m),
|
|
168
|
+
"trend_5m_pct": scale_pct_map(trend_5m),
|
|
169
|
+
"volatility_regime": dominant_vol,
|
|
170
|
+
"volatility_by_asset": {
|
|
171
|
+
asset: regime for asset, regime in vol_by_asset.items() if regime
|
|
172
|
+
}
|
|
173
|
+
or None,
|
|
174
|
+
}
|
|
175
|
+
cluster_context = cluster_advisory_fields(cluster)
|
|
176
|
+
return tape_context, cluster_context, now
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from alloccontext.config import AppConfig
|
|
12
|
+
from alloccontext.mcp.staleness import age_seconds, parse_as_of
|
|
13
|
+
from alloccontext.store.db import SCHEMA_VERSION
|
|
14
|
+
from alloccontext.store.status import ingest_status
|
|
15
|
+
from alloccontext.timeutil import utc_now
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_mcp_health_url() -> str:
|
|
19
|
+
explicit = os.environ.get("ALLOC_CONTEXT_HEALTH_URL", "").strip()
|
|
20
|
+
if explicit:
|
|
21
|
+
return explicit
|
|
22
|
+
host = os.environ.get("ALLOC_CONTEXT_MCP_HEALTH_HOST", "127.0.0.1").strip()
|
|
23
|
+
port = os.environ.get("ALLOC_CONTEXT_MCP_PORT", "8000").strip()
|
|
24
|
+
return f"http://{host}:{port}/health"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _age_from_iso(ts: str | None, *, now: datetime | None = None) -> int | None:
|
|
28
|
+
if not ts:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return age_seconds(parse_as_of(str(ts)), now=now)
|
|
32
|
+
except (TypeError, ValueError):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_age(seconds: int | None) -> str:
|
|
37
|
+
if seconds is None:
|
|
38
|
+
return "unknown"
|
|
39
|
+
if seconds < 60:
|
|
40
|
+
return f"{seconds}s ago"
|
|
41
|
+
if seconds < 3600:
|
|
42
|
+
return f"{seconds // 60}m ago"
|
|
43
|
+
if seconds < 86_400:
|
|
44
|
+
return f"{seconds // 3600}h ago"
|
|
45
|
+
return f"{seconds // 86_400}d ago"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def probe_mcp_health(url: str, *, timeout_seconds: float = 5.0) -> dict[str, Any]:
|
|
49
|
+
try:
|
|
50
|
+
response = requests.get(url, timeout=timeout_seconds)
|
|
51
|
+
detail: dict[str, Any] = {
|
|
52
|
+
"url": url,
|
|
53
|
+
"reachable": True,
|
|
54
|
+
"http_status": response.status_code,
|
|
55
|
+
"ok": response.status_code == 200,
|
|
56
|
+
}
|
|
57
|
+
if response.status_code != 200:
|
|
58
|
+
detail["error"] = response.text[:200]
|
|
59
|
+
return detail
|
|
60
|
+
try:
|
|
61
|
+
body = response.json()
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
detail["ok"] = False
|
|
64
|
+
detail["error"] = "response was not JSON"
|
|
65
|
+
return detail
|
|
66
|
+
if not isinstance(body, dict):
|
|
67
|
+
detail["ok"] = False
|
|
68
|
+
detail["error"] = "response was not a JSON object"
|
|
69
|
+
return detail
|
|
70
|
+
detail["service_ok"] = bool(body.get("ok"))
|
|
71
|
+
detail["ingest_ok"] = body.get("ingest_ok")
|
|
72
|
+
detail["source_health"] = body.get("source_health")
|
|
73
|
+
detail["status_detail"] = body.get("status_detail")
|
|
74
|
+
if not detail["service_ok"]:
|
|
75
|
+
detail["ok"] = False
|
|
76
|
+
return detail
|
|
77
|
+
except requests.RequestException as exc:
|
|
78
|
+
return {
|
|
79
|
+
"url": url,
|
|
80
|
+
"reachable": False,
|
|
81
|
+
"ok": False,
|
|
82
|
+
"error": str(exc),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _classify_sources(
|
|
87
|
+
config: AppConfig,
|
|
88
|
+
last_by_source: dict[str, dict[str, Any]],
|
|
89
|
+
*,
|
|
90
|
+
now: datetime,
|
|
91
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
92
|
+
optional = config.ingest.optional_sources
|
|
93
|
+
required: list[dict[str, Any]] = []
|
|
94
|
+
optional_rows: list[dict[str, Any]] = []
|
|
95
|
+
|
|
96
|
+
def row_for(name: str, ingest_row: dict[str, Any] | None) -> dict[str, Any]:
|
|
97
|
+
if ingest_row is None:
|
|
98
|
+
return {
|
|
99
|
+
"source": name,
|
|
100
|
+
"ok": False,
|
|
101
|
+
"never_run": True,
|
|
102
|
+
"finished_at": None,
|
|
103
|
+
"age_seconds": None,
|
|
104
|
+
"rows_upserted": None,
|
|
105
|
+
"error": "no ingest run recorded",
|
|
106
|
+
}
|
|
107
|
+
finished_at = ingest_row.get("finished_at")
|
|
108
|
+
age = _age_from_iso(finished_at, now=now)
|
|
109
|
+
error = ingest_row.get("error")
|
|
110
|
+
return {
|
|
111
|
+
"source": name,
|
|
112
|
+
"ok": error is None,
|
|
113
|
+
"never_run": False,
|
|
114
|
+
"finished_at": finished_at,
|
|
115
|
+
"age_seconds": age,
|
|
116
|
+
"rows_upserted": ingest_row.get("rows_upserted"),
|
|
117
|
+
"error": error,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for name, enabled in sorted(config.ingest.sources.items()):
|
|
121
|
+
if not enabled:
|
|
122
|
+
continue
|
|
123
|
+
entry = row_for(name, last_by_source.get(name))
|
|
124
|
+
if name in optional:
|
|
125
|
+
optional_rows.append(entry)
|
|
126
|
+
else:
|
|
127
|
+
required.append(entry)
|
|
128
|
+
|
|
129
|
+
enabled_names = {n for n, e in config.ingest.sources.items() if e}
|
|
130
|
+
for name, ingest_row in sorted(last_by_source.items()):
|
|
131
|
+
if name in enabled_names:
|
|
132
|
+
continue
|
|
133
|
+
if name not in optional:
|
|
134
|
+
continue
|
|
135
|
+
optional_rows.append(row_for(name, ingest_row))
|
|
136
|
+
|
|
137
|
+
return required, optional_rows
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def mcp_health_ingest_summary(
|
|
141
|
+
config: AppConfig,
|
|
142
|
+
conn: sqlite3.Connection,
|
|
143
|
+
*,
|
|
144
|
+
now: datetime | None = None,
|
|
145
|
+
) -> dict[str, Any]:
|
|
146
|
+
"""Required-only ingest_ok for public MCP /health."""
|
|
147
|
+
ref = now or utc_now()
|
|
148
|
+
snapshot = ingest_status(conn, now=ref)
|
|
149
|
+
last_by_source = snapshot.get("last_ingest_by_source") or {}
|
|
150
|
+
required, optional_rows = _classify_sources(config, last_by_source, now=ref)
|
|
151
|
+
required_failures = [r["source"] for r in required if not r["ok"]]
|
|
152
|
+
optional_failures = [r["source"] for r in optional_rows if not r["ok"]]
|
|
153
|
+
return {
|
|
154
|
+
"ingest_ok": not required_failures,
|
|
155
|
+
"required_failures": required_failures,
|
|
156
|
+
"optional_feed_failures": optional_failures,
|
|
157
|
+
"source_health": snapshot.get("source_health") or {},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def build_status_report(
|
|
162
|
+
config: AppConfig,
|
|
163
|
+
conn: sqlite3.Connection,
|
|
164
|
+
*,
|
|
165
|
+
probe_mcp: bool = True,
|
|
166
|
+
mcp_health_url: str | None = None,
|
|
167
|
+
mcp_timeout_seconds: float = 5.0,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
now = utc_now()
|
|
170
|
+
snapshot = ingest_status(conn, now=now)
|
|
171
|
+
last_by_source = snapshot.get("last_ingest_by_source") or {}
|
|
172
|
+
required, optional_rows = _classify_sources(config, last_by_source, now=now)
|
|
173
|
+
|
|
174
|
+
context_rows = conn.execute(
|
|
175
|
+
"""
|
|
176
|
+
SELECT scope, as_of FROM context_snapshots
|
|
177
|
+
ORDER BY scope, as_of DESC
|
|
178
|
+
"""
|
|
179
|
+
).fetchall()
|
|
180
|
+
latest_by_scope: dict[str, str] = {}
|
|
181
|
+
for row in context_rows:
|
|
182
|
+
scope = str(row["scope"])
|
|
183
|
+
if scope not in latest_by_scope:
|
|
184
|
+
latest_by_scope[scope] = str(row["as_of"])
|
|
185
|
+
|
|
186
|
+
context_snapshots = [
|
|
187
|
+
{
|
|
188
|
+
"scope": scope,
|
|
189
|
+
"as_of": as_of,
|
|
190
|
+
"age_seconds": _age_from_iso(as_of, now=now),
|
|
191
|
+
}
|
|
192
|
+
for scope, as_of in sorted(latest_by_scope.items())
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
portfolio = snapshot.get("portfolio_latest")
|
|
196
|
+
portfolio_freshness: dict[str, Any] | None = None
|
|
197
|
+
if portfolio and portfolio.get("ts"):
|
|
198
|
+
ts = str(portfolio["ts"])
|
|
199
|
+
portfolio_freshness = {
|
|
200
|
+
"ts": ts,
|
|
201
|
+
"age_seconds": _age_from_iso(ts, now=now),
|
|
202
|
+
"nav_usd": portfolio.get("nav_usd"),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
required_failures = [r["source"] for r in required if not r["ok"]]
|
|
206
|
+
optional_failures = [r["source"] for r in optional_rows if not r["ok"]]
|
|
207
|
+
ingest_ok = not required_failures
|
|
208
|
+
|
|
209
|
+
mcp: dict[str, Any] | None = None
|
|
210
|
+
if probe_mcp:
|
|
211
|
+
url = (mcp_health_url or default_mcp_health_url()).strip()
|
|
212
|
+
mcp = probe_mcp_health(url, timeout_seconds=mcp_timeout_seconds)
|
|
213
|
+
|
|
214
|
+
summary_ok = ingest_ok and (mcp is None or mcp.get("ok"))
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"ok": summary_ok,
|
|
218
|
+
"db": str(config.paths.db),
|
|
219
|
+
"schema_version": SCHEMA_VERSION,
|
|
220
|
+
"horizon_days": config.horizon.days,
|
|
221
|
+
"ingest_sources_enabled": config.ingest.sources,
|
|
222
|
+
"ingest_ok": ingest_ok,
|
|
223
|
+
"required_failures": required_failures,
|
|
224
|
+
"optional_failures": optional_failures,
|
|
225
|
+
"ingest": {"required": required, "optional": optional_rows},
|
|
226
|
+
"context_snapshots": context_snapshots,
|
|
227
|
+
"portfolio_snapshot": portfolio_freshness,
|
|
228
|
+
"fear_greed_latest": snapshot.get("fear_greed_latest"),
|
|
229
|
+
"market_bars": snapshot.get("market_bars"),
|
|
230
|
+
"source_health": snapshot.get("source_health"),
|
|
231
|
+
"last_ingest_by_source": last_by_source,
|
|
232
|
+
"recent_ingest": snapshot.get("recent_ingest"),
|
|
233
|
+
"mcp_health": mcp,
|
|
234
|
+
"summary": {
|
|
235
|
+
"ok": summary_ok,
|
|
236
|
+
"ingest_ok": ingest_ok,
|
|
237
|
+
"mcp_ok": mcp.get("ok") if mcp else None,
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _format_ingest_section(title: str, rows: list[dict[str, Any]]) -> list[str]:
|
|
243
|
+
lines = [title]
|
|
244
|
+
if not rows:
|
|
245
|
+
lines.append(" (none)")
|
|
246
|
+
return lines
|
|
247
|
+
for row in rows:
|
|
248
|
+
name = row["source"]
|
|
249
|
+
if row.get("never_run"):
|
|
250
|
+
lines.append(f" {name:<16} NEVER RUN")
|
|
251
|
+
continue
|
|
252
|
+
status = "ok" if row.get("ok") else "FAIL"
|
|
253
|
+
age = format_age(row.get("age_seconds"))
|
|
254
|
+
rows_n = row.get("rows_upserted")
|
|
255
|
+
extra = f" rows={rows_n}" if rows_n is not None else ""
|
|
256
|
+
line = f" {name:<16} {status:<4} {age}{extra}"
|
|
257
|
+
if not row.get("ok") and row.get("error"):
|
|
258
|
+
err = str(row["error"])[:80]
|
|
259
|
+
line = f"{line} ({err})"
|
|
260
|
+
lines.append(line)
|
|
261
|
+
return lines
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def format_status_report(report: dict[str, Any]) -> str:
|
|
265
|
+
lines = ["AllocContext status", f"DB: {report['db']} (schema v{report['schema_version']})"]
|
|
266
|
+
lines.append(f"Horizon: {report['horizon_days']} days")
|
|
267
|
+
lines.append("")
|
|
268
|
+
|
|
269
|
+
ingest = report.get("ingest") or {}
|
|
270
|
+
lines.extend(_format_ingest_section("Ingest (required)", ingest.get("required") or []))
|
|
271
|
+
lines.append("")
|
|
272
|
+
lines.extend(_format_ingest_section("Ingest (optional)", ingest.get("optional") or []))
|
|
273
|
+
lines.append("")
|
|
274
|
+
|
|
275
|
+
lines.append("Context snapshots")
|
|
276
|
+
snapshots = report.get("context_snapshots") or []
|
|
277
|
+
if not snapshots:
|
|
278
|
+
lines.append(" (none)")
|
|
279
|
+
else:
|
|
280
|
+
for row in snapshots:
|
|
281
|
+
age = format_age(row.get("age_seconds"))
|
|
282
|
+
lines.append(f" {row['scope']:<8} {row['as_of']} ({age})")
|
|
283
|
+
lines.append("")
|
|
284
|
+
|
|
285
|
+
portfolio = report.get("portfolio_snapshot")
|
|
286
|
+
if portfolio:
|
|
287
|
+
age = format_age(portfolio.get("age_seconds"))
|
|
288
|
+
nav = portfolio.get("nav_usd")
|
|
289
|
+
nav_part = f" NAV ${nav}" if nav is not None else ""
|
|
290
|
+
lines.append(f"Portfolio snapshot: {portfolio['ts']} ({age}){nav_part}")
|
|
291
|
+
else:
|
|
292
|
+
lines.append("Portfolio snapshot: (none)")
|
|
293
|
+
lines.append("")
|
|
294
|
+
|
|
295
|
+
mcp = report.get("mcp_health")
|
|
296
|
+
if mcp is None:
|
|
297
|
+
lines.append("MCP /health: (probe skipped)")
|
|
298
|
+
else:
|
|
299
|
+
lines.append(f"MCP /health: {mcp.get('url')}")
|
|
300
|
+
if not mcp.get("reachable"):
|
|
301
|
+
lines.append(f" unreachable ({mcp.get('error', 'unknown')})")
|
|
302
|
+
else:
|
|
303
|
+
http = mcp.get("http_status")
|
|
304
|
+
lines.append(f" HTTP {http} service_ok={mcp.get('service_ok')}")
|
|
305
|
+
if mcp.get("ingest_ok") is not None:
|
|
306
|
+
lines.append(f" ingest_ok={mcp.get('ingest_ok')}")
|
|
307
|
+
if mcp.get("status_detail"):
|
|
308
|
+
lines.append(f" detail: {mcp['status_detail']}")
|
|
309
|
+
if mcp.get("error") and not mcp.get("ok"):
|
|
310
|
+
lines.append(f" error: {mcp['error']}")
|
|
311
|
+
|
|
312
|
+
summary = report.get("summary") or {}
|
|
313
|
+
lines.append("")
|
|
314
|
+
overall = "OK" if summary.get("ok") else "ATTENTION"
|
|
315
|
+
lines.append(f"Overall: {overall}")
|
|
316
|
+
if not summary.get("ingest_ok"):
|
|
317
|
+
failed = report.get("required_failures") or []
|
|
318
|
+
lines.append(f" required ingest failures: {', '.join(failed)}")
|
|
319
|
+
if summary.get("mcp_ok") is False:
|
|
320
|
+
lines.append(" MCP health check failed")
|
|
321
|
+
return "\n".join(lines)
|
|
File without changes
|