curator-data 0.2.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.
@@ -0,0 +1,25 @@
1
+ """Market data for the curator agent.
2
+
3
+ The public surface is deliberately small: build a registry, ask it for a
4
+ snapshot. Everything else is an implementation detail of some source.
5
+
6
+ from curator_data import build_registry
7
+
8
+ registry = build_registry()
9
+ snapshot = await registry.snapshot(["messari", "token_api"], ["USDC", "WETH"])
10
+
11
+ No name in this package's public API refers to a data provider. Sources are
12
+ addressed by registry key, and those keys come from the mandate.
13
+ """
14
+
15
+ from .facts import FactBuilder
16
+ from .registry import Registry, build_registry
17
+ from .sources import SOURCE_FACTORIES, available_sources
18
+
19
+ __all__ = [
20
+ "Registry",
21
+ "build_registry",
22
+ "available_sources",
23
+ "SOURCE_FACTORIES",
24
+ "FactBuilder",
25
+ ]
@@ -0,0 +1,12 @@
1
+ """Reading data straight off a chain.
2
+
3
+ Every other source in this package speaks HTTP to somebody's API. This one
4
+ speaks JSON-RPC to a node, which is the point: the `DataSource` port abstracts
5
+ *kinds of provider*, not just endpoints. A source that reads a contract and a
6
+ source that queries a subgraph merge into the same `MarketSnapshot` without
7
+ either knowing the other exists.
8
+ """
9
+
10
+ from .rpc import RpcClient, RpcError
11
+
12
+ __all__ = ["RpcClient", "RpcError"]
@@ -0,0 +1,121 @@
1
+ """A minimal `eth_call` client.
2
+
3
+ Deliberately not web3.py. This lane needs exactly one JSON-RPC method against
4
+ two view functions with fixed, argument-free selectors — that is a POST and
5
+ some slicing, on the `httpx` client already in the tree. Pulling in a full node
6
+ library for it would add a large dependency to a package whose whole selling
7
+ point is that a new provider is cheap to add. (The root `pyproject.toml` also
8
+ records a broken global `web3` that breaks pytest collection, and Lane D
9
+ reached the same conclusion independently for the same reasons.)
10
+
11
+ ABI decoding here is limited to what static-word returns need: fixed 32-byte
12
+ slots, signed and unsigned. Anything more would be the point to reconsider.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+
19
+ import httpx
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ WORD = 32
24
+
25
+
26
+ class RpcError(RuntimeError):
27
+ """The node was unreachable, or returned an error or unusable payload."""
28
+
29
+
30
+ def decode_word(data: bytes, index: int, *, signed: bool = False) -> int:
31
+ """The `index`-th 32-byte word of an ABI return, as an integer."""
32
+ start = index * WORD
33
+ end = start + WORD
34
+ if len(data) < end:
35
+ raise RpcError(f"response too short for word {index} ({len(data)} bytes)")
36
+ return int.from_bytes(data[start:end], "big", signed=signed)
37
+
38
+
39
+ def decode_string(data: bytes) -> str:
40
+ """A single dynamically-sized `string` return.
41
+
42
+ Layout is offset, then length, then the bytes. Only used for
43
+ `description()`, which is how a price feed states its own identity.
44
+ """
45
+ offset = decode_word(data, 0)
46
+ if len(data) < offset + WORD:
47
+ raise RpcError("string offset points past the end of the response")
48
+ length = int.from_bytes(data[offset : offset + WORD], "big")
49
+ start = offset + WORD
50
+ if len(data) < start + length:
51
+ raise RpcError("string length runs past the end of the response")
52
+ return data[start : start + length].decode("utf-8", errors="replace")
53
+
54
+
55
+ class RpcClient:
56
+ """`eth_call` against one JSON-RPC endpoint."""
57
+
58
+ def __init__(
59
+ self,
60
+ url: str,
61
+ *,
62
+ timeout_s: float = 15.0,
63
+ client: httpx.AsyncClient | None = None,
64
+ ):
65
+ if not url:
66
+ raise ValueError("an RPC url is required")
67
+ self.url = url
68
+ self._timeout = timeout_s
69
+ self._client = client
70
+ self._owns_client = client is None
71
+
72
+ @property
73
+ def client(self) -> httpx.AsyncClient:
74
+ if self._client is None:
75
+ self._client = httpx.AsyncClient(timeout=self._timeout)
76
+ return self._client
77
+
78
+ async def call(self, to: str, selector: str) -> bytes:
79
+ """`eth_call` a no-argument view function. Returns raw return data."""
80
+ payload = {
81
+ "jsonrpc": "2.0",
82
+ "id": 1,
83
+ "method": "eth_call",
84
+ "params": [{"to": to, "data": selector}, "latest"],
85
+ }
86
+ try:
87
+ response = await self.client.post(self.url, json=payload)
88
+ except httpx.HTTPError as exc:
89
+ raise RpcError(f"node unreachable at {self.url}: {type(exc).__name__}") from exc
90
+
91
+ if response.status_code >= 400:
92
+ raise RpcError(f"node returned HTTP {response.status_code}")
93
+
94
+ try:
95
+ body = response.json()
96
+ except ValueError as exc:
97
+ raise RpcError("node returned non-JSON") from exc
98
+
99
+ if isinstance(body, dict) and body.get("error"):
100
+ raise RpcError(f"eth_call failed: {body['error'].get('message', body['error'])}")
101
+
102
+ result = body.get("result") if isinstance(body, dict) else None
103
+ if not isinstance(result, str) or not result.startswith("0x"):
104
+ raise RpcError(f"unusable eth_call result: {result!r}")
105
+ # `0x` means the call reverted or the address holds no code. Callers
106
+ # must not read that as a zero value.
107
+ if len(result) <= 2:
108
+ raise RpcError(f"empty return from {to} - no contract, or the call reverted")
109
+
110
+ try:
111
+ return bytes.fromhex(result[2:])
112
+ except ValueError as exc:
113
+ raise RpcError("eth_call result was not valid hex") from exc
114
+
115
+ async def aclose(self) -> None:
116
+ if self._client is not None and self._owns_client:
117
+ await self._client.aclose()
118
+ self._client = None
119
+
120
+
121
+ __all__ = ["RpcClient", "RpcError", "decode_word", "decode_string", "WORD"]
curator_data/cli.py ADDED
@@ -0,0 +1,204 @@
1
+ """`curator-data` — one parameterised tool, not a folder of one-off scripts.
2
+
3
+ Four subcommands covering everything this lane needs operationally:
4
+
5
+ curator-data sources what can be granted in a mandate
6
+ curator-data protocols what is configured, and where to add more
7
+ curator-data snapshot take a real snapshot and print it
8
+ curator-data verify-live prove the demo path hits live data
9
+
10
+ `snapshot --json` emits a schema-valid `MarketSnapshot`, so it doubles as a way
11
+ to hand another lane real data without them running any of this code.
12
+
13
+ Output is ASCII: this runs in PowerShell, WSL and macOS terminals, and a
14
+ cp1252 console turns box-drawing characters into noise.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import sys
24
+
25
+ from .config import Settings
26
+ from .queries import MARKET_KINDS, errors_as_dicts, pivot_markets, pivot_pools, prices
27
+ from .registry import build_registry
28
+ from .sources.protocols import ALL
29
+ from .verify import report, summarise, verify_live
30
+
31
+
32
+ def _fmt_usd(value: float | None) -> str:
33
+ if value is None:
34
+ return "-"
35
+ for threshold, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")):
36
+ if abs(value) >= threshold:
37
+ return f"${value / threshold:,.1f}{suffix}"
38
+ return f"${value:,.2f}"
39
+
40
+
41
+ def _fmt_pct(value: float | None) -> str:
42
+ return "-" if value is None else f"{value * 100:.2f}%"
43
+
44
+
45
+ # ── subcommands ───────────────────────────────────────────────────────────
46
+
47
+
48
+ def cmd_sources(args: argparse.Namespace) -> int:
49
+ """What a mandate may name in `permitted_data_sources`."""
50
+ registry = build_registry(Settings.from_env())
51
+ described = registry.describe()
52
+ if args.json:
53
+ print(json.dumps(described, indent=2))
54
+ return 0
55
+
56
+ print("Registered data sources (name these in Mandate.permitted_data_sources):\n")
57
+ for entry in described:
58
+ print(f" {entry['key']}")
59
+ print(f" {entry.get('description', '')}")
60
+ if entry.get("provides"):
61
+ print(f" provides: {entry['provides']}")
62
+ print("\nAdd one: implement BaseSource in curator_data/sources/, then add a single")
63
+ print("line to SOURCE_FACTORIES in curator_data/sources/__init__.py.")
64
+ return 0
65
+
66
+
67
+ def cmd_protocols(args: argparse.Namespace) -> int:
68
+ """The protocol table — the "adding a protocol is one line" claim, printed."""
69
+ if args.json:
70
+ print(json.dumps([p.__dict__ for p in ALL], indent=2))
71
+ return 0
72
+
73
+ print("Configured protocols:\n")
74
+ print(f" {'key':<14} {'family':<10} {'chain':<7} {'on':<4} label")
75
+ print(f" {'-' * 14} {'-' * 10} {'-' * 7} {'-' * 4} {'-' * 24}")
76
+ for p in ALL:
77
+ print(
78
+ f" {p.key:<14} {p.family:<10} {p.chain:<7} "
79
+ f"{'yes' if p.enabled else 'no':<4} {p.label}"
80
+ )
81
+ print("\nAdding a protocol is one Protocol(...) line in")
82
+ print("curator_data/sources/protocols.py - Messari's standardized schema means")
83
+ print("every lending market answers the same query, so no adapter is needed.")
84
+ return 0
85
+
86
+
87
+ async def _snapshot(args: argparse.Namespace) -> int:
88
+ settings = Settings.from_env()
89
+ assets = [a.strip().upper() for a in args.assets.split(",") if a.strip()]
90
+ registry = build_registry(settings)
91
+ try:
92
+ if args.sources:
93
+ keys = [s.strip() for s in args.sources.split(",") if s.strip()]
94
+ else:
95
+ keys = registry.sources_providing(*MARKET_KINDS, "price")
96
+ snapshot = await registry.snapshot(keys, assets)
97
+ finally:
98
+ await registry.aclose()
99
+
100
+ if args.json:
101
+ # Schema-valid MarketSnapshot: pipe it straight into another lane.
102
+ print(snapshot.model_dump_json(indent=2))
103
+ return 0 if snapshot.facts else 1
104
+
105
+ print(f"Snapshot at {snapshot.taken_at.isoformat(timespec='seconds')}")
106
+ print(f"Sources: {', '.join(keys) or 'none'} Assets: {', '.join(assets)}\n")
107
+
108
+ markets = pivot_markets(snapshot)
109
+ if markets:
110
+ print(f" {'protocol':<14} {'market':<8} {'APY':>8} {'TVL':>10} {'util':>7}")
111
+ print(f" {'-' * 14} {'-' * 8} {'-' * 8} {'-' * 10} {'-' * 7}")
112
+ for row in markets:
113
+ print(
114
+ f" {row.protocol:<14} {row.market:<8} {_fmt_pct(row.supply_apy):>8} "
115
+ f"{_fmt_usd(row.tvl_usd):>10} {_fmt_pct(row.utilization):>7}"
116
+ )
117
+ print()
118
+
119
+ pools = pivot_pools(snapshot)
120
+ for pool in pools:
121
+ print(f" pool {pool.protocol} {'/'.join(pool.pair)}: {_fmt_usd(pool.liquidity_usd)}")
122
+ if pools:
123
+ print()
124
+
125
+ for symbol, price in prices(snapshot).items():
126
+ via = ", ".join(price["sources"])
127
+ line = f" price {symbol}: ${price['price_usd']:,.2f} (via {via}"
128
+ if len(price["sources"]) > 1:
129
+ # Independent mechanisms agreeing is worth showing, not just
130
+ # asserting — and disagreeing is worth showing even more.
131
+ line += f", spread {price['spread_pct']:.2f}%"
132
+ if price["disagreement"]:
133
+ line += " DISAGREEMENT"
134
+ print(line + ")")
135
+
136
+ errors = errors_as_dicts(snapshot)
137
+ if errors:
138
+ # Printed last and labelled plainly: a partial snapshot must look
139
+ # partial, or the number above it gets read as the whole market.
140
+ print("\n Degraded - these sources could not be read:")
141
+ for error in errors:
142
+ print(f" [{error['source']}] {error['message']}")
143
+
144
+ print(f"\n{len(snapshot.facts)} facts, {len(errors)} errors")
145
+ return 0 if snapshot.facts else 1
146
+
147
+
148
+ async def _verify(args: argparse.Namespace) -> int:
149
+ results = await verify_live(Settings.from_env(), only=args.protocol)
150
+ print(report(results))
151
+ _, failed, skipped = summarise(results)
152
+ # Skipped counts as failure here: this command exists to prove the live
153
+ # path works, and "we did not check" is not proof.
154
+ return 1 if (failed or skipped) else 0
155
+
156
+
157
+ # ── entry point ───────────────────────────────────────────────────────────
158
+
159
+
160
+ def build_parser() -> argparse.ArgumentParser:
161
+ parser = argparse.ArgumentParser(
162
+ prog="curator-data",
163
+ description="Market data registry: inspect sources, take snapshots, verify live data.",
164
+ )
165
+ parser.add_argument("-v", "--verbose", action="store_true", help="debug logging to stderr")
166
+ subparsers = parser.add_subparsers(dest="command", required=True)
167
+
168
+ p_sources = subparsers.add_parser("sources", help="list registered data sources")
169
+ p_sources.add_argument("--json", action="store_true")
170
+ p_sources.set_defaults(func=lambda a: cmd_sources(a))
171
+
172
+ p_protocols = subparsers.add_parser("protocols", help="list configured protocols")
173
+ p_protocols.add_argument("--json", action="store_true")
174
+ p_protocols.set_defaults(func=lambda a: cmd_protocols(a))
175
+
176
+ p_snapshot = subparsers.add_parser("snapshot", help="take a live MarketSnapshot")
177
+ p_snapshot.add_argument("--assets", default="USDC,WETH", help="comma-separated symbols")
178
+ p_snapshot.add_argument("--sources", default="", help="comma-separated keys (default: all)")
179
+ p_snapshot.add_argument(
180
+ "--json", action="store_true", help="emit a schema-valid MarketSnapshot"
181
+ )
182
+ p_snapshot.set_defaults(func=lambda a: asyncio.run(_snapshot(a)))
183
+
184
+ p_verify = subparsers.add_parser(
185
+ "verify-live", help="prove the demo path reaches live gateway data"
186
+ )
187
+ p_verify.add_argument("--protocol", default=None, help="check only this protocol key")
188
+ p_verify.set_defaults(func=lambda a: asyncio.run(_verify(a)))
189
+
190
+ return parser
191
+
192
+
193
+ def main(argv: list[str] | None = None) -> int:
194
+ args = build_parser().parse_args(argv)
195
+ logging.basicConfig(
196
+ level=logging.DEBUG if args.verbose else logging.WARNING,
197
+ format="%(levelname)s %(name)s: %(message)s",
198
+ stream=sys.stderr,
199
+ )
200
+ return args.func(args)
201
+
202
+
203
+ if __name__ == "__main__": # pragma: no cover
204
+ raise SystemExit(main())
curator_data/config.py ADDED
@@ -0,0 +1,175 @@
1
+ """Every environment-dependent value, resolved in one place.
2
+
3
+ Scattered `os.getenv` calls are how a lane ends up with three different
4
+ opinions about which gateway URL is current. One `Settings` object, loaded
5
+ once, passed explicitly.
6
+
7
+ Loading `.env` is opt-in via `Settings.from_env()` so importing this module
8
+ never has a side effect on a caller's process environment.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ # The Graph's decentralised gateway. The API key travels as a Bearer header
18
+ # rather than in the path (`/api/{key}/subgraphs/id/{id}`, also supported) so
19
+ # it cannot leak into an access log, a traceback or a screen share during the
20
+ # demo.
21
+ DEFAULT_GATEWAY_URL = "https://gateway.thegraph.com/api"
22
+
23
+ # Token API is a REST service on its own host with its own credential — it is
24
+ # NOT the same key as the subgraph gateway. See README "Credentials".
25
+ #
26
+ # Host verified by probe on 2026-07-25: `token-api.thegraph.com` (the name in
27
+ # The Graph's own docs) does not resolve at all, and the docs now redirect to
28
+ # Pinax, a Graph core developer who operates the service. `GET /health` here
29
+ # returns {"status":"OK"}.
30
+ DEFAULT_TOKEN_API_URL = "https://api.pinax.network/v1"
31
+
32
+ # x402: the same subgraph gateway, on the payment-gated route. Deliberately
33
+ # needs no API key — that is the entire point of the path.
34
+ DEFAULT_X402_GATEWAY_URL = "https://gateway.thegraph.com/api/x402"
35
+
36
+ DEFAULT_CHAIN = "base"
37
+
38
+
39
+ def _env_flag(name: str, default: bool = False) -> bool:
40
+ raw = os.getenv(name)
41
+ if raw is None or raw == "":
42
+ return default
43
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
44
+
45
+
46
+ def _env_float(name: str, default: float) -> float:
47
+ raw = os.getenv(name)
48
+ if raw is None or raw.strip() == "":
49
+ return default
50
+ try:
51
+ return float(raw)
52
+ except ValueError:
53
+ return default
54
+
55
+
56
+ def _find_dotenv(start: Path | None = None) -> Path | None:
57
+ """Walk up from `start` looking for a .env.
58
+
59
+ Relative to the file, never to the process CWD — the MCP server is launched
60
+ from whatever directory the host client happens to be in, and a teammate on
61
+ macOS must get the same answer as we do on Windows.
62
+ """
63
+ here = (start or Path(__file__).resolve()).parent
64
+ for candidate in [here, *here.parents]:
65
+ dotenv = candidate / ".env"
66
+ if dotenv.is_file():
67
+ return dotenv
68
+ return None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Settings:
73
+ """Resolved configuration. Immutable — construct a new one to change it."""
74
+
75
+ graph_api_key: str | None = None
76
+ gateway_url: str = DEFAULT_GATEWAY_URL
77
+ token_api_url: str = DEFAULT_TOKEN_API_URL
78
+ token_api_key: str | None = None
79
+ chain: str = DEFAULT_CHAIN
80
+ #: JSON-RPC endpoint for on-chain sources (Chainlink feeds). Prefers the
81
+ #: local fork so a snapshot matches the chain the vault is actually on.
82
+ rpc_url: str | None = None
83
+ #: 30s, not 15: the live Uniswap V3 Base subgraph repeatedly answered in
84
+ #: ~20s during testing (its indexers are slow and intermittently
85
+ #: unavailable). A timeout below that turns a working source into a
86
+ #: permanently failing one.
87
+ request_timeout_s: float = 30.0
88
+
89
+ #: Per-source ceiling inside `Registry.snapshot`. A source that hangs must
90
+ #: not hold the decision loop open; it lands in `errors[]` instead. Sits
91
+ #: above `request_timeout_s` so a slow *request* fails with a useful
92
+ #: message rather than being cut off by the outer deadline.
93
+ source_timeout_s: float = 45.0
94
+
95
+ # ── x402 (feature-flagged, default off) ───────────────────────────────
96
+ x402_enabled: bool = False
97
+ x402_gateway_url: str = DEFAULT_X402_GATEWAY_URL
98
+ x402_private_key: str | None = None
99
+ x402_chain: str = DEFAULT_CHAIN
100
+
101
+ #: Extra registry keys are inert unless a mandate names them, so carrying
102
+ #: unknown config here is harmless.
103
+ extra: dict[str, str] = field(default_factory=dict)
104
+
105
+ @classmethod
106
+ def from_env(cls, *, load_dotenv: bool = True) -> Settings:
107
+ """Read the environment, optionally loading the repo-root `.env` first.
108
+
109
+ Real process environment always wins over the file, so a teammate can
110
+ override any single value without editing a committed template.
111
+ """
112
+ if load_dotenv:
113
+ dotenv_path = _find_dotenv()
114
+ if dotenv_path is not None:
115
+ try:
116
+ from dotenv import load_dotenv as _load
117
+
118
+ _load(dotenv_path, override=False)
119
+ except ImportError: # pragma: no cover - dotenv is a hard dep
120
+ pass
121
+
122
+ return cls(
123
+ graph_api_key=os.getenv("GRAPH_API_KEY") or None,
124
+ gateway_url=os.getenv("GRAPH_GATEWAY_URL") or DEFAULT_GATEWAY_URL,
125
+ token_api_url=os.getenv("TOKEN_API_URL") or DEFAULT_TOKEN_API_URL,
126
+ # Falls back to the gateway key: The Graph Market issues one
127
+ # credential that works for both in the common case, and a
128
+ # confusing 401 is worse than an attempt.
129
+ token_api_key=os.getenv("TOKEN_API_KEY") or os.getenv("GRAPH_API_KEY") or None,
130
+ chain=os.getenv("DATA_CHAIN") or DEFAULT_CHAIN,
131
+ # Fork first: the agent reasons about the chain its vault lives on,
132
+ # and during development that is anvil, not mainnet.
133
+ rpc_url=(
134
+ os.getenv("DATA_RPC_URL")
135
+ or os.getenv("ANVIL_RPC_URL")
136
+ or os.getenv("BASE_RPC_URL")
137
+ or None
138
+ ),
139
+ request_timeout_s=_env_float("DATA_REQUEST_TIMEOUT_S", 15.0),
140
+ source_timeout_s=_env_float("DATA_SOURCE_TIMEOUT_S", 20.0),
141
+ x402_enabled=_env_flag("X402_ENABLED", False),
142
+ x402_gateway_url=os.getenv("X402_GATEWAY_URL") or DEFAULT_X402_GATEWAY_URL,
143
+ x402_private_key=os.getenv("X402_PRIVATE_KEY") or None,
144
+ x402_chain=os.getenv("X402_CHAIN") or DEFAULT_CHAIN,
145
+ )
146
+
147
+ # ── Capability checks, so callers report rather than guess ────────────
148
+
149
+ @property
150
+ def has_gateway_credential(self) -> bool:
151
+ return bool(self.graph_api_key)
152
+
153
+ @property
154
+ def has_token_api_credential(self) -> bool:
155
+ return bool(self.token_api_key)
156
+
157
+ @property
158
+ def x402_ready(self) -> bool:
159
+ """Flag on AND a key to sign with. Either missing means fall back."""
160
+ return self.x402_enabled and bool(self.x402_private_key)
161
+
162
+ def subgraph_url(self, subgraph_id: str) -> str:
163
+ return f"{self.gateway_url.rstrip('/')}/subgraphs/id/{subgraph_id}"
164
+
165
+ def x402_subgraph_url(self, subgraph_id: str) -> str:
166
+ return f"{self.x402_gateway_url.rstrip('/')}/subgraphs/id/{subgraph_id}"
167
+
168
+
169
+ __all__ = [
170
+ "Settings",
171
+ "DEFAULT_GATEWAY_URL",
172
+ "DEFAULT_TOKEN_API_URL",
173
+ "DEFAULT_X402_GATEWAY_URL",
174
+ "DEFAULT_CHAIN",
175
+ ]
@@ -0,0 +1,34 @@
1
+ """A ready-made registry instance, for consumers that late-bind by reference.
2
+
3
+ Lane B's harness resolves its data seam from configuration —
4
+ `AGENT_DATA_REGISTRY=<module>:<attribute>` — imports it on first use, and checks
5
+ it against the frozen `DataSourceRegistry` Protocol. That mechanism needs an
6
+ *instance* at a stable import path, which `build_registry()` (a function) does
7
+ not provide.
8
+
9
+ So the integration point is:
10
+
11
+ AGENT_DATA_REGISTRY=curator_data.default:registry
12
+
13
+ Kept in its own module rather than on `curator_data` itself, because
14
+ constructing this reads `.env`. Importing the package should have no side
15
+ effect; importing *this* module is an explicit request for the configured
16
+ default. Anyone wanting different settings calls `build_registry(settings)`
17
+ instead.
18
+
19
+ Construction is cheap and cannot fail on missing credentials: sources are
20
+ built lazily on first use, so an absent `GRAPH_API_KEY` surfaces later as a
21
+ `MarketSnapshot.errors` entry rather than an import-time crash. That matters
22
+ for a late-binding consumer — a raise here would silently drop them back to
23
+ fixture data.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from .registry import Registry, build_registry
29
+
30
+ #: The configured registry. Safe to import at any time; holds no connections
31
+ #: until a source is actually consulted.
32
+ registry: Registry = build_registry()
33
+
34
+ __all__ = ["registry"]