drift-quant / src /drift_quant /market_data.py
deveshc23
Add availability-aware market data foundation
fc76b70
Raw History Blame Contribute Delete
5.54 kB
"""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)