refactor(BT-001C): share historical fixture provider

This commit is contained in:
Bu5hm4nn
2026-03-27 21:41:50 +01:00
parent 477514f838
commit 554a41a060
8 changed files with 236 additions and 110 deletions

View File

@@ -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)

View File

@@ -13,13 +13,7 @@ from app.models.backtest import (
ProviderRef, ProviderRef,
TemplateRef, TemplateRef,
) )
from app.services.backtesting.historical_provider import ( from app.services.backtesting.fixture_source import bind_fixture_source, build_backtest_ui_fixture_source
DailyClosePoint,
HistoricalOptionMark,
HistoricalOptionPosition,
SyntheticHistoricalProvider,
SyntheticOptionQuote,
)
from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs
from app.services.backtesting.service import BacktestService from app.services.backtesting.service import BacktestService
from app.services.strategy_templates import StrategyTemplateService 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) @dataclass(frozen=True)
class BacktestPageRunResult: class BacktestPageRunResult:
scenario: BacktestScenario scenario: BacktestScenario
@@ -67,40 +38,6 @@ class BacktestPageRunResult:
entry_spot: float 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: class BacktestPageService:
def __init__( def __init__(
self, self,
@@ -112,7 +49,7 @@ class BacktestPageService:
provider=None, provider=None,
) )
self.template_service = template_service or base_service.template_service 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 = copy(base_service)
self.backtest_service.provider = fixture_provider self.backtest_service.provider = fixture_provider
self.backtest_service.template_service = self.template_service self.backtest_service.template_service = self.template_service

View File

@@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date
from app.models.backtest import BacktestScenario, EventComparisonRanking, EventComparisonReport from app.models.backtest import BacktestScenario, EventComparisonRanking, EventComparisonReport
from app.services.backtesting.comparison import EventComparisonService 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.backtesting.input_normalization import normalize_historical_scenario_inputs
from app.services.event_presets import EventPresetService from app.services.event_presets import EventPresetService
from app.services.strategy_templates import StrategyTemplateService 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, ...] = ( EventComparisonFixtureHistoricalPriceSource = build_event_comparison_fixture_source
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
]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -102,7 +77,10 @@ class EventComparisonPageService:
self.event_preset_service = event_preset_service or EventPresetService() self.event_preset_service = event_preset_service or EventPresetService()
self.template_service = template_service or StrategyTemplateService() self.template_service = template_service or StrategyTemplateService()
if comparison_service is None: if comparison_service is None:
provider = SyntheticHistoricalProvider(source=EventComparisonFixtureHistoricalPriceSource()) provider = bind_fixture_source(
SyntheticHistoricalProvider(),
build_event_comparison_fixture_source(),
)
comparison_service = EventComparisonService( comparison_service = EventComparisonService(
provider=provider, provider=provider,
event_preset_service=self.event_preset_service, event_preset_service=self.event_preset_service,

View File

@@ -13,7 +13,6 @@ notes:
- Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared. - 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. - Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required.
priority_queue: priority_queue:
- BT-001C
- EXEC-001 - EXEC-001
- EXEC-002 - EXEC-002
- DATA-002A - DATA-002A
@@ -22,6 +21,7 @@ priority_queue:
- BT-003 - BT-003
- BT-002A - BT-002A
recently_completed: recently_completed:
- BT-001C
- BT-002 - BT-002
- PORT-003 - PORT-003
- BT-003B - BT-003B
@@ -45,7 +45,6 @@ states:
- EXEC-001 - EXEC-001
- EXEC-002 - EXEC-002
- BT-003 - BT-003
- BT-001C
- BT-002A - BT-002A
in_progress: [] in_progress: []
done: done:
@@ -62,6 +61,7 @@ states:
- EXEC-001A - EXEC-001A
- BT-001 - BT-001
- BT-001A - BT-001A
- BT-001C
- BT-002 - BT-002
- BT-003A - BT-003A
- BT-003B - BT-003B

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.provider_id == "custom_v1"
assert result.scenario.provider_ref.pricing_mode == "custom_mode" 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

View File

@@ -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