diff --git a/app/models/backtest.py b/app/models/backtest.py index c9c6b16..4d907f0 100644 --- a/app/models/backtest.py +++ b/app/models/backtest.py @@ -137,6 +137,7 @@ class EventComparisonRanking: template_slug: str template_name: str survived_margin_call: bool + margin_call_days_hedged: int max_ltv_hedged: float hedge_cost: float final_equity: float diff --git a/app/pages/__init__.py b/app/pages/__init__.py index 8d90a93..807cb20 100644 --- a/app/pages/__init__.py +++ b/app/pages/__init__.py @@ -1,3 +1,3 @@ -from . import backtests, hedge, options, overview, settings +from . import backtests, event_comparison, hedge, options, overview, settings -__all__ = ["overview", "hedge", "options", "backtests", "settings"] +__all__ = ["overview", "hedge", "options", "backtests", "event_comparison", "settings"] diff --git a/app/pages/common.py b/app/pages/common.py index f0a521d..f32623d 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -13,6 +13,7 @@ NAV_ITEMS: list[tuple[str, str, str]] = [ ("hedge", "/hedge", "Hedge Analysis"), ("options", "/options", "Options Chain"), ("backtests", "/backtests", "Backtests"), + ("event-comparison", "/event-comparison", "Event Comparison"), ("settings", "/settings", "Settings"), ] diff --git a/app/pages/event_comparison.py b/app/pages/event_comparison.py new file mode 100644 index 0000000..2e01f76 --- /dev/null +++ b/app/pages/event_comparison.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from nicegui import ui + +from app.pages.common import dashboard_page +from app.services.event_comparison_ui import EventComparisonPageService + + +def _chart_options(dates: tuple[str, ...], series: tuple[dict[str, object], ...]) -> dict: + return { + "tooltip": {"trigger": "axis"}, + "legend": {"type": "scroll"}, + "xAxis": {"type": "category", "data": list(dates)}, + "yAxis": {"type": "value", "name": "Net value"}, + "series": [ + { + "name": item["name"], + "type": "line", + "smooth": True, + "data": item["values"], + } + for item in series + ], + } + + +@ui.page("/event-comparison") +def event_comparison_page() -> None: + service = EventComparisonPageService() + preset_options = service.preset_options("GLD") + template_options = service.template_options("GLD") + + preset_select_options = {str(option["slug"]): str(option["label"]) for option in preset_options} + template_select_options = {str(option["slug"]): str(option["label"]) for option in template_options} + default_preset_slug = str(preset_options[0]["slug"]) if preset_options else None + default_template_slugs = list(preset_options[0]["default_template_slugs"]) if preset_options else [] + preset_lookup = {str(option["slug"]): option for option in preset_options} + + with dashboard_page( + "Event Comparison", + "Thin BT-003A read-only UI over EventComparisonService with deterministic seeded GLD presets.", + "event-comparison", + ): + with ui.row().classes("w-full gap-6 max-lg:flex-col"): + with ui.card().classes( + "w-full max-w-xl rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): + ui.label("Comparison Form").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label( + "Preset selection is deterministic and read-only in the sense that runs reuse seeded event windows and existing BT-003 ranking logic." + ).classes("text-sm text-slate-500 dark:text-slate-400") + preset_select = ui.select( + preset_select_options, + value=default_preset_slug, + label="Event preset", + ).classes("w-full") + template_select = ui.select( + template_select_options, + value=default_template_slugs, + label="Strategy templates", + multiple=True, + ).classes("w-full") + ui.label("Changing the preset resets strategy templates to that preset's default comparison set.").classes( + "text-xs text-slate-500 dark:text-slate-400" + ) + units_input = ui.number("Underlying units", value=1000.0, min=0.0001, step=1).classes("w-full") + loan_input = ui.number("Loan amount", value=68000.0, min=0, step=1000).classes("w-full") + ltv_input = ui.number("Margin call LTV", value=0.75, min=0.01, max=0.99, step=0.01).classes("w-full") + metadata_label = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400") + scenario_label = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400") + validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300") + run_button = ui.button("Run comparison").props("color=primary") + + result_panel = ui.column().classes("w-full gap-6") + + def selected_template_slugs() -> tuple[str, ...]: + raw_value = template_select.value or [] + if isinstance(raw_value, str): + return (raw_value,) if raw_value else () + return tuple(str(item) for item in raw_value if item) + + def refresh_preset_details() -> None: + option = preset_lookup.get(str(preset_select.value or "")) + if option is None: + metadata_label.set_text("") + scenario_label.set_text("") + return + template_select.value = list(service.default_template_selection(str(option["slug"]))) + try: + scenario = service.preview_scenario( + preset_slug=str(option["slug"]), + template_slugs=selected_template_slugs(), + 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), + ) + except (ValueError, KeyError) as exc: + metadata_label.set_text(f"Preset: {option['label']} — {option['description']}") + scenario_label.set_text(str(exc)) + return + preset = service.event_preset_service.get_preset(str(option["slug"])) + metadata_label.set_text(f"Preset: {option['label']} — {option['description']}") + scenario_label.set_text( + "Scenario preview: " + f"{scenario.start_date.isoformat()} → {scenario.end_date.isoformat()}" + + ( + f" · Anchor date: {preset.anchor_date.isoformat()}" + if preset.anchor_date is not None + else " · Anchor date: none" + ) + + f" · Entry spot: ${scenario.initial_portfolio.entry_spot:,.2f}" + ) + + def render_report() -> None: + validation_label.set_text("") + result_panel.clear() + try: + report = service.run_read_only_comparison( + preset_slug=str(preset_select.value or ""), + template_slugs=selected_template_slugs(), + 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), + ) + except (ValueError, KeyError) as exc: + validation_label.set_text(str(exc)) + return + except Exception: + validation_label.set_text("Event comparison failed. Please verify the seeded inputs and try again.") + return + + preset = report.event_preset + scenario = report.scenario + metadata_label.set_text( + f"Preset: {preset.display_name} ({preset.event_type}) · Tags: {', '.join(preset.tags) or 'none'}" + ) + scenario_label.set_text( + "Scenario dates used: " + f"{scenario.start_date.isoformat()} → {scenario.end_date.isoformat()} · " + f"Entry spot: ${scenario.initial_portfolio.entry_spot:,.2f}" + ) + chart_model = service.chart_model(report) + + with result_panel: + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): + ui.label("Scenario Metadata").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): + cards = [ + ("Symbol", scenario.symbol), + ("Event window", f"{preset.window_start.isoformat()} → {preset.window_end.isoformat()}"), + ( + "Anchor date", + preset.anchor_date.isoformat() if preset.anchor_date is not None else "None", + ), + ("Scenario dates used", f"{scenario.start_date.isoformat()} → {scenario.end_date.isoformat()}"), + ("Underlying units", f"{scenario.initial_portfolio.underlying_units:,.0f}"), + ("Loan amount", f"${scenario.initial_portfolio.loan_amount:,.0f}"), + ("Margin call LTV", f"{scenario.initial_portfolio.margin_call_ltv:.1%}"), + ("Templates compared", str(len(report.rankings))), + ] + for label, value in cards: + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): + ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(value).classes("text-xl font-bold text-slate-900 dark:text-slate-100") + + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): + ui.label("Ranked Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.table( + columns=[ + {"name": "rank", "label": "Rank", "field": "rank", "align": "right"}, + {"name": "template_name", "label": "Template", "field": "template_name", "align": "left"}, + { + "name": "survived_margin_call", + "label": "Survived margin call", + "field": "survived_margin_call", + "align": "center", + }, + { + "name": "margin_call_days_hedged", + "label": "Hedged margin call days", + "field": "margin_call_days_hedged", + "align": "right", + }, + { + "name": "max_ltv_hedged", + "label": "Max hedged LTV", + "field": "max_ltv_hedged", + "align": "right", + }, + {"name": "hedge_cost", "label": "Hedge cost", "field": "hedge_cost", "align": "right"}, + { + "name": "final_equity", + "label": "Final equity", + "field": "final_equity", + "align": "right", + }, + ], + rows=[ + { + "rank": item.rank, + "template_name": item.template_name, + "survived_margin_call": "Yes" if item.survived_margin_call else "No", + "margin_call_days_hedged": item.margin_call_days_hedged, + "max_ltv_hedged": f"{item.max_ltv_hedged:.1%}", + "hedge_cost": f"${item.hedge_cost:,.0f}", + "final_equity": f"${item.final_equity:,.0f}", + } + for item in report.rankings + ], + row_key="rank", + ).classes("w-full") + + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): + ui.label("Portfolio Value Paths").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label( + "Baseline series shows the unhedged collateral value path for the same seeded event window." + ).classes("text-sm text-slate-500 dark:text-slate-400") + ui.echart( + _chart_options( + chart_model.dates, + tuple( + {"name": item.name, "values": list(item.values)} for item in chart_model.series + ), + ) + ).classes("h-96 w-full") + + preset_select.on_value_change(lambda _: refresh_preset_details()) + run_button.on_click(lambda: render_report()) + refresh_preset_details() + render_report() diff --git a/app/services/backtesting/comparison.py b/app/services/backtesting/comparison.py index 70323ed..57fd917 100644 --- a/app/services/backtesting/comparison.py +++ b/app/services/backtesting/comparison.py @@ -11,7 +11,7 @@ from app.models.backtest import ( TemplateRef, ) from app.models.event_preset import EventPreset -from app.services.backtesting.historical_provider import SyntheticHistoricalProvider +from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider from app.services.backtesting.service import BacktestService from app.services.event_presets import EventPresetService from app.services.strategy_templates import StrategyTemplateService @@ -48,6 +48,111 @@ class EventComparisonService: template_slugs=template_slugs, provider_ref=provider_ref, ) + return self._compare_materialized_event(preset=preset, scenario=scenario) + + def compare_event_from_inputs( + self, + *, + preset_slug: str, + underlying_units: float, + loan_amount: float, + margin_call_ltv: float, + template_slugs: tuple[str, ...] | None = None, + currency: str = "USD", + cash_balance: float = 0.0, + financing_rate: float = 0.0, + provider_ref: ProviderRef | None = None, + ) -> EventComparisonReport: + preset = self.event_preset_service.get_preset(preset_slug) + scenario = self.preview_scenario_from_inputs( + preset_slug=preset_slug, + underlying_units=underlying_units, + loan_amount=loan_amount, + margin_call_ltv=margin_call_ltv, + template_slugs=template_slugs, + currency=currency, + cash_balance=cash_balance, + financing_rate=financing_rate, + provider_ref=provider_ref, + ) + return self._compare_materialized_event(preset=preset, scenario=scenario) + + def preview_scenario_from_inputs( + self, + *, + preset_slug: str, + underlying_units: float, + loan_amount: float, + margin_call_ltv: float, + template_slugs: tuple[str, ...] | None = None, + currency: str = "USD", + cash_balance: float = 0.0, + financing_rate: float = 0.0, + provider_ref: ProviderRef | None = None, + ) -> BacktestScenario: + preset = self.event_preset_service.get_preset(preset_slug) + history = self._load_preset_history(preset) + entry_spot = history[0].close + initial_portfolio = BacktestPortfolioState( + currency=currency, + underlying_units=underlying_units, + entry_spot=entry_spot, + loan_amount=loan_amount, + margin_call_ltv=margin_call_ltv, + cash_balance=cash_balance, + financing_rate=financing_rate, + ) + return self.materialize_scenario( + preset, + initial_portfolio=initial_portfolio, + template_slugs=template_slugs, + provider_ref=provider_ref, + history=history, + ) + + def materialize_scenario( + self, + preset: EventPreset, + *, + initial_portfolio: BacktestPortfolioState, + template_slugs: tuple[str, ...] | None = None, + provider_ref: ProviderRef | None = None, + history: list[DailyClosePoint] | None = None, + ) -> BacktestScenario: + selected_template_slugs = tuple(template_slugs or preset.scenario_overrides.default_template_slugs) + if not selected_template_slugs: + raise ValueError("Event comparison requires at least one template slug") + + resolved_history = history or self._load_preset_history(preset) + scenario_portfolio = BacktestPortfolioState( + currency=initial_portfolio.currency, + underlying_units=initial_portfolio.underlying_units, + entry_spot=resolved_history[0].close, + loan_amount=initial_portfolio.loan_amount, + margin_call_ltv=initial_portfolio.margin_call_ltv, + cash_balance=initial_portfolio.cash_balance, + financing_rate=initial_portfolio.financing_rate, + ) + template_refs = tuple( + TemplateRef(slug=slug, version=self.template_service.get_template(slug).version) + for slug in selected_template_slugs + ) + return BacktestScenario( + scenario_id=f"event-{preset.slug}", + display_name=preset.display_name, + symbol=preset.symbol, + start_date=resolved_history[0].date, + end_date=resolved_history[-1].date, + initial_portfolio=scenario_portfolio, + template_refs=template_refs, + provider_ref=provider_ref + or ProviderRef( + provider_id=self.provider.provider_id, + pricing_mode=self.provider.pricing_mode, + ), + ) + + def _compare_materialized_event(self, *, preset: EventPreset, scenario: BacktestScenario) -> EventComparisonReport: run_result = self.backtest_service.run_scenario(scenario) ranked_results = sorted( run_result.template_results, @@ -65,6 +170,7 @@ class EventComparisonService: template_slug=result.template_slug, template_name=result.template_name, survived_margin_call=not result.summary_metrics.margin_threshold_breached_hedged, + margin_call_days_hedged=result.summary_metrics.margin_call_days_hedged, max_ltv_hedged=result.summary_metrics.max_ltv_hedged, hedge_cost=result.summary_metrics.total_hedge_cost, final_equity=result.summary_metrics.end_value_hedged_net, @@ -79,48 +185,10 @@ class EventComparisonService: run_result=run_result, ) - def materialize_scenario( - self, - preset: EventPreset, - *, - initial_portfolio: BacktestPortfolioState, - template_slugs: tuple[str, ...] | None = None, - provider_ref: ProviderRef | None = None, - ) -> BacktestScenario: - selected_template_slugs = tuple(template_slugs or preset.scenario_overrides.default_template_slugs) - if not selected_template_slugs: - raise ValueError("Event comparison requires at least one template slug") - + def _load_preset_history(self, preset: EventPreset) -> list[DailyClosePoint]: requested_start = preset.window_start - timedelta(days=preset.scenario_overrides.lookback_days or 0) requested_end = preset.window_end + timedelta(days=preset.scenario_overrides.recovery_days or 0) history = self.provider.load_history(preset.symbol, requested_start, requested_end) if not history: raise ValueError(f"No historical prices found for event preset: {preset.slug}") - - scenario_portfolio = BacktestPortfolioState( - currency=initial_portfolio.currency, - underlying_units=initial_portfolio.underlying_units, - entry_spot=history[0].close, - loan_amount=initial_portfolio.loan_amount, - margin_call_ltv=initial_portfolio.margin_call_ltv, - cash_balance=initial_portfolio.cash_balance, - financing_rate=initial_portfolio.financing_rate, - ) - template_refs = tuple( - TemplateRef(slug=slug, version=self.template_service.get_template(slug).version) - for slug in selected_template_slugs - ) - return BacktestScenario( - scenario_id=f"event-{preset.slug}", - display_name=preset.display_name, - symbol=preset.symbol, - start_date=history[0].date, - end_date=history[-1].date, - initial_portfolio=scenario_portfolio, - template_refs=template_refs, - provider_ref=provider_ref - or ProviderRef( - provider_id=self.provider.provider_id, - pricing_mode=self.provider.pricing_mode, - ), - ) + return history diff --git a/app/services/event_comparison_ui.py b/app/services/event_comparison_ui.py new file mode 100644 index 0000000..ec060fb --- /dev/null +++ b/app/services/event_comparison_ui.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from app.models.backtest import BacktestScenario, EventComparisonReport +from app.services.backtesting.comparison import EventComparisonService +from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider +from app.services.event_presets import EventPresetService +from app.services.strategy_templates import StrategyTemplateService + +SUPPORTED_EVENT_COMPARISON_SYMBOL = "GLD" + +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 + ] + + +@dataclass(frozen=True) +class EventComparisonChartSeries: + name: str + values: tuple[float, ...] + + +@dataclass(frozen=True) +class EventComparisonChartModel: + dates: tuple[str, ...] + series: tuple[EventComparisonChartSeries, ...] + + +class EventComparisonPageService: + def __init__( + self, + comparison_service: EventComparisonService | None = None, + event_preset_service: EventPresetService | None = None, + template_service: StrategyTemplateService | None = None, + ) -> None: + 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()) + comparison_service = EventComparisonService( + provider=provider, + event_preset_service=self.event_preset_service, + template_service=self.template_service, + ) + self.comparison_service = comparison_service + + def preset_options(self, symbol: str = SUPPORTED_EVENT_COMPARISON_SYMBOL) -> list[dict[str, object]]: + return [ + { + "slug": preset.slug, + "label": preset.display_name, + "description": preset.description, + "default_template_slugs": list(preset.scenario_overrides.default_template_slugs), + } + for preset in self.event_preset_service.list_presets(symbol) + ] + + def template_options(self, symbol: str = SUPPORTED_EVENT_COMPARISON_SYMBOL) -> list[dict[str, object]]: + return [ + { + "slug": template.slug, + "label": template.display_name, + "description": template.description, + } + for template in self.template_service.list_active_templates(symbol) + ] + + def default_template_selection(self, preset_slug: str) -> tuple[str, ...]: + preset = self.event_preset_service.get_preset(preset_slug) + return tuple(preset.scenario_overrides.default_template_slugs) + + def preview_scenario( + self, + *, + preset_slug: str, + template_slugs: tuple[str, ...], + underlying_units: float, + 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, + ) + + def run_read_only_comparison( + self, + *, + preset_slug: str, + template_slugs: tuple[str, ...], + underlying_units: float, + loan_amount: float, + margin_call_ltv: float, + ) -> EventComparisonReport: + if not preset_slug: + raise ValueError("Preset selection is required") + if underlying_units <= 0: + raise ValueError("Underlying units must be positive") + if loan_amount < 0: + raise ValueError("Loan amount must be non-negative") + if not 0 < margin_call_ltv < 1: + raise ValueError("Margin call LTV must be between 0 and 1") + + preset = self.event_preset_service.get_preset(preset_slug) + normalized_symbol = preset.symbol.strip().upper() + if normalized_symbol != SUPPORTED_EVENT_COMPARISON_SYMBOL: + raise ValueError("BT-003A event comparison is currently limited to GLD on this page") + + return self.comparison_service.compare_event_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, + ) + + @staticmethod + def chart_model(report: EventComparisonReport, max_ranked_series: int = 3) -> EventComparisonChartModel: + ranked = report.rankings[:max_ranked_series] + if not ranked: + return EventComparisonChartModel(dates=(), series=()) + dates = tuple(point.date.isoformat() for point in ranked[0].result.daily_path) + series = [ + EventComparisonChartSeries( + name="Unhedged collateral baseline", + values=tuple(round(point.underlying_value, 2) for point in ranked[0].result.daily_path), + ) + ] + for item in ranked: + series.append( + EventComparisonChartSeries( + name=item.template_name, + values=tuple(round(point.net_portfolio_value, 2) for point in item.result.daily_path), + ) + ) + return EventComparisonChartModel(dates=dates, series=tuple(series)) diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 9c52daa..a4e168e 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -44,6 +44,33 @@ def test_homepage_and_options_page_render() -> None: assert "RuntimeError" not in rerun_text assert "Server error" not in rerun_text + page.goto(f"{BASE_URL}/event-comparison", wait_until="networkidle", timeout=30000) + expect(page.locator("text=Event Comparison").first).to_be_visible(timeout=15000) + expect(page.locator("text=Comparison Form").first).to_be_visible(timeout=15000) + expect(page.locator("text=Ranked Results").first).to_be_visible(timeout=15000) + expect(page.locator("text=Scenario Metadata").first).to_be_visible(timeout=15000) + expect(page.locator("text=Portfolio Value Paths").first).to_be_visible(timeout=15000) + event_text = page.locator("body").inner_text(timeout=15000) + assert "GLD January 2024 Selloff" in event_text + assert "Protective Put ATM" in event_text + assert "Baseline series shows the unhedged collateral value path" in event_text + assert "Hedged margin call days" in event_text + assert "Templates compared" in event_text and "4" in event_text + assert "RuntimeError" not in event_text + assert "Server error" not in event_text + assert "Traceback" not in event_text + + page.get_by_label("Event preset").click() + page.get_by_text("GLD January 2024 Drawdown", exact=True).click() + page.get_by_role("button", name="Run comparison").click() + expect(page.locator("text=GLD January 2024 Drawdown").first).to_be_visible(timeout=15000) + rerun_event_text = page.locator("body").inner_text(timeout=15000) + assert "Laddered Puts 33/33/33 ATM + 95% + 90%" in rerun_event_text + assert "Templates compared" in rerun_event_text and "3" in rerun_event_text + assert "RuntimeError" not in rerun_event_text + assert "Server error" not in rerun_event_text + page.screenshot(path=str(ARTIFACTS / "event-comparison.png"), full_page=True) + page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000) expect(page.locator("text=Options Chain").first).to_be_visible(timeout=15000) expect(page.locator("text=Filters").first).to_be_visible(timeout=15000) diff --git a/tests/test_event_comparison_ui.py b/tests/test_event_comparison_ui.py new file mode 100644 index 0000000..de7a1a6 --- /dev/null +++ b/tests/test_event_comparison_ui.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from datetime import date + +import pytest + +from app.services.event_comparison_ui import EventComparisonFixtureHistoricalPriceSource, EventComparisonPageService + + +def test_event_comparison_page_service_runs_seeded_gld_preset_deterministically() -> None: + service = EventComparisonPageService() + + report = service.run_read_only_comparison( + preset_slug="gld-jan-2024-selloff", + template_slugs=("protective-put-atm-12m", "protective-put-95pct-12m"), + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + ) + + assert report.event_preset.slug == "gld-jan-2024-selloff" + assert report.scenario.start_date.isoformat() == "2024-01-02" + assert report.scenario.end_date.isoformat() == "2024-01-08" + assert [item.template_slug for item in report.rankings] == [ + "protective-put-atm-12m", + "protective-put-95pct-12m", + ] + assert report.rankings[0].rank == 1 + assert ( + report.rankings[0].result.daily_path[-1].net_portfolio_value + > report.rankings[-1].result.daily_path[-1].net_portfolio_value + ) + + +def test_event_comparison_page_service_uses_preset_default_templates_when_none_selected() -> 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, + ) + + 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", + ] + + +def test_event_comparison_page_service_exposes_seeded_preset_options() -> None: + service = EventComparisonPageService() + + options = service.preset_options("GLD") + + assert options[0]["slug"] == "gld-jan-2024-selloff" + assert options[0]["label"] == "GLD January 2024 Selloff" + assert tuple(options[0]["default_template_slugs"]) == ( + "protective-put-atm-12m", + "protective-put-95pct-12m", + "protective-put-90pct-12m", + "ladder-50-50-atm-95pct-12m", + ) + + +def test_event_comparison_page_service_resets_template_selection_to_preset_defaults() -> None: + service = EventComparisonPageService() + + assert service.default_template_selection("gld-jan-2024-selloff") == ( + "protective-put-atm-12m", + "protective-put-95pct-12m", + "protective-put-90pct-12m", + "ladder-50-50-atm-95pct-12m", + ) + assert service.default_template_selection("gld-jan-2024-drawdown") == ( + "protective-put-atm-12m", + "ladder-50-50-atm-95pct-12m", + "ladder-33-33-33-atm-95pct-90pct-12m", + ) + + +def test_event_comparison_page_service_preview_uses_same_materialization_path() -> None: + service = EventComparisonPageService() + + scenario = service.preview_scenario( + preset_slug="gld-jan-2024-selloff", + template_slugs=("protective-put-atm-12m",), + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + ) + + assert scenario.start_date.isoformat() == "2024-01-02" + assert scenario.end_date.isoformat() == "2024-01-08" + assert scenario.initial_portfolio.entry_spot == 100.0 + assert [ref.slug for ref in scenario.template_refs] == ["protective-put-atm-12m"] + + +def test_event_comparison_fixture_fails_closed_for_unsupported_range() -> None: + source = EventComparisonFixtureHistoricalPriceSource() + + with pytest.raises(ValueError, match="seeded 2024-01-02 through 2024-01-08"): + source.load_daily_closes("GLD", date(2024, 1, 1), date(2024, 1, 8)) + + +def test_event_comparison_page_service_builds_chart_model_with_unhedged_reference() -> None: + service = EventComparisonPageService() + + report = service.run_read_only_comparison( + preset_slug="gld-jan-2024-selloff", + template_slugs=("protective-put-atm-12m", "protective-put-95pct-12m"), + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + ) + chart_model = service.chart_model(report) + + assert chart_model.dates == ( + "2024-01-02", + "2024-01-03", + "2024-01-04", + "2024-01-05", + "2024-01-08", + ) + assert chart_model.series[0].name == "Unhedged collateral baseline" + assert chart_model.series[1].name == "Protective Put ATM" + assert len(chart_model.series[0].values) == len(chart_model.dates)