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,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from alloccontext.store.jsonutil import canonical_json
|
|
9
|
+
|
|
10
|
+
from alloccontext.rollup.cluster_config import RollupConfig, load_rollup_config
|
|
11
|
+
from alloccontext.rollup.delta import build_delta_context
|
|
12
|
+
from alloccontext.rollup.macro import build_macro_context
|
|
13
|
+
from alloccontext.rollup.portfolio import build_market_context, build_portfolio_context
|
|
14
|
+
from alloccontext.rollup.regime import build_regime_context
|
|
15
|
+
from alloccontext.rollup.sentiment import build_sentiment_context
|
|
16
|
+
|
|
17
|
+
Scope = Literal["daily", "weekly"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_prior_context(
|
|
21
|
+
conn: sqlite3.Connection,
|
|
22
|
+
*,
|
|
23
|
+
scope: Scope,
|
|
24
|
+
prior_as_of: str | None,
|
|
25
|
+
) -> dict[str, Any] | None:
|
|
26
|
+
if not prior_as_of:
|
|
27
|
+
return None
|
|
28
|
+
row = conn.execute(
|
|
29
|
+
"""
|
|
30
|
+
SELECT context_json FROM context_snapshots
|
|
31
|
+
WHERE scope = ? AND as_of = ?
|
|
32
|
+
""",
|
|
33
|
+
(scope, prior_as_of),
|
|
34
|
+
).fetchone()
|
|
35
|
+
if row is None:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(row["context_json"])
|
|
39
|
+
except (TypeError, json.JSONDecodeError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save_context_snapshot(
|
|
44
|
+
conn: sqlite3.Connection,
|
|
45
|
+
*,
|
|
46
|
+
scope: Scope,
|
|
47
|
+
as_of: str,
|
|
48
|
+
context: dict[str, Any],
|
|
49
|
+
) -> None:
|
|
50
|
+
conn.execute(
|
|
51
|
+
"""
|
|
52
|
+
INSERT INTO context_snapshots(scope, as_of, context_json)
|
|
53
|
+
VALUES (?, ?, ?)
|
|
54
|
+
ON CONFLICT(scope, as_of) DO UPDATE SET
|
|
55
|
+
context_json = excluded.context_json
|
|
56
|
+
""",
|
|
57
|
+
(scope, as_of, canonical_json(context)),
|
|
58
|
+
)
|
|
59
|
+
conn.commit()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_context_bundle(
|
|
63
|
+
conn,
|
|
64
|
+
config,
|
|
65
|
+
*,
|
|
66
|
+
scope: Scope,
|
|
67
|
+
rollup: RollupConfig,
|
|
68
|
+
as_of: datetime | None = None,
|
|
69
|
+
save_snapshot: bool = False,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
now = (as_of or datetime.now(timezone.utc)).replace(microsecond=0)
|
|
72
|
+
|
|
73
|
+
prior_row = conn.execute(
|
|
74
|
+
"""
|
|
75
|
+
SELECT as_of FROM context_snapshots
|
|
76
|
+
WHERE scope = ? AND as_of < ?
|
|
77
|
+
ORDER BY as_of DESC LIMIT 1
|
|
78
|
+
""",
|
|
79
|
+
(scope, now.isoformat()),
|
|
80
|
+
).fetchone()
|
|
81
|
+
prior_as_of = prior_row["as_of"] if prior_row else None
|
|
82
|
+
prior_context = _load_prior_context(conn, scope=scope, prior_as_of=prior_as_of)
|
|
83
|
+
|
|
84
|
+
portfolio = build_portfolio_context(conn, config)
|
|
85
|
+
market = build_market_context(conn, config)
|
|
86
|
+
sentiment = build_sentiment_context(conn, config, rollup, now=now)
|
|
87
|
+
macro = build_macro_context(conn, config, now=now, scope=scope)
|
|
88
|
+
delta = build_delta_context(
|
|
89
|
+
conn,
|
|
90
|
+
now=now,
|
|
91
|
+
portfolio=portfolio,
|
|
92
|
+
sentiment=sentiment,
|
|
93
|
+
market=market,
|
|
94
|
+
prior_context=prior_context,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
bundle = {
|
|
98
|
+
"bundle_id": f"{scope}:{now.isoformat()}",
|
|
99
|
+
"scope": scope,
|
|
100
|
+
"as_of": now.isoformat(),
|
|
101
|
+
"prior_as_of": prior_as_of,
|
|
102
|
+
"horizon_days": config.horizon.days,
|
|
103
|
+
"portfolio": portfolio,
|
|
104
|
+
"market": market,
|
|
105
|
+
"sentiment": sentiment,
|
|
106
|
+
"macro": macro,
|
|
107
|
+
"delta": delta,
|
|
108
|
+
"regime": build_regime_context(
|
|
109
|
+
portfolio=portfolio,
|
|
110
|
+
sentiment=sentiment,
|
|
111
|
+
delta=delta,
|
|
112
|
+
prior_as_of=prior_as_of,
|
|
113
|
+
max_cash_risk_off=config.portfolio.max_cash_risk_off,
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
if save_snapshot:
|
|
117
|
+
_save_context_snapshot(conn, scope=scope, as_of=bundle["as_of"], context=bundle)
|
|
118
|
+
return bundle
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _asset_price(context: dict[str, Any] | None, symbol: str) -> float | None:
|
|
9
|
+
if not context:
|
|
10
|
+
return None
|
|
11
|
+
market = context.get("market") or {}
|
|
12
|
+
if not market.get("available"):
|
|
13
|
+
return None
|
|
14
|
+
assets = market.get("assets") or {}
|
|
15
|
+
block = assets.get(symbol.lower()) or assets.get(symbol)
|
|
16
|
+
if not isinstance(block, dict):
|
|
17
|
+
return None
|
|
18
|
+
price = block.get("price_usd")
|
|
19
|
+
return float(price) if price is not None else None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pct_change(current: float | None, prior: float | None) -> float | None:
|
|
23
|
+
if current is None or prior is None or prior == 0:
|
|
24
|
+
return None
|
|
25
|
+
return round((current - prior) / prior * 100, 2)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_delta_context(
|
|
29
|
+
conn: sqlite3.Connection,
|
|
30
|
+
*,
|
|
31
|
+
now: datetime,
|
|
32
|
+
portfolio: dict[str, Any],
|
|
33
|
+
sentiment: dict[str, Any],
|
|
34
|
+
market: dict[str, Any],
|
|
35
|
+
prior_context: dict[str, Any] | None = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
delta: dict[str, Any] = {"available": True, "notable_shifts": []}
|
|
38
|
+
|
|
39
|
+
fg = sentiment.get("fear_greed") if sentiment.get("available") else None
|
|
40
|
+
if fg:
|
|
41
|
+
prior_fg_value = None
|
|
42
|
+
if prior_context:
|
|
43
|
+
prior_sentiment = prior_context.get("sentiment") or {}
|
|
44
|
+
prior_fg = prior_sentiment.get("fear_greed") or {}
|
|
45
|
+
if prior_fg.get("value") is not None:
|
|
46
|
+
prior_fg_value = int(prior_fg["value"])
|
|
47
|
+
if prior_fg_value is None:
|
|
48
|
+
from alloccontext.rollup.fear_greed import fear_greed_at_or_before
|
|
49
|
+
|
|
50
|
+
day_ago = int((now - timedelta(days=1)).timestamp())
|
|
51
|
+
prior_row = fear_greed_at_or_before(conn, at_ts=day_ago)
|
|
52
|
+
if prior_row:
|
|
53
|
+
prior_fg_value = int(prior_row["value"])
|
|
54
|
+
|
|
55
|
+
if prior_fg_value is not None:
|
|
56
|
+
change = int(fg["value"]) - prior_fg_value
|
|
57
|
+
delta["fear_greed_change"] = change
|
|
58
|
+
if abs(change) >= 5:
|
|
59
|
+
delta["notable_shifts"].append(
|
|
60
|
+
f"F&G {prior_fg_value} → {fg['value']} ({change:+d})"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if portfolio.get("available"):
|
|
64
|
+
nav = portfolio.get("nav_usd")
|
|
65
|
+
prior_nav = None
|
|
66
|
+
if prior_context:
|
|
67
|
+
prior_portfolio = prior_context.get("portfolio") or {}
|
|
68
|
+
if prior_portfolio.get("nav_usd") is not None:
|
|
69
|
+
prior_nav = float(prior_portfolio["nav_usd"])
|
|
70
|
+
if prior_nav is not None and nav is not None:
|
|
71
|
+
pnl = round(float(nav) - prior_nav, 2)
|
|
72
|
+
delta["portfolio_nav_change_usd"] = pnl
|
|
73
|
+
if abs(pnl) >= 100:
|
|
74
|
+
delta["notable_shifts"].append(
|
|
75
|
+
f"Portfolio Δ ${pnl:+.2f} since prior snapshot"
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
pnl = (portfolio.get("pnl_usd") or {}).get("since_prior_snapshot")
|
|
79
|
+
if pnl is not None:
|
|
80
|
+
delta["portfolio_nav_change_usd"] = pnl
|
|
81
|
+
if abs(float(pnl)) >= 100:
|
|
82
|
+
delta["notable_shifts"].append(
|
|
83
|
+
f"Portfolio Δ ${pnl:+.2f} vs prior snapshot"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
market_changes: dict[str, float | None] = {}
|
|
87
|
+
for symbol in ("btc", "eth"):
|
|
88
|
+
current = _asset_price(market, symbol)
|
|
89
|
+
prior = _asset_price(prior_context, symbol)
|
|
90
|
+
change = _pct_change(current, prior)
|
|
91
|
+
if change is not None:
|
|
92
|
+
market_changes[f"{symbol}_change_pct_since_prior"] = change
|
|
93
|
+
if abs(change) >= 2:
|
|
94
|
+
delta["notable_shifts"].append(
|
|
95
|
+
f"{symbol.upper()} {change:+.2f}% since prior snapshot"
|
|
96
|
+
)
|
|
97
|
+
if market_changes:
|
|
98
|
+
delta["market"] = market_changes
|
|
99
|
+
|
|
100
|
+
if not delta.get("notable_shifts"):
|
|
101
|
+
has_metrics = (
|
|
102
|
+
delta.get("fear_greed_change") is not None
|
|
103
|
+
or delta.get("portfolio_nav_change_usd") is not None
|
|
104
|
+
or bool(market_changes)
|
|
105
|
+
)
|
|
106
|
+
if not has_metrics:
|
|
107
|
+
delta["available"] = False
|
|
108
|
+
|
|
109
|
+
return delta
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
Scope = Literal["daily", "weekly"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _sum_flows(rows) -> float | None:
|
|
10
|
+
values = [float(row["net_flow_usd"]) for row in rows if row["net_flow_usd"] is not None]
|
|
11
|
+
if not values:
|
|
12
|
+
return None
|
|
13
|
+
return round(sum(values), 2)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _latest_flow_row(conn, asset: str):
|
|
17
|
+
return conn.execute(
|
|
18
|
+
"""
|
|
19
|
+
SELECT flow_date, net_flow_usd, total_net_assets_usd, cum_net_inflow_usd, source
|
|
20
|
+
FROM etf_flow_days
|
|
21
|
+
WHERE asset = ?
|
|
22
|
+
ORDER BY flow_date DESC
|
|
23
|
+
LIMIT 1
|
|
24
|
+
""",
|
|
25
|
+
(asset,),
|
|
26
|
+
).fetchone()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _flows_since(conn, asset: str, since_date: str) -> list:
|
|
30
|
+
return conn.execute(
|
|
31
|
+
"""
|
|
32
|
+
SELECT flow_date, net_flow_usd
|
|
33
|
+
FROM etf_flow_days
|
|
34
|
+
WHERE asset = ? AND flow_date >= ?
|
|
35
|
+
ORDER BY flow_date ASC
|
|
36
|
+
""",
|
|
37
|
+
(asset, since_date),
|
|
38
|
+
).fetchall()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ticker_breakdown(conn, asset: str, flow_date: str | None) -> dict[str, float]:
|
|
42
|
+
if not flow_date:
|
|
43
|
+
return {}
|
|
44
|
+
rows = conn.execute(
|
|
45
|
+
"""
|
|
46
|
+
SELECT ticker, net_flow_usd
|
|
47
|
+
FROM etf_ticker_flows
|
|
48
|
+
WHERE asset = ? AND flow_date = ?
|
|
49
|
+
ORDER BY ABS(COALESCE(net_flow_usd, 0)) DESC
|
|
50
|
+
""",
|
|
51
|
+
(asset, flow_date),
|
|
52
|
+
).fetchall()
|
|
53
|
+
out: dict[str, float] = {}
|
|
54
|
+
for row in rows:
|
|
55
|
+
if row["net_flow_usd"] is None:
|
|
56
|
+
continue
|
|
57
|
+
out[str(row["ticker"])] = round(float(row["net_flow_usd"]), 2)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_etf_asset_context(conn, *, asset: str, now: datetime, scope: Scope) -> dict[str, Any]:
|
|
62
|
+
latest = _latest_flow_row(conn, asset)
|
|
63
|
+
if latest is None:
|
|
64
|
+
return {"available": False, "reason": "no_data"}
|
|
65
|
+
|
|
66
|
+
window_days = 1 if scope == "daily" else 7
|
|
67
|
+
since = (now.date() - timedelta(days=window_days - 1)).isoformat()
|
|
68
|
+
window_rows = _flows_since(conn, asset, since)
|
|
69
|
+
net_window = _sum_flows(window_rows)
|
|
70
|
+
|
|
71
|
+
result: dict[str, Any] = {
|
|
72
|
+
"available": True,
|
|
73
|
+
"as_of_date": latest["flow_date"],
|
|
74
|
+
"source": latest["source"],
|
|
75
|
+
"net_flow_usd_1d": (
|
|
76
|
+
round(float(latest["net_flow_usd"]), 2)
|
|
77
|
+
if latest["net_flow_usd"] is not None
|
|
78
|
+
else None
|
|
79
|
+
),
|
|
80
|
+
"net_flow_usd_7d": net_window if scope == "weekly" else _sum_flows(_flows_since(
|
|
81
|
+
conn,
|
|
82
|
+
asset,
|
|
83
|
+
(now.date() - timedelta(days=6)).isoformat(),
|
|
84
|
+
)),
|
|
85
|
+
"total_net_assets_usd": (
|
|
86
|
+
round(float(latest["total_net_assets_usd"]), 2)
|
|
87
|
+
if latest["total_net_assets_usd"] is not None
|
|
88
|
+
else None
|
|
89
|
+
),
|
|
90
|
+
"by_ticker": _ticker_breakdown(conn, asset, latest["flow_date"]),
|
|
91
|
+
}
|
|
92
|
+
if scope == "daily":
|
|
93
|
+
result["net_flow_usd_24h"] = result["net_flow_usd_1d"]
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_etf_context(conn, *, now: datetime, scope: Scope, assets: list[str]) -> dict[str, Any]:
|
|
98
|
+
blocks: dict[str, Any] = {}
|
|
99
|
+
sources: set[str] = set()
|
|
100
|
+
for asset in assets:
|
|
101
|
+
block = build_etf_asset_context(conn, asset=asset, now=now, scope=scope)
|
|
102
|
+
blocks[asset.lower()] = block
|
|
103
|
+
if block.get("available") and block.get("source"):
|
|
104
|
+
sources.add(str(block["source"]))
|
|
105
|
+
|
|
106
|
+
if not any(block.get("available") for block in blocks.values()):
|
|
107
|
+
return {"available": False, "reason": "no_etf_data"}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"available": True,
|
|
111
|
+
"assets": blocks,
|
|
112
|
+
"sources": sorted(sources),
|
|
113
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
|
|
9
|
+
def _row_to_context(row: sqlite3.Row, *, now: datetime) -> dict[str, Any]:
|
|
10
|
+
ts = int(row["ts"])
|
|
11
|
+
computed_at = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
12
|
+
age = max(0, int((now - computed_at).total_seconds()))
|
|
13
|
+
return {
|
|
14
|
+
"value": int(row["value"]),
|
|
15
|
+
"classification": str(row["classification"]),
|
|
16
|
+
"timestamp": ts,
|
|
17
|
+
"computed_at": computed_at.replace(microsecond=0).isoformat(),
|
|
18
|
+
"age_seconds": age,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_fear_greed_context(
|
|
23
|
+
conn: sqlite3.Connection,
|
|
24
|
+
*,
|
|
25
|
+
now: datetime | None = None,
|
|
26
|
+
max_age_seconds: int = 86_400 * 2,
|
|
27
|
+
) -> dict[str, Any] | None:
|
|
28
|
+
now = now or datetime.now(timezone.utc)
|
|
29
|
+
row = conn.execute(
|
|
30
|
+
"""
|
|
31
|
+
SELECT ts, value, classification, fetched_at
|
|
32
|
+
FROM fear_greed ORDER BY CAST(ts AS INTEGER) DESC LIMIT 1
|
|
33
|
+
"""
|
|
34
|
+
).fetchone()
|
|
35
|
+
if row is None:
|
|
36
|
+
return None
|
|
37
|
+
ctx = _row_to_context(row, now=now)
|
|
38
|
+
if ctx["age_seconds"] > max_age_seconds:
|
|
39
|
+
ctx["stale"] = True
|
|
40
|
+
return ctx
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fear_greed_at_or_before(
|
|
44
|
+
conn: sqlite3.Connection,
|
|
45
|
+
*,
|
|
46
|
+
at_ts: int,
|
|
47
|
+
) -> dict[str, Any] | None:
|
|
48
|
+
row = conn.execute(
|
|
49
|
+
"""
|
|
50
|
+
SELECT ts, value, classification, fetched_at
|
|
51
|
+
FROM fear_greed
|
|
52
|
+
WHERE CAST(ts AS INTEGER) <= ?
|
|
53
|
+
ORDER BY CAST(ts AS INTEGER) DESC
|
|
54
|
+
LIMIT 1
|
|
55
|
+
""",
|
|
56
|
+
(at_ts,),
|
|
57
|
+
).fetchone()
|
|
58
|
+
if row is None:
|
|
59
|
+
return None
|
|
60
|
+
at = datetime.fromtimestamp(at_ts, tz=timezone.utc)
|
|
61
|
+
return _row_to_context(row, now=at)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime, timedelta, timezone
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from alloccontext.rollup.etf import build_etf_context
|
|
7
|
+
|
|
8
|
+
Scope = Literal["daily", "weekly"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _row_to_event(row) -> dict[str, Any]:
|
|
12
|
+
return {
|
|
13
|
+
"event_id": row["event_id"],
|
|
14
|
+
"event_ts": row["event_ts"],
|
|
15
|
+
"country": row["country"],
|
|
16
|
+
"name": row["name"],
|
|
17
|
+
"impact": row["impact"],
|
|
18
|
+
"category": row["category"],
|
|
19
|
+
"actual": row["actual"],
|
|
20
|
+
"estimate": row["estimate"],
|
|
21
|
+
"previous": row["previous"],
|
|
22
|
+
"unit": row["unit"],
|
|
23
|
+
"source": row["source"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _value_on_or_before(conn, *, series_id: str, target_date: date) -> tuple[str, float] | None:
|
|
28
|
+
row = conn.execute(
|
|
29
|
+
"""
|
|
30
|
+
SELECT obs_date, value
|
|
31
|
+
FROM fred_observations
|
|
32
|
+
WHERE series_id = ? AND obs_date <= ? AND value IS NOT NULL
|
|
33
|
+
ORDER BY obs_date DESC
|
|
34
|
+
LIMIT 1
|
|
35
|
+
""",
|
|
36
|
+
(series_id, target_date.isoformat()),
|
|
37
|
+
).fetchone()
|
|
38
|
+
if row is None:
|
|
39
|
+
return None
|
|
40
|
+
return str(row["obs_date"]), float(row["value"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _latest_value(conn, *, series_id: str) -> tuple[str, float] | None:
|
|
44
|
+
row = conn.execute(
|
|
45
|
+
"""
|
|
46
|
+
SELECT obs_date, value
|
|
47
|
+
FROM fred_observations
|
|
48
|
+
WHERE series_id = ? AND value IS NOT NULL
|
|
49
|
+
ORDER BY obs_date DESC
|
|
50
|
+
LIMIT 1
|
|
51
|
+
""",
|
|
52
|
+
(series_id,),
|
|
53
|
+
).fetchone()
|
|
54
|
+
if row is None:
|
|
55
|
+
return None
|
|
56
|
+
return str(row["obs_date"]), float(row["value"])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pct_change(current: float, prior: float) -> float | None:
|
|
60
|
+
if prior == 0:
|
|
61
|
+
return None
|
|
62
|
+
return round((current - prior) / prior * 100.0, 2)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_indicators(conn, config, *, now: datetime) -> dict[str, Any]:
|
|
66
|
+
specs = config.fred.series
|
|
67
|
+
if not specs:
|
|
68
|
+
return {"available": False, "reason": "no_series_configured"}
|
|
69
|
+
|
|
70
|
+
as_of_date = now.date()
|
|
71
|
+
indicators: dict[str, Any] = {}
|
|
72
|
+
for spec in specs:
|
|
73
|
+
latest = _latest_value(conn, series_id=spec.id)
|
|
74
|
+
if latest is None:
|
|
75
|
+
continue
|
|
76
|
+
obs_date, value = latest
|
|
77
|
+
entry: dict[str, Any] = {
|
|
78
|
+
"label": spec.label,
|
|
79
|
+
"category": spec.category,
|
|
80
|
+
"value": round(value, 4),
|
|
81
|
+
"obs_date": obs_date,
|
|
82
|
+
}
|
|
83
|
+
for days, key in ((7, "change_7d"), (30, "change_30d")):
|
|
84
|
+
prior = _value_on_or_before(
|
|
85
|
+
conn,
|
|
86
|
+
series_id=spec.id,
|
|
87
|
+
target_date=as_of_date - timedelta(days=days),
|
|
88
|
+
)
|
|
89
|
+
if prior is None:
|
|
90
|
+
continue
|
|
91
|
+
prior_date, prior_value = prior
|
|
92
|
+
delta = round(value - prior_value, 4)
|
|
93
|
+
entry[key] = delta
|
|
94
|
+
entry[f"{key}_from_date"] = prior_date
|
|
95
|
+
pct = _pct_change(value, prior_value)
|
|
96
|
+
if pct is not None:
|
|
97
|
+
entry[key.replace("change", "change_pct")] = pct
|
|
98
|
+
indicators[spec.id] = entry
|
|
99
|
+
|
|
100
|
+
if not indicators:
|
|
101
|
+
return {"available": False, "reason": "no_fred_data"}
|
|
102
|
+
|
|
103
|
+
return {"available": True, "indicators": indicators}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_events(conn, *, now: datetime, scope: Scope) -> dict[str, Any]:
|
|
107
|
+
past_hours = 24 if scope == "daily" else 24 * 7
|
|
108
|
+
future_days = 7
|
|
109
|
+
past_start = (now - timedelta(hours=past_hours)).isoformat()
|
|
110
|
+
future_end = (now + timedelta(days=future_days)).isoformat()
|
|
111
|
+
now_iso = now.isoformat()
|
|
112
|
+
|
|
113
|
+
rows = conn.execute(
|
|
114
|
+
"""
|
|
115
|
+
SELECT event_id, event_ts, country, name, impact, category,
|
|
116
|
+
actual, estimate, previous, unit, source
|
|
117
|
+
FROM macro_events
|
|
118
|
+
WHERE event_ts >= ? AND event_ts <= ?
|
|
119
|
+
ORDER BY event_ts ASC
|
|
120
|
+
""",
|
|
121
|
+
(past_start, future_end),
|
|
122
|
+
).fetchall()
|
|
123
|
+
|
|
124
|
+
if not rows:
|
|
125
|
+
return {"available": False, "reason": "no_events"}
|
|
126
|
+
|
|
127
|
+
past_key = "past_24h" if scope == "daily" else "past_7d"
|
|
128
|
+
past_events = [_row_to_event(row) for row in rows if row["event_ts"] < now_iso]
|
|
129
|
+
upcoming = [_row_to_event(row) for row in rows if row["event_ts"] >= now_iso]
|
|
130
|
+
sources = sorted({row["source"] for row in rows})
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"available": True,
|
|
134
|
+
"events": {
|
|
135
|
+
past_key: past_events,
|
|
136
|
+
"next_7d": upcoming,
|
|
137
|
+
},
|
|
138
|
+
"sources": sources,
|
|
139
|
+
"counts": {
|
|
140
|
+
past_key: len(past_events),
|
|
141
|
+
"next_7d": len(upcoming),
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_macro_context(
|
|
147
|
+
conn,
|
|
148
|
+
config,
|
|
149
|
+
*,
|
|
150
|
+
now: datetime,
|
|
151
|
+
scope: Scope,
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
if now.tzinfo is None:
|
|
154
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
155
|
+
|
|
156
|
+
events_block = _build_events(conn, now=now, scope=scope)
|
|
157
|
+
etf_block = build_etf_context(
|
|
158
|
+
conn,
|
|
159
|
+
now=now,
|
|
160
|
+
scope=scope,
|
|
161
|
+
assets=list(config.etf.assets),
|
|
162
|
+
)
|
|
163
|
+
indicators_block = _build_indicators(conn, config, now=now)
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
not events_block.get("available")
|
|
167
|
+
and not etf_block.get("available")
|
|
168
|
+
and not indicators_block.get("available")
|
|
169
|
+
):
|
|
170
|
+
return {"available": False, "reason": "no_macro_data"}
|
|
171
|
+
|
|
172
|
+
sources = sorted(
|
|
173
|
+
set(events_block.get("sources") or [])
|
|
174
|
+
| set(etf_block.get("sources") or [])
|
|
175
|
+
| ({"fred"} if indicators_block.get("available") else set())
|
|
176
|
+
)
|
|
177
|
+
result: dict[str, Any] = {"available": True, "sources": sources}
|
|
178
|
+
if events_block.get("available"):
|
|
179
|
+
result["events"] = events_block["events"]
|
|
180
|
+
result["counts"] = events_block.get("counts", {})
|
|
181
|
+
if etf_block.get("available"):
|
|
182
|
+
result["etf"] = etf_block["assets"]
|
|
183
|
+
if indicators_block.get("available"):
|
|
184
|
+
result["indicators"] = indicators_block["indicators"]
|
|
185
|
+
return result
|