From bac98ab2cd2987fed1b6327e44d13bd453889118 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Fri, 10 Jul 2026 21:49:48 +0800 Subject: [PATCH] Abstract provider market catalogs --- CONTRIBUTING.md | 24 +++- README.md | 55 +++++---- cpp/providers/README.md | 9 -- docs/provider-contract.md | 90 ++++++++++++++ src/pineforge_data/__init__.py | 38 +++++- src/pineforge_data/cli/backtest.py | 103 ++++++++++++---- src/pineforge_data/models.py | 83 ++++++++++++- src/pineforge_data/providers/__init__.py | 30 ++++- src/pineforge_data/providers/base.py | 25 +++- src/pineforge_data/providers/ccxt.py | 143 ++++++++++++++++++++- src/pineforge_data/providers/registry.py | 124 +++++++++++++++++++ src/pineforge_data/requests.py | 38 +++++- tests/test_backtest.py | 32 ++++- tests/test_ccxt.py | 151 +++++++++++++++++++++++ tests/test_models.py | 57 ++++++++- tests/test_providers.py | 60 ++++++++- 16 files changed, 983 insertions(+), 79 deletions(-) delete mode 100644 cpp/providers/README.md create mode 100644 docs/provider-contract.md create mode 100644 src/pineforge_data/providers/registry.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82a23cb..e3507ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,8 @@ # Contributing Community data providers are the reason this repository exists. Keep the core -contracts small and put vendor behavior behind provider modules. +contracts small and put vendor behavior behind Python provider modules. This +repository does not accept a separate C++ provider implementation surface. Docker and initialized codegen/engine submodules are required for raw-Pine integration work: @@ -13,7 +14,14 @@ docker version ## Provider checklist -- Implement one or more protocols from `pineforge_data.providers`. +- Implement `MarketDataProvider` for backtest-compatible adapters; live-trade + and macro support remain separate optional protocols. +- Resolve exact normalized symbols from the upstream market catalog. Do not + infer base, quote, settlement currency, or contract terms by parsing symbols. +- Preserve both the normalized symbol and the provider-native `provider_id`. +- Represent spot/cash/CFD/swap/future/option separately and populate derivative + contract size, linear/inverse flags, expiry, strike, and option type when the + upstream supplies them. - Normalize all timestamps to Unix milliseconds. - Preserve the source name and normalized instrument on every record. - Keep credentials out of logs and exception messages; prefer authorization @@ -25,6 +33,18 @@ docker version extending the normalized records. - Never make `pineforge-engine` depend on this package. +Adapters may be contributed in-tree or shipped by another Python package. An +external package registers a factory without changing this repository's CLI: + +```toml +[project.entry-points."pineforge_data.providers"] +example = "example_pineforge:provider_factory" +``` + +The callable receives `(venue, config)` and returns a structural +`MarketDataProvider`. See [docs/provider-contract.md](docs/provider-contract.md) +for the normalized model and a complete skeleton. + ## Determinism and macro vintages Historical results must be reproducible. Cache or snapshot mutable upstream diff --git a/README.md b/README.md index 01d772c..5bca1c3 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,19 @@ and asynchronous I/O. Python makes those integrations accessible to community contributors. Engine throughput remains native: normalized records are packed into contiguous C ABI arrays and submitted in one call. -If profiling later identifies a normalization hot path, it can gain an optional -native extension without changing the public provider contracts. - ## Initial contracts -- `Instrument` — normalized symbol, venue, timezone, session, and volume units. +- `Instrument` — normalized symbol, provider-native ID, venue, asset/market type, + currencies, contract terms, timezone, session, and volume units. +- `MarketListing` and `MarketQuery` — catalog discovery without vendor schemas. - `Bar` — confirmed OHLCV with source provenance. - `TradeTick` — the provider-neutral four-field engine payload plus provenance. - `MacroObservation` — observation period, first release, and vintage timestamps to prevent revised-data lookahead. -- `HistoricalBarProvider`, `LiveTradeProvider`, and `MacroDataProvider` — small - structural protocols that community adapters implement. +- `MarketCatalogProvider`, `HistoricalBarProvider`, `LiveTradeProvider`, and + `MacroDataProvider` — small structural protocols that community adapters + implement. +- `ProviderRegistry` — built-in and installed broker adapters selected by name. - `PfBar`, `PfTradeTick`, and `EngineStreamSink` — dependency-free `ctypes` interoperability with PineForge strategy libraries. @@ -59,14 +60,21 @@ request = BarRequest( ) async with CcxtProvider("kraken") as provider: + listing = await provider.resolve_market("BTC/USDT") confirmed_bars = await provider.fetch_bars(request) ``` -`Instrument.symbol` uses CCXT's unified spelling. Exchange credentials and -exchange-specific options can be passed through `config`, while endpoint -options remain isolated in `ohlcv_params` and `trade_params`. Realtime public -trades use REST polling in this bootstrap; a WebSocket transport can implement -the same `LiveTradeProvider` contract later. +`Instrument.symbol` uses CCXT's exact unified spelling. Do not infer a market +by parsing it: `resolve_market()` uses CCXT's catalog fields to distinguish +spot, swap, future, and option listings and captures `base`, `quote`, `settle`, +raw exchange ID, contract size, linear/inverse settlement, expiry, strike, and +option type. For example, `BTC/USDT` and `BTC/USDT:USDT` are separate markets. + +Exchange credentials and exchange-specific options can be passed through +`config`, while endpoint options remain isolated in `market_params`, +`ohlcv_params`, and `trade_params`. Realtime public trades use REST polling in +this bootstrap; a WebSocket transport can implement the same +`LiveTradeProvider` contract later. `TradeSubscription.start_ms` can pin the live handoff to the next timestamp after an engine warmup. `start_sequence` is the last accepted sequence, so the @@ -104,7 +112,8 @@ git submodule update --init ```bash .venv/bin/pineforge-backtest \ --pine strategy.pine \ - --exchange kraken \ + --provider ccxt \ + --venue kraken \ --symbol BTC/USD \ --timeframe 15m \ --start 2026-07-01T00:00:00Z \ @@ -136,11 +145,9 @@ source-available under its own PolyForm Noncommercial license and supplemental terms; review `vendor/pineforge-codegen-oss/LICENSE` before distribution or commercial use. The engine remains Apache-2.0. -Provider implementations are organized by their strongest supported runtime. -The current Python bucket contains CCXT and the harness; native low-latency -providers live under `cpp/providers`. Both buckets must emit the same normalized -records, but an individual provider does not need implementations in both -languages. +Provider implementations in this repository are Python-only. The compiled C++ +strategy and engine stay behind the Docker/runtime boundary; broker SDKs and +provider-specific types do not cross into `pineforge-engine`. ## Development @@ -157,14 +164,16 @@ PINEFORGE_DOCKER_TEST=1 .venv/bin/pytest tests/test_docker_integration.py A provider adapter should: -1. fetch or subscribe to its external service; -2. retain source and instrument provenance; -3. normalize timestamps to Unix milliseconds and records to the public models; -4. emit stable ordering sequences when the provider supplies them; -5. batch records before crossing the engine ABI when practical. +1. expose exact market discovery and symbol resolution; +2. fetch or subscribe to its external service; +3. retain source and instrument provenance; +4. normalize timestamps to Unix milliseconds and records to the public models; +5. emit stable ordering sequences when the provider supplies them; +6. batch records before crossing the engine ABI when practical. It should not add provider-specific fields to `pineforge-engine`. Data that the engine does not consume remains in provider-owned metadata or higher-level models in this repository. -See [CONTRIBUTING.md](CONTRIBUTING.md) before adding a provider. +See [the provider contract](docs/provider-contract.md) and +[CONTRIBUTING.md](CONTRIBUTING.md) before adding a provider. diff --git a/cpp/providers/README.md b/cpp/providers/README.md deleted file mode 100644 index 8b191a5..0000000 --- a/cpp/providers/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# C++ providers - -This directory is the native provider bucket for feeds that have a supported -C++ SDK or a measured latency/throughput requirement. - -A native provider must normalize into the same bar and trade field contract as -the Python package and use shared conformance fixtures. Providers do not need a -duplicate Python implementation, and provider-specific types must not leak into -`pineforge-engine`. diff --git a/docs/provider-contract.md b/docs/provider-contract.md new file mode 100644 index 0000000..86cdda7 --- /dev/null +++ b/docs/provider-contract.md @@ -0,0 +1,90 @@ +# Provider contract + +PineForge Data treats a provider and a venue as separate identities: + +- **provider** selects an adapter implementation, such as `ccxt` or a broker SDK; +- **venue** selects the exchange, broker environment, or data source instance; +- **symbol** is the provider's normalized public symbol; +- **provider_id** is the raw venue identifier used for provenance and debugging. + +An adapter must resolve an exact symbol through its upstream catalog before it +fetches bars. Symbol text is not a portable schema: derivative symbols can +encode settlement currency, expiry, strike, or option side differently across +providers. + +## Normalized market model + +`Instrument` carries: + +- `asset_class`: crypto, equity, forex, commodity, index, fund, bond, or unknown; +- `market_type`: spot, cash, swap, future, option, CFD, or unknown; +- `base`, `quote`, and `settle` currencies/assets; +- `contract`: optional `ContractSpec` with contract size, linear/inverse flags, + expiry, strike, and option type; +- venue, normalized symbol, provider ID, volume unit, timezone, and session. + +Margin availability is a capability on `MarketListing`, not a market type. A +spot listing can support margin while remaining a spot market. + +Unknown and unavailable fields should remain explicit (`UNKNOWN`, empty text, +or `None`). An adapter must not manufacture metadata by parsing the symbol. + +## In-tree adapter skeleton + +```python +from collections.abc import Mapping, Sequence + +from pineforge_data import ( + Bar, + BarRequest, + Instrument, + MarketListing, + MarketNotFoundError, + MarketQuery, +) + + +class BrokerProvider: + def __init__(self, venue: str, config: Mapping[str, object]) -> None: + self.venue = venue + self.name = f"broker:{venue}" + + async def list_markets( + self, query: MarketQuery | None = None + ) -> Sequence[MarketListing]: + listings = [] # Normalize the broker's catalog here. + return listings if query is None else [x for x in listings if query.matches(x)] + + async def resolve_market(self, symbol: str) -> MarketListing: + for listing in await self.list_markets(): + if listing.instrument.symbol == symbol: + return listing + raise MarketNotFoundError(f"no exact market {symbol!r} on {self.venue}") + + async def fetch_bars(self, request: BarRequest) -> Sequence[Bar]: + ... + + async def close(self) -> None: + ... + + +def provider_factory( + venue: str, config: Mapping[str, object] +) -> BrokerProvider: + return BrokerProvider(venue, config) +``` + +Add an in-tree factory to `providers/registry.py`. Out-of-tree packages use the +`pineforge_data.providers` Python entry-point group shown in +`CONTRIBUTING.md`. The CLI accepts any registered name through `--provider` and +passes `--venue` plus the JSON object from `--provider-config` to the factory. + +## Tests required for a provider PR + +- offline catalog fixtures for every supported market type; +- exact symbol resolution and a missing-symbol error; +- normalized provider ID, venue, base/quote/settle, and contract terms; +- catalog filtering through `MarketQuery`; +- confirmed OHLCV behavior, pagination, deduplication, and source provenance; +- close/cleanup behavior and explicit missing-capability errors; +- no network access or credentials in CI. diff --git a/src/pineforge_data/__init__.py b/src/pineforge_data/__init__.py index 91f8b10..c3821a5 100644 --- a/src/pineforge_data/__init__.py +++ b/src/pineforge_data/__init__.py @@ -15,7 +15,17 @@ ) from .engine import EngineStreamSink, PfBar, PfTradeTick, pack_bars, pack_trade_ticks from .errors import EngineStreamError -from .models import Bar, Instrument, MacroObservation, TradeTick +from .models import ( + AssetClass, + Bar, + ContractSpec, + Instrument, + MacroObservation, + MarketListing, + MarketType, + OptionType, + TradeTick, +) from .providers import ( CcxtCapabilityError, CcxtDataError, @@ -25,10 +35,20 @@ HistoricalBarProvider, LiveTradeProvider, MacroDataProvider, + MarketCatalogProvider, + MarketDataProvider, + MarketNotFoundError, + ProviderFactory, + ProviderNotFoundError, + ProviderRegistry, + ProviderRegistryError, + create_provider, + default_registry, ) -from .requests import BarRequest, MacroRequest, TradeSubscription +from .requests import BarRequest, MacroRequest, MarketQuery, TradeSubscription __all__ = [ + "AssetClass", "BacktestOptions", "BacktestReport", "Bar", @@ -38,6 +58,7 @@ "CcxtDependencyError", "CcxtError", "CcxtProvider", + "ContractSpec", "DockerBacktestRuntime", "DockerExecutionError", "DockerPrerequisiteError", @@ -51,11 +72,24 @@ "MacroObservation", "MacroRequest", "MagnifierDistribution", + "MarketCatalogProvider", + "MarketDataProvider", + "MarketListing", + "MarketNotFoundError", + "MarketQuery", + "MarketType", + "OptionType", "PfBar", "PfTradeTick", "PineForgeBacktestRunner", + "ProviderFactory", + "ProviderNotFoundError", + "ProviderRegistry", + "ProviderRegistryError", "TradeSubscription", "TradeTick", + "create_provider", + "default_registry", "discover_repository_root", "pack_bars", "pack_trade_ticks", diff --git a/src/pineforge_data/cli/backtest.py b/src/pineforge_data/cli/backtest.py index 4233579..e6431e5 100644 --- a/src/pineforge_data/cli/backtest.py +++ b/src/pineforge_data/cli/backtest.py @@ -7,14 +7,15 @@ import json import re import sys +from dataclasses import replace from datetime import datetime from pathlib import Path from typing import cast from ..backtest import BacktestOptions, JsonValue from ..docker_runtime import DockerBacktestRuntime, discover_repository_root -from ..models import Instrument -from ..providers import CcxtProvider +from ..models import MarketListing +from ..providers import create_provider from ..requests import BarRequest @@ -36,12 +37,12 @@ def parse_timestamp(value: str) -> int: return timestamp -def ccxt_timeframe_to_pine(timeframe: str) -> str: - """Translate common CCXT timeframe spelling into Pine timeframe spelling.""" +def source_timeframe_to_pine(timeframe: str) -> str: + """Translate common compact timeframe spelling into Pine timeframe spelling.""" match = re.fullmatch(r"([1-9][0-9]*)([smhdwM])", timeframe) if match is None: - raise ValueError(f"cannot translate CCXT timeframe to PineForge: {timeframe}") + raise ValueError(f"cannot translate source timeframe to PineForge: {timeframe}") count = int(match.group(1)) unit = match.group(2) if unit == "s": @@ -53,6 +54,10 @@ def ccxt_timeframe_to_pine(timeframe: str) -> str: return f"{count}{unit.upper() if unit != 'M' else unit}" +# Backward-compatible import for callers of the CCXT-only bootstrap API. +ccxt_timeframe_to_pine = source_timeframe_to_pine + + def _load_json_object(path: Path | None) -> dict[str, JsonValue]: if path is None: return {} @@ -62,16 +67,56 @@ def _load_json_object(path: Path | None) -> dict[str, JsonValue]: return cast(dict[str, JsonValue], value) +def _market_payload(listing: MarketListing) -> dict[str, JsonValue]: + instrument = listing.instrument + contract = instrument.contract + contract_payload: JsonValue = None + if contract is not None: + contract_payload = { + "contract_size": contract.contract_size, + "linear": contract.linear, + "inverse": contract.inverse, + "expiry_ms": contract.expiry_ms, + "strike": contract.strike, + "option_type": contract.option_type.value if contract.option_type else None, + } + return { + "symbol": instrument.symbol, + "provider_id": instrument.provider_id, + "asset_class": instrument.asset_class.value, + "market_type": instrument.market_type.value, + "base": instrument.base, + "quote": instrument.quote, + "settle": instrument.settle, + "volume_unit": instrument.volume_unit, + "active": listing.active, + "margin_supported": listing.margin_supported, + "contract": contract_payload, + } + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="pineforge-backtest", description="Transpile raw PineScript and backtest provider OHLCV in Docker", ) parser.add_argument("--pine", type=Path, required=True, help="raw PineScript v6 strategy") - parser.add_argument("--provider", choices=("ccxt",), default="ccxt") - parser.add_argument("--exchange", required=True, help="CCXT exchange id, such as kraken") - parser.add_argument("--symbol", required=True, help="CCXT unified symbol, such as BTC/USD") - parser.add_argument("--timeframe", required=True, help="CCXT timeframe, such as 15m or 1h") + parser.add_argument( + "--provider", default="ccxt", help="provider adapter name; defaults to ccxt" + ) + parser.add_argument( + "--venue", + "--exchange", + dest="venue", + required=True, + help="exchange, broker, or provider environment; for example kraken", + ) + parser.add_argument( + "--symbol", + required=True, + help="exact provider-normalized symbol, such as BTC/USD or BTC/USDT:USDT", + ) + parser.add_argument("--timeframe", required=True, help="source timeframe, such as 15m or 1h") parser.add_argument("--start", type=parse_timestamp, required=True) parser.add_argument("--end", type=parse_timestamp, required=True) parser.add_argument("--limit", type=int) @@ -82,7 +127,7 @@ def build_parser() -> argparse.ArgumentParser: help="PineForge input timeframe; defaults to a translation of --timeframe", ) parser.add_argument("--script-timeframe", default="") - parser.add_argument("--provider-config", type=Path, help="CCXT constructor options JSON file") + parser.add_argument("--provider-config", type=Path, help="provider options JSON file") parser.add_argument("--strategy-params", type=Path, help="strategy parameters JSON file") parser.add_argument("--bar-magnifier", action="store_true") parser.add_argument("--magnifier-samples", type=int, default=4) @@ -105,26 +150,29 @@ async def run_harness(args: argparse.Namespace) -> dict[str, JsonValue]: provider_config = _load_json_object(args.provider_config) strategy_params = _load_json_object(args.strategy_params) - instrument = Instrument( - args.symbol, - venue=args.exchange, - timezone=args.timezone, - session=args.session, - ) - request = BarRequest( - instrument, - args.timeframe, - args.start, - args.end, - limit=args.limit, - ) - async with CcxtProvider(args.exchange, config=provider_config) as provider: + provider = create_provider(args.provider, args.venue, config=provider_config) + try: + listing = await provider.resolve_market(args.symbol) + instrument = replace( + listing.instrument, + timezone=args.timezone, + session=args.session, + ) + request = BarRequest( + instrument, + args.timeframe, + args.start, + args.end, + limit=args.limit, + ) bars = await provider.fetch_bars(request) provider_name = provider.name + finally: + await provider.close() if not bars: raise RuntimeError("provider returned no confirmed bars for the requested interval") - engine_timeframe = args.engine_timeframe or ccxt_timeframe_to_pine(args.timeframe) + engine_timeframe = args.engine_timeframe or source_timeframe_to_pine(args.timeframe) options = BacktestOptions( input_timeframe=engine_timeframe, script_timeframe=args.script_timeframe or engine_timeframe, @@ -157,9 +205,10 @@ async def run_harness(args: argparse.Namespace) -> dict[str, JsonValue]: return { "provider": { "name": provider_name, - "exchange": args.exchange, - "symbol": args.symbol, + "adapter": args.provider, + "venue": args.venue, "source_timeframe": args.timeframe, + "market": _market_payload(listing), }, "data": { "requested_start_ms": args.start, diff --git a/src/pineforge_data/models.py b/src/pineforge_data/models.py index 5643d9b..07bf47b 100644 --- a/src/pineforge_data/models.py +++ b/src/pineforge_data/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum from math import isfinite @@ -22,21 +23,101 @@ def _unix_ms(value: int, field: str) -> None: raise ValueError(f"{field} must fit a signed 64-bit integer") +def _optional_text(value: str, field: str) -> None: + if value and not value.strip(): + raise ValueError(f"{field} must not contain only whitespace") + + +class AssetClass(StrEnum): + """Broad economic asset class, independent of execution venue.""" + + UNKNOWN = "unknown" + CRYPTO = "crypto" + EQUITY = "equity" + FOREX = "forex" + COMMODITY = "commodity" + INDEX = "index" + FUND = "fund" + BOND = "bond" + + +class MarketType(StrEnum): + """Trading-market structure for an instrument listing.""" + + UNKNOWN = "unknown" + SPOT = "spot" + CASH = "cash" + SWAP = "swap" + FUTURE = "future" + OPTION = "option" + CFD = "cfd" + + +class OptionType(StrEnum): + CALL = "call" + PUT = "put" + + +@dataclass(frozen=True, slots=True) +class ContractSpec: + """Normalized derivative terms supplied by a market catalog.""" + + contract_size: float | None = None + linear: bool | None = None + inverse: bool | None = None + expiry_ms: int | None = None + strike: float | None = None + option_type: OptionType | None = None + + def __post_init__(self) -> None: + if self.contract_size is not None and ( + not isfinite(self.contract_size) or self.contract_size <= 0 + ): + raise ValueError("contract_size must be finite and positive") + if self.linear is True and self.inverse is True: + raise ValueError("a contract cannot be both linear and inverse") + if self.expiry_ms is not None: + _unix_ms(self.expiry_ms, "expiry_ms") + if self.strike is not None and (not isfinite(self.strike) or self.strike <= 0): + raise ValueError("strike must be finite and positive") + + @dataclass(frozen=True, slots=True) class Instrument: - """A normalized instrument independent of any provider's symbol spelling.""" + """A normalized market instrument with provider identity kept explicit.""" symbol: str venue: str = "" timezone: str = "UTC" session: str = "24x7" volume_unit: str = "base" + asset_class: AssetClass = AssetClass.UNKNOWN + market_type: MarketType = MarketType.UNKNOWN + base: str = "" + quote: str = "" + settle: str = "" + provider_id: str = "" + contract: ContractSpec | None = None def __post_init__(self) -> None: _non_empty(self.symbol, "symbol") _non_empty(self.timezone, "timezone") _non_empty(self.session, "session") _non_empty(self.volume_unit, "volume_unit") + _optional_text(self.venue, "venue") + _optional_text(self.base, "base") + _optional_text(self.quote, "quote") + _optional_text(self.settle, "settle") + _optional_text(self.provider_id, "provider_id") + + +@dataclass(frozen=True, slots=True) +class MarketListing: + """One instrument as listed by one provider-bound venue.""" + + instrument: Instrument + active: bool | None = None + margin_supported: bool | None = None @dataclass(frozen=True, slots=True) diff --git a/src/pineforge_data/providers/__init__.py b/src/pineforge_data/providers/__init__.py index 6bde6c8..c8b061a 100644 --- a/src/pineforge_data/providers/__init__.py +++ b/src/pineforge_data/providers/__init__.py @@ -1,6 +1,13 @@ -"""Public provider protocols.""" +"""Public provider protocols and registry.""" -from .base import HistoricalBarProvider, LiveTradeProvider, MacroDataProvider +from .base import ( + HistoricalBarProvider, + LiveTradeProvider, + MacroDataProvider, + MarketCatalogProvider, + MarketDataProvider, + MarketNotFoundError, +) from .ccxt import ( CcxtCapabilityError, CcxtDataError, @@ -8,8 +15,18 @@ CcxtError, CcxtProvider, ) +from .registry import ( + ENTRY_POINT_GROUP, + ProviderFactory, + ProviderNotFoundError, + ProviderRegistry, + ProviderRegistryError, + create_provider, + default_registry, +) __all__ = [ + "ENTRY_POINT_GROUP", "CcxtCapabilityError", "CcxtDataError", "CcxtDependencyError", @@ -18,4 +35,13 @@ "HistoricalBarProvider", "LiveTradeProvider", "MacroDataProvider", + "MarketCatalogProvider", + "MarketDataProvider", + "MarketNotFoundError", + "ProviderFactory", + "ProviderNotFoundError", + "ProviderRegistry", + "ProviderRegistryError", + "create_provider", + "default_registry", ] diff --git a/src/pineforge_data/providers/base.py b/src/pineforge_data/providers/base.py index 0d2d6a4..046fd39 100644 --- a/src/pineforge_data/providers/base.py +++ b/src/pineforge_data/providers/base.py @@ -5,8 +5,12 @@ from collections.abc import AsyncIterator, Sequence from typing import Protocol, runtime_checkable -from ..models import Bar, MacroObservation, TradeTick -from ..requests import BarRequest, MacroRequest, TradeSubscription +from ..models import Bar, MacroObservation, MarketListing, TradeTick +from ..requests import BarRequest, MacroRequest, MarketQuery, TradeSubscription + + +class MarketNotFoundError(LookupError): + """A provider catalog has no exact match for a normalized symbol.""" @runtime_checkable @@ -16,6 +20,16 @@ class HistoricalBarProvider(Protocol): async def fetch_bars(self, request: BarRequest) -> Sequence[Bar]: ... +@runtime_checkable +class MarketCatalogProvider(Protocol): + name: str + venue: str + + async def list_markets(self, query: MarketQuery | None = None) -> Sequence[MarketListing]: ... + + async def resolve_market(self, symbol: str) -> MarketListing: ... + + @runtime_checkable class LiveTradeProvider(Protocol): name: str @@ -28,3 +42,10 @@ class MacroDataProvider(Protocol): name: str async def fetch_observations(self, request: MacroRequest) -> Sequence[MacroObservation]: ... + + +@runtime_checkable +class MarketDataProvider(HistoricalBarProvider, MarketCatalogProvider, Protocol): + """Catalog plus historical bars required by the backtest harness.""" + + async def close(self) -> None: ... diff --git a/src/pineforge_data/providers/ccxt.py b/src/pineforge_data/providers/ccxt.py index 7ed28d9..d70f962 100644 --- a/src/pineforge_data/providers/ccxt.py +++ b/src/pineforge_data/providers/ccxt.py @@ -9,8 +9,18 @@ from math import isfinite from typing import Protocol, cast -from ..models import Bar, TradeTick -from ..requests import BarRequest, TradeSubscription +from ..models import ( + AssetClass, + Bar, + ContractSpec, + Instrument, + MarketListing, + MarketType, + OptionType, + TradeTick, +) +from ..requests import BarRequest, MarketQuery, TradeSubscription +from .base import MarketNotFoundError class CcxtError(RuntimeError): @@ -37,6 +47,12 @@ def milliseconds(self) -> int: ... def parse_timeframe(self, timeframe: str) -> float: ... + async def load_markets( + self, + reload: bool = False, + params: Mapping[str, object] | None = None, + ) -> Mapping[str, Mapping[str, object]]: ... + async def fetch_ohlcv( self, symbol: str, @@ -89,6 +105,44 @@ def _timestamp(value: object, field: str = "timestamp") -> int: return int(normalized) +def _optional_number(value: object, field: str) -> float | None: + return None if value is None else _number(value, field) + + +def _optional_timestamp(value: object, field: str) -> int | None: + return None if value is None else _timestamp(value, field) + + +def _optional_bool(value: object, field: str) -> bool | None: + if value is None: + return None + if not isinstance(value, bool): + raise CcxtDataError(f"{field} must be boolean") + return value + + +def _text(value: object, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise CcxtDataError(f"{field} must be a non-empty string") + return value + + +def _optional_text(value: object, field: str) -> str: + if value is None: + return "" + return _text(value, field) + + +_CCXT_MARKET_TYPES = { + "spot": MarketType.SPOT, + "margin": MarketType.SPOT, + "swap": MarketType.SWAP, + "future": MarketType.FUTURE, + "option": MarketType.OPTION, +} +_CONTRACT_MARKET_TYPES = {MarketType.SWAP, MarketType.FUTURE, MarketType.OPTION} + + class CcxtProvider: """Normalize one CCXT exchange into PineForge bars and trade ticks. @@ -106,6 +160,8 @@ def __init__( page_limit: int = 1_000, poll_interval_ms: int = 1_000, dedup_window: int = 10_000, + reload_markets: bool = False, + market_params: Mapping[str, object] | None = None, ohlcv_params: Mapping[str, object] | None = None, trade_params: Mapping[str, object] | None = None, ) -> None: @@ -125,10 +181,13 @@ def __init__( f"injected CCXT exchange id {self._exchange.id!r} does not match {exchange_id!r}" ) self.exchange_id = exchange_id + self.venue = exchange_id self.name = f"ccxt:{exchange_id}" self.page_limit = page_limit self.poll_interval_ms = poll_interval_ms self.dedup_window = dedup_window + self.reload_markets = reload_markets + self.market_params = dict(market_params or {}) self.ohlcv_params = dict(ohlcv_params or {}) self.trade_params = dict(trade_params or {}) @@ -136,6 +195,80 @@ def _require_capability(self, name: str) -> None: if not self._exchange.has.get(name): raise CcxtCapabilityError(f"{self.exchange_id} does not support {name}") + def _validate_instrument_venue(self, instrument: Instrument) -> None: + if instrument.venue and instrument.venue != self.venue: + raise ValueError( + f"instrument venue {instrument.venue!r} does not match provider " + f"venue {self.venue!r}" + ) + + def _normalize_market(self, raw: Mapping[str, object]) -> MarketListing: + symbol = _text(raw.get("symbol"), "market.symbol") + provider_id = _text(raw.get("id"), "market.id") + raw_type = _optional_text(raw.get("type"), "market.type").casefold() + market_type = _CCXT_MARKET_TYPES.get(raw_type, MarketType.UNKNOWN) + declared_contract = _optional_bool(raw.get("contract"), "market.contract") + is_contract = declared_contract is True or market_type in _CONTRACT_MARKET_TYPES + + option_type: OptionType | None = None + raw_option_type = _optional_text(raw.get("optionType"), "market.optionType") + if raw_option_type: + try: + option_type = OptionType(raw_option_type.casefold()) + except ValueError as exc: + raise CcxtDataError(f"unsupported market.optionType: {raw_option_type!r}") from exc + + contract = None + if is_contract: + try: + contract = ContractSpec( + contract_size=_optional_number(raw.get("contractSize"), "market.contractSize"), + linear=_optional_bool(raw.get("linear"), "market.linear"), + inverse=_optional_bool(raw.get("inverse"), "market.inverse"), + expiry_ms=_optional_timestamp(raw.get("expiry"), "market.expiry"), + strike=_optional_number(raw.get("strike"), "market.strike"), + option_type=option_type, + ) + except ValueError as exc: + raise CcxtDataError(f"invalid contract metadata for {symbol}: {exc}") from exc + + return MarketListing( + instrument=Instrument( + symbol=symbol, + venue=self.venue, + volume_unit="contracts" if is_contract else "base", + asset_class=AssetClass.CRYPTO, + market_type=market_type, + base=_optional_text(raw.get("base"), "market.base"), + quote=_optional_text(raw.get("quote"), "market.quote"), + settle=_optional_text(raw.get("settle"), "market.settle"), + provider_id=provider_id, + contract=contract, + ), + active=_optional_bool(raw.get("active"), "market.active"), + margin_supported=_optional_bool(raw.get("margin"), "market.margin"), + ) + + async def list_markets(self, query: MarketQuery | None = None) -> Sequence[MarketListing]: + """Load and normalize every market advertised by this CCXT exchange.""" + + raw_markets = await self._exchange.load_markets(self.reload_markets, self.market_params) + listings = [self._normalize_market(raw) for raw in raw_markets.values()] + if query is not None: + listings = [listing for listing in listings if query.matches(listing)] + return sorted(listings, key=lambda listing: listing.instrument.symbol) + + async def resolve_market(self, symbol: str) -> MarketListing: + """Resolve one exact CCXT unified symbol into normalized market metadata.""" + + if not symbol.strip(): + raise ValueError("symbol must not be empty") + raw_markets = await self._exchange.load_markets(self.reload_markets, self.market_params) + raw = raw_markets.get(symbol) + if raw is None: + raise MarketNotFoundError(f"{self.name} has no exact unified market symbol {symbol!r}") + return self._normalize_market(raw) + def _normalize_bar(self, raw: Sequence[object], request: BarRequest) -> Bar: if len(raw) < 6: raise CcxtDataError("OHLCV record must contain at least six fields") @@ -153,6 +286,7 @@ def _normalize_bar(self, raw: Sequence[object], request: BarRequest) -> Bar: async def fetch_bars(self, request: BarRequest) -> Sequence[Bar]: """Fetch paginated, deduplicated, confirmed OHLCV bars.""" + self._validate_instrument_venue(request.instrument) self._require_capability("fetchOHLCV") timeframe_seconds = self._exchange.parse_timeframe(request.timeframe) timeframe_ms = int(_number(timeframe_seconds, "timeframe seconds") * 1_000) @@ -216,11 +350,10 @@ def _normalize_trade( source=self.name, ) - async def stream_trades( - self, subscription: TradeSubscription - ) -> AsyncIterator[TradeTick]: + async def stream_trades(self, subscription: TradeSubscription) -> AsyncIterator[TradeTick]: """Poll CCXT public trades and emit a strictly ordered local sequence.""" + self._validate_instrument_venue(subscription.instrument) self._require_capability("fetchTrades") since = ( subscription.start_ms diff --git a/src/pineforge_data/providers/registry.py b/src/pineforge_data/providers/registry.py new file mode 100644 index 0000000..8a8c4fa --- /dev/null +++ b/src/pineforge_data/providers/registry.py @@ -0,0 +1,124 @@ +"""Provider factories and third-party entry-point discovery.""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib.metadata import EntryPoint, entry_points +from typing import Protocol, cast + +from .base import MarketDataProvider +from .ccxt import CcxtProvider + +ENTRY_POINT_GROUP = "pineforge_data.providers" + + +class ProviderRegistryError(RuntimeError): + """A provider factory cannot be registered or loaded.""" + + +class ProviderNotFoundError(ProviderRegistryError): + """No built-in or installed provider has the requested name.""" + + +class ProviderFactory(Protocol): + def __call__(self, venue: str, config: Mapping[str, object]) -> MarketDataProvider: ... + + +def _ccxt_factory(venue: str, config: Mapping[str, object]) -> MarketDataProvider: + return CcxtProvider(venue, config=config) + + +class ProviderRegistry: + """Resolve built-ins and externally installed provider factories by name.""" + + def __init__(self, *, include_builtin: bool = True) -> None: + self._factories: dict[str, ProviderFactory] = {} + if include_builtin: + self.register("ccxt", _ccxt_factory) + + @staticmethod + def _normalize_name(name: str) -> str: + normalized = name.strip().casefold() + if not normalized: + raise ValueError("provider name must not be empty") + return normalized + + def register(self, name: str, factory: ProviderFactory, *, replace: bool = False) -> None: + """Register an in-process provider factory.""" + + normalized = self._normalize_name(name) + if normalized in self._factories and not replace: + raise ProviderRegistryError(f"provider already registered: {normalized}") + self._factories[normalized] = factory + + def _matching_entry_point(self, name: str) -> EntryPoint | None: + matches = [ + candidate + for candidate in entry_points().select(group=ENTRY_POINT_GROUP) + if candidate.name.casefold() == name + ] + if len(matches) > 1: + packages = ", ".join(sorted(candidate.value for candidate in matches)) + raise ProviderRegistryError( + f"multiple entry points registered for provider {name!r}: {packages}" + ) + return matches[0] if matches else None + + def _load_external(self, name: str) -> ProviderFactory | None: + entry_point = self._matching_entry_point(name) + if entry_point is None: + return None + candidate = entry_point.load() + if not callable(candidate): + raise ProviderRegistryError( + f"provider entry point {entry_point.value!r} is not callable" + ) + factory = cast(ProviderFactory, candidate) + self._factories[name] = factory + return factory + + def create( + self, + name: str, + venue: str, + *, + config: Mapping[str, object] | None = None, + ) -> MarketDataProvider: + """Create one provider bound to a venue or broker environment.""" + + normalized = self._normalize_name(name) + factory = self._factories.get(normalized) or self._load_external(normalized) + if factory is None: + available = ", ".join(self.names()) or "none" + raise ProviderNotFoundError( + f"unknown provider {name!r}; available providers: {available}" + ) + provider = factory(venue, config or {}) + if not isinstance(provider, MarketDataProvider): + raise ProviderRegistryError( + f"provider {normalized!r} does not implement MarketDataProvider" + ) + return provider + + def names(self) -> tuple[str, ...]: + """List built-in, registered, and advertised provider names.""" + + advertised = { + candidate.name.casefold() + for candidate in entry_points().select(group=ENTRY_POINT_GROUP) + } + return tuple(sorted(self._factories.keys() | advertised)) + + +default_registry = ProviderRegistry() + + +def create_provider( + name: str, + venue: str, + *, + config: Mapping[str, object] | None = None, +) -> MarketDataProvider: + """Create a provider from the process-wide registry.""" + + return default_registry.create(name, venue, config=config) diff --git a/src/pineforge_data/requests.py b/src/pineforge_data/requests.py index f2619df..3467585 100644 --- a/src/pineforge_data/requests.py +++ b/src/pineforge_data/requests.py @@ -2,9 +2,43 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field -from .models import Instrument +from .models import AssetClass, Instrument, MarketListing, MarketType + + +@dataclass(frozen=True, slots=True) +class MarketQuery: + """Provider-neutral filters for catalog discovery.""" + + asset_class: AssetClass | None = None + market_types: frozenset[MarketType] = field(default_factory=frozenset) + base: str = "" + quote: str = "" + settle: str = "" + active: bool | None = None + margin_supported: bool | None = None + linear: bool | None = None + inverse: bool | None = None + + def matches(self, listing: MarketListing) -> bool: + """Return whether a normalized listing satisfies every supplied filter.""" + + instrument = listing.instrument + contract = instrument.contract + return ( + (self.asset_class is None or instrument.asset_class is self.asset_class) + and (not self.market_types or instrument.market_type in self.market_types) + and (not self.base or instrument.base.casefold() == self.base.casefold()) + and (not self.quote or instrument.quote.casefold() == self.quote.casefold()) + and (not self.settle or instrument.settle.casefold() == self.settle.casefold()) + and (self.active is None or listing.active is self.active) + and (self.margin_supported is None or listing.margin_supported is self.margin_supported) + and (self.linear is None or (contract is not None and contract.linear is self.linear)) + and ( + self.inverse is None or (contract is not None and contract.inverse is self.inverse) + ) + ) @dataclass(frozen=True, slots=True) diff --git a/tests/test_backtest.py b/tests/test_backtest.py index bb680ff..866f6e7 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -16,7 +16,12 @@ PineForgeBacktestRunner, ) from pineforge_data.backtest import _PfEquityPoint, _PfReport, _PfTrade -from pineforge_data.cli.backtest import build_parser, ccxt_timeframe_to_pine, parse_timestamp +from pineforge_data.cli.backtest import ( + build_parser, + ccxt_timeframe_to_pine, + parse_timestamp, + source_timeframe_to_pine, +) class FakeFunction: @@ -139,6 +144,7 @@ def test_runner_rejects_abi_mismatch_and_unsorted_bars() -> None: ) def test_ccxt_timeframe_conversion(ccxt: str, pine: str) -> None: assert ccxt_timeframe_to_pine(ccxt) == pine + assert source_timeframe_to_pine(ccxt) == pine def test_timestamp_parser_accepts_unix_ms_and_iso_8601() -> None: @@ -165,4 +171,28 @@ def test_cli_requires_raw_pine_instead_of_shared_library() -> None: ) assert args.pine == Path("strategy.pine") + assert args.venue == "kraken" assert not hasattr(args, "strategy") + + +def test_cli_accepts_generic_provider_and_venue_names() -> None: + args = build_parser().parse_args( + [ + "--pine", + "strategy.pine", + "--provider", + "community-broker", + "--venue", + "paper", + "--symbol", + "ES/SEP26", + "--timeframe", + "1m", + "--start", + "1000", + "--end", + "2000", + ] + ) + + assert (args.provider, args.venue) == ("community-broker", "paper") diff --git a/tests/test_ccxt.py b/tests/test_ccxt.py index b436aa8..0c6ed8a 100644 --- a/tests/test_ccxt.py +++ b/tests/test_ccxt.py @@ -6,12 +6,18 @@ import pytest from pineforge_data import ( + AssetClass, BarRequest, CcxtCapabilityError, CcxtProvider, HistoricalBarProvider, Instrument, LiveTradeProvider, + MarketCatalogProvider, + MarketNotFoundError, + MarketQuery, + MarketType, + OptionType, TradeSubscription, ) @@ -32,6 +38,69 @@ def __init__(self) -> None: {"id": "b", "timestamp": 180_002, "price": 12.0, "amount": 0.2}, {"id": "a", "timestamp": 180_001, "price": 11.0, "amount": 0.1}, ] + self.markets: dict[str, Mapping[str, object]] = { + "BTC/USDT": { + "id": "BTCUSDT", + "symbol": "BTC/USDT", + "base": "BTC", + "quote": "USDT", + "settle": None, + "type": "spot", + "spot": True, + "margin": True, + "contract": False, + "active": True, + }, + "BTC/USDT:USDT": { + "id": "BTC-USDT-SWAP", + "symbol": "BTC/USDT:USDT", + "base": "BTC", + "quote": "USDT", + "settle": "USDT", + "type": "swap", + "swap": True, + "margin": False, + "contract": True, + "linear": True, + "inverse": False, + "contractSize": 0.001, + "active": True, + }, + "BTC/USD:BTC-260925": { + "id": "PI_XBTUSD_260925", + "symbol": "BTC/USD:BTC-260925", + "base": "BTC", + "quote": "USD", + "settle": "BTC", + "type": "future", + "future": True, + "margin": False, + "contract": True, + "linear": False, + "inverse": True, + "contractSize": 1, + "expiry": 1_790_294_400_000, + "active": True, + }, + "BTC/USD:BTC-260925-100000-C": { + "id": "BTC-260925-100000-C", + "symbol": "BTC/USD:BTC-260925-100000-C", + "base": "BTC", + "quote": "USD", + "settle": "BTC", + "type": "option", + "option": True, + "margin": False, + "contract": True, + "linear": False, + "inverse": True, + "contractSize": 1, + "expiry": 1_790_294_400_000, + "strike": 100_000, + "optionType": "call", + "active": True, + }, + } def milliseconds(self) -> int: return 180_000 @@ -40,6 +109,15 @@ def parse_timeframe(self, timeframe: str) -> float: assert timeframe == "1m" return 60.0 + async def load_markets( + self, + reload: bool = False, + params: Mapping[str, object] | None = None, + ) -> Mapping[str, Mapping[str, object]]: + assert not reload + assert params == {} + return self.markets + async def fetch_ohlcv( self, symbol: str, @@ -113,11 +191,84 @@ async def run() -> None: assert provider.name == "ccxt:kraken" assert isinstance(provider, HistoricalBarProvider) assert isinstance(provider, LiveTradeProvider) + assert isinstance(provider, MarketCatalogProvider) await provider.close() asyncio.run(run()) +def test_market_catalog_normalizes_spot_and_contract_metadata() -> None: + async def run() -> None: + provider = CcxtProvider("fake", exchange=FakeCcxtExchange()) + + spot = await provider.resolve_market("BTC/USDT") + swap = await provider.resolve_market("BTC/USDT:USDT") + future = await provider.resolve_market("BTC/USD:BTC-260925") + option = await provider.resolve_market("BTC/USD:BTC-260925-100000-C") + + assert spot.instrument.asset_class is AssetClass.CRYPTO + assert spot.instrument.market_type is MarketType.SPOT + assert spot.instrument.provider_id == "BTCUSDT" + assert spot.instrument.contract is None + assert spot.margin_supported is True + + assert swap.instrument.market_type is MarketType.SWAP + assert swap.instrument.volume_unit == "contracts" + assert swap.instrument.contract is not None + assert swap.instrument.contract.contract_size == 0.001 + assert swap.instrument.contract.linear is True + + assert future.instrument.market_type is MarketType.FUTURE + assert future.instrument.contract is not None + assert future.instrument.contract.inverse is True + assert future.instrument.contract.expiry_ms == 1_790_294_400_000 + + assert option.instrument.market_type is MarketType.OPTION + assert option.instrument.contract is not None + assert option.instrument.contract.strike == 100_000 + assert option.instrument.contract.option_type is OptionType.CALL + + asyncio.run(run()) + + +def test_market_query_filters_by_type_settlement_and_contract_shape() -> None: + async def run() -> None: + provider = CcxtProvider("fake", exchange=FakeCcxtExchange()) + query = MarketQuery( + market_types=frozenset({MarketType.SWAP}), + settle="usdt", + active=True, + linear=True, + ) + + listings = await provider.list_markets(query) + + assert [listing.instrument.symbol for listing in listings] == ["BTC/USDT:USDT"] + + asyncio.run(run()) + + +def test_market_resolution_requires_an_exact_unified_symbol() -> None: + async def run() -> None: + provider = CcxtProvider("fake", exchange=FakeCcxtExchange()) + + with pytest.raises(MarketNotFoundError, match="exact unified market symbol"): + await provider.resolve_market("BTCUSDT") + + asyncio.run(run()) + + +def test_rejects_an_instrument_from_another_venue() -> None: + async def run() -> None: + provider = CcxtProvider("fake", exchange=FakeCcxtExchange()) + request = BarRequest(Instrument("BTC/USDT", venue="other"), "1m", 0, 60_000) + + with pytest.raises(ValueError, match="does not match provider venue"): + await provider.fetch_bars(request) + + asyncio.run(run()) + + def test_missing_exchange_capability_is_explicit() -> None: async def run() -> None: exchange = FakeCcxtExchange() diff --git a/tests/test_models.py b/tests/test_models.py index e214fad..bfed32d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,7 +2,17 @@ import pytest -from pineforge_data import Bar, Instrument, MacroObservation, TradeTick +from pineforge_data import ( + Bar, + ContractSpec, + Instrument, + MacroObservation, + MarketListing, + MarketQuery, + MarketType, + OptionType, + TradeTick, +) def test_normalized_records_accept_valid_values() -> None: @@ -64,3 +74,48 @@ def test_trade_tick_rejects_values_outside_c_abi_ranges() -> None: with pytest.raises(ValueError, match="unsigned 64-bit"): TradeTick(Instrument("BTCUSD"), 1_000, 2**64, 10.0, 1.0, "example") + + +def test_contract_spec_and_market_query_are_provider_neutral() -> None: + contract = ContractSpec( + contract_size=0.01, + linear=True, + inverse=False, + expiry_ms=1_800_000_000_000, + strike=100_000, + option_type=OptionType.CALL, + ) + listing = MarketListing( + Instrument( + "BTC/USD:USD-OPTION", + venue="broker", + market_type=MarketType.OPTION, + base="BTC", + quote="USD", + settle="USD", + provider_id="native-123", + contract=contract, + ), + active=True, + margin_supported=False, + ) + + assert MarketQuery( + market_types=frozenset({MarketType.OPTION}), + settle="usd", + linear=True, + ).matches(listing) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"contract_size": 0}, "contract_size"), + ({"linear": True, "inverse": True}, "both linear and inverse"), + ({"expiry_ms": -1}, "expiry_ms"), + ({"strike": -1}, "strike"), + ], +) +def test_contract_spec_rejects_invalid_terms(kwargs: dict[str, object], message: str) -> None: + with pytest.raises(ValueError, match=message): + ContractSpec(**kwargs) # type: ignore[arg-type] diff --git a/tests/test_providers.py b/tests/test_providers.py index de7f974..01de075 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1,9 +1,22 @@ from __future__ import annotations import asyncio -from collections.abc import Sequence +from collections.abc import Mapping, Sequence -from pineforge_data import Bar, BarRequest, HistoricalBarProvider, Instrument +import pytest + +from pineforge_data import ( + Bar, + BarRequest, + HistoricalBarProvider, + Instrument, + MarketDataProvider, + MarketListing, + MarketNotFoundError, + MarketQuery, + ProviderNotFoundError, + ProviderRegistry, +) class ExampleBarProvider: @@ -30,3 +43,46 @@ def test_structural_provider_protocol_needs_no_base_class() -> None: assert isinstance(provider, HistoricalBarProvider) assert asyncio.run(provider.fetch_bars(request))[0].source == "example" + + +class ExampleMarketProvider: + name = "example:paper" + venue = "paper" + + async def list_markets(self, query: MarketQuery | None = None) -> Sequence[MarketListing]: + listing = MarketListing(Instrument("TEST/USD", venue=self.venue)) + return [listing] if query is None or query.matches(listing) else [] + + async def resolve_market(self, symbol: str) -> MarketListing: + if symbol != "TEST/USD": + raise MarketNotFoundError(symbol) + return MarketListing(Instrument(symbol, venue=self.venue)) + + async def fetch_bars(self, request: BarRequest) -> Sequence[Bar]: + return [] + + async def close(self) -> None: + return None + + +def example_factory(venue: str, config: Mapping[str, object]) -> MarketDataProvider: + assert venue == "paper" + assert config == {"environment": "test"} + return ExampleMarketProvider() + + +def test_provider_registry_accepts_broker_plugins_without_cli_branching() -> None: + registry = ProviderRegistry(include_builtin=False) + registry.register("example", example_factory) + + provider = registry.create("EXAMPLE", "paper", config={"environment": "test"}) + + assert isinstance(provider, MarketDataProvider) + assert asyncio.run(provider.resolve_market("TEST/USD")).instrument.venue == "paper" + + +def test_provider_registry_reports_unknown_adapters() -> None: + registry = ProviderRegistry(include_builtin=False) + + with pytest.raises(ProviderNotFoundError, match="unknown provider"): + registry.create("missing", "paper")