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
alloccontext/store/db.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SCHEMA_VERSION = 8
|
|
7
|
+
|
|
8
|
+
_SCHEMA = """
|
|
9
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
10
|
+
key TEXT PRIMARY KEY,
|
|
11
|
+
value TEXT NOT NULL
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS ingest_runs (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
source TEXT NOT NULL,
|
|
17
|
+
started_at TEXT NOT NULL,
|
|
18
|
+
finished_at TEXT,
|
|
19
|
+
rows_upserted INTEGER NOT NULL DEFAULT 0,
|
|
20
|
+
error TEXT
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
ts TEXT NOT NULL UNIQUE,
|
|
26
|
+
nav_usd REAL,
|
|
27
|
+
cash_usd REAL,
|
|
28
|
+
allocation_json TEXT,
|
|
29
|
+
raw_json TEXT
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS market_bars (
|
|
33
|
+
pair TEXT NOT NULL,
|
|
34
|
+
interval_minutes INTEGER NOT NULL,
|
|
35
|
+
bar_ts INTEGER NOT NULL,
|
|
36
|
+
open REAL NOT NULL,
|
|
37
|
+
high REAL NOT NULL,
|
|
38
|
+
low REAL NOT NULL,
|
|
39
|
+
close REAL NOT NULL,
|
|
40
|
+
PRIMARY KEY (pair, interval_minutes, bar_ts)
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS fear_greed (
|
|
44
|
+
ts TEXT PRIMARY KEY,
|
|
45
|
+
value INTEGER NOT NULL,
|
|
46
|
+
classification TEXT,
|
|
47
|
+
fetched_at TEXT NOT NULL
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS kalshi_snapshots (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
ts TEXT NOT NULL,
|
|
53
|
+
tape_summary TEXT,
|
|
54
|
+
cluster_json TEXT,
|
|
55
|
+
raw_json TEXT
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS context_snapshots (
|
|
59
|
+
scope TEXT NOT NULL,
|
|
60
|
+
as_of TEXT NOT NULL,
|
|
61
|
+
context_json TEXT NOT NULL,
|
|
62
|
+
PRIMARY KEY (scope, as_of)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_context_snapshots_scope_as_of
|
|
66
|
+
ON context_snapshots(scope, as_of);
|
|
67
|
+
|
|
68
|
+
CREATE TABLE IF NOT EXISTS macro_events (
|
|
69
|
+
event_id TEXT PRIMARY KEY,
|
|
70
|
+
event_ts TEXT NOT NULL,
|
|
71
|
+
country TEXT NOT NULL,
|
|
72
|
+
name TEXT NOT NULL,
|
|
73
|
+
impact TEXT NOT NULL,
|
|
74
|
+
category TEXT,
|
|
75
|
+
actual TEXT,
|
|
76
|
+
estimate TEXT,
|
|
77
|
+
previous TEXT,
|
|
78
|
+
unit TEXT,
|
|
79
|
+
source TEXT NOT NULL,
|
|
80
|
+
raw_json TEXT,
|
|
81
|
+
fetched_at TEXT NOT NULL
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_macro_events_ts ON macro_events(event_ts);
|
|
85
|
+
|
|
86
|
+
CREATE TABLE IF NOT EXISTS etf_flow_days (
|
|
87
|
+
asset TEXT NOT NULL,
|
|
88
|
+
flow_date TEXT NOT NULL,
|
|
89
|
+
net_flow_usd REAL,
|
|
90
|
+
total_value_traded_usd REAL,
|
|
91
|
+
total_net_assets_usd REAL,
|
|
92
|
+
cum_net_inflow_usd REAL,
|
|
93
|
+
source TEXT NOT NULL,
|
|
94
|
+
fetched_at TEXT NOT NULL,
|
|
95
|
+
PRIMARY KEY (asset, flow_date)
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS etf_ticker_flows (
|
|
99
|
+
asset TEXT NOT NULL,
|
|
100
|
+
ticker TEXT NOT NULL,
|
|
101
|
+
flow_date TEXT NOT NULL,
|
|
102
|
+
net_flow_usd REAL,
|
|
103
|
+
net_assets_usd REAL,
|
|
104
|
+
institute TEXT,
|
|
105
|
+
source TEXT NOT NULL,
|
|
106
|
+
fetched_at TEXT NOT NULL,
|
|
107
|
+
PRIMARY KEY (asset, ticker, flow_date)
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
CREATE TABLE IF NOT EXISTS crypto_market_snapshots (
|
|
111
|
+
source TEXT NOT NULL,
|
|
112
|
+
snapshot_ts TEXT NOT NULL,
|
|
113
|
+
total_market_cap_usd REAL,
|
|
114
|
+
btc_dominance_pct REAL,
|
|
115
|
+
eth_dominance_pct REAL,
|
|
116
|
+
btc_rank INTEGER,
|
|
117
|
+
eth_rank INTEGER,
|
|
118
|
+
btc_price_usd REAL,
|
|
119
|
+
eth_price_usd REAL,
|
|
120
|
+
btc_market_cap_usd REAL,
|
|
121
|
+
eth_market_cap_usd REAL,
|
|
122
|
+
btc_change_pct_24h REAL,
|
|
123
|
+
eth_change_pct_24h REAL,
|
|
124
|
+
fetched_at TEXT NOT NULL,
|
|
125
|
+
PRIMARY KEY (source, snapshot_ts)
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
CREATE INDEX IF NOT EXISTS idx_crypto_market_snapshots_ts ON crypto_market_snapshots(snapshot_ts);
|
|
129
|
+
|
|
130
|
+
CREATE TABLE IF NOT EXISTS fred_observations (
|
|
131
|
+
series_id TEXT NOT NULL,
|
|
132
|
+
obs_date TEXT NOT NULL,
|
|
133
|
+
value REAL,
|
|
134
|
+
fetched_at TEXT NOT NULL,
|
|
135
|
+
PRIMARY KEY (series_id, obs_date)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_fred_observations_series_date
|
|
139
|
+
ON fred_observations(series_id, obs_date);
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def connect(db_path: Path) -> sqlite3.Connection:
|
|
144
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
conn = sqlite3.connect(str(db_path))
|
|
146
|
+
conn.row_factory = sqlite3.Row
|
|
147
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
148
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
149
|
+
conn.execute("PRAGMA busy_timeout = 5000")
|
|
150
|
+
migrate(conn)
|
|
151
|
+
return conn
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _migrate_brief_archive_to_context_snapshots(conn: sqlite3.Connection) -> None:
|
|
155
|
+
row = conn.execute(
|
|
156
|
+
"""
|
|
157
|
+
SELECT name FROM sqlite_master
|
|
158
|
+
WHERE type = 'table' AND name = 'brief_archive'
|
|
159
|
+
"""
|
|
160
|
+
).fetchone()
|
|
161
|
+
if row is None:
|
|
162
|
+
return
|
|
163
|
+
conn.execute(
|
|
164
|
+
"""
|
|
165
|
+
INSERT INTO context_snapshots(scope, as_of, context_json)
|
|
166
|
+
SELECT scope, as_of, context_json
|
|
167
|
+
FROM brief_archive
|
|
168
|
+
WHERE context_json IS NOT NULL AND context_json != ''
|
|
169
|
+
ON CONFLICT(scope, as_of) DO NOTHING
|
|
170
|
+
"""
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def migrate(conn: sqlite3.Connection) -> None:
|
|
175
|
+
conn.executescript(_SCHEMA)
|
|
176
|
+
row = conn.execute(
|
|
177
|
+
"SELECT value FROM schema_meta WHERE key = 'version'"
|
|
178
|
+
).fetchone()
|
|
179
|
+
if row is None:
|
|
180
|
+
conn.execute(
|
|
181
|
+
"INSERT INTO schema_meta(key, value) VALUES ('version', ?)",
|
|
182
|
+
(str(SCHEMA_VERSION),),
|
|
183
|
+
)
|
|
184
|
+
conn.commit()
|
|
185
|
+
elif int(row["value"]) < SCHEMA_VERSION:
|
|
186
|
+
if int(row["value"]) <= 7:
|
|
187
|
+
_migrate_brief_archive_to_context_snapshots(conn)
|
|
188
|
+
conn.execute(
|
|
189
|
+
"UPDATE schema_meta SET value = ? WHERE key = 'version'",
|
|
190
|
+
(str(SCHEMA_VERSION),),
|
|
191
|
+
)
|
|
192
|
+
conn.commit()
|
|
193
|
+
elif int(row["value"]) > SCHEMA_VERSION:
|
|
194
|
+
raise RuntimeError(
|
|
195
|
+
f"database schema version {row['value']} is newer than "
|
|
196
|
+
f"supported {SCHEMA_VERSION}; upgrade the application"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def record_ingest_run(
|
|
201
|
+
conn: sqlite3.Connection,
|
|
202
|
+
*,
|
|
203
|
+
source: str,
|
|
204
|
+
started_at: str,
|
|
205
|
+
finished_at: str,
|
|
206
|
+
rows_upserted: int,
|
|
207
|
+
error: str | None = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
conn.execute(
|
|
210
|
+
"""
|
|
211
|
+
INSERT INTO ingest_runs(source, started_at, finished_at, rows_upserted, error)
|
|
212
|
+
VALUES (?, ?, ?, ?, ?)
|
|
213
|
+
""",
|
|
214
|
+
(source, started_at, finished_at, rows_upserted, error),
|
|
215
|
+
)
|
|
216
|
+
conn.commit()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_meta(conn: sqlite3.Connection, key: str) -> str | None:
|
|
7
|
+
row = conn.execute(
|
|
8
|
+
"SELECT value FROM schema_meta WHERE key = ?", (key,)
|
|
9
|
+
).fetchone()
|
|
10
|
+
return str(row["value"]) if row else None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
|
|
14
|
+
conn.execute(
|
|
15
|
+
"""
|
|
16
|
+
INSERT INTO schema_meta(key, value) VALUES (?, ?)
|
|
17
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
18
|
+
""",
|
|
19
|
+
(key, value),
|
|
20
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
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 cutoff_iso, cutoff_unix, horizon_days
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def prune_to_horizon(conn: sqlite3.Connection, config) -> dict[str, int]:
|
|
11
|
+
"""Drop rows older than the configured quarterly horizon."""
|
|
12
|
+
days = horizon_days(config)
|
|
13
|
+
fg_cutoff = str(cutoff_unix(days=days))
|
|
14
|
+
bar_cutoff = cutoff_unix(days=days)
|
|
15
|
+
ts_cutoff = cutoff_iso(days=days)
|
|
16
|
+
|
|
17
|
+
deleted: dict[str, int] = {}
|
|
18
|
+
deleted["fear_greed"] = conn.execute(
|
|
19
|
+
"DELETE FROM fear_greed WHERE CAST(ts AS INTEGER) < ?",
|
|
20
|
+
(fg_cutoff,),
|
|
21
|
+
).rowcount
|
|
22
|
+
deleted["market_bars"] = conn.execute(
|
|
23
|
+
"DELETE FROM market_bars WHERE bar_ts < ?",
|
|
24
|
+
(bar_cutoff,),
|
|
25
|
+
).rowcount
|
|
26
|
+
deleted["portfolio_snapshots"] = conn.execute(
|
|
27
|
+
"DELETE FROM portfolio_snapshots WHERE ts < ?",
|
|
28
|
+
(ts_cutoff,),
|
|
29
|
+
).rowcount
|
|
30
|
+
deleted["kalshi_snapshots"] = conn.execute(
|
|
31
|
+
"DELETE FROM kalshi_snapshots WHERE ts < ?",
|
|
32
|
+
(ts_cutoff,),
|
|
33
|
+
).rowcount
|
|
34
|
+
deleted["context_snapshots"] = conn.execute(
|
|
35
|
+
"DELETE FROM context_snapshots WHERE as_of < ?",
|
|
36
|
+
(ts_cutoff,),
|
|
37
|
+
).rowcount
|
|
38
|
+
deleted["macro_events"] = conn.execute(
|
|
39
|
+
"DELETE FROM macro_events WHERE event_ts < ?",
|
|
40
|
+
(ts_cutoff,),
|
|
41
|
+
).rowcount
|
|
42
|
+
deleted["etf_flow_days"] = conn.execute(
|
|
43
|
+
"DELETE FROM etf_flow_days WHERE flow_date < ?",
|
|
44
|
+
(ts_cutoff[:10],),
|
|
45
|
+
).rowcount
|
|
46
|
+
deleted["etf_ticker_flows"] = conn.execute(
|
|
47
|
+
"DELETE FROM etf_ticker_flows WHERE flow_date < ?",
|
|
48
|
+
(ts_cutoff[:10],),
|
|
49
|
+
).rowcount
|
|
50
|
+
deleted["crypto_market_snapshots"] = conn.execute(
|
|
51
|
+
"DELETE FROM crypto_market_snapshots WHERE snapshot_ts < ?",
|
|
52
|
+
(ts_cutoff,),
|
|
53
|
+
).rowcount
|
|
54
|
+
deleted["fred_observations"] = conn.execute(
|
|
55
|
+
"DELETE FROM fred_observations WHERE obs_date < ?",
|
|
56
|
+
(ts_cutoff[:10],),
|
|
57
|
+
).rowcount
|
|
58
|
+
deleted["ingest_runs"] = conn.execute(
|
|
59
|
+
"DELETE FROM ingest_runs WHERE finished_at < ?",
|
|
60
|
+
(ts_cutoff,),
|
|
61
|
+
).rowcount
|
|
62
|
+
conn.commit()
|
|
63
|
+
return deleted
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from alloccontext.mcp.staleness import age_seconds, parse_as_of
|
|
8
|
+
from alloccontext.timeutil import utc_now
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _finished_age_seconds(
|
|
12
|
+
finished_at: str | None,
|
|
13
|
+
*,
|
|
14
|
+
now: datetime | None = None,
|
|
15
|
+
) -> int | None:
|
|
16
|
+
if not finished_at:
|
|
17
|
+
return None
|
|
18
|
+
try:
|
|
19
|
+
return age_seconds(parse_as_of(str(finished_at)), now=now)
|
|
20
|
+
except (TypeError, ValueError):
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _source_health(
|
|
25
|
+
last_by_source: dict[str, dict[str, Any]],
|
|
26
|
+
*,
|
|
27
|
+
now: datetime | None = None,
|
|
28
|
+
) -> dict[str, dict[str, Any]]:
|
|
29
|
+
ref = now or utc_now()
|
|
30
|
+
health: dict[str, dict[str, Any]] = {}
|
|
31
|
+
for source, row in last_by_source.items():
|
|
32
|
+
error = row.get("error")
|
|
33
|
+
finished_at = row.get("finished_at")
|
|
34
|
+
health[source] = {
|
|
35
|
+
"ok": error is None,
|
|
36
|
+
"finished_at": finished_at,
|
|
37
|
+
"age_seconds": _finished_age_seconds(finished_at, now=ref),
|
|
38
|
+
"rows_upserted": row.get("rows_upserted"),
|
|
39
|
+
"error": error,
|
|
40
|
+
}
|
|
41
|
+
return health
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ingest_status(
|
|
45
|
+
conn: sqlite3.Connection,
|
|
46
|
+
*,
|
|
47
|
+
now: datetime | None = None,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
fg = conn.execute(
|
|
50
|
+
"""
|
|
51
|
+
SELECT ts, value, classification, fetched_at
|
|
52
|
+
FROM fear_greed ORDER BY CAST(ts AS INTEGER) DESC LIMIT 1
|
|
53
|
+
"""
|
|
54
|
+
).fetchone()
|
|
55
|
+
portfolio = conn.execute(
|
|
56
|
+
"""
|
|
57
|
+
SELECT ts, nav_usd, cash_usd, allocation_json
|
|
58
|
+
FROM portfolio_snapshots ORDER BY ts DESC LIMIT 1
|
|
59
|
+
"""
|
|
60
|
+
).fetchone()
|
|
61
|
+
bars = conn.execute(
|
|
62
|
+
"""
|
|
63
|
+
SELECT pair, interval_minutes, MAX(bar_ts) AS latest_bar_ts, COUNT(*) AS bar_count
|
|
64
|
+
FROM market_bars
|
|
65
|
+
GROUP BY pair, interval_minutes
|
|
66
|
+
ORDER BY pair
|
|
67
|
+
"""
|
|
68
|
+
).fetchall()
|
|
69
|
+
recent_ingest = conn.execute(
|
|
70
|
+
"""
|
|
71
|
+
SELECT source, started_at, finished_at, rows_upserted, error
|
|
72
|
+
FROM ingest_runs ORDER BY id DESC LIMIT 20
|
|
73
|
+
"""
|
|
74
|
+
).fetchall()
|
|
75
|
+
last_by_source: dict[str, dict[str, Any]] = {}
|
|
76
|
+
for row in recent_ingest:
|
|
77
|
+
source = str(row["source"])
|
|
78
|
+
if source not in last_by_source:
|
|
79
|
+
last_by_source[source] = dict(row)
|
|
80
|
+
|
|
81
|
+
ref = now or utc_now()
|
|
82
|
+
return {
|
|
83
|
+
"fear_greed_latest": dict(fg) if fg else None,
|
|
84
|
+
"portfolio_latest": dict(portfolio) if portfolio else None,
|
|
85
|
+
"market_bars": [dict(row) for row in bars],
|
|
86
|
+
"last_ingest_by_source": last_by_source,
|
|
87
|
+
"source_health": _source_health(last_by_source, now=ref),
|
|
88
|
+
"recent_ingest": [dict(row) for row in recent_ingest[:10]],
|
|
89
|
+
}
|
alloccontext/timeutil.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""x402 production verification (CDP facilitator + discovery endpoints)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class X402ProductionCheckError(RuntimeError):
|
|
14
|
+
"""One or more production checks failed."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class X402CheckConfig:
|
|
19
|
+
public_url: str
|
|
20
|
+
local_url: str
|
|
21
|
+
pay_to: str
|
|
22
|
+
network: str
|
|
23
|
+
facilitator: str
|
|
24
|
+
cdp_api_key_id: str | None
|
|
25
|
+
cdp_api_key_secret: str | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_check_config(env: Mapping[str, str] | None = None) -> X402CheckConfig:
|
|
29
|
+
source = env if env is not None else os.environ
|
|
30
|
+
public_url = source.get("X402_PUBLIC_URL", "").rstrip("/")
|
|
31
|
+
if not public_url:
|
|
32
|
+
raise X402ProductionCheckError("X402_PUBLIC_URL is required")
|
|
33
|
+
pay_to = source.get("X402_PAY_TO", "").strip()
|
|
34
|
+
if not pay_to:
|
|
35
|
+
raise X402ProductionCheckError("X402_PAY_TO is required")
|
|
36
|
+
return X402CheckConfig(
|
|
37
|
+
public_url=public_url,
|
|
38
|
+
local_url=source.get("X402_CHECK_LOCAL", "http://127.0.0.1:8000").rstrip("/"),
|
|
39
|
+
pay_to=pay_to,
|
|
40
|
+
network=source.get("X402_NETWORK", "eip155:84532").strip(),
|
|
41
|
+
facilitator=source.get(
|
|
42
|
+
"X402_FACILITATOR_URL",
|
|
43
|
+
"https://x402.org/facilitator",
|
|
44
|
+
).strip(),
|
|
45
|
+
cdp_api_key_id=source.get("CDP_API_KEY_ID"),
|
|
46
|
+
cdp_api_key_secret=source.get("CDP_API_KEY_SECRET"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_cdp_facilitator(config: X402CheckConfig) -> str:
|
|
51
|
+
if not config.facilitator.startswith("https://api.cdp.coinbase.com"):
|
|
52
|
+
return f"facilitator {config.facilitator} (non-CDP)"
|
|
53
|
+
if not (config.cdp_api_key_id and config.cdp_api_key_secret):
|
|
54
|
+
raise X402ProductionCheckError(
|
|
55
|
+
"CDP facilitator requires CDP_API_KEY_ID and CDP_API_KEY_SECRET"
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
import httpx
|
|
59
|
+
from cdp.x402 import create_facilitator_config
|
|
60
|
+
except ImportError as exc:
|
|
61
|
+
raise X402ProductionCheckError(
|
|
62
|
+
"cdp-sdk and httpx required for CDP facilitator check"
|
|
63
|
+
) from exc
|
|
64
|
+
cfg = create_facilitator_config()
|
|
65
|
+
headers = cfg["create_headers"]()["supported"]
|
|
66
|
+
with httpx.Client(timeout=30) as client:
|
|
67
|
+
response = client.get(f"{cfg['url']}/supported", headers=headers)
|
|
68
|
+
if response.status_code != 200:
|
|
69
|
+
raise X402ProductionCheckError(
|
|
70
|
+
f"CDP /supported returned {response.status_code}: {response.text[:200]}"
|
|
71
|
+
)
|
|
72
|
+
return "CDP facilitator /supported authenticated"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fetch_ok(url: str, *, timeout: float = 20) -> tuple[int, bytes]:
|
|
76
|
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
77
|
+
return response.status, response.read()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_discovery_paths(config: X402CheckConfig) -> list[str]:
|
|
81
|
+
messages: list[str] = []
|
|
82
|
+
paths = ("/health", "/llms.txt", "/.well-known/x402.json")
|
|
83
|
+
for path in paths:
|
|
84
|
+
checked = False
|
|
85
|
+
for base in (config.local_url, config.public_url):
|
|
86
|
+
if not base:
|
|
87
|
+
continue
|
|
88
|
+
url = f"{base}{path}"
|
|
89
|
+
try:
|
|
90
|
+
status, _body = _fetch_ok(url)
|
|
91
|
+
except urllib.error.HTTPError as exc:
|
|
92
|
+
if base == config.local_url:
|
|
93
|
+
continue
|
|
94
|
+
raise X402ProductionCheckError(f"{path} returned HTTP {exc.code}") from exc
|
|
95
|
+
except urllib.error.URLError:
|
|
96
|
+
if base == config.local_url:
|
|
97
|
+
continue
|
|
98
|
+
raise
|
|
99
|
+
else:
|
|
100
|
+
if status != 200:
|
|
101
|
+
if base == config.local_url:
|
|
102
|
+
continue
|
|
103
|
+
raise X402ProductionCheckError(f"{path} returned HTTP {status}")
|
|
104
|
+
messages.append(f"GET {path} -> 200 ({base})")
|
|
105
|
+
checked = True
|
|
106
|
+
break
|
|
107
|
+
if not checked:
|
|
108
|
+
raise X402ProductionCheckError(
|
|
109
|
+
f"could not reach {path} on local or public URL"
|
|
110
|
+
)
|
|
111
|
+
return messages
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def check_manifest_pay_to(config: X402CheckConfig) -> None:
|
|
115
|
+
manifest_base = config.local_url or config.public_url
|
|
116
|
+
_status, body = _fetch_ok(f"{manifest_base}/.well-known/x402.json")
|
|
117
|
+
manifest = json.loads(body)
|
|
118
|
+
if manifest.get("payment", {}).get("payTo") != config.pay_to:
|
|
119
|
+
raise X402ProductionCheckError("x402.json payTo does not match X402_PAY_TO")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_discovery_metadata(config: X402CheckConfig) -> list[str]:
|
|
123
|
+
"""Assert static discovery copy includes Bazaar title, tags, and keywords."""
|
|
124
|
+
from alloccontext.mcp.bazaar import (
|
|
125
|
+
BAZAAR_INDEX_TAGS,
|
|
126
|
+
BAZAAR_SERVICE_NAME,
|
|
127
|
+
DISCOVERY_KEYWORD_MARKERS,
|
|
128
|
+
SERVICE_NAME,
|
|
129
|
+
SERVICE_TITLE,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
messages: list[str] = []
|
|
133
|
+
manifest_base = config.local_url or config.public_url
|
|
134
|
+
_status, manifest_body = _fetch_ok(f"{manifest_base}/.well-known/x402.json")
|
|
135
|
+
manifest = json.loads(manifest_body)
|
|
136
|
+
if manifest.get("name") != SERVICE_NAME:
|
|
137
|
+
raise X402ProductionCheckError("x402.json name missing or wrong")
|
|
138
|
+
if manifest.get("title") != SERVICE_TITLE:
|
|
139
|
+
raise X402ProductionCheckError("x402.json title missing or wrong")
|
|
140
|
+
manifest_tags = manifest.get("tags") or []
|
|
141
|
+
for tag in BAZAAR_INDEX_TAGS:
|
|
142
|
+
if tag not in manifest_tags:
|
|
143
|
+
raise X402ProductionCheckError(f"x402.json missing tag {tag!r}")
|
|
144
|
+
messages.append(f"x402.json title/tags ok ({len(manifest_tags)} tags)")
|
|
145
|
+
|
|
146
|
+
_status, llms_body = _fetch_ok(f"{manifest_base}/llms.txt")
|
|
147
|
+
llms = llms_body.decode("utf-8")
|
|
148
|
+
if BAZAAR_SERVICE_NAME not in llms and SERVICE_TITLE not in llms:
|
|
149
|
+
raise X402ProductionCheckError("llms.txt missing service title")
|
|
150
|
+
for marker in DISCOVERY_KEYWORD_MARKERS:
|
|
151
|
+
if marker not in llms.lower():
|
|
152
|
+
raise X402ProductionCheckError(f"llms.txt missing keyword marker {marker!r}")
|
|
153
|
+
messages.append("llms.txt discovery keywords ok")
|
|
154
|
+
return messages
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_mcp_payment_gate(config: X402CheckConfig) -> str:
|
|
158
|
+
mcp_base = config.local_url or config.public_url
|
|
159
|
+
req = urllib.request.Request(
|
|
160
|
+
f"{mcp_base}/mcp",
|
|
161
|
+
data=b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
|
|
162
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
163
|
+
method="POST",
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
urllib.request.urlopen(req, timeout=20)
|
|
167
|
+
raise X402ProductionCheckError("POST /mcp should return 402 without payment")
|
|
168
|
+
except urllib.error.HTTPError as exc:
|
|
169
|
+
if exc.code != 402:
|
|
170
|
+
raise X402ProductionCheckError(
|
|
171
|
+
f"POST /mcp returned HTTP {exc.code}, expected 402"
|
|
172
|
+
) from exc
|
|
173
|
+
payment_required = exc.headers.get("PAYMENT-REQUIRED") or exc.headers.get(
|
|
174
|
+
"payment-required"
|
|
175
|
+
)
|
|
176
|
+
if not payment_required:
|
|
177
|
+
raise X402ProductionCheckError("POST /mcp 402 missing PAYMENT-REQUIRED header")
|
|
178
|
+
return "POST /mcp returns 402 with PAYMENT-REQUIRED"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_production_checks(env: Mapping[str, str] | None = None) -> list[str]:
|
|
182
|
+
"""Run all x402 production checks; return OK messages."""
|
|
183
|
+
config = load_check_config(env)
|
|
184
|
+
messages = [
|
|
185
|
+
f"public URL {config.public_url}",
|
|
186
|
+
f"network {config.network}",
|
|
187
|
+
]
|
|
188
|
+
messages.append(check_cdp_facilitator(config))
|
|
189
|
+
messages.extend(check_discovery_paths(config))
|
|
190
|
+
check_manifest_pay_to(config)
|
|
191
|
+
messages.extend(check_discovery_metadata(config))
|
|
192
|
+
messages.append(check_mcp_payment_gate(config))
|
|
193
|
+
return messages
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Redact EVM addresses from paid smoke logs (CI-safe output)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
_EVM_ADDRESS = re.compile(r"0x[a-fA-F0-9]{40}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def smoke_log_addresses_enabled() -> bool:
|
|
12
|
+
"""When true, print full 0x addresses (local debugging only)."""
|
|
13
|
+
return os.environ.get("SMOKE_LOG_ADDRESSES", "").strip().lower() in (
|
|
14
|
+
"1",
|
|
15
|
+
"true",
|
|
16
|
+
"yes",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mask_evm_address(address: str) -> str:
|
|
21
|
+
"""Return a shortened address safe for CI logs."""
|
|
22
|
+
value = address.strip()
|
|
23
|
+
if not value.startswith("0x") or len(value) < 10:
|
|
24
|
+
return value
|
|
25
|
+
return f"{value[:6]}…{value[-4:]}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def redact_evm_addresses(text: str) -> str:
|
|
29
|
+
"""Replace EVM addresses in free-form text unless logging is enabled."""
|
|
30
|
+
if smoke_log_addresses_enabled():
|
|
31
|
+
return text
|
|
32
|
+
|
|
33
|
+
def _replace(match: re.Match[str]) -> str:
|
|
34
|
+
return mask_evm_address(match.group(0))
|
|
35
|
+
|
|
36
|
+
return _EVM_ADDRESS.sub(_replace, text)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def smoke_log(message: str) -> None:
|
|
40
|
+
"""Print a line with addresses redacted unless SMOKE_LOG_ADDRESSES is set."""
|
|
41
|
+
print(redact_evm_addresses(message))
|