From 3f13da518bb2bf3300ec1a1c2e2f8c6dfbe48254 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:34:17 +0800 Subject: [PATCH] feat: add strict adjusted history request contract Co-Authored-By: Codex --- src/quant_platform_kit/ibkr/__init__.py | 10 ++ src/quant_platform_kit/ibkr/market_data.py | 190 ++++++++++++++++++++- tests/test_ibkr_market_data.py | 148 ++++++++++++++++ 3 files changed, 346 insertions(+), 2 deletions(-) diff --git a/src/quant_platform_kit/ibkr/__init__.py b/src/quant_platform_kit/ibkr/__init__.py index 64ae2e72..0dfba284 100644 --- a/src/quant_platform_kit/ibkr/__init__.py +++ b/src/quant_platform_kit/ibkr/__init__.py @@ -1,10 +1,15 @@ from .connection import connect_ib, ensure_event_loop from .execution import submit_order_intent from .market_data import ( + AdjustedHistoricalCandle, + StrictAdjustedHistoryError, + StrictAdjustedHistoryProvenance, + StrictAdjustedHistoryResult, fetch_historical_price_candles, fetch_historical_price_series, fetch_option_chain_snapshot, fetch_quote_snapshots, + fetch_strict_adjusted_historical_price_candles, ) from .portfolio import fetch_portfolio_snapshot from .runtime_inputs import ( @@ -16,6 +21,10 @@ ) __all__ = [ + "AdjustedHistoricalCandle", + "StrictAdjustedHistoryError", + "StrictAdjustedHistoryProvenance", + "StrictAdjustedHistoryResult", "build_benchmark_history_inputs", "build_ibkr_strategy_context", "build_market_history_inputs", @@ -28,5 +37,6 @@ "submit_order_intent", "fetch_historical_price_series", "fetch_quote_snapshots", + "fetch_strict_adjusted_historical_price_candles", "fetch_portfolio_snapshot", ] diff --git a/src/quant_platform_kit/ibkr/market_data.py b/src/quant_platform_kit/ibkr/market_data.py index 4599b81a..bd5e72ba 100644 --- a/src/quant_platform_kit/ibkr/market_data.py +++ b/src/quant_platform_kit/ibkr/market_data.py @@ -1,10 +1,11 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import date, datetime, time, timezone from math import ceil -from math import isnan +from math import isfinite, isnan import re -from typing import Any, Callable +from typing import Any, Callable, Sequence from quant_platform_kit.common.models import PricePoint, PriceSeries, QuoteSnapshot @@ -83,6 +84,191 @@ def _normalize_duration_for_ibkr(duration: str) -> str: return f"{quantity} {unit}" +class StrictAdjustedHistoryError(RuntimeError): + """A strict adjusted-history request or response violated its contract.""" + + +@dataclass(frozen=True) +class AdjustedHistoricalCandle: + session: date + open: float + high: float + low: float + close: float + volume: float + + +@dataclass(frozen=True) +class StrictAdjustedHistoryProvenance: + symbol: str + exchange: str + currency: str + end_datetime: str + duration: str + bar_size: str + what_to_show: str + use_rth: bool + format_date: int + keep_up_to_date: bool + returned_row_count: int + + +@dataclass(frozen=True) +class StrictAdjustedHistoryResult: + candles: tuple[AdjustedHistoricalCandle, ...] + provenance: StrictAdjustedHistoryProvenance + + +def _strict_history_request_inputs( + symbol: str, + *, + end_datetime: datetime, + duration: str, + expected_sessions: Sequence[date], +) -> tuple[str, datetime, str, tuple[date, ...]]: + if not isinstance(symbol, str) or re.fullmatch(r"[A-Z][A-Z0-9.-]*", symbol) is None: + raise StrictAdjustedHistoryError("strict_adjusted_history:invalid_symbol") + if ( + not isinstance(end_datetime, datetime) + or end_datetime.tzinfo is None + or end_datetime.utcoffset() is None + or end_datetime.utcoffset().total_seconds() != 0 + or end_datetime.microsecond != 0 + ): + raise StrictAdjustedHistoryError("strict_adjusted_history:invalid_end_datetime") + if not isinstance(duration, str) or re.fullmatch(r"[1-9][0-9]* [DWMY]", duration) is None: + raise StrictAdjustedHistoryError("strict_adjusted_history:invalid_duration") + + sessions = tuple(expected_sessions) + if ( + not sessions + or any(not isinstance(session, date) or isinstance(session, datetime) for session in sessions) + or sessions != tuple(sorted(set(sessions))) + ): + raise StrictAdjustedHistoryError("strict_adjusted_history:invalid_expected_sessions") + return symbol, end_datetime, duration, sessions + + +def _strict_history_number(bar: Any, field: str, *, allow_zero: bool = False) -> float: + value = getattr(bar, field, None) + if isinstance(value, bool): + raise StrictAdjustedHistoryError("strict_adjusted_history:invalid_bar_field") + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise StrictAdjustedHistoryError("strict_adjusted_history:invalid_bar_field") from exc + if not isfinite(numeric) or numeric < 0 or (not allow_zero and numeric == 0): + raise StrictAdjustedHistoryError("strict_adjusted_history:invalid_bar_field") + return numeric + + +def _strict_history_candle(bar: Any) -> AdjustedHistoricalCandle: + try: + session = _coerce_as_of(getattr(bar, "date")).date() + except (AttributeError, TypeError, ValueError) as exc: + raise StrictAdjustedHistoryError("strict_adjusted_history:invalid_bar_session") from exc + open_price = _strict_history_number(bar, "open") + high_price = _strict_history_number(bar, "high") + low_price = _strict_history_number(bar, "low") + close_price = _strict_history_number(bar, "close") + volume = _strict_history_number(bar, "volume", allow_zero=True) + if high_price < max(open_price, low_price, close_price) or low_price > min( + open_price, + high_price, + close_price, + ): + raise StrictAdjustedHistoryError("strict_adjusted_history:invalid_ohlc") + return AdjustedHistoricalCandle( + session=session, + open=open_price, + high=high_price, + low=low_price, + close=close_price, + volume=volume, + ) + + +def fetch_strict_adjusted_historical_price_candles( + ib: Any, + symbol: str, + *, + end_datetime: datetime, + duration: str, + expected_sessions: Sequence[date], + stock_factory: Callable[..., Any] | None = None, +) -> StrictAdjustedHistoryResult: + """Fetch one exact ADJUSTED_LAST daily series without any provider fallback.""" + + symbol, end_datetime, duration, sessions = _strict_history_request_inputs( + symbol, + end_datetime=end_datetime, + duration=duration, + expected_sessions=expected_sessions, + ) + contract = _build_stock_contract( + symbol, + exchange="SMART", + currency="USD", + stock_factory=stock_factory, + ) + try: + qualified = tuple(ib.qualifyContracts(contract) or ()) + except Exception: + raise StrictAdjustedHistoryError( + "strict_adjusted_history:contract_qualification_failed" + ) from None + if len(qualified) != 1: + raise StrictAdjustedHistoryError( + "strict_adjusted_history:contract_qualification_failed" + ) + qualified_contract = qualified[0] + if ( + getattr(qualified_contract, "symbol", None) != symbol + or getattr(qualified_contract, "exchange", None) != "SMART" + or getattr(qualified_contract, "currency", None) != "USD" + ): + raise StrictAdjustedHistoryError( + "strict_adjusted_history:qualified_contract_mismatch" + ) + + try: + bars = ib.reqHistoricalData( + qualified_contract, + endDateTime=end_datetime, + durationStr=duration, + barSizeSetting="1 day", + whatToShow="ADJUSTED_LAST", + useRTH=True, + formatDate=1, + keepUpToDate=False, + ) + except Exception: + raise StrictAdjustedHistoryError("strict_adjusted_history:request_failed") from None + if not bars: + raise StrictAdjustedHistoryError("strict_adjusted_history:empty_response") + + candles = tuple(_strict_history_candle(bar) for bar in bars) + if tuple(candle.session for candle in candles) != sessions: + raise StrictAdjustedHistoryError("strict_adjusted_history:session_contract_mismatch") + + provenance = StrictAdjustedHistoryProvenance( + symbol=symbol, + exchange="SMART", + currency="USD", + end_datetime=end_datetime.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + duration=duration, + bar_size="1 day", + what_to_show="ADJUSTED_LAST", + use_rth=True, + format_date=1, + keep_up_to_date=False, + returned_row_count=len(candles), + ) + return StrictAdjustedHistoryResult(candles=candles, provenance=provenance) + + def _request_historical_bars( ib: Any, contract: Any, diff --git a/tests/test_ibkr_market_data.py b/tests/test_ibkr_market_data.py index e5d13439..57ab6a60 100644 --- a/tests/test_ibkr_market_data.py +++ b/tests/test_ibkr_market_data.py @@ -7,6 +7,8 @@ from unittest.mock import patch from quant_platform_kit.ibkr.market_data import ( + StrictAdjustedHistoryError, + fetch_strict_adjusted_historical_price_candles, fetch_historical_price_candles, fetch_historical_price_series, fetch_option_chain_snapshot, @@ -86,6 +88,152 @@ def cancelMktData(self, contract): class IbkrMarketDataTests(unittest.TestCase): + @staticmethod + def _strict_ib(*, bars=None, error: Exception | None = None): + class StrictIB(FakeIB): + def __init__(self): + super().__init__() + self.history_calls = [] + self.market_data_type_calls = [] + + def qualifyContracts(self, contract): + self.qualified.append(contract) + return [contract] + + def reqMarketDataType(self, market_data_type): + self.market_data_type_calls.append(market_data_type) + + def reqHistoricalData(self, contract, **kwargs): + self.history_calls.append(kwargs) + if error is not None: + raise error + return bars + + return StrictIB() + + def test_strict_adjusted_history_uses_one_exact_request_and_sanitized_provenance( + self, + ) -> None: + bars = [ + FakeBar(date=date(2026, 8, 3), open=100.0, high=101.0, low=99.5, close=100.5, volume=1000.0), + FakeBar(date=date(2026, 8, 4), open=100.5, high=101.5, low=100.0, close=101.0, volume=1200.0), + ] + ib = self._strict_ib(bars=bars) + cutoff = datetime(2026, 8, 5, 3, 59, 59, tzinfo=timezone.utc) + + result = fetch_strict_adjusted_historical_price_candles( + ib, + "SOXL", + end_datetime=cutoff, + duration="9 Y", + expected_sessions=(date(2026, 8, 3), date(2026, 8, 4)), + stock_factory=FakeContract, + ) + + self.assertEqual(len(ib.history_calls), 1) + self.assertEqual( + ib.history_calls[0], + { + "endDateTime": cutoff, + "durationStr": "9 Y", + "barSizeSetting": "1 day", + "whatToShow": "ADJUSTED_LAST", + "useRTH": True, + "formatDate": 1, + "keepUpToDate": False, + }, + ) + self.assertEqual(ib.market_data_type_calls, []) + self.assertEqual(result.candles[-1].close, 101.0) + self.assertEqual(result.provenance.symbol, "SOXL") + self.assertEqual(result.provenance.end_datetime, "2026-08-05T03:59:59Z") + self.assertEqual(result.provenance.what_to_show, "ADJUSTED_LAST") + self.assertEqual(result.provenance.returned_row_count, 2) + self.assertFalse(hasattr(result.provenance, "candles")) + + def test_strict_adjusted_history_never_falls_back_on_empty_or_error(self) -> None: + cutoff = datetime(2026, 8, 5, 3, 59, 59, tzinfo=timezone.utc) + cases = ( + self._strict_ib(bars=[]), + self._strict_ib(error=RuntimeError("provider failure")), + ) + + for ib in cases: + with self.subTest(error=bool(getattr(ib, "history_calls", None))): + with self.assertRaises(StrictAdjustedHistoryError): + fetch_strict_adjusted_historical_price_candles( + ib, + "SOXL", + end_datetime=cutoff, + duration="9 Y", + expected_sessions=(date(2026, 8, 4),), + stock_factory=FakeContract, + ) + + self.assertEqual(len(ib.history_calls), 1) + self.assertEqual( + [call["whatToShow"] for call in ib.history_calls], + ["ADJUSTED_LAST"], + ) + self.assertEqual(ib.market_data_type_calls, []) + + def test_strict_adjusted_history_rejects_missing_or_duplicate_sessions(self) -> None: + cutoff = datetime(2026, 8, 5, 3, 59, 59, tzinfo=timezone.utc) + valid_bar = FakeBar( + date=date(2026, 8, 4), + open=100.0, + high=101.0, + low=99.5, + close=100.5, + volume=1000.0, + ) + cases = ( + [valid_bar], + [valid_bar, valid_bar], + ) + + for bars in cases: + ib = self._strict_ib(bars=bars) + with self.subTest(row_count=len(bars)): + with self.assertRaises(StrictAdjustedHistoryError): + fetch_strict_adjusted_historical_price_candles( + ib, + "SOXL", + end_datetime=cutoff, + duration="9 Y", + expected_sessions=(date(2026, 8, 3), date(2026, 8, 4)), + stock_factory=FakeContract, + ) + + self.assertEqual(len(ib.history_calls), 1) + + def test_strict_adjusted_history_rejects_invalid_fields_without_provider_fallback( + self, + ) -> None: + ib = self._strict_ib( + bars=[ + SimpleNamespace( + date=date(2026, 8, 4), + open=100.0, + high=101.0, + low=99.5, + close=float("nan"), + ) + ] + ) + + with self.assertRaises(StrictAdjustedHistoryError): + fetch_strict_adjusted_historical_price_candles( + ib, + "SOXL", + end_datetime=datetime(2026, 8, 5, 3, 59, 59, tzinfo=timezone.utc), + duration="9 Y", + expected_sessions=(date(2026, 8, 4),), + stock_factory=FakeContract, + ) + + self.assertEqual(len(ib.history_calls), 1) + def test_fetch_historical_price_series_builds_price_points(self) -> None: ib = FakeIB() series = fetch_historical_price_series(