"""Provider-neutral, availability-aware market-data contracts.""" from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass from datetime import datetime from decimal import Decimal from typing import Protocol, runtime_checkable class MarketDataError(ValueError): """Raised when normalized market data or provider registration is invalid.""" def _require_aware(timestamp: datetime, field_name: str) -> None: if timestamp.tzinfo is None or timestamp.utcoffset() is None: raise MarketDataError(f"{field_name} must be timezone-aware.") @dataclass(frozen=True, slots=True) class DailyBar: """Normalized, unadjusted daily OHLCV data with explicit availability time.""" instrument_id: str event_at: datetime available_at: datetime open: Decimal high: Decimal low: Decimal close: Decimal volume: int provider_name: str dataset_id: str def __post_init__(self) -> None: if not self.instrument_id.strip(): raise MarketDataError("Instrument ID must not be blank.") if not self.provider_name.strip() or not self.dataset_id.strip(): raise MarketDataError("Provider name and dataset ID must not be blank.") _require_aware(self.event_at, "event_at") _require_aware(self.available_at, "available_at") if self.available_at < self.event_at: raise MarketDataError("available_at must not precede event_at.") if min(self.open, self.high, self.low, self.close) <= Decimal("0"): raise MarketDataError("OHLC prices must be positive.") if self.high < max(self.open, self.close) or self.low > min(self.open, self.close): raise MarketDataError("OHLC prices are internally inconsistent.") if self.volume < 0: raise MarketDataError("Volume must not be negative.") @dataclass(frozen=True, slots=True) class BarRequest: """A time-bounded query whose `as_of` prevents look-ahead use.""" instrument_ids: tuple[str, ...] start_at: datetime end_at: datetime as_of: datetime def __post_init__(self) -> None: if not self.instrument_ids or any( not instrument.strip() for instrument in self.instrument_ids ): raise MarketDataError("At least one non-blank instrument ID is required.") _require_aware(self.start_at, "start_at") _require_aware(self.end_at, "end_at") _require_aware(self.as_of, "as_of") if self.start_at >= self.end_at: raise MarketDataError("start_at must be before end_at.") @runtime_checkable class MarketDataProvider(Protocol): """Port implemented by all market-data providers.""" @property def name(self) -> str: ... def daily_bars(self, request: BarRequest) -> tuple[DailyBar, ...]: ... class ProviderRegistry: """Explicit provider registry with no vendor dependency in consumers.""" def __init__(self) -> None: self._providers: dict[str, MarketDataProvider] = {} def register(self, provider: MarketDataProvider) -> None: name = provider.name.strip().lower() if not name: raise MarketDataError("Provider name must not be blank.") if name in self._providers: raise MarketDataError(f"Provider {name} is already registered.") self._providers[name] = provider def get(self, name: str) -> MarketDataProvider: try: return self._providers[name.strip().lower()] except KeyError as exc: raise MarketDataError(f"Provider {name!r} is not registered.") from exc def names(self) -> tuple[str, ...]: return tuple(sorted(self._providers)) class FixtureMarketDataProvider: """A deterministic local provider used by tests and reproducible examples.""" def __init__(self, name: str, bars: Iterable[DailyBar]) -> None: if not name.strip(): raise MarketDataError("Provider name must not be blank.") self._name = name grouped: dict[str, list[DailyBar]] = {} for bar in bars: if bar.provider_name != name: raise MarketDataError("Fixture bars must name the fixture provider.") grouped.setdefault(bar.instrument_id, []).append(bar) self._bars: Mapping[str, tuple[DailyBar, ...]] = { instrument_id: self._validate_and_sort(instrument_bars) for instrument_id, instrument_bars in grouped.items() } @property def name(self) -> str: return self._name def daily_bars(self, request: BarRequest) -> tuple[DailyBar, ...]: result: list[DailyBar] = [] for instrument_id in request.instrument_ids: for bar in self._bars.get(instrument_id, ()): if ( request.start_at <= bar.event_at < request.end_at and bar.available_at <= request.as_of ): result.append(bar) return tuple(sorted(result, key=lambda bar: (bar.event_at, bar.instrument_id))) @staticmethod def _validate_and_sort(bars: list[DailyBar]) -> tuple[DailyBar, ...]: ordered = sorted(bars, key=lambda bar: bar.event_at) event_times = [bar.event_at for bar in ordered] if len(event_times) != len(set(event_times)): raise MarketDataError( "Fixture bars must not duplicate an event timestamp per instrument." ) return tuple(ordered)