refactor(BT-001C): share historical fixture provider
This commit is contained in:
97
app/services/backtesting/fixture_source.py
Normal file
97
app/services/backtesting/fixture_source.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
74
tests/test_backtesting_fixture_source.py
Normal file
74
tests/test_backtesting_fixture_source.py
Normal 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
|
||||
Reference in New Issue
Block a user