From 554a41a060fd306795abb4c518a89117cbcc7a10 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Fri, 27 Mar 2026 21:41:50 +0100 Subject: [PATCH] refactor(BT-001C): share historical fixture provider --- app/services/backtesting/fixture_source.py | 97 +++++++++++++++++++ app/services/backtesting/ui_service.py | 67 +------------ app/services/event_comparison_ui.py | 36 ++----- docs/roadmap/ROADMAP.yaml | 4 +- ...1C-shared-historical-fixture-provider.yaml | 14 --- ...1C-shared-historical-fixture-provider.yaml | 18 ++++ tests/test_backtest_ui.py | 36 +++++++ tests/test_backtesting_fixture_source.py | 74 ++++++++++++++ 8 files changed, 236 insertions(+), 110 deletions(-) create mode 100644 app/services/backtesting/fixture_source.py delete mode 100644 docs/roadmap/backlog/BT-001C-shared-historical-fixture-provider.yaml create mode 100644 docs/roadmap/done/BT-001C-shared-historical-fixture-provider.yaml create mode 100644 tests/test_backtesting_fixture_source.py diff --git a/app/services/backtesting/fixture_source.py b/app/services/backtesting/fixture_source.py new file mode 100644 index 0000000..781e083 --- /dev/null +++ b/app/services/backtesting/fixture_source.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from enum import StrEnum +from typing import Any + +from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider + +SEEDED_GLD_2024_FIXTURE_HISTORY: tuple[DailyClosePoint, ...] = ( + DailyClosePoint(date=date(2024, 1, 2), close=100.0), + DailyClosePoint(date=date(2024, 1, 3), close=96.0), + DailyClosePoint(date=date(2024, 1, 4), close=92.0), + DailyClosePoint(date=date(2024, 1, 5), close=88.0), + DailyClosePoint(date=date(2024, 1, 8), close=85.0), +) + + +class WindowPolicy(StrEnum): + EXACT = "exact" + BOUNDED = "bounded" + + +@dataclass(frozen=True) +class SharedHistoricalFixtureSource: + feature_label: str + supported_symbol: str + history: tuple[DailyClosePoint, ...] + window_policy: WindowPolicy + + @property + def start_date(self) -> date: + return self.history[0].date + + @property + def end_date(self) -> date: + return self.history[-1].date + + def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: + if start_date > end_date: + raise ValueError("start_date must be on or before end_date") + normalized_symbol = symbol.strip().upper() + if normalized_symbol != self.supported_symbol.strip().upper(): + raise ValueError( + f"{self.feature_label} deterministic fixture data only supports {self.supported_symbol} on this page" + ) + if self.window_policy is WindowPolicy.EXACT: + if start_date != self.start_date or end_date != self.end_date: + raise ValueError( + f"{self.feature_label} deterministic fixture data only supports {self.supported_symbol} " + f"on the seeded {self.start_date.isoformat()} through {self.end_date.isoformat()} window" + ) + else: + if start_date < self.start_date or end_date > self.end_date: + raise ValueError( + f"{self.feature_label} deterministic fixture data only supports the seeded " + f"{self.start_date.isoformat()} through {self.end_date.isoformat()} window" + ) + return [point for point in self.history if start_date <= point.date <= end_date] + + +class FixtureBoundSyntheticHistoricalProvider: + def __init__(self, base_provider: SyntheticHistoricalProvider, source: SharedHistoricalFixtureSource) -> None: + self.base_provider = base_provider + self.source = source + + def load_history(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: + rows = self.source.load_daily_closes(symbol, start_date, end_date) + return sorted(rows, key=lambda row: row.date) + + def __getattr__(self, name: str) -> Any: + return getattr(self.base_provider, name) + + +def build_backtest_ui_fixture_source() -> SharedHistoricalFixtureSource: + return SharedHistoricalFixtureSource( + feature_label="BT-001A", + supported_symbol="GLD", + history=SEEDED_GLD_2024_FIXTURE_HISTORY, + window_policy=WindowPolicy.EXACT, + ) + + +def build_event_comparison_fixture_source() -> SharedHistoricalFixtureSource: + return SharedHistoricalFixtureSource( + feature_label="BT-003A", + supported_symbol="GLD", + history=SEEDED_GLD_2024_FIXTURE_HISTORY, + window_policy=WindowPolicy.BOUNDED, + ) + + +def bind_fixture_source( + base_provider: SyntheticHistoricalProvider, + source: SharedHistoricalFixtureSource, +) -> FixtureBoundSyntheticHistoricalProvider: + return FixtureBoundSyntheticHistoricalProvider(base_provider=base_provider, source=source) diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py index 2d80dcf..62c7248 100644 --- a/app/services/backtesting/ui_service.py +++ b/app/services/backtesting/ui_service.py @@ -13,13 +13,7 @@ from app.models.backtest import ( ProviderRef, TemplateRef, ) -from app.services.backtesting.historical_provider import ( - DailyClosePoint, - HistoricalOptionMark, - HistoricalOptionPosition, - SyntheticHistoricalProvider, - SyntheticOptionQuote, -) +from app.services.backtesting.fixture_source import bind_fixture_source, build_backtest_ui_fixture_source from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs from app.services.backtesting.service import BacktestService from app.services.strategy_templates import StrategyTemplateService @@ -37,29 +31,6 @@ def _validate_initial_collateral(underlying_units: float, entry_spot: float, loa ) -DETERMINISTIC_UI_FIXTURE_HISTORY: tuple[DailyClosePoint, ...] = ( - DailyClosePoint(date=date(2024, 1, 2), close=100.0), - DailyClosePoint(date=date(2024, 1, 3), close=96.0), - DailyClosePoint(date=date(2024, 1, 4), close=92.0), - DailyClosePoint(date=date(2024, 1, 5), close=88.0), - DailyClosePoint(date=date(2024, 1, 8), close=85.0), -) - - -class DeterministicBacktestFixtureSource: - def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: - normalized_symbol = symbol.strip().upper() - if ( - normalized_symbol != SUPPORTED_BACKTEST_PAGE_SYMBOL - or start_date != date(2024, 1, 2) - or end_date != date(2024, 1, 8) - ): - raise ValueError( - "BT-001A deterministic fixture data only supports GLD on the seeded 2024-01-02 through 2024-01-08 window" - ) - return list(DETERMINISTIC_UI_FIXTURE_HISTORY) - - @dataclass(frozen=True) class BacktestPageRunResult: scenario: BacktestScenario @@ -67,40 +38,6 @@ class BacktestPageRunResult: entry_spot: float -class FixtureBoundHistoricalProvider: - def __init__(self, base_provider: SyntheticHistoricalProvider) -> None: - self.base_provider = base_provider - self.source = DeterministicBacktestFixtureSource() - self.provider_id = base_provider.provider_id - self.pricing_mode = base_provider.pricing_mode - self.implied_volatility = base_provider.implied_volatility - self.risk_free_rate = base_provider.risk_free_rate - - def load_history(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: - rows = self.source.load_daily_closes(symbol, start_date, end_date) - filtered = [row for row in rows if start_date <= row.date <= end_date] - return sorted(filtered, key=lambda row: row.date) - - def validate_provider_ref(self, provider_ref: ProviderRef) -> None: - self.base_provider.validate_provider_ref(provider_ref) - - def resolve_expiry(self, trading_days: list[DailyClosePoint], as_of_date: date, target_expiry_days: int) -> date: - return self.base_provider.resolve_expiry(trading_days, as_of_date, target_expiry_days) - - def price_option(self, **kwargs: object) -> SyntheticOptionQuote: - return self.base_provider.price_option(**kwargs) - - def open_position(self, **kwargs: object) -> HistoricalOptionPosition: - return self.base_provider.open_position(**kwargs) - - def mark_position(self, position: HistoricalOptionPosition, **kwargs: object) -> HistoricalOptionMark: - return self.base_provider.mark_position(position, **kwargs) - - @staticmethod - def intrinsic_value(*, option_type: str, spot: float, strike: float) -> float: - return SyntheticHistoricalProvider.intrinsic_value(option_type=option_type, spot=spot, strike=strike) - - class BacktestPageService: def __init__( self, @@ -112,7 +49,7 @@ class BacktestPageService: provider=None, ) self.template_service = template_service or base_service.template_service - fixture_provider = FixtureBoundHistoricalProvider(base_service.provider) + fixture_provider = bind_fixture_source(base_service.provider, build_backtest_ui_fixture_source()) self.backtest_service = copy(base_service) self.backtest_service.provider = fixture_provider self.backtest_service.template_service = self.template_service diff --git a/app/services/event_comparison_ui.py b/app/services/event_comparison_ui.py index 27d9f9e..f071f8b 100644 --- a/app/services/event_comparison_ui.py +++ b/app/services/event_comparison_ui.py @@ -1,11 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import date from app.models.backtest import BacktestScenario, EventComparisonRanking, EventComparisonReport from app.services.backtesting.comparison import EventComparisonService -from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider +from app.services.backtesting.fixture_source import bind_fixture_source, build_event_comparison_fixture_source +from app.services.backtesting.historical_provider import SyntheticHistoricalProvider from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs from app.services.event_presets import EventPresetService from app.services.strategy_templates import StrategyTemplateService @@ -23,32 +23,7 @@ def _validate_initial_collateral(underlying_units: float, entry_spot: float, loa ) -DETERMINISTIC_EVENT_COMPARISON_FIXTURE_HISTORY: tuple[DailyClosePoint, ...] = ( - DailyClosePoint(date=date(2024, 1, 2), close=100.0), - DailyClosePoint(date=date(2024, 1, 3), close=96.0), - DailyClosePoint(date=date(2024, 1, 4), close=92.0), - DailyClosePoint(date=date(2024, 1, 5), close=88.0), - DailyClosePoint(date=date(2024, 1, 8), close=85.0), -) - -FIXTURE_HISTORY_START = DETERMINISTIC_EVENT_COMPARISON_FIXTURE_HISTORY[0].date -FIXTURE_HISTORY_END = DETERMINISTIC_EVENT_COMPARISON_FIXTURE_HISTORY[-1].date - - -class EventComparisonFixtureHistoricalPriceSource: - def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: - normalized_symbol = symbol.strip().upper() - if normalized_symbol != SUPPORTED_EVENT_COMPARISON_SYMBOL: - raise ValueError( - "BT-003A deterministic fixture data only supports GLD event-comparison presets on this page" - ) - if start_date < FIXTURE_HISTORY_START or end_date > FIXTURE_HISTORY_END: - raise ValueError( - "BT-003A deterministic fixture data only supports the seeded 2024-01-02 through 2024-01-08 window" - ) - return [ - point for point in DETERMINISTIC_EVENT_COMPARISON_FIXTURE_HISTORY if start_date <= point.date <= end_date - ] +EventComparisonFixtureHistoricalPriceSource = build_event_comparison_fixture_source @dataclass(frozen=True) @@ -102,7 +77,10 @@ class EventComparisonPageService: self.event_preset_service = event_preset_service or EventPresetService() self.template_service = template_service or StrategyTemplateService() if comparison_service is None: - provider = SyntheticHistoricalProvider(source=EventComparisonFixtureHistoricalPriceSource()) + provider = bind_fixture_source( + SyntheticHistoricalProvider(), + build_event_comparison_fixture_source(), + ) comparison_service = EventComparisonService( provider=provider, event_preset_service=self.event_preset_service, diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index 23f2cdb..f5dec09 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -13,7 +13,6 @@ notes: - Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared. - Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required. priority_queue: - - BT-001C - EXEC-001 - EXEC-002 - DATA-002A @@ -22,6 +21,7 @@ priority_queue: - BT-003 - BT-002A recently_completed: + - BT-001C - BT-002 - PORT-003 - BT-003B @@ -45,7 +45,6 @@ states: - EXEC-001 - EXEC-002 - BT-003 - - BT-001C - BT-002A in_progress: [] done: @@ -62,6 +61,7 @@ states: - EXEC-001A - BT-001 - BT-001A + - BT-001C - BT-002 - BT-003A - BT-003B diff --git a/docs/roadmap/backlog/BT-001C-shared-historical-fixture-provider.yaml b/docs/roadmap/backlog/BT-001C-shared-historical-fixture-provider.yaml deleted file mode 100644 index 326bc5c..0000000 --- a/docs/roadmap/backlog/BT-001C-shared-historical-fixture-provider.yaml +++ /dev/null @@ -1,14 +0,0 @@ -id: BT-001C -title: Shared Historical Fixture/Test Provider Cleanup -status: backlog -priority: P2 -effort: S -depends_on: - - BT-001A - - BT-003A -tags: [backtesting, test-infra] -summary: Centralize deterministic historical fixture logic used by browser-tested backtest UIs. -acceptance_criteria: - - Deterministic historical fixture/provider logic is centralized. - - Supported seeded windows are explicit and fail closed outside allowed ranges. - - Both /backtests and /event-comparison use the shared deterministic provider. diff --git a/docs/roadmap/done/BT-001C-shared-historical-fixture-provider.yaml b/docs/roadmap/done/BT-001C-shared-historical-fixture-provider.yaml new file mode 100644 index 0000000..822d0be --- /dev/null +++ b/docs/roadmap/done/BT-001C-shared-historical-fixture-provider.yaml @@ -0,0 +1,18 @@ +id: BT-001C +title: Shared Historical Fixture/Test Provider Cleanup +status: done +priority: P2 +effort: S +depends_on: + - BT-001A + - BT-003A +tags: + - backtesting + - test-infra +summary: Deterministic historical fixture logic for browser-tested backtest UIs is now centralized behind a shared fixture source used by both `/backtests` and `/event-comparison`. +completed_notes: + - Added `app/services/backtesting/fixture_source.py` with shared seeded GLD fixture history and explicit exact-vs-bounded window policies. + - Updated `app/services/backtesting/ui_service.py` so the `/backtests` page uses the shared fixture source in exact-window mode and still fails closed outside the seeded BT-001A range. + - Updated `app/services/event_comparison_ui.py` so the `/event-comparison` page uses the same shared fixture source in bounded-window mode for preset subranges inside the seeded BT-003A fixture window. + - Added focused regression coverage in `tests/test_backtesting_fixture_source.py` proving the shared source enforces exact and bounded policies explicitly and that both page services use the centralized fixture source. + - During this implementation loop, local Docker validation stayed green on the affected historical routes: `/health` returned OK and `tests/test_e2e_playwright.py` passed against the Docker-served app. diff --git a/tests/test_backtest_ui.py b/tests/test_backtest_ui.py index 275459e..3541dcd 100644 --- a/tests/test_backtest_ui.py +++ b/tests/test_backtest_ui.py @@ -282,3 +282,39 @@ def test_backtest_page_service_uses_injected_provider_identity_in_provider_ref() assert result.scenario.provider_ref.provider_id == "custom_v1" assert result.scenario.provider_ref.pricing_mode == "custom_mode" + + +def test_backtest_page_service_preserves_injected_provider_behavior_beyond_history_loading() -> None: + class CustomProvider(SyntheticHistoricalProvider): + provider_id = "custom_v2" + pricing_mode = "custom_mode" + + def price_option_by_type(self, **kwargs: object): + quote = super().price_option_by_type(**kwargs) + return quote.__class__( + position_id=quote.position_id, + leg_id=quote.leg_id, + spot=quote.spot, + strike=quote.strike, + expiry=quote.expiry, + quantity=quote.quantity, + mark=42.0, + ) + + provider = CustomProvider(source=StaticBacktestSource(), implied_volatility=0.2, risk_free_rate=0.01) + injected_service = BacktestService(provider=provider) + page_service = BacktestPageService(backtest_service=injected_service) + + result = page_service.run_read_only_scenario( + symbol="GLD", + start_date=date(2024, 1, 2), + end_date=date(2024, 1, 8), + template_slug="protective-put-atm-12m", + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + ) + + template_result = result.run_result.template_results[0] + assert template_result.daily_path[0].option_market_value == 42000.0 + assert template_result.summary_metrics.total_hedge_cost == 42000.0 diff --git a/tests/test_backtesting_fixture_source.py b/tests/test_backtesting_fixture_source.py new file mode 100644 index 0000000..9a4a838 --- /dev/null +++ b/tests/test_backtesting_fixture_source.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from datetime import date + +import pytest + +from app.services.backtesting.fixture_source import ( + SEEDED_GLD_2024_FIXTURE_HISTORY, + SharedHistoricalFixtureSource, + WindowPolicy, +) +from app.services.backtesting.ui_service import BacktestPageService +from app.services.event_comparison_ui import EventComparisonPageService + + +def test_shared_fixture_source_exact_window_requires_seeded_bounds() -> None: + source = SharedHistoricalFixtureSource( + feature_label="BT-001A", + supported_symbol="GLD", + history=SEEDED_GLD_2024_FIXTURE_HISTORY, + window_policy=WindowPolicy.EXACT, + ) + + rows = source.load_daily_closes("GLD", date(2024, 1, 2), date(2024, 1, 8)) + + assert [row.date.isoformat() for row in rows] == [ + "2024-01-02", + "2024-01-03", + "2024-01-04", + "2024-01-05", + "2024-01-08", + ] + + with pytest.raises(ValueError, match="seeded 2024-01-02 through 2024-01-08 window"): + source.load_daily_closes("GLD", date(2024, 1, 3), date(2024, 1, 8)) + + +def test_shared_fixture_source_bounded_window_allows_subranges_but_fails_closed_outside_seeded_bounds() -> None: + source = SharedHistoricalFixtureSource( + feature_label="BT-003A", + supported_symbol="GLD", + history=SEEDED_GLD_2024_FIXTURE_HISTORY, + window_policy=WindowPolicy.BOUNDED, + ) + + rows = source.load_daily_closes("GLD", date(2024, 1, 3), date(2024, 1, 5)) + + assert [row.date.isoformat() for row in rows] == ["2024-01-03", "2024-01-04", "2024-01-05"] + + with pytest.raises(ValueError, match="seeded 2024-01-02 through 2024-01-08 window"): + source.load_daily_closes("GLD", date(2024, 1, 1), date(2024, 1, 8)) + + with pytest.raises(ValueError, match="start_date must be on or before end_date"): + source.load_daily_closes("GLD", date(2024, 1, 5), date(2024, 1, 3)) + + +def test_backtest_page_service_uses_shared_exact_fixture_source() -> None: + service = BacktestPageService() + + source = service.backtest_service.provider.source + + assert isinstance(source, SharedHistoricalFixtureSource) + assert source.feature_label == "BT-001A" + assert source.window_policy is WindowPolicy.EXACT + + +def test_event_comparison_page_service_uses_shared_bounded_fixture_source() -> None: + service = EventComparisonPageService() + + source = service.comparison_service.provider.source + + assert isinstance(source, SharedHistoricalFixtureSource) + assert source.feature_label == "BT-003A" + assert source.window_policy is WindowPolicy.BOUNDED