From 18fd0681ca4b5dc7cf0caf6689381a260afabd4c Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Thu, 26 Mar 2026 12:11:45 +0100 Subject: [PATCH] refactor(pre-alpha): align preview and runtime fixture validation --- app/services/backtesting/comparison.py | 4 ++- app/services/backtesting/ui_service.py | 17 +++++------ tests/test_backtest_ui.py | 39 +++++++++++++++++-------- tests/test_event_comparison.py | 40 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/app/services/backtesting/comparison.py b/app/services/backtesting/comparison.py index f58a975..c7dc8d7 100644 --- a/app/services/backtesting/comparison.py +++ b/app/services/backtesting/comparison.py @@ -127,7 +127,9 @@ class EventComparisonService: if not selected_template_slugs: raise ValueError("Event comparison requires at least one template slug") - resolved_history = history or self._load_preset_history(preset) + resolved_history = self._load_preset_history(preset) if history is None else history + if not resolved_history: + raise ValueError("Event comparison history must not be empty") scenario_portfolio = materialize_backtest_portfolio_state( symbol=preset.symbol, underlying_units=initial_portfolio.underlying_units, diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py index 6703788..d9c2330 100644 --- a/app/services/backtesting/ui_service.py +++ b/app/services/backtesting/ui_service.py @@ -68,9 +68,8 @@ class BacktestPageService: template_service=self.template_service, provider=None, ) - if backtest_service is None: - provider = self.backtest_service.provider - provider.source = DeterministicBacktestFixtureSource() + provider = self.backtest_service.provider + provider.source = DeterministicBacktestFixtureSource() def template_options(self, symbol: str = "GLD") -> list[dict[str, str | int]]: return [ @@ -122,11 +121,13 @@ class BacktestPageService: raise ValueError("Template selection is required") self.template_service.get_template(template_slug) - resolved_entry_spot = ( - entry_spot if entry_spot is not None else self.derive_entry_spot(normalized_symbol, start_date, end_date) - ) - _validate_initial_collateral(underlying_units, resolved_entry_spot, loan_amount) - return resolved_entry_spot + 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: + raise ValueError( + f"Supplied entry spot ${entry_spot:,.2f} does not match derived historical entry spot ${derived_entry_spot:,.2f}" + ) + _validate_initial_collateral(underlying_units, derived_entry_spot, loan_amount) + return derived_entry_spot def run_read_only_scenario( self, diff --git a/tests/test_backtest_ui.py b/tests/test_backtest_ui.py index ab922ba..a0f40e3 100644 --- a/tests/test_backtest_ui.py +++ b/tests/test_backtest_ui.py @@ -170,18 +170,33 @@ def test_backtest_page_service_fails_closed_outside_seeded_fixture_window() -> N ) -def test_backtest_preview_validation_reuses_supplied_entry_spot() -> None: +def test_backtest_preview_validation_requires_supported_fixture_window_even_with_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.0, - ) + with pytest.raises(ValueError, match="deterministic fixture data only supports GLD"): + service.validate_preview_inputs( + symbol="GLD", + start_date=date(2024, 1, 3), + 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.0, + ) - assert entry_spot == 100.0 + +def test_backtest_preview_validation_rejects_mismatched_supplied_entry_spot() -> None: + service = BacktestPageService() + + with pytest.raises(ValueError, match="does not match derived historical 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=99.0, + ) diff --git a/tests/test_event_comparison.py b/tests/test_event_comparison.py index 2802ec0..7ee7723 100644 --- a/tests/test_event_comparison.py +++ b/tests/test_event_comparison.py @@ -315,3 +315,43 @@ def test_event_comparison_uses_preset_defaults_only_when_template_slugs_are_omit "protective-put-90pct-12m", "ladder-50-50-atm-95pct-12m", ] + + with pytest.raises(ValueError, match="at least one template slug"): + service.materialize_scenario( + service.event_preset_service.get_preset("gld-jan-2024-selloff"), + initial_portfolio=BacktestPortfolioState( + currency="USD", + underlying_units=1000.0, + entry_spot=100.0, + loan_amount=68_000.0, + margin_call_ltv=0.75, + ), + template_slugs=(), + ) + + +def test_event_comparison_materialize_scenario_rejects_explicit_empty_history() -> None: + provider = SyntheticHistoricalProvider( + source=FakeHistorySource(FIXTURE_HISTORY), + implied_volatility=0.35, + risk_free_rate=0.0, + ) + service = EventComparisonService( + provider=provider, + template_service=StrategyTemplateService(), + event_preset_service=EventPresetService(repository=FileEventPresetRepository()), + ) + + with pytest.raises(ValueError, match="history must not be empty"): + service.materialize_scenario( + service.event_preset_service.get_preset("gld-jan-2024-selloff"), + initial_portfolio=BacktestPortfolioState( + currency="USD", + underlying_units=1000.0, + entry_spot=100.0, + loan_amount=68_000.0, + margin_call_ltv=0.75, + ), + template_slugs=("protective-put-atm-12m",), + history=[], + )