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.
Files changed (85) hide show
  1. alloc_context-0.1.0.dist-info/METADATA +154 -0
  2. alloc_context-0.1.0.dist-info/RECORD +85 -0
  3. alloc_context-0.1.0.dist-info/WHEEL +5 -0
  4. alloc_context-0.1.0.dist-info/entry_points.txt +4 -0
  5. alloc_context-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. alloc_context-0.1.0.dist-info/top_level.txt +1 -0
  7. alloccontext/__init__.py +3 -0
  8. alloccontext/__main__.py +149 -0
  9. alloccontext/config.py +415 -0
  10. alloccontext/horizon.py +30 -0
  11. alloccontext/ingest/__init__.py +1 -0
  12. alloccontext/ingest/cf_benchmarks.py +38 -0
  13. alloccontext/ingest/cf_history.py +65 -0
  14. alloccontext/ingest/coinbase_client.py +234 -0
  15. alloccontext/ingest/coinbase_portfolio.py +53 -0
  16. alloccontext/ingest/coingecko.py +148 -0
  17. alloccontext/ingest/coinmarketcap.py +135 -0
  18. alloccontext/ingest/env_keys.py +12 -0
  19. alloccontext/ingest/etf_flows.py +282 -0
  20. alloccontext/ingest/exchange/__init__.py +4 -0
  21. alloccontext/ingest/exchange/coinbase_adapter.py +64 -0
  22. alloccontext/ingest/exchange/kraken_adapter.py +66 -0
  23. alloccontext/ingest/exchange/live.py +95 -0
  24. alloccontext/ingest/exchange/portfolio.py +8 -0
  25. alloccontext/ingest/exchange/registry.py +27 -0
  26. alloccontext/ingest/exchange/types.py +5 -0
  27. alloccontext/ingest/exchange_http.py +28 -0
  28. alloccontext/ingest/fear_greed.py +89 -0
  29. alloccontext/ingest/fred.py +138 -0
  30. alloccontext/ingest/http_errors.py +29 -0
  31. alloccontext/ingest/kalshi.py +84 -0
  32. alloccontext/ingest/kalshi_api.py +199 -0
  33. alloccontext/ingest/kalshi_client.py +95 -0
  34. alloccontext/ingest/kalshi_files.py +44 -0
  35. alloccontext/ingest/kalshi_state.py +67 -0
  36. alloccontext/ingest/kraken_client.py +177 -0
  37. alloccontext/ingest/kraken_portfolio.py +161 -0
  38. alloccontext/ingest/macro_calendar.py +310 -0
  39. alloccontext/ingest/macro_normalize.py +98 -0
  40. alloccontext/ingest/market_snapshots.py +113 -0
  41. alloccontext/ingest/outcome.py +110 -0
  42. alloccontext/ingest/parse_helpers.py +23 -0
  43. alloccontext/ingest/runner.py +148 -0
  44. alloccontext/mcp/__init__.py +1 -0
  45. alloccontext/mcp/assets.py +153 -0
  46. alloccontext/mcp/bazaar.py +630 -0
  47. alloccontext/mcp/contracts.py +286 -0
  48. alloccontext/mcp/handlers.py +487 -0
  49. alloccontext/mcp/http.py +250 -0
  50. alloccontext/mcp/payment_middleware.py +211 -0
  51. alloccontext/mcp/server.py +319 -0
  52. alloccontext/mcp/staleness.py +30 -0
  53. alloccontext/mcp/validation.py +56 -0
  54. alloccontext/mcp/x402_bazaar_dynamic.py +104 -0
  55. alloccontext/mcp/x402_config.py +131 -0
  56. alloccontext/mcp/x402_pricing.py +55 -0
  57. alloccontext/mcp/x402_stables.py +179 -0
  58. alloccontext/rollup/__init__.py +1 -0
  59. alloccontext/rollup/band.py +50 -0
  60. alloccontext/rollup/breadth.py +45 -0
  61. alloccontext/rollup/cf_math.py +103 -0
  62. alloccontext/rollup/cluster.py +149 -0
  63. alloccontext/rollup/cluster_config.py +86 -0
  64. alloccontext/rollup/comparison.py +67 -0
  65. alloccontext/rollup/context.py +118 -0
  66. alloccontext/rollup/delta.py +109 -0
  67. alloccontext/rollup/etf.py +113 -0
  68. alloccontext/rollup/fear_greed.py +61 -0
  69. alloccontext/rollup/macro.py +185 -0
  70. alloccontext/rollup/portfolio.py +137 -0
  71. alloccontext/rollup/rebalance.py +125 -0
  72. alloccontext/rollup/regime.py +188 -0
  73. alloccontext/rollup/sentiment.py +118 -0
  74. alloccontext/rollup/snapshots.py +64 -0
  75. alloccontext/rollup/tape.py +176 -0
  76. alloccontext/status_report.py +321 -0
  77. alloccontext/store/__init__.py +0 -0
  78. alloccontext/store/db.py +216 -0
  79. alloccontext/store/jsonutil.py +10 -0
  80. alloccontext/store/meta.py +20 -0
  81. alloccontext/store/retention.py +63 -0
  82. alloccontext/store/status.py +89 -0
  83. alloccontext/timeutil.py +11 -0
  84. alloccontext/x402_production_check.py +193 -0
  85. 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]