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,487 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from alloccontext.rollup.band import check_allocation_band
|
|
8
|
+
from alloccontext.ingest.exchange.live import (
|
|
9
|
+
LivePortfolioError,
|
|
10
|
+
fetch_live_portfolio_snapshot,
|
|
11
|
+
portfolio_state_from_snapshot,
|
|
12
|
+
validate_exchange_id,
|
|
13
|
+
)
|
|
14
|
+
from alloccontext.rollup.context import Scope
|
|
15
|
+
from alloccontext.rollup.macro import build_macro_context
|
|
16
|
+
from alloccontext.rollup.portfolio import build_market_context
|
|
17
|
+
from alloccontext.rollup.rebalance import compute_rebalance_plan
|
|
18
|
+
from alloccontext.rollup.sentiment import build_sentiment_context
|
|
19
|
+
from alloccontext.mcp.assets import (
|
|
20
|
+
apply_assets_filter_to_bundle,
|
|
21
|
+
apply_assets_filter_to_market_payload,
|
|
22
|
+
filter_market_assets,
|
|
23
|
+
validate_view_assets,
|
|
24
|
+
)
|
|
25
|
+
from alloccontext.mcp.staleness import with_staleness
|
|
26
|
+
from alloccontext.mcp.validation import validate_band, validate_nav_usd, validate_target_pct
|
|
27
|
+
from alloccontext.rollup.comparison import compare_context_bundles
|
|
28
|
+
from alloccontext.rollup.regime import build_regime_context
|
|
29
|
+
from alloccontext.rollup.snapshots import (
|
|
30
|
+
SnapshotNotFoundError,
|
|
31
|
+
load_context_bundle_snapshot,
|
|
32
|
+
resolve_context_snapshot_as_of,
|
|
33
|
+
)
|
|
34
|
+
from alloccontext.timeutil import utc_now
|
|
35
|
+
|
|
36
|
+
_ASSETS = ("BTC", "ETH", "CASH")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_pct(values: dict[str, float]) -> dict[str, float]:
|
|
40
|
+
return {asset: float(values.get(asset) or 0) for asset in _ASSETS}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Freshness = Literal["cached", "live"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_freshness(freshness: str) -> Freshness:
|
|
47
|
+
if freshness not in ("cached", "live"):
|
|
48
|
+
raise ValueError("freshness must be 'cached' or 'live'")
|
|
49
|
+
return freshness # type: ignore[return-value]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ingest_summary(result: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"ok": bool(result.get("ok")),
|
|
55
|
+
"errors": dict(result.get("errors") or {}),
|
|
56
|
+
"counts": dict(result.get("counts") or {}),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _live_ingest_failure_payload(
|
|
61
|
+
ingest_result: dict[str, Any],
|
|
62
|
+
*,
|
|
63
|
+
as_of: datetime,
|
|
64
|
+
) -> dict[str, Any] | None:
|
|
65
|
+
"""Return a fail-closed MCP payload when live ingest had required failures."""
|
|
66
|
+
fatal = ingest_result.get("fatal_errors") or {}
|
|
67
|
+
if not fatal:
|
|
68
|
+
return None
|
|
69
|
+
return with_staleness(
|
|
70
|
+
{
|
|
71
|
+
"available": False,
|
|
72
|
+
"reason": "live_ingest_failed",
|
|
73
|
+
"fatal_errors": dict(fatal),
|
|
74
|
+
"ingest": _ingest_summary(ingest_result),
|
|
75
|
+
"freshness": "live",
|
|
76
|
+
},
|
|
77
|
+
as_of=as_of,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _apply_allocation_targets(
|
|
82
|
+
portfolio: dict[str, Any],
|
|
83
|
+
config,
|
|
84
|
+
*,
|
|
85
|
+
target_pct: dict[str, float] | None,
|
|
86
|
+
band: float | None,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
if not portfolio.get("available"):
|
|
89
|
+
return portfolio
|
|
90
|
+
if target_pct is None and band is None:
|
|
91
|
+
return portfolio
|
|
92
|
+
|
|
93
|
+
target = (
|
|
94
|
+
validate_target_pct(target_pct)
|
|
95
|
+
if target_pct is not None
|
|
96
|
+
else validate_target_pct(
|
|
97
|
+
portfolio.get("target_allocation_pct")
|
|
98
|
+
or dict(config.portfolio.target_allocations)
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
band_width = (
|
|
102
|
+
validate_band(band)
|
|
103
|
+
if band is not None
|
|
104
|
+
else validate_band(portfolio.get("band", config.portfolio.rebalance_band))
|
|
105
|
+
)
|
|
106
|
+
band_result = check_allocation_band(
|
|
107
|
+
portfolio.get("allocation_pct") or {},
|
|
108
|
+
target,
|
|
109
|
+
band_width,
|
|
110
|
+
)
|
|
111
|
+
updated = dict(portfolio)
|
|
112
|
+
updated["target_allocation_pct"] = target
|
|
113
|
+
updated["drift"] = band_result["drift"]
|
|
114
|
+
updated["rebalance_hint"] = band_result["hint"]
|
|
115
|
+
updated["outside_band"] = band_result["outside_band"]
|
|
116
|
+
updated["max_drift"] = band_result["max_drift"]
|
|
117
|
+
updated["band"] = band_width
|
|
118
|
+
return updated
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _attach_regime(bundle: dict[str, Any], config) -> dict[str, Any]:
|
|
122
|
+
bundle["regime"] = build_regime_context(
|
|
123
|
+
portfolio=bundle.get("portfolio") or {},
|
|
124
|
+
sentiment=bundle.get("sentiment") or {},
|
|
125
|
+
delta=bundle.get("delta") or {},
|
|
126
|
+
prior_as_of=bundle.get("prior_as_of"),
|
|
127
|
+
max_cash_risk_off=config.portfolio.max_cash_risk_off,
|
|
128
|
+
)
|
|
129
|
+
return bundle
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_context_at(
|
|
133
|
+
conn: sqlite3.Connection,
|
|
134
|
+
config,
|
|
135
|
+
*,
|
|
136
|
+
scope: Scope = "daily",
|
|
137
|
+
as_of: str,
|
|
138
|
+
match: Literal["exact", "at_or_before"] = "at_or_before",
|
|
139
|
+
assets: list[str] | None = None,
|
|
140
|
+
target_pct: dict[str, float] | None = None,
|
|
141
|
+
band: float | None = None,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
view_assets = validate_view_assets(assets)
|
|
144
|
+
try:
|
|
145
|
+
resolved = resolve_context_snapshot_as_of(
|
|
146
|
+
conn,
|
|
147
|
+
scope=scope,
|
|
148
|
+
as_of=as_of,
|
|
149
|
+
mode=match,
|
|
150
|
+
)
|
|
151
|
+
bundle = load_context_bundle_snapshot(conn, scope=scope, as_of=resolved)
|
|
152
|
+
except SnapshotNotFoundError as exc:
|
|
153
|
+
return {
|
|
154
|
+
"available": False,
|
|
155
|
+
"reason": str(exc),
|
|
156
|
+
"scope": scope,
|
|
157
|
+
"requested_as_of": as_of,
|
|
158
|
+
"match": match,
|
|
159
|
+
}
|
|
160
|
+
if target_pct is not None or band is not None:
|
|
161
|
+
bundle["portfolio"] = _apply_allocation_targets(
|
|
162
|
+
bundle.get("portfolio") or {},
|
|
163
|
+
config,
|
|
164
|
+
target_pct=target_pct,
|
|
165
|
+
band=band,
|
|
166
|
+
)
|
|
167
|
+
bundle = apply_assets_filter_to_bundle(bundle, view_assets)
|
|
168
|
+
bundle = _attach_regime(bundle, config)
|
|
169
|
+
if target_pct is not None:
|
|
170
|
+
bundle["target_pct"] = validate_target_pct(target_pct)
|
|
171
|
+
if band is not None:
|
|
172
|
+
bundle["band"] = validate_band(band)
|
|
173
|
+
bundle["snapshot_as_of"] = resolved
|
|
174
|
+
bundle["requested_as_of"] = as_of
|
|
175
|
+
bundle["match"] = match
|
|
176
|
+
return bundle
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_context_delta(
|
|
180
|
+
conn: sqlite3.Connection,
|
|
181
|
+
config,
|
|
182
|
+
*,
|
|
183
|
+
scope: Scope = "daily",
|
|
184
|
+
prior_as_of: str,
|
|
185
|
+
current_as_of: str | None = None,
|
|
186
|
+
assets: list[str] | None = None,
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
view_assets = validate_view_assets(assets)
|
|
189
|
+
try:
|
|
190
|
+
prior_resolved = resolve_context_snapshot_as_of(
|
|
191
|
+
conn,
|
|
192
|
+
scope=scope,
|
|
193
|
+
as_of=prior_as_of,
|
|
194
|
+
mode="at_or_before",
|
|
195
|
+
)
|
|
196
|
+
prior = load_context_bundle_snapshot(conn, scope=scope, as_of=prior_resolved)
|
|
197
|
+
if current_as_of:
|
|
198
|
+
current_resolved = resolve_context_snapshot_as_of(
|
|
199
|
+
conn,
|
|
200
|
+
scope=scope,
|
|
201
|
+
as_of=current_as_of,
|
|
202
|
+
mode="at_or_before",
|
|
203
|
+
)
|
|
204
|
+
current = load_context_bundle_snapshot(conn, scope=scope, as_of=current_resolved)
|
|
205
|
+
else:
|
|
206
|
+
from alloccontext.rollup.context import build_context_bundle
|
|
207
|
+
|
|
208
|
+
current = build_context_bundle(
|
|
209
|
+
conn,
|
|
210
|
+
config,
|
|
211
|
+
scope=scope,
|
|
212
|
+
rollup=config.rollup,
|
|
213
|
+
save_snapshot=False,
|
|
214
|
+
)
|
|
215
|
+
current_resolved = current.get("as_of")
|
|
216
|
+
except SnapshotNotFoundError as exc:
|
|
217
|
+
return {
|
|
218
|
+
"available": False,
|
|
219
|
+
"reason": str(exc),
|
|
220
|
+
"scope": scope,
|
|
221
|
+
"prior_as_of": prior_as_of,
|
|
222
|
+
"current_as_of": current_as_of,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
prior = apply_assets_filter_to_bundle(prior, view_assets)
|
|
226
|
+
current = apply_assets_filter_to_bundle(current, view_assets)
|
|
227
|
+
diff = compare_context_bundles(prior, current)
|
|
228
|
+
diff["scope"] = scope
|
|
229
|
+
diff["prior_snapshot_as_of"] = prior_resolved
|
|
230
|
+
diff["current_snapshot_as_of"] = current_resolved
|
|
231
|
+
return diff
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def check_allocation_bands(
|
|
235
|
+
allocation_pct: dict[str, float],
|
|
236
|
+
scenarios: list[dict[str, Any]],
|
|
237
|
+
*,
|
|
238
|
+
as_of: datetime | None = None,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
now = (as_of or utc_now()).replace(microsecond=0)
|
|
241
|
+
normalized_allocation = _normalize_pct(allocation_pct)
|
|
242
|
+
results: list[dict[str, Any]] = []
|
|
243
|
+
for index, scenario in enumerate(scenarios):
|
|
244
|
+
name = str(scenario.get("name") or f"scenario_{index + 1}")
|
|
245
|
+
target = validate_target_pct(scenario.get("target_pct") or {})
|
|
246
|
+
band = validate_band(scenario.get("band", 0.15))
|
|
247
|
+
check = check_allocation_band(normalized_allocation, target, band)
|
|
248
|
+
results.append(
|
|
249
|
+
{
|
|
250
|
+
"name": name,
|
|
251
|
+
"target_pct": target,
|
|
252
|
+
"band": band,
|
|
253
|
+
**check,
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
return with_staleness(
|
|
257
|
+
{
|
|
258
|
+
"allocation_pct": normalized_allocation,
|
|
259
|
+
"scenarios": results,
|
|
260
|
+
},
|
|
261
|
+
as_of=now,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def get_context_bundle(
|
|
266
|
+
conn: sqlite3.Connection,
|
|
267
|
+
config,
|
|
268
|
+
*,
|
|
269
|
+
scope: Scope = "daily",
|
|
270
|
+
freshness: Freshness = "cached",
|
|
271
|
+
as_of: datetime | None = None,
|
|
272
|
+
assets: list[str] | None = None,
|
|
273
|
+
target_pct: dict[str, float] | None = None,
|
|
274
|
+
band: float | None = None,
|
|
275
|
+
) -> dict[str, Any]:
|
|
276
|
+
view_assets = validate_view_assets(assets)
|
|
277
|
+
now = (as_of or utc_now()).replace(microsecond=0)
|
|
278
|
+
if now.tzinfo is None:
|
|
279
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
280
|
+
|
|
281
|
+
ingest_result: dict[str, Any] | None = None
|
|
282
|
+
if freshness == "live":
|
|
283
|
+
from alloccontext.ingest.runner import run_ingest
|
|
284
|
+
|
|
285
|
+
ingest_result = run_ingest(conn, config)
|
|
286
|
+
failure = _live_ingest_failure_payload(ingest_result, as_of=now)
|
|
287
|
+
if failure is not None:
|
|
288
|
+
return failure
|
|
289
|
+
|
|
290
|
+
from alloccontext.rollup.context import build_context_bundle
|
|
291
|
+
|
|
292
|
+
bundle = build_context_bundle(
|
|
293
|
+
conn,
|
|
294
|
+
config,
|
|
295
|
+
scope=scope,
|
|
296
|
+
rollup=config.rollup,
|
|
297
|
+
as_of=now,
|
|
298
|
+
save_snapshot=False,
|
|
299
|
+
)
|
|
300
|
+
if target_pct is not None or band is not None:
|
|
301
|
+
bundle["portfolio"] = _apply_allocation_targets(
|
|
302
|
+
bundle.get("portfolio") or {},
|
|
303
|
+
config,
|
|
304
|
+
target_pct=target_pct,
|
|
305
|
+
band=band,
|
|
306
|
+
)
|
|
307
|
+
bundle = apply_assets_filter_to_bundle(bundle, view_assets)
|
|
308
|
+
bundle = _attach_regime(bundle, config)
|
|
309
|
+
if target_pct is not None:
|
|
310
|
+
bundle["target_pct"] = validate_target_pct(target_pct)
|
|
311
|
+
if band is not None:
|
|
312
|
+
bundle["band"] = validate_band(band)
|
|
313
|
+
payload = with_staleness(bundle, as_of=now)
|
|
314
|
+
payload["freshness"] = freshness
|
|
315
|
+
if ingest_result is not None:
|
|
316
|
+
payload["ingest"] = _ingest_summary(ingest_result)
|
|
317
|
+
return payload
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def get_market_context(
|
|
321
|
+
conn: sqlite3.Connection,
|
|
322
|
+
config,
|
|
323
|
+
*,
|
|
324
|
+
scope: Scope = "daily",
|
|
325
|
+
as_of: datetime | None = None,
|
|
326
|
+
freshness: Freshness = "cached",
|
|
327
|
+
assets: list[str] | None = None,
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
view_assets = validate_view_assets(assets)
|
|
330
|
+
now = (as_of or utc_now()).replace(microsecond=0)
|
|
331
|
+
if now.tzinfo is None:
|
|
332
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
333
|
+
|
|
334
|
+
ingest_result: dict[str, Any] | None = None
|
|
335
|
+
if freshness == "live":
|
|
336
|
+
from alloccontext.ingest.runner import run_ingest
|
|
337
|
+
|
|
338
|
+
ingest_result = run_ingest(conn, config)
|
|
339
|
+
failure = _live_ingest_failure_payload(ingest_result, as_of=now)
|
|
340
|
+
if failure is not None:
|
|
341
|
+
return failure
|
|
342
|
+
|
|
343
|
+
sentiment = build_sentiment_context(conn, config, config.rollup, now=now)
|
|
344
|
+
macro = build_macro_context(conn, config, now=now, scope=scope)
|
|
345
|
+
market = filter_market_assets(build_market_context(conn, config), view_assets)
|
|
346
|
+
|
|
347
|
+
macro_subset: dict[str, Any]
|
|
348
|
+
if macro.get("available"):
|
|
349
|
+
macro_subset = {
|
|
350
|
+
"available": True,
|
|
351
|
+
"sources": macro.get("sources") or [],
|
|
352
|
+
}
|
|
353
|
+
for key in ("events", "indicators", "counts"):
|
|
354
|
+
if key in macro:
|
|
355
|
+
macro_subset[key] = macro[key]
|
|
356
|
+
else:
|
|
357
|
+
macro_subset = macro
|
|
358
|
+
|
|
359
|
+
etf_block: dict[str, Any]
|
|
360
|
+
if macro.get("available") and macro.get("etf"):
|
|
361
|
+
etf_block = {"available": True, "assets": macro["etf"]}
|
|
362
|
+
else:
|
|
363
|
+
etf_block = {"available": False, "reason": "no_etf_data"}
|
|
364
|
+
|
|
365
|
+
if market.get("available") and market.get("breadth"):
|
|
366
|
+
breadth = market["breadth"]
|
|
367
|
+
else:
|
|
368
|
+
breadth = {"available": False, "reason": "no_breadth_data"}
|
|
369
|
+
|
|
370
|
+
payload = with_staleness(
|
|
371
|
+
{
|
|
372
|
+
"scope": scope,
|
|
373
|
+
"freshness": freshness,
|
|
374
|
+
"market": market,
|
|
375
|
+
"sentiment": sentiment,
|
|
376
|
+
"macro": macro_subset,
|
|
377
|
+
"etf": etf_block,
|
|
378
|
+
"breadth": breadth,
|
|
379
|
+
},
|
|
380
|
+
as_of=now,
|
|
381
|
+
)
|
|
382
|
+
payload = apply_assets_filter_to_market_payload(payload, view_assets)
|
|
383
|
+
if ingest_result is not None:
|
|
384
|
+
payload["ingest"] = _ingest_summary(ingest_result)
|
|
385
|
+
return payload
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def get_rebalance_plan(
|
|
389
|
+
allocation_pct: dict[str, float],
|
|
390
|
+
target_pct: dict[str, float],
|
|
391
|
+
nav_usd: float,
|
|
392
|
+
*,
|
|
393
|
+
exchange: str = "kraken",
|
|
394
|
+
band: float | None = None,
|
|
395
|
+
as_of: datetime | None = None,
|
|
396
|
+
) -> dict[str, Any]:
|
|
397
|
+
now = (as_of or utc_now()).replace(microsecond=0)
|
|
398
|
+
exchange_id = validate_exchange_id(exchange)
|
|
399
|
+
normalized_allocation = _normalize_pct(allocation_pct)
|
|
400
|
+
normalized_target = validate_target_pct(target_pct)
|
|
401
|
+
nav = validate_nav_usd(nav_usd)
|
|
402
|
+
plan = compute_rebalance_plan(
|
|
403
|
+
nav,
|
|
404
|
+
normalized_allocation,
|
|
405
|
+
normalized_target,
|
|
406
|
+
exchange=exchange_id,
|
|
407
|
+
)
|
|
408
|
+
body: dict[str, Any] = {
|
|
409
|
+
"allocation_pct": normalized_allocation,
|
|
410
|
+
"target_pct": normalized_target,
|
|
411
|
+
**plan,
|
|
412
|
+
}
|
|
413
|
+
if band is not None:
|
|
414
|
+
body["band_check"] = check_allocation_band(
|
|
415
|
+
normalized_allocation,
|
|
416
|
+
normalized_target,
|
|
417
|
+
validate_band(band),
|
|
418
|
+
)
|
|
419
|
+
return with_staleness(body, as_of=now)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def get_portfolio_state(
|
|
423
|
+
config,
|
|
424
|
+
*,
|
|
425
|
+
exchange: str,
|
|
426
|
+
api_key: str,
|
|
427
|
+
api_secret: str,
|
|
428
|
+
target_pct: dict[str, float] | None = None,
|
|
429
|
+
band: float | None = None,
|
|
430
|
+
as_of: datetime | None = None,
|
|
431
|
+
) -> dict[str, Any]:
|
|
432
|
+
exchange_id = validate_exchange_id(exchange)
|
|
433
|
+
target = validate_target_pct(target_pct or dict(config.portfolio.target_allocations))
|
|
434
|
+
band_width = validate_band(
|
|
435
|
+
band if band is not None else config.portfolio.rebalance_band
|
|
436
|
+
)
|
|
437
|
+
try:
|
|
438
|
+
snap = fetch_live_portfolio_snapshot(
|
|
439
|
+
exchange_id,
|
|
440
|
+
api_key,
|
|
441
|
+
api_secret,
|
|
442
|
+
config,
|
|
443
|
+
)
|
|
444
|
+
except LivePortfolioError as exc:
|
|
445
|
+
return with_staleness(
|
|
446
|
+
{
|
|
447
|
+
"available": False,
|
|
448
|
+
"exchange": exchange_id,
|
|
449
|
+
"source": "live",
|
|
450
|
+
"reason": str(exc),
|
|
451
|
+
},
|
|
452
|
+
as_of=as_of or utc_now(),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
payload = portfolio_state_from_snapshot(
|
|
456
|
+
snap,
|
|
457
|
+
exchange_id=exchange_id,
|
|
458
|
+
target_pct=target,
|
|
459
|
+
band=band_width,
|
|
460
|
+
)
|
|
461
|
+
snapshot_ts = payload.pop("snapshot_ts", None)
|
|
462
|
+
as_of_dt = as_of
|
|
463
|
+
if as_of_dt is None and snapshot_ts:
|
|
464
|
+
as_of_dt = datetime.fromisoformat(snapshot_ts)
|
|
465
|
+
return with_staleness(payload, as_of=as_of_dt or utc_now())
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def check_band(
|
|
469
|
+
allocation_pct: dict[str, float],
|
|
470
|
+
target_pct: dict[str, float],
|
|
471
|
+
band: float,
|
|
472
|
+
*,
|
|
473
|
+
as_of: datetime | None = None,
|
|
474
|
+
) -> dict[str, Any]:
|
|
475
|
+
now = (as_of or utc_now()).replace(microsecond=0)
|
|
476
|
+
result = check_allocation_band(
|
|
477
|
+
_normalize_pct(allocation_pct),
|
|
478
|
+
validate_target_pct(target_pct),
|
|
479
|
+
validate_band(band),
|
|
480
|
+
)
|
|
481
|
+
return with_staleness(result, as_of=now)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def validate_scope(scope: str) -> Scope:
|
|
485
|
+
if scope not in ("daily", "weekly"):
|
|
486
|
+
raise ValueError("scope must be 'daily' or 'weekly'")
|
|
487
|
+
return scope # type: ignore[return-value]
|