diff --git a/app/pages/backtests.py b/app/pages/backtests.py index bc1b86a..ff55e5f 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -289,9 +289,9 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: row_key="date", ).classes("w-full") - def validate_current_scenario() -> str | None: + def validate_current_scenario(*, entry_spot: float | None = None) -> str | None: try: - service.run_read_only_scenario( + service.validate_preview_inputs( symbol=str(symbol_input.value or ""), start_date=parse_iso_date(start_input.value, "Start date"), end_date=parse_iso_date(end_input.value, "End date"), @@ -299,6 +299,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: underlying_units=float(units_input.value or 0.0), loan_amount=float(loan_input.value or 0.0), margin_call_ltv=float(ltv_input.value or 0.0), + entry_spot=entry_spot, ) except (ValueError, KeyError) as exc: return str(exc) @@ -320,12 +321,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: def refresh_workspace_seeded_units() -> None: validation_label.set_text("") entry_spot, entry_error = derive_entry_spot() - if ( - workspace_id - and config is not None - and config.gold_value is not None - and entry_spot is not None - ): + if workspace_id and config is not None and config.gold_value is not None and entry_spot is not None: units_input.value = asset_quantity_from_workspace_config(config, entry_spot=entry_spot, symbol="GLD") entry_spot_hint.set_text(f"Auto-derived entry spot: ${entry_spot:,.2f}") else: @@ -335,7 +331,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: validation_label.set_text(entry_error) render_result_state("Scenario validation failed", entry_error, tone="warning") return - validation_error = validate_current_scenario() + validation_error = validate_current_scenario(entry_spot=entry_spot) if validation_error: validation_label.set_text(validation_error) render_result_state("Scenario validation failed", validation_error, tone="warning") @@ -351,7 +347,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: validation_label.set_text(entry_error) render_result_state("Scenario validation failed", entry_error, tone="warning") return - validation_error = validate_current_scenario() + validation_error = validate_current_scenario(entry_spot=entry_spot) if validation_error: validation_label.set_text(validation_error) render_result_state("Scenario validation failed", validation_error, tone="warning") @@ -371,12 +367,18 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: margin_call_ltv=float(ltv_input.value or 0.0), ) except (ValueError, KeyError) as exc: - render_seeded_summary(entry_spot=None, entry_spot_error=None) + entry_spot, entry_error = derive_entry_spot() + render_seeded_summary(entry_spot=entry_spot, entry_spot_error=entry_error) + if entry_spot is None: + entry_spot_hint.set_text("Entry spot unavailable until the scenario dates are valid.") validation_label.set_text(str(exc)) render_result_state("Scenario validation failed", str(exc), tone="warning") return except Exception: - render_seeded_summary(entry_spot=None, entry_spot_error=None) + entry_spot, entry_error = derive_entry_spot() + render_seeded_summary(entry_spot=entry_spot, entry_spot_error=entry_error) + if entry_spot is None: + entry_spot_hint.set_text("Entry spot unavailable until the scenario dates are valid.") message = "Backtest failed. Please verify the scenario inputs and try again." logger.exception( "Backtest page run failed for workspace=%s symbol=%s start=%s end=%s template=%s units=%s loan=%s margin_call_ltv=%s", diff --git a/app/pages/event_comparison.py b/app/pages/event_comparison.py index cbfbcf1..a365d5e 100644 --- a/app/pages/event_comparison.py +++ b/app/pages/event_comparison.py @@ -203,13 +203,6 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: syncing_controls["value"] = False template_slugs = selected_template_slugs() - if not template_slugs: - template_slugs = tuple(service.default_template_selection(str(option["slug"]))) - syncing_controls["value"] = True - try: - template_select.value = list(template_slugs) - finally: - syncing_controls["value"] = False try: preview_units = float(units_input.value or 0.0) @@ -243,6 +236,21 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: scenario_label.set_text(str(exc)) render_selected_summary(entry_spot=None, entry_spot_error=str(exc)) return str(exc) + except Exception: + logger.exception( + "Event comparison preview failed for workspace=%s preset=%s templates=%s units=%s loan=%s margin_call_ltv=%s", + workspace_id, + preset_select.value, + selected_template_slugs(), + units_input.value, + loan_input.value, + ltv_input.value, + ) + message = "Event comparison preview failed. Please verify the seeded inputs and try again." + metadata_label.set_text(f"Preset: {option['label']} — {option['description']}") + scenario_label.set_text(message) + render_selected_summary(entry_spot=None, entry_spot_error=message) + return message preset = service.event_preset_service.get_preset(str(option["slug"])) metadata_label.set_text(f"Preset: {option['label']} — {option['description']}") scenario_label.set_text( @@ -261,15 +269,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: def render_report() -> None: validation_label.set_text("") result_panel.clear() - option = preset_lookup.get(str(preset_select.value or "")) template_slugs = selected_template_slugs() - if option is not None and not template_slugs: - template_slugs = tuple(service.default_template_selection(str(option["slug"]))) - syncing_controls["value"] = True - try: - template_select.value = list(template_slugs) - finally: - syncing_controls["value"] = False try: report = service.run_read_only_comparison( preset_slug=str(preset_select.value or ""), diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py index 01c8896..6f007bc 100644 --- a/app/services/backtesting/ui_service.py +++ b/app/services/backtesting/ui_service.py @@ -98,7 +98,7 @@ class BacktestPageService: ) return history[0].close - def run_read_only_scenario( + def validate_preview_inputs( self, *, symbol: str, @@ -108,7 +108,8 @@ class BacktestPageService: underlying_units: float, loan_amount: float, margin_call_ltv: float, - ) -> BacktestPageRunResult: + entry_spot: float | None = None, + ) -> float: normalized_symbol = symbol.strip().upper() if not normalized_symbol: raise ValueError("Symbol is required") @@ -125,9 +126,35 @@ class BacktestPageService: if not template_slug: 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 + + def run_read_only_scenario( + self, + *, + symbol: str, + start_date: date, + end_date: date, + template_slug: str, + underlying_units: float, + loan_amount: float, + margin_call_ltv: float, + ) -> BacktestPageRunResult: + normalized_symbol = symbol.strip().upper() + entry_spot = self.validate_preview_inputs( + symbol=normalized_symbol, + start_date=start_date, + end_date=end_date, + template_slug=template_slug, + underlying_units=underlying_units, + loan_amount=loan_amount, + margin_call_ltv=margin_call_ltv, + ) 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 1b5178b..33e3873 100644 --- a/app/services/event_comparison_ui.py +++ b/app/services/event_comparison_ui.py @@ -114,6 +114,8 @@ class EventComparisonPageService: loan_amount: float, margin_call_ltv: float, ) -> BacktestScenario: + if not template_slugs: + raise ValueError("Select at least one strategy template.") try: scenario = self.comparison_service.preview_scenario_from_inputs( preset_slug=preset_slug, @@ -147,6 +149,8 @@ class EventComparisonPageService: ) -> EventComparisonReport: if not preset_slug: raise ValueError("Preset selection is required") + if not template_slugs: + raise ValueError("Select at least one strategy template.") if underlying_units <= 0: raise ValueError("Underlying units must be positive") if loan_amount < 0: @@ -162,7 +166,7 @@ class EventComparisonPageService: 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), + template_slugs=template_slugs, underlying_units=underlying_units, loan_amount=loan_amount, margin_call_ltv=margin_call_ltv, @@ -180,7 +184,7 @@ class EventComparisonPageService: _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), + template_slugs=template_slugs, underlying_units=underlying_units, loan_amount=loan_amount, margin_call_ltv=margin_call_ltv, diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index 74e0cc4..bc685cb 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -10,6 +10,8 @@ notes: - The roadmap source of truth is this index plus the per-task YAML files in the status folders. - One task lives in one YAML file and changes state by moving between status folders. - Priority ordering is maintained here so agents can parse one short file first. + - 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: - CORE-001D - BT-003B diff --git a/tests/test_event_comparison_ui.py b/tests/test_event_comparison_ui.py index 551066b..15239fa 100644 --- a/tests/test_event_comparison_ui.py +++ b/tests/test_event_comparison_ui.py @@ -32,23 +32,26 @@ def test_event_comparison_page_service_runs_seeded_gld_preset_deterministically( ) -def test_event_comparison_page_service_uses_preset_default_templates_when_none_selected() -> None: +def test_event_comparison_page_service_rejects_empty_template_selection() -> None: service = EventComparisonPageService() - report = service.run_read_only_comparison( - preset_slug="gld-jan-2024-selloff", - template_slugs=(), - underlying_units=1000.0, - loan_amount=68000.0, - margin_call_ltv=0.75, - ) + with pytest.raises(ValueError, match="Select at least one strategy template"): + service.run_read_only_comparison( + preset_slug="gld-jan-2024-selloff", + template_slugs=(), + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + ) - assert [item.template_slug for item in report.rankings] == [ - "protective-put-atm-12m", - "ladder-50-50-atm-95pct-12m", - "protective-put-95pct-12m", - "protective-put-90pct-12m", - ] + with pytest.raises(ValueError, match="Select at least one strategy template"): + service.preview_scenario( + preset_slug="gld-jan-2024-selloff", + template_slugs=(), + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + ) def test_event_comparison_page_service_exposes_seeded_preset_options() -> None: