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,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from datetime import date, datetime, timedelta, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
FRED_OBSERVATIONS_URL = "https://api.stlouisfed.org/fred/series/observations"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fetch_series_observations(
|
|
16
|
+
*,
|
|
17
|
+
series_id: str,
|
|
18
|
+
api_key: str,
|
|
19
|
+
observation_start: date,
|
|
20
|
+
observation_end: date,
|
|
21
|
+
timeout: float,
|
|
22
|
+
) -> list[dict[str, Any]]:
|
|
23
|
+
params = urllib.parse.urlencode(
|
|
24
|
+
{
|
|
25
|
+
"series_id": series_id,
|
|
26
|
+
"api_key": api_key,
|
|
27
|
+
"file_type": "json",
|
|
28
|
+
"observation_start": observation_start.isoformat(),
|
|
29
|
+
"observation_end": observation_end.isoformat(),
|
|
30
|
+
"sort_order": "asc",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
url = f"{FRED_OBSERVATIONS_URL}?{params}"
|
|
34
|
+
request = urllib.request.Request(url, headers={"User-Agent": "alloc-context/0.1"})
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
37
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
38
|
+
except urllib.error.HTTPError as exc:
|
|
39
|
+
from alloccontext.ingest.http_errors import http_error_message
|
|
40
|
+
|
|
41
|
+
raise ValueError(
|
|
42
|
+
http_error_message(exc, context=f"fred series {series_id}")
|
|
43
|
+
) from exc
|
|
44
|
+
if not isinstance(payload, dict):
|
|
45
|
+
raise ValueError(f"invalid FRED payload for {series_id}")
|
|
46
|
+
rows = payload.get("observations") or []
|
|
47
|
+
if not isinstance(rows, list):
|
|
48
|
+
raise ValueError(f"invalid FRED observations for {series_id}")
|
|
49
|
+
return [row for row in rows if isinstance(row, dict)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_observation(row: dict[str, Any]) -> tuple[str, float | None] | None:
|
|
53
|
+
obs_date = str(row.get("date") or "").strip()
|
|
54
|
+
if not obs_date:
|
|
55
|
+
return None
|
|
56
|
+
raw_value = row.get("value")
|
|
57
|
+
if raw_value is None or raw_value == ".":
|
|
58
|
+
return obs_date, None
|
|
59
|
+
try:
|
|
60
|
+
return obs_date, float(raw_value)
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
return obs_date, None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def upsert_fred_observations(
|
|
66
|
+
conn: sqlite3.Connection,
|
|
67
|
+
*,
|
|
68
|
+
series_id: str,
|
|
69
|
+
observations: list[dict[str, Any]],
|
|
70
|
+
) -> int:
|
|
71
|
+
fetched_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
72
|
+
count = 0
|
|
73
|
+
for row in observations:
|
|
74
|
+
parsed = _parse_observation(row)
|
|
75
|
+
if parsed is None:
|
|
76
|
+
continue
|
|
77
|
+
obs_date, value = parsed
|
|
78
|
+
conn.execute(
|
|
79
|
+
"""
|
|
80
|
+
INSERT INTO fred_observations(series_id, obs_date, value, fetched_at)
|
|
81
|
+
VALUES (?, ?, ?, ?)
|
|
82
|
+
ON CONFLICT(series_id, obs_date) DO UPDATE SET
|
|
83
|
+
value=excluded.value,
|
|
84
|
+
fetched_at=excluded.fetched_at
|
|
85
|
+
""",
|
|
86
|
+
(series_id, obs_date, value, fetched_at),
|
|
87
|
+
)
|
|
88
|
+
count += 1
|
|
89
|
+
return count
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def refresh_fred(conn: sqlite3.Connection, config) -> dict[str, Any]:
|
|
93
|
+
api_key = os.environ.get("FRED_API_KEY")
|
|
94
|
+
if not api_key:
|
|
95
|
+
return {
|
|
96
|
+
"ok": True,
|
|
97
|
+
"rows": 0,
|
|
98
|
+
"skipped": True,
|
|
99
|
+
"reason": "FRED_API_KEY not set",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fred = config.fred
|
|
103
|
+
if not fred.series:
|
|
104
|
+
return {
|
|
105
|
+
"ok": True,
|
|
106
|
+
"rows": 0,
|
|
107
|
+
"skipped": True,
|
|
108
|
+
"reason": "no_fred_series_configured",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
today = datetime.now(timezone.utc).date()
|
|
112
|
+
start = today - timedelta(days=fred.lookback_days)
|
|
113
|
+
total = 0
|
|
114
|
+
series_ids: list[str] = []
|
|
115
|
+
|
|
116
|
+
for spec in fred.series:
|
|
117
|
+
try:
|
|
118
|
+
observations = fetch_series_observations(
|
|
119
|
+
series_id=spec.id,
|
|
120
|
+
api_key=api_key,
|
|
121
|
+
observation_start=start,
|
|
122
|
+
observation_end=today,
|
|
123
|
+
timeout=fred.timeout_seconds,
|
|
124
|
+
)
|
|
125
|
+
total += upsert_fred_observations(conn, series_id=spec.id, observations=observations)
|
|
126
|
+
series_ids.append(spec.id)
|
|
127
|
+
except (
|
|
128
|
+
urllib.error.URLError,
|
|
129
|
+
TimeoutError,
|
|
130
|
+
ValueError,
|
|
131
|
+
json.JSONDecodeError,
|
|
132
|
+
RuntimeError,
|
|
133
|
+
) as exc:
|
|
134
|
+
conn.rollback()
|
|
135
|
+
return {"ok": False, "error": f"{spec.id}: {exc}", "rows": 0}
|
|
136
|
+
|
|
137
|
+
conn.commit()
|
|
138
|
+
return {"ok": True, "rows": total, "series": series_ids}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib.error
|
|
4
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
5
|
+
|
|
6
|
+
_REDACTED_PARAMS = frozenset(
|
|
7
|
+
{
|
|
8
|
+
"token",
|
|
9
|
+
"apikey",
|
|
10
|
+
"api_key",
|
|
11
|
+
}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def redact_url_secrets(url: str) -> str:
|
|
16
|
+
"""Remove common API key query params before logging or error text."""
|
|
17
|
+
parsed = urlparse(url)
|
|
18
|
+
if not parsed.query:
|
|
19
|
+
return url
|
|
20
|
+
query = [
|
|
21
|
+
(key, "***" if key.lower() in _REDACTED_PARAMS else value)
|
|
22
|
+
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
|
|
23
|
+
]
|
|
24
|
+
return urlunparse(parsed._replace(query=urlencode(query)))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def http_error_message(exc: urllib.error.HTTPError, *, context: str) -> str:
|
|
28
|
+
"""HTTP failure text without embedding request URLs or secrets."""
|
|
29
|
+
return f"{context} HTTP {exc.code}"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from alloccontext.ingest.kalshi_files import load_tactical_snapshot
|
|
8
|
+
from alloccontext.ingest.kalshi_state import (
|
|
9
|
+
extract_cf_price_history,
|
|
10
|
+
extract_market_quotes_from_state,
|
|
11
|
+
load_state_json,
|
|
12
|
+
tactical_to_storage,
|
|
13
|
+
)
|
|
14
|
+
from alloccontext.store.meta import set_meta
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upsert_kalshi_snapshot(conn: sqlite3.Connection, row: dict[str, Any]) -> None:
|
|
18
|
+
conn.execute(
|
|
19
|
+
"""
|
|
20
|
+
INSERT INTO kalshi_snapshots(ts, tape_summary, cluster_json, raw_json)
|
|
21
|
+
VALUES (?, ?, ?, ?)
|
|
22
|
+
""",
|
|
23
|
+
(
|
|
24
|
+
str(row["ts"]),
|
|
25
|
+
row.get("tape_summary"),
|
|
26
|
+
row.get("cluster_json"),
|
|
27
|
+
row.get("raw_json"),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _refresh_kalshi_files(conn: sqlite3.Connection, config) -> dict[str, Any]:
|
|
33
|
+
tactical_path = config.kalshi.fallback_tactical_snapshot
|
|
34
|
+
if tactical_path is None or not tactical_path.is_file():
|
|
35
|
+
return {"ok": False, "error": "missing_kalshi_tactical_snapshot", "rows": 0}
|
|
36
|
+
|
|
37
|
+
raw = load_state_json(tactical_path)
|
|
38
|
+
if raw is None:
|
|
39
|
+
return {"ok": False, "error": "invalid_tactical_snapshot", "rows": 0}
|
|
40
|
+
|
|
41
|
+
snapshot = load_tactical_snapshot(tactical_path)
|
|
42
|
+
if snapshot is None:
|
|
43
|
+
return {"ok": False, "error": "invalid_tactical_snapshot", "rows": 0}
|
|
44
|
+
|
|
45
|
+
storage = tactical_to_storage(snapshot, raw)
|
|
46
|
+
upsert_kalshi_snapshot(conn, storage)
|
|
47
|
+
|
|
48
|
+
state_path = config.kalshi.fallback_state
|
|
49
|
+
if state_path is not None and state_path.is_file():
|
|
50
|
+
state = load_state_json(state_path)
|
|
51
|
+
if state:
|
|
52
|
+
cf = extract_cf_price_history(state)
|
|
53
|
+
if cf:
|
|
54
|
+
set_meta(conn, "cf_price_history", json.dumps(cf))
|
|
55
|
+
quotes = extract_market_quotes_from_state(state)
|
|
56
|
+
if quotes:
|
|
57
|
+
set_meta(conn, "kalshi_markets", json.dumps(quotes))
|
|
58
|
+
|
|
59
|
+
conn.commit()
|
|
60
|
+
return {
|
|
61
|
+
"ok": True,
|
|
62
|
+
"rows": 1,
|
|
63
|
+
"ts": storage["ts"],
|
|
64
|
+
"tape_summary": storage.get("tape_summary"),
|
|
65
|
+
"source": "kalshi_files",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def refresh_kalshi(conn: sqlite3.Connection, config) -> dict[str, Any]:
|
|
70
|
+
if config.kalshi.use_api:
|
|
71
|
+
from alloccontext.ingest.kalshi_api import refresh_kalshi_api
|
|
72
|
+
|
|
73
|
+
result = refresh_kalshi_api(conn, config)
|
|
74
|
+
if result.get("ok"):
|
|
75
|
+
return result
|
|
76
|
+
fallback = config.kalshi.fallback_tactical_snapshot
|
|
77
|
+
if fallback is not None and fallback.is_file():
|
|
78
|
+
file_result = _refresh_kalshi_files(conn, config)
|
|
79
|
+
if file_result.get("ok"):
|
|
80
|
+
file_result["api_error"] = result.get("error")
|
|
81
|
+
return file_result
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
return _refresh_kalshi_files(conn, config)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from alloccontext.ingest.cf_benchmarks import CFBenchmarksPriceError, fetch_prices
|
|
9
|
+
from alloccontext.ingest.cf_history import load_cf_history, record_cf_price_samples, save_cf_history
|
|
10
|
+
from alloccontext.ingest.kalshi_client import KalshiAPIError, KalshiClient, no_ask_cents_from_row, price_cents_from_row
|
|
11
|
+
from alloccontext.ingest.kalshi_state import tactical_to_storage
|
|
12
|
+
from alloccontext.rollup.cluster import MarketQuote, sentiment_up_fraction
|
|
13
|
+
from alloccontext.rollup.cluster_config import RollupConfig
|
|
14
|
+
from alloccontext.rollup.tape import build_live_tape_context, format_tape_summary
|
|
15
|
+
from alloccontext.store.meta import set_meta
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _series_tickers(config) -> list[str]:
|
|
19
|
+
return [row.series for row in config.kalshi.series if row.series]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cf_indices(config) -> list[str]:
|
|
23
|
+
seen: set[str] = set()
|
|
24
|
+
indices: list[str] = []
|
|
25
|
+
for row in config.kalshi.series:
|
|
26
|
+
if row.cf_index and row.cf_index not in seen:
|
|
27
|
+
seen.add(row.cf_index)
|
|
28
|
+
indices.append(row.cf_index)
|
|
29
|
+
return indices
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fetch_series_market_quotes(
|
|
33
|
+
client: KalshiClient, series_tickers: list[str]
|
|
34
|
+
) -> list[MarketQuote]:
|
|
35
|
+
quotes: list[MarketQuote] = []
|
|
36
|
+
seen: set[str] = set()
|
|
37
|
+
for series in series_tickers:
|
|
38
|
+
payload = client.get_markets(status="open", limit=100, series_ticker=series)
|
|
39
|
+
for row in payload.get("markets") or []:
|
|
40
|
+
if not isinstance(row, dict):
|
|
41
|
+
continue
|
|
42
|
+
ticker = str(row.get("ticker") or "")
|
|
43
|
+
if not ticker or ticker in seen:
|
|
44
|
+
continue
|
|
45
|
+
if not ticker.upper().startswith(series.upper()):
|
|
46
|
+
continue
|
|
47
|
+
yes_bid, yes_ask = price_cents_from_row(row)
|
|
48
|
+
no_ask = no_ask_cents_from_row(row)
|
|
49
|
+
if yes_ask is None and no_ask is None:
|
|
50
|
+
continue
|
|
51
|
+
quotes.append(
|
|
52
|
+
MarketQuote(
|
|
53
|
+
ticker=ticker,
|
|
54
|
+
yes_ask_cents=yes_ask if yes_ask is not None else yes_bid,
|
|
55
|
+
no_ask_cents=no_ask,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
seen.add(ticker)
|
|
59
|
+
return quotes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _markets_meta_rows(markets: list[MarketQuote]) -> list[dict[str, Any]]:
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
"ticker": market.ticker,
|
|
66
|
+
"yes_ask_cents": market.yes_ask_cents,
|
|
67
|
+
"no_ask_cents": market.no_ask_cents,
|
|
68
|
+
}
|
|
69
|
+
for market in markets
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_api_tactical_payload(
|
|
74
|
+
rollup: RollupConfig,
|
|
75
|
+
*,
|
|
76
|
+
cf_history: dict[str, list[dict[str, Any]]],
|
|
77
|
+
markets: list[MarketQuote],
|
|
78
|
+
now: datetime,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
computed = build_live_tape_context(
|
|
81
|
+
rollup,
|
|
82
|
+
cf_history=cf_history or None,
|
|
83
|
+
markets=markets,
|
|
84
|
+
now=now,
|
|
85
|
+
)
|
|
86
|
+
if computed is not None:
|
|
87
|
+
tape_ctx, cluster_ctx, at = computed
|
|
88
|
+
return {
|
|
89
|
+
"at": at.astimezone(timezone.utc).replace(microsecond=0).isoformat(),
|
|
90
|
+
"tape_summary": tape_ctx.get("summary"),
|
|
91
|
+
"trend_by_asset_60m": tape_ctx.get("trend_60m_pct") or {},
|
|
92
|
+
"trend_by_asset_15m": tape_ctx.get("trend_15m_pct") or {},
|
|
93
|
+
"volatility_regime": tape_ctx.get("volatility_regime"),
|
|
94
|
+
"cluster": cluster_ctx,
|
|
95
|
+
"daily_stats": {},
|
|
96
|
+
"source": "kalshi_api",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
sentiment_up_frac, sentiment_sample = sentiment_up_fraction(markets)
|
|
100
|
+
cluster: dict[str, Any] = {}
|
|
101
|
+
if sentiment_sample:
|
|
102
|
+
cluster["sentiment_sample"] = sentiment_sample
|
|
103
|
+
if sentiment_up_frac is not None:
|
|
104
|
+
cluster["sentiment_up_frac"] = round(sentiment_up_frac, 4)
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"at": now.astimezone(timezone.utc).replace(microsecond=0).isoformat(),
|
|
108
|
+
"tape_summary": format_tape_summary(
|
|
109
|
+
trend_60m={},
|
|
110
|
+
vol_regime=None,
|
|
111
|
+
sentiment_up_frac=sentiment_up_frac,
|
|
112
|
+
),
|
|
113
|
+
"trend_by_asset_60m": {},
|
|
114
|
+
"trend_by_asset_15m": {},
|
|
115
|
+
"volatility_regime": None,
|
|
116
|
+
"cluster": cluster,
|
|
117
|
+
"daily_stats": {},
|
|
118
|
+
"source": "kalshi_api",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def refresh_kalshi_api(conn: sqlite3.Connection, config) -> dict[str, Any]:
|
|
123
|
+
kalshi = config.kalshi
|
|
124
|
+
series = _series_tickers(config)
|
|
125
|
+
if not series:
|
|
126
|
+
return {"ok": False, "error": "no_kalshi_series_configured", "rows": 0}
|
|
127
|
+
|
|
128
|
+
now = datetime.now(timezone.utc)
|
|
129
|
+
client = KalshiClient(kalshi.base_url, timeout=kalshi.timeout_seconds)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
markets = fetch_series_market_quotes(client, series)
|
|
133
|
+
except KalshiAPIError as exc:
|
|
134
|
+
return {"ok": False, "error": str(exc), "rows": 0}
|
|
135
|
+
|
|
136
|
+
cf_history = load_cf_history(conn)
|
|
137
|
+
indices = _cf_indices(config)
|
|
138
|
+
cf_errors: dict[str, str] = {}
|
|
139
|
+
if indices:
|
|
140
|
+
try:
|
|
141
|
+
prices = fetch_prices(indices, timeout=kalshi.timeout_seconds)
|
|
142
|
+
cf_history = record_cf_price_samples(
|
|
143
|
+
cf_history,
|
|
144
|
+
prices,
|
|
145
|
+
now,
|
|
146
|
+
max_age_minutes=float(kalshi.cf_history_max_age_minutes),
|
|
147
|
+
)
|
|
148
|
+
save_cf_history(conn, cf_history)
|
|
149
|
+
except CFBenchmarksPriceError as exc:
|
|
150
|
+
cf_errors["cf_benchmarks"] = str(exc)
|
|
151
|
+
|
|
152
|
+
if markets:
|
|
153
|
+
set_meta(conn, "kalshi_markets", json.dumps(_markets_meta_rows(markets)))
|
|
154
|
+
|
|
155
|
+
payload = build_api_tactical_payload(
|
|
156
|
+
config.rollup,
|
|
157
|
+
cf_history=cf_history,
|
|
158
|
+
markets=markets,
|
|
159
|
+
now=now,
|
|
160
|
+
)
|
|
161
|
+
if not markets and not cf_history:
|
|
162
|
+
conn.rollback()
|
|
163
|
+
return {
|
|
164
|
+
"ok": False,
|
|
165
|
+
"error": "no_kalshi_markets_or_cf_history",
|
|
166
|
+
"rows": 0,
|
|
167
|
+
"feed_errors": cf_errors,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
from alloccontext.ingest.kalshi_files import KalshiTacticalSnapshot
|
|
171
|
+
|
|
172
|
+
snapshot = KalshiTacticalSnapshot(
|
|
173
|
+
at=payload.get("at"),
|
|
174
|
+
tape_summary=payload.get("tape_summary"),
|
|
175
|
+
trend_by_asset_60m=dict(payload.get("trend_by_asset_60m") or {}),
|
|
176
|
+
trend_by_asset_15m=dict(payload.get("trend_by_asset_15m") or {}),
|
|
177
|
+
volatility_regime=payload.get("volatility_regime"),
|
|
178
|
+
sentiment_up_frac=(payload.get("cluster") or {}).get("sentiment_up_frac"),
|
|
179
|
+
sentiment_sample=(payload.get("cluster") or {}).get("sentiment_sample"),
|
|
180
|
+
daily_stats=dict(payload.get("daily_stats") or {}),
|
|
181
|
+
)
|
|
182
|
+
storage = tactical_to_storage(snapshot, payload)
|
|
183
|
+
|
|
184
|
+
from alloccontext.ingest.kalshi import upsert_kalshi_snapshot
|
|
185
|
+
|
|
186
|
+
upsert_kalshi_snapshot(conn, storage)
|
|
187
|
+
|
|
188
|
+
result: dict[str, Any] = {
|
|
189
|
+
"ok": True,
|
|
190
|
+
"rows": 1,
|
|
191
|
+
"ts": storage["ts"],
|
|
192
|
+
"tape_summary": storage.get("tape_summary"),
|
|
193
|
+
"markets_sampled": len(markets),
|
|
194
|
+
"source": "kalshi_api",
|
|
195
|
+
}
|
|
196
|
+
if cf_errors:
|
|
197
|
+
result["feed_errors"] = cf_errors
|
|
198
|
+
conn.commit()
|
|
199
|
+
return result
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KalshiAPIError(RuntimeError):
|
|
12
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class KalshiClient:
|
|
18
|
+
"""Read-only Kalshi REST client (public market endpoints)."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, base_url: str, *, timeout: float = 20.0):
|
|
21
|
+
self.base_url = base_url.rstrip("/")
|
|
22
|
+
self.timeout = timeout
|
|
23
|
+
|
|
24
|
+
def _request(self, method: str, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
25
|
+
if not path.startswith("/"):
|
|
26
|
+
path = f"/{path}"
|
|
27
|
+
query = f"?{urllib.parse.urlencode(params)}" if params else ""
|
|
28
|
+
url = f"{self.base_url}{path}{query}"
|
|
29
|
+
request = urllib.request.Request(
|
|
30
|
+
url,
|
|
31
|
+
headers={"Accept": "application/json", "User-Agent": "alloc-context/0.1"},
|
|
32
|
+
method=method.upper(),
|
|
33
|
+
)
|
|
34
|
+
for attempt in range(4):
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
37
|
+
body = response.read().decode("utf-8")
|
|
38
|
+
except urllib.error.HTTPError as exc:
|
|
39
|
+
if exc.code == 429 and attempt < 3:
|
|
40
|
+
time.sleep(2**attempt)
|
|
41
|
+
continue
|
|
42
|
+
raise KalshiAPIError(
|
|
43
|
+
f"Kalshi API {method.upper()} {path} failed: {exc.code}",
|
|
44
|
+
status_code=exc.code,
|
|
45
|
+
) from exc
|
|
46
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
47
|
+
raise KalshiAPIError(f"Kalshi API {method.upper()} {path} failed: {exc}") from exc
|
|
48
|
+
break
|
|
49
|
+
if not body:
|
|
50
|
+
return {}
|
|
51
|
+
parsed = json.loads(body)
|
|
52
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
53
|
+
|
|
54
|
+
def get_markets(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
status: str | None = None,
|
|
58
|
+
limit: int = 100,
|
|
59
|
+
cursor: str | None = None,
|
|
60
|
+
series_ticker: str | None = None,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
params: dict[str, Any] = {"limit": limit}
|
|
63
|
+
if status:
|
|
64
|
+
params["status"] = status
|
|
65
|
+
if cursor:
|
|
66
|
+
params["cursor"] = cursor
|
|
67
|
+
if series_ticker:
|
|
68
|
+
params["series_ticker"] = series_ticker
|
|
69
|
+
return self._request("GET", "/markets", params=params)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dollars_to_cents(value: str | int | float | None) -> int | None:
|
|
73
|
+
if value is None or value == "":
|
|
74
|
+
return None
|
|
75
|
+
return max(1, min(99, round(float(value) * 100)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def price_cents_from_row(row: dict[str, Any]) -> tuple[int | None, int | None]:
|
|
79
|
+
if row.get("yes_bid") is not None or row.get("yes_ask") is not None:
|
|
80
|
+
bid = row.get("yes_bid")
|
|
81
|
+
ask = row.get("yes_ask")
|
|
82
|
+
return (
|
|
83
|
+
int(bid) if bid is not None else None,
|
|
84
|
+
int(ask) if ask is not None else None,
|
|
85
|
+
)
|
|
86
|
+
return (
|
|
87
|
+
dollars_to_cents(row.get("yes_bid_dollars")),
|
|
88
|
+
dollars_to_cents(row.get("yes_ask_dollars")),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def no_ask_cents_from_row(row: dict[str, Any]) -> int | None:
|
|
93
|
+
if row.get("no_ask") is not None:
|
|
94
|
+
return int(row["no_ask"])
|
|
95
|
+
return dollars_to_cents(row.get("no_ask_dollars"))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class KalshiTacticalSnapshot:
|
|
11
|
+
at: str | None
|
|
12
|
+
tape_summary: str | None
|
|
13
|
+
trend_by_asset_60m: dict[str, float | None]
|
|
14
|
+
trend_by_asset_15m: dict[str, float | None]
|
|
15
|
+
volatility_regime: str | None
|
|
16
|
+
sentiment_up_frac: float | None
|
|
17
|
+
sentiment_sample: int | None
|
|
18
|
+
daily_stats: dict[str, Any]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_json(path: Path) -> Any | None:
|
|
22
|
+
if not path.exists():
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
26
|
+
except (json.JSONDecodeError, OSError):
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_tactical_snapshot(path: Path) -> KalshiTacticalSnapshot | None:
|
|
31
|
+
raw = _read_json(path)
|
|
32
|
+
if not isinstance(raw, dict):
|
|
33
|
+
return None
|
|
34
|
+
cluster = raw.get("cluster") if isinstance(raw.get("cluster"), dict) else {}
|
|
35
|
+
return KalshiTacticalSnapshot(
|
|
36
|
+
at=raw.get("at"),
|
|
37
|
+
tape_summary=raw.get("tape_summary"),
|
|
38
|
+
trend_by_asset_60m=dict(raw.get("trend_by_asset_60m") or {}),
|
|
39
|
+
trend_by_asset_15m=dict(raw.get("trend_by_asset_15m") or {}),
|
|
40
|
+
volatility_regime=raw.get("volatility_regime"),
|
|
41
|
+
sentiment_up_frac=cluster.get("sentiment_up_frac"),
|
|
42
|
+
sentiment_sample=cluster.get("sentiment_sample"),
|
|
43
|
+
daily_stats=dict(raw.get("daily_stats") or {}),
|
|
44
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from alloccontext.ingest.kalshi_files import KalshiTacticalSnapshot, load_tactical_snapshot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_cf_price_history(raw: dict[str, Any]) -> dict[str, Any]:
|
|
11
|
+
history = raw.get("cf_price_history")
|
|
12
|
+
if isinstance(history, dict):
|
|
13
|
+
return history
|
|
14
|
+
return {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_market_quotes_from_state(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
|
18
|
+
by_ticker: dict[str, dict[str, Any]] = {}
|
|
19
|
+
for source in ("opportunity_log_expiry", "journal_expiry"):
|
|
20
|
+
for entry in raw.get(source) or []:
|
|
21
|
+
ticker = entry.get("ticker")
|
|
22
|
+
if not ticker:
|
|
23
|
+
continue
|
|
24
|
+
upper = str(ticker).upper()
|
|
25
|
+
if not any(tag in upper for tag in ("BTCD", "ETHD", "15M")):
|
|
26
|
+
continue
|
|
27
|
+
yes_ask = entry.get("yes_ask_cents")
|
|
28
|
+
no_ask = entry.get("no_ask_cents")
|
|
29
|
+
if yes_ask is None and no_ask is None:
|
|
30
|
+
continue
|
|
31
|
+
ts = str(entry.get("at") or "")
|
|
32
|
+
existing = by_ticker.get(str(ticker))
|
|
33
|
+
if existing is None or ts >= str(existing.get("at") or ""):
|
|
34
|
+
by_ticker[str(ticker)] = {
|
|
35
|
+
"ticker": str(ticker),
|
|
36
|
+
"yes_ask_cents": yes_ask,
|
|
37
|
+
"no_ask_cents": no_ask,
|
|
38
|
+
"at": ts,
|
|
39
|
+
}
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
"ticker": row["ticker"],
|
|
43
|
+
"yes_ask_cents": row.get("yes_ask_cents"),
|
|
44
|
+
"no_ask_cents": row.get("no_ask_cents"),
|
|
45
|
+
}
|
|
46
|
+
for row in by_ticker.values()
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_state_json(path: Path) -> dict[str, Any] | None:
|
|
51
|
+
if not path.is_file():
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
return None
|
|
57
|
+
return raw if isinstance(raw, dict) else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def tactical_to_storage(snapshot: KalshiTacticalSnapshot, raw: dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
cluster = raw.get("cluster") if isinstance(raw.get("cluster"), dict) else {}
|
|
62
|
+
return {
|
|
63
|
+
"ts": snapshot.at or raw.get("at"),
|
|
64
|
+
"tape_summary": snapshot.tape_summary,
|
|
65
|
+
"cluster_json": json.dumps(cluster),
|
|
66
|
+
"raw_json": json.dumps(raw),
|
|
67
|
+
}
|