From 695f3d07ed395102aba3e0dbbbd1d075805db2ae Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Wed, 25 Mar 2026 21:44:30 +0100 Subject: [PATCH] fix(CORE-002C): explain undercollateralized historical seeds --- app/services/backtesting/ui_service.py | 12 ++++++ app/services/event_comparison_ui.py | 58 ++++++++++++++++++++++---- tests/test_backtest_ui.py | 12 ++++++ tests/test_event_comparison_ui.py | 22 ++++++++++ 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py index adea4df..01c8896 100644 --- a/app/services/backtesting/ui_service.py +++ b/app/services/backtesting/ui_service.py @@ -20,6 +20,17 @@ from app.services.strategy_templates import StrategyTemplateService SUPPORTED_BACKTEST_PAGE_SYMBOL = "GLD" + +def _validate_initial_collateral(underlying_units: float, entry_spot: float, loan_amount: float) -> None: + initial_collateral_value = underlying_units * entry_spot + if loan_amount >= initial_collateral_value: + raise ValueError( + "Historical scenario starts undercollateralized: " + f"loan ${loan_amount:,.0f} exceeds initial collateral ${initial_collateral_value:,.0f} " + f"at entry spot ${entry_spot:,.2f}. Reduce loan amount or increase underlying units." + ) + + DETERMINISTIC_UI_FIXTURE_HISTORY: tuple[DailyClosePoint, ...] = ( DailyClosePoint(date=date(2024, 1, 2), close=100.0), DailyClosePoint(date=date(2024, 1, 3), close=96.0), @@ -116,6 +127,7 @@ class BacktestPageService: template = self.template_service.get_template(template_slug) entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date) + _validate_initial_collateral(underlying_units, entry_spot, loan_amount) initial_portfolio = materialize_backtest_portfolio_state( symbol=normalized_symbol, underlying_units=underlying_units, diff --git a/app/services/event_comparison_ui.py b/app/services/event_comparison_ui.py index ec060fb..1b5178b 100644 --- a/app/services/event_comparison_ui.py +++ b/app/services/event_comparison_ui.py @@ -11,6 +11,17 @@ from app.services.strategy_templates import StrategyTemplateService SUPPORTED_EVENT_COMPARISON_SYMBOL = "GLD" + +def _validate_initial_collateral(underlying_units: float, entry_spot: float, loan_amount: float) -> None: + initial_collateral_value = underlying_units * entry_spot + if loan_amount >= initial_collateral_value: + raise ValueError( + "Historical scenario starts undercollateralized: " + f"loan ${loan_amount:,.0f} exceeds initial collateral ${initial_collateral_value:,.0f} " + f"at entry spot ${entry_spot:,.2f}. Reduce loan amount or increase underlying units." + ) + + 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), @@ -103,13 +114,27 @@ class EventComparisonPageService: loan_amount: float, margin_call_ltv: float, ) -> BacktestScenario: - return self.comparison_service.preview_scenario_from_inputs( - preset_slug=preset_slug, - template_slugs=template_slugs, - underlying_units=underlying_units, - loan_amount=loan_amount, - margin_call_ltv=margin_call_ltv, - ) + try: + scenario = self.comparison_service.preview_scenario_from_inputs( + preset_slug=preset_slug, + template_slugs=template_slugs, + underlying_units=underlying_units, + loan_amount=loan_amount, + margin_call_ltv=margin_call_ltv, + ) + except ValueError as exc: + if str(exc) == "loan_amount must be less than initial collateral value": + preset = self.event_preset_service.get_preset(preset_slug) + preview = self.comparison_service.provider.load_history( + preset.symbol.strip().upper(), + preset.window_start, + preset.window_end, + ) + if preview: + _validate_initial_collateral(underlying_units, preview[0].close, loan_amount) + raise + _validate_initial_collateral(underlying_units, scenario.initial_portfolio.entry_spot, loan_amount) + return scenario def run_read_only_comparison( self, @@ -134,6 +159,25 @@ class EventComparisonPageService: if normalized_symbol != SUPPORTED_EVENT_COMPARISON_SYMBOL: raise ValueError("BT-003A event comparison is currently limited to GLD on this page") + try: + preview = self.comparison_service.preview_scenario_from_inputs( + preset_slug=preset.slug, + template_slugs=tuple(template_slugs or preset.scenario_overrides.default_template_slugs), + underlying_units=underlying_units, + loan_amount=loan_amount, + margin_call_ltv=margin_call_ltv, + ) + except ValueError as exc: + if str(exc) == "loan_amount must be less than initial collateral value": + preview_history = self.comparison_service.provider.load_history( + normalized_symbol, + preset.window_start, + preset.window_end, + ) + if preview_history: + _validate_initial_collateral(underlying_units, preview_history[0].close, loan_amount) + raise + _validate_initial_collateral(underlying_units, preview.initial_portfolio.entry_spot, loan_amount) return self.comparison_service.compare_event_from_inputs( preset_slug=preset.slug, template_slugs=tuple(template_slugs or preset.scenario_overrides.default_template_slugs), diff --git a/tests/test_backtest_ui.py b/tests/test_backtest_ui.py index 3d81827..32037f3 100644 --- a/tests/test_backtest_ui.py +++ b/tests/test_backtest_ui.py @@ -131,6 +131,18 @@ def test_backtest_page_service_keeps_fixture_history_while_using_caller_portfoli }, "BT-001A backtests are currently limited to GLD on this page", ), + ( + { + "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": 145000.0, + "margin_call_ltv": 0.75, + }, + "Historical scenario starts undercollateralized", + ), ], ) def test_backtest_page_service_validation_errors_are_user_facing(kwargs: dict[str, object], message: str) -> None: diff --git a/tests/test_event_comparison_ui.py b/tests/test_event_comparison_ui.py index 397b5d4..551066b 100644 --- a/tests/test_event_comparison_ui.py +++ b/tests/test_event_comparison_ui.py @@ -118,6 +118,28 @@ def test_event_comparison_page_service_preview_uses_same_materialization_path() assert [ref.slug for ref in scenario.template_refs] == ["protective-put-atm-12m"] +def test_event_comparison_page_service_rejects_undercollateralized_historical_start() -> None: + service = EventComparisonPageService() + + with pytest.raises(ValueError, match="Historical scenario starts undercollateralized"): + service.preview_scenario( + preset_slug="gld-jan-2024-selloff", + template_slugs=("protective-put-atm-12m",), + underlying_units=1000.0, + loan_amount=145000.0, + margin_call_ltv=0.75, + ) + + with pytest.raises(ValueError, match="Historical scenario starts undercollateralized"): + service.run_read_only_comparison( + preset_slug="gld-jan-2024-selloff", + template_slugs=("protective-put-atm-12m",), + underlying_units=1000.0, + loan_amount=145000.0, + margin_call_ltv=0.75, + ) + + def test_event_comparison_fixture_fails_closed_for_unsupported_range() -> None: source = EventComparisonFixtureHistoricalPriceSource()