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,250 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+ from starlette.applications import Starlette
9
+ from starlette.middleware import Middleware
10
+ from starlette.responses import JSONResponse, PlainTextResponse
11
+ from starlette.routing import Mount, Route
12
+
13
+ from alloccontext.mcp.bazaar import (
14
+ build_llms_txt,
15
+ build_well_known_x402,
16
+ resolve_public_base_url,
17
+ )
18
+ from alloccontext.mcp.server import create_server
19
+ from alloccontext.mcp.x402_config import (
20
+ CDP_FACILITATOR_URL,
21
+ X402Settings,
22
+ build_x402_resource_server,
23
+ build_x402_routes,
24
+ load_x402_settings,
25
+ )
26
+ from alloccontext.mcp.x402_stables import effective_accepted_stable_symbols
27
+
28
+
29
+ def _health_verbose_enabled() -> bool:
30
+ return os.environ.get("ALLOC_CONTEXT_HEALTH_VERBOSE", "").lower() in (
31
+ "1",
32
+ "true",
33
+ "yes",
34
+ )
35
+
36
+
37
+ def _discovery_link_headers() -> dict[str, str]:
38
+ if resolve_public_base_url():
39
+ return {"Link": '</llms.txt>; rel="describedby"'}
40
+ return {}
41
+
42
+
43
+ def _make_health_handler(config_path: str | None) -> Any:
44
+ def _health(_: Any) -> JSONResponse:
45
+ payload: dict[str, Any] = {"ok": True, "service": "alloc-context-mcp"}
46
+ verbose = _health_verbose_enabled()
47
+ try:
48
+ from alloccontext.config import load_config
49
+ from alloccontext.store.db import connect
50
+ from alloccontext.status_report import mcp_health_ingest_summary
51
+
52
+ config = load_config(config_path)
53
+ conn = connect(config.paths.db)
54
+ try:
55
+ summary = mcp_health_ingest_summary(config, conn)
56
+ payload["ingest_ok"] = summary["ingest_ok"]
57
+ optional_failures = summary.get("optional_feed_failures") or []
58
+ if optional_failures:
59
+ payload["optional_feed_failures"] = optional_failures
60
+ if verbose:
61
+ payload["source_health"] = summary.get("source_health")
62
+ required_failures = summary.get("required_failures") or []
63
+ if required_failures:
64
+ payload["required_failures"] = required_failures
65
+ finally:
66
+ conn.close()
67
+ except Exception:
68
+ payload["status_detail"] = "database_unavailable"
69
+ payload["ok"] = False
70
+ payload["ingest_ok"] = False
71
+ return JSONResponse(payload, headers=_discovery_link_headers())
72
+
73
+ return _health
74
+
75
+
76
+ def _health(_: Any) -> JSONResponse:
77
+ """Default handler for tests; production apps use _make_health_handler."""
78
+ return _make_health_handler(None)(_)
79
+
80
+
81
+ def _llms_txt(settings: X402Settings) -> PlainTextResponse:
82
+ public_base = resolve_public_base_url()
83
+ if not public_base:
84
+ return PlainTextResponse(
85
+ "Set X402_PUBLIC_URL for discovery metadata.\n",
86
+ status_code=404,
87
+ )
88
+ stables = effective_accepted_stable_symbols(settings.accepted_stables)
89
+ body = build_llms_txt(
90
+ public_url=public_base,
91
+ mcp_path=settings.mcp_path,
92
+ accepted_stables=stables,
93
+ )
94
+ return PlainTextResponse(body, media_type="text/plain; charset=utf-8")
95
+
96
+
97
+ def _well_known_x402(settings: X402Settings) -> JSONResponse:
98
+ public_base = resolve_public_base_url()
99
+ if not public_base or not settings.pay_to:
100
+ return JSONResponse({"error": "discovery metadata unavailable"}, status_code=404)
101
+ stables = effective_accepted_stable_symbols(settings.accepted_stables)
102
+ payload = build_well_known_x402(
103
+ public_url=public_base,
104
+ mcp_path=settings.mcp_path,
105
+ pay_to=settings.pay_to,
106
+ price_light=settings.mcp_price,
107
+ price_heavy=settings.mcp_price_heavy,
108
+ network=settings.network,
109
+ accepted_stables=stables,
110
+ )
111
+ return JSONResponse(payload)
112
+
113
+
114
+ def _is_loopback_host(host: str) -> bool:
115
+ normalized = host.strip().lower()
116
+ return normalized in {"127.0.0.1", "localhost", "::1"}
117
+
118
+
119
+ def build_http_app(
120
+ *,
121
+ config_path: str | None = None,
122
+ host: str = "127.0.0.1",
123
+ port: int = 8000,
124
+ stateless_http: bool = True,
125
+ x402: bool = False,
126
+ ) -> Starlette:
127
+ if not _is_loopback_host(host) and not x402:
128
+ raise RuntimeError(
129
+ "HTTP MCP on a non-loopback host requires x402 payment protection"
130
+ )
131
+ mcp = create_server(
132
+ config_path=config_path,
133
+ host=host,
134
+ port=port,
135
+ stateless_http=stateless_http,
136
+ )
137
+ inner = mcp.streamable_http_app()
138
+ settings = load_x402_settings(require_payment=x402)
139
+
140
+ @contextlib.asynccontextmanager
141
+ async def mcp_lifespan(_app: Starlette) -> AsyncIterator[None]:
142
+ async with mcp.session_manager.run():
143
+ yield
144
+
145
+ discovery_routes = [
146
+ Route("/health", _make_health_handler(config_path)),
147
+ Route("/llms.txt", lambda req: _llms_txt(settings)),
148
+ Route("/.well-known/x402.json", lambda req: _well_known_x402(settings)),
149
+ ]
150
+
151
+ if not settings.enabled:
152
+ return Starlette(
153
+ routes=[
154
+ *discovery_routes,
155
+ Mount("/", app=inner),
156
+ ],
157
+ lifespan=mcp_lifespan,
158
+ )
159
+
160
+ from alloccontext.mcp.payment_middleware import AllocContextPaymentMiddlewareASGI
161
+
162
+ resource_server = build_x402_resource_server(settings)
163
+ routes = build_x402_routes(settings)
164
+ return Starlette(
165
+ middleware=[
166
+ Middleware(
167
+ AllocContextPaymentMiddlewareASGI,
168
+ routes=routes,
169
+ server=resource_server,
170
+ ),
171
+ ],
172
+ routes=[
173
+ *discovery_routes,
174
+ Mount("/", app=inner),
175
+ ],
176
+ lifespan=mcp_lifespan,
177
+ )
178
+
179
+
180
+ def run_http(
181
+ *,
182
+ config_path: str | None = None,
183
+ host: str = "127.0.0.1",
184
+ port: int = 8000,
185
+ x402: bool = False,
186
+ ) -> None:
187
+ import uvicorn
188
+
189
+ app = build_http_app(
190
+ config_path=config_path,
191
+ host=host,
192
+ port=port,
193
+ x402=x402,
194
+ )
195
+ uvicorn.run(app, host=host, port=port, log_level="info")
196
+
197
+
198
+ async def run_http_async(
199
+ *,
200
+ config_path: str | None = None,
201
+ host: str = "127.0.0.1",
202
+ port: int = 8000,
203
+ x402: bool = False,
204
+ ) -> None:
205
+ import uvicorn
206
+
207
+ app = build_http_app(
208
+ config_path=config_path,
209
+ host=host,
210
+ port=port,
211
+ x402=x402,
212
+ )
213
+ config = uvicorn.Config(app, host=host, port=port, log_level="info")
214
+ server = uvicorn.Server(config)
215
+ await server.serve()
216
+
217
+
218
+ def _parse_mcp_port(raw: str) -> int:
219
+ try:
220
+ port = int(raw)
221
+ except ValueError as exc:
222
+ raise SystemExit(
223
+ f"ALLOC_CONTEXT_MCP_PORT must be an integer, got {raw!r}"
224
+ ) from exc
225
+ if not 1 <= port <= 65535:
226
+ raise SystemExit(f"ALLOC_CONTEXT_MCP_PORT out of range: {port}")
227
+ return port
228
+
229
+
230
+ def main() -> None:
231
+ import logging
232
+
233
+ logger = logging.getLogger(__name__)
234
+ host = os.environ.get("ALLOC_CONTEXT_MCP_HOST", "127.0.0.1")
235
+ port = _parse_mcp_port(os.environ.get("ALLOC_CONTEXT_MCP_PORT", "8000"))
236
+ x402 = os.environ.get("X402_ENABLED", "").lower() in ("1", "true", "yes")
237
+ payment_env = (
238
+ os.environ.get("X402_FACILITATOR_URL", "").startswith(CDP_FACILITATOR_URL)
239
+ and os.environ.get("X402_PAY_TO", "").strip()
240
+ )
241
+ if payment_env and not x402:
242
+ logger.warning(
243
+ "X402 payment env vars are set but X402 is not enabled; "
244
+ "set X402_ENABLED=1 or pass --x402 to require payment"
245
+ )
246
+ run_http(host=host, port=port, x402=x402)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
@@ -0,0 +1,211 @@
1
+ """AllocContext payment middleware with per-tool Bazaar discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from starlette.middleware.base import BaseHTTPMiddleware
10
+ from starlette.requests import Request
11
+ from starlette.responses import HTMLResponse, JSONResponse, Response
12
+ from starlette.types import ASGIApp
13
+
14
+ from alloccontext.mcp.x402_bazaar_dynamic import (
15
+ AllocContextHTTPResourceServer,
16
+ patch_resource_info_for_bazaar,
17
+ )
18
+ from x402.http.constants import SETTLEMENT_OVERRIDES_HEADER
19
+ from x402.http.facilitator_client_base import FacilitatorResponseError
20
+ from x402.http.middleware.fastapi import (
21
+ FastAPIAdapter,
22
+ _check_if_bazaar_needed,
23
+ _facilitator_error_response,
24
+ _register_bazaar_extension,
25
+ )
26
+ from x402.http.types import HTTPRequestContext, HTTPTransportContext, PaywallConfig, RoutesConfig
27
+ from x402.schemas.hooks import VerifiedPaymentCancelOptions
28
+
29
+ if TYPE_CHECKING:
30
+ from x402.http.x402_http_server import PaywallProvider
31
+ from x402.server import x402ResourceServer
32
+
33
+
34
+ def alloc_payment_middleware(
35
+ routes: RoutesConfig,
36
+ server: x402ResourceServer,
37
+ paywall_config: PaywallConfig | None = None,
38
+ paywall_provider: PaywallProvider | None = None,
39
+ sync_facilitator_on_start: bool = True,
40
+ ) -> Callable[[Request, Callable[[Request], Awaitable[Response]]], Awaitable[Response]]:
41
+ """Like x402 fastapi payment_middleware but with per-tool Bazaar metadata."""
42
+ patch_resource_info_for_bazaar()
43
+ if _check_if_bazaar_needed(routes):
44
+ _register_bazaar_extension(server)
45
+
46
+ http_server = AllocContextHTTPResourceServer(server, routes)
47
+ if paywall_provider:
48
+ http_server.register_paywall_provider(paywall_provider)
49
+
50
+ init_done = False
51
+ init_lock = asyncio.Lock()
52
+
53
+ async def middleware(
54
+ request: Request,
55
+ call_next: Callable[[Request], Awaitable[Response]],
56
+ ) -> Response:
57
+ nonlocal init_done
58
+
59
+ adapter = FastAPIAdapter(request)
60
+ context = HTTPRequestContext(
61
+ adapter=adapter,
62
+ path=request.url.path,
63
+ method=request.method,
64
+ payment_header=(
65
+ adapter.get_header("payment-signature") or adapter.get_header("x-payment")
66
+ ),
67
+ )
68
+
69
+ if not http_server.requires_payment(context):
70
+ return await call_next(request)
71
+
72
+ if sync_facilitator_on_start and not init_done:
73
+ async with init_lock:
74
+ if not init_done:
75
+ try:
76
+ http_server.initialize()
77
+ except FacilitatorResponseError as error:
78
+ return _facilitator_error_response(error)
79
+ init_done = True
80
+
81
+ try:
82
+ result = await http_server.process_http_request(context, paywall_config)
83
+ except FacilitatorResponseError as error:
84
+ return _facilitator_error_response(error)
85
+
86
+ if result.type == "no-payment-required":
87
+ return await call_next(request)
88
+
89
+ if result.type == "payment-error":
90
+ response = result.response
91
+ if response is None:
92
+ return JSONResponse(content={"error": "Payment required"}, status_code=402)
93
+ if response.is_html:
94
+ return HTMLResponse(
95
+ content=response.body,
96
+ status_code=response.status,
97
+ headers=response.headers,
98
+ )
99
+ return JSONResponse(
100
+ content=response.body or {},
101
+ status_code=response.status,
102
+ headers=response.headers,
103
+ )
104
+
105
+ if result.type == "payment-verified":
106
+ request.state.payment_payload = result.payment_payload
107
+ request.state.payment_requirements = result.payment_requirements
108
+ dispatcher = result.cancellation_dispatcher
109
+ transport_context = HTTPTransportContext(request=context)
110
+
111
+ try:
112
+ response = await call_next(request)
113
+ except Exception as error:
114
+ if dispatcher is not None:
115
+ await dispatcher.cancel(
116
+ VerifiedPaymentCancelOptions(reason="handler_threw", error=error)
117
+ )
118
+ raise
119
+
120
+ if response.status_code >= 400:
121
+ if dispatcher is not None:
122
+ await dispatcher.cancel(
123
+ VerifiedPaymentCancelOptions(
124
+ reason="handler_failed",
125
+ response_status=response.status_code,
126
+ )
127
+ )
128
+ return response
129
+
130
+ body = b""
131
+ async for chunk in response.body_iterator:
132
+ body += chunk
133
+
134
+ overrides = http_server._extract_settlement_overrides(dict(response.headers))
135
+ if overrides is not None:
136
+ for key in list(response.headers.keys()):
137
+ if key.lower() == SETTLEMENT_OVERRIDES_HEADER.lower():
138
+ del response.headers[key]
139
+
140
+ transport_context.response_headers = dict(response.headers)
141
+
142
+ try:
143
+ settle_result = await http_server.process_settlement(
144
+ result.payment_payload,
145
+ result.payment_requirements,
146
+ context=context,
147
+ settlement_overrides=overrides,
148
+ declared_extensions=result.declared_extensions,
149
+ transport_context=transport_context,
150
+ )
151
+ except FacilitatorResponseError as error:
152
+ return _facilitator_error_response(error)
153
+ except Exception:
154
+ return JSONResponse(content={}, status_code=402)
155
+
156
+ if not settle_result.success:
157
+ resp = settle_result.response
158
+ if resp is None:
159
+ return JSONResponse(content={}, status_code=402)
160
+ if resp.is_html:
161
+ return Response(
162
+ content=resp.body,
163
+ status_code=resp.status,
164
+ headers=resp.headers,
165
+ media_type="text/html",
166
+ )
167
+ return JSONResponse(
168
+ content=resp.body or {},
169
+ status_code=resp.status,
170
+ headers=resp.headers,
171
+ )
172
+
173
+ headers = dict(response.headers)
174
+ headers.update(settle_result.headers)
175
+ return Response(
176
+ content=body,
177
+ status_code=response.status_code,
178
+ headers=headers,
179
+ media_type=response.media_type,
180
+ )
181
+
182
+ return await call_next(request)
183
+
184
+ return middleware
185
+
186
+
187
+ class AllocContextPaymentMiddlewareASGI(BaseHTTPMiddleware):
188
+ """Starlette middleware with AllocContext Bazaar discovery behavior."""
189
+
190
+ def __init__(
191
+ self,
192
+ app: ASGIApp,
193
+ routes: RoutesConfig,
194
+ server: x402ResourceServer,
195
+ paywall_config: PaywallConfig | None = None,
196
+ paywall_provider: PaywallProvider | None = None,
197
+ ) -> None:
198
+ super().__init__(app)
199
+ self._middleware = alloc_payment_middleware(
200
+ routes,
201
+ server,
202
+ paywall_config,
203
+ paywall_provider,
204
+ )
205
+
206
+ async def dispatch(
207
+ self,
208
+ request: Request,
209
+ call_next: Callable[[Request], Awaitable[Response]],
210
+ ) -> Response:
211
+ return await self._middleware(request, call_next)