diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py index d9c2330..31c7bc1 100644 --- a/app/services/backtesting/ui_service.py +++ b/app/services/backtesting/ui_service.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import date +from math import isclose from app.domain.backtesting_math import materialize_backtest_portfolio_state from app.models.backtest import ( @@ -10,7 +11,7 @@ from app.models.backtest import ( ProviderRef, TemplateRef, ) -from app.services.backtesting.historical_provider import DailyClosePoint +from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider from app.services.backtesting.service import BacktestService from app.services.strategy_templates import StrategyTemplateService @@ -64,12 +65,19 @@ class BacktestPageService: template_service: StrategyTemplateService | None = None, ) -> None: self.template_service = template_service or StrategyTemplateService() - self.backtest_service = backtest_service or BacktestService( + base_service = backtest_service or BacktestService( template_service=self.template_service, provider=None, ) - provider = self.backtest_service.provider - provider.source = DeterministicBacktestFixtureSource() + fixture_provider = SyntheticHistoricalProvider( + source=DeterministicBacktestFixtureSource(), + implied_volatility=base_service.provider.implied_volatility, + risk_free_rate=base_service.provider.risk_free_rate, + ) + self.backtest_service = BacktestService( + provider=fixture_provider, + template_service=self.template_service, + ) def template_options(self, symbol: str = "GLD") -> list[dict[str, str | int]]: return [ @@ -122,7 +130,12 @@ class BacktestPageService: self.template_service.get_template(template_slug) derived_entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date) - if entry_spot is not None and entry_spot != derived_entry_spot: + if entry_spot is not None and not isclose( + entry_spot, + derived_entry_spot, + rel_tol=BacktestService.ENTRY_SPOT_REL_TOLERANCE, + abs_tol=BacktestService.ENTRY_SPOT_ABS_TOLERANCE, + ): raise ValueError( f"Supplied entry spot ${entry_spot:,.2f} does not match derived historical entry spot ${derived_entry_spot:,.2f}" ) diff --git a/tests/helpers_backtest_sources.py b/tests/helpers_backtest_sources.py new file mode 100644 index 0000000..40fb9eb --- /dev/null +++ b/tests/helpers_backtest_sources.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from datetime import date + +from app.services.backtesting.historical_provider import DailyClosePoint + + +class StaticBacktestSource: + def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: + return [DailyClosePoint(date=date(2024, 1, 3), close=123.0)] diff --git a/tests/test_backtest_ui.py b/tests/test_backtest_ui.py index a0f40e3..a91c86f 100644 --- a/tests/test_backtest_ui.py +++ b/tests/test_backtest_ui.py @@ -4,7 +4,10 @@ from datetime import date import pytest +from app.services.backtesting.historical_provider import SyntheticHistoricalProvider +from app.services.backtesting.service import BacktestService from app.services.backtesting.ui_service import BacktestPageService +from tests.helpers_backtest_sources import StaticBacktestSource def test_backtest_page_service_uses_fixture_window_for_deterministic_run() -> None: @@ -186,6 +189,23 @@ def test_backtest_preview_validation_requires_supported_fixture_window_even_with ) +def test_backtest_preview_validation_accepts_close_supplied_entry_spot() -> None: + service = BacktestPageService() + + entry_spot = service.validate_preview_inputs( + 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, + entry_spot=100.005, + ) + + assert entry_spot == 100.0 + + def test_backtest_preview_validation_rejects_mismatched_supplied_entry_spot() -> None: service = BacktestPageService() @@ -200,3 +220,17 @@ def test_backtest_preview_validation_rejects_mismatched_supplied_entry_spot() -> margin_call_ltv=0.75, entry_spot=99.0, ) + + +def test_backtest_page_service_does_not_mutate_injected_backtest_service() -> None: + provider = SyntheticHistoricalProvider( + source=StaticBacktestSource(), + implied_volatility=0.2, + risk_free_rate=0.01, + ) + injected_service = BacktestService(provider=provider) + + BacktestPageService(backtest_service=injected_service) + + history = injected_service.provider.load_history("GLD", date(2024, 1, 3), date(2024, 1, 3)) + assert history[0].close == 123.0