From 3c9ff201e1b96138e1e1f3d794adb6546911a605 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Thu, 26 Mar 2026 22:05:31 +0100 Subject: [PATCH] feat(BT-003B): add event comparison drilldown --- app/pages/event_comparison.py | 128 ++++++++++++++++++ app/services/event_comparison_ui.py | 94 ++++++++++++- docs/roadmap/ROADMAP.yaml | 7 +- .../BT-003B-event-comparison-drilldown.yaml | 14 -- ...DOCKER-001-local-bind-mount-integrity.yaml | 17 +++ .../BT-003B-event-comparison-drilldown.yaml | 20 +++ tests/test_e2e_playwright.py | 12 ++ tests/test_event_comparison_ui.py | 58 ++++++++ 8 files changed, 329 insertions(+), 21 deletions(-) delete mode 100644 docs/roadmap/backlog/BT-003B-event-comparison-drilldown.yaml create mode 100644 docs/roadmap/backlog/DEV-DOCKER-001-local-bind-mount-integrity.yaml create mode 100644 docs/roadmap/in-progress/BT-003B-event-comparison-drilldown.yaml diff --git a/app/pages/event_comparison.py b/app/pages/event_comparison.py index a365d5e..8f9a377 100644 --- a/app/pages/event_comparison.py +++ b/app/pages/event_comparison.py @@ -309,6 +309,8 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: ) render_selected_summary(entry_spot=float(scenario.initial_portfolio.entry_spot)) chart_model = service.chart_model(report) + drilldown_options = service.drilldown_options(report) + initial_drilldown_slug = next(iter(drilldown_options), None) with result_panel: with ui.card().classes( @@ -388,6 +390,132 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: 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("Strategy Drilldown").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label( + "Select a ranked strategy to inspect margin-call pressure, payoff realization, and the full seeded daily path." + ).classes("text-sm text-slate-500 dark:text-slate-400") + drilldown_select = ui.select( + drilldown_options, + value=initial_drilldown_slug, + label="Strategy drilldown", + ).classes("w-full") + drilldown_container = ui.column().classes("w-full gap-4") + + def render_drilldown() -> None: + drilldown_container.clear() + if drilldown_select.value is None: + return + drilldown = service.drilldown_model(report, template_slug=str(drilldown_select.value)) + breach_dates = ", ".join(drilldown.breach_dates) if drilldown.breach_dates else "None" + worst_ltv_point = ( + f"{drilldown.worst_ltv_date} · {drilldown.worst_ltv_hedged:.1%}" + if drilldown.worst_ltv_date is not None + else "Unavailable" + ) + with drilldown_container: + ui.label(f"Selected strategy: {drilldown.template_name}").classes( + "text-lg font-semibold text-slate-900 dark:text-slate-100" + ) + ui.label( + f"Rank #{drilldown.rank} · {'Survived margin call' if drilldown.survived_margin_call else 'Breached margin threshold'}" + ).classes("text-sm text-slate-500 dark:text-slate-400") + with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): + cards = [ + ("Margin-call days", str(drilldown.margin_call_days_hedged)), + ("Payoff realized", f"${drilldown.total_option_payoff_realized:,.0f}"), + ("Hedge cost", f"${drilldown.hedge_cost:,.0f}"), + ("Final equity", f"${drilldown.final_equity:,.0f}"), + ] + 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.grid(columns=2).classes("w-full gap-4 max-md:grid-cols-1"): + 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("Worst LTV point").classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(worst_ltv_point).classes( + "text-xl font-bold text-slate-900 dark:text-slate-100" + ) + with ui.card().classes( + "rounded-xl border border-amber-200 bg-amber-50 p-4 shadow-none dark:border-amber-900/60 dark:bg-amber-950/30" + ): + ui.label("Margin threshold breach dates").classes( + "text-sm text-amber-700 dark:text-amber-300" + ) + ui.label(breach_dates).classes( + "text-base font-semibold text-amber-800 dark:text-amber-200" + ) + with ui.card().classes( + "w-full rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): + ui.label("Daily path details").classes( + "text-base font-semibold text-slate-900 dark:text-slate-100" + ) + ui.table( + columns=[ + {"name": "date", "label": "Date", "field": "date", "align": "left"}, + { + "name": "spot_close", + "label": "Spot", + "field": "spot_close", + "align": "right", + }, + { + "name": "net_portfolio_value", + "label": "Net hedged", + "field": "net_portfolio_value", + "align": "right", + }, + { + "name": "option_market_value", + "label": "Option value", + "field": "option_market_value", + "align": "right", + }, + { + "name": "realized_option_cashflow", + "label": "Payoff realized", + "field": "realized_option_cashflow", + "align": "right", + }, + { + "name": "ltv_hedged", + "label": "Hedged LTV", + "field": "ltv_hedged", + "align": "right", + }, + { + "name": "margin_call_hedged", + "label": "Breach", + "field": "margin_call_hedged", + "align": "center", + }, + ], + rows=[ + { + "date": row.date, + "spot_close": f"${row.spot_close:,.2f}", + "net_portfolio_value": f"${row.net_portfolio_value:,.0f}", + "option_market_value": f"${row.option_market_value:,.0f}", + "realized_option_cashflow": f"${row.realized_option_cashflow:,.0f}", + "ltv_hedged": f"{row.ltv_hedged:.1%}", + "margin_call_hedged": "Yes" if row.margin_call_hedged else "No", + } + for row in drilldown.rows + ], + row_key="date", + ).classes("w-full") + + drilldown_select.on_value_change(lambda _: render_drilldown()) + render_drilldown() + with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): diff --git a/app/services/event_comparison_ui.py b/app/services/event_comparison_ui.py index 16979e3..f27252a 100644 --- a/app/services/event_comparison_ui.py +++ b/app/services/event_comparison_ui.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import date -from app.models.backtest import BacktestScenario, EventComparisonReport +from app.models.backtest import BacktestScenario, EventComparisonRanking, EventComparisonReport from app.services.backtesting.comparison import EventComparisonService from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs @@ -63,6 +63,35 @@ class EventComparisonChartModel: series: tuple[EventComparisonChartSeries, ...] +@dataclass(frozen=True) +class EventComparisonDrilldownRow: + date: str + spot_close: float + net_portfolio_value: float + option_market_value: float + realized_option_cashflow: float + ltv_unhedged: float + ltv_hedged: float + margin_call_hedged: bool + active_position_ids: tuple[str, ...] + + +@dataclass(frozen=True) +class EventComparisonDrilldownModel: + rank: int + template_slug: str + template_name: str + survived_margin_call: bool + margin_call_days_hedged: int + total_option_payoff_realized: float + hedge_cost: float + final_equity: float + worst_ltv_hedged: float + worst_ltv_date: str | None + breach_dates: tuple[str, ...] + rows: tuple[EventComparisonDrilldownRow, ...] + + class EventComparisonPageService: def __init__( self, @@ -181,9 +210,9 @@ class EventComparisonPageService: preview = 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, + underlying_units=normalized_inputs.underlying_units, + loan_amount=normalized_inputs.loan_amount, + margin_call_ltv=normalized_inputs.margin_call_ltv, ) except ValueError as exc: if str(exc) == "loan_amount must be less than initial collateral value": @@ -232,3 +261,60 @@ class EventComparisonPageService: ) ) return EventComparisonChartModel(dates=dates, series=tuple(series)) + + @staticmethod + def drilldown_model( + report: EventComparisonReport, + *, + template_slug: str | None = None, + ) -> EventComparisonDrilldownModel: + ranking = EventComparisonPageService._select_ranking(report, template_slug=template_slug) + daily_path = ranking.result.daily_path + worst_ltv_point = max(daily_path, key=lambda point: point.ltv_hedged, default=None) + breach_dates = tuple(point.date.isoformat() for point in daily_path if point.margin_call_hedged) + return EventComparisonDrilldownModel( + rank=ranking.rank, + template_slug=ranking.template_slug, + template_name=ranking.template_name, + survived_margin_call=ranking.survived_margin_call, + margin_call_days_hedged=ranking.margin_call_days_hedged, + total_option_payoff_realized=ranking.result.summary_metrics.total_option_payoff_realized, + hedge_cost=ranking.hedge_cost, + final_equity=ranking.final_equity, + worst_ltv_hedged=ranking.max_ltv_hedged, + worst_ltv_date=worst_ltv_point.date.isoformat() if worst_ltv_point is not None else None, + breach_dates=breach_dates, + rows=tuple( + EventComparisonDrilldownRow( + date=point.date.isoformat(), + spot_close=point.spot_close, + net_portfolio_value=point.net_portfolio_value, + option_market_value=point.option_market_value, + realized_option_cashflow=point.realized_option_cashflow, + ltv_unhedged=point.ltv_unhedged, + ltv_hedged=point.ltv_hedged, + margin_call_hedged=point.margin_call_hedged, + active_position_ids=point.active_position_ids, + ) + for point in daily_path + ), + ) + + @staticmethod + def drilldown_options(report: EventComparisonReport) -> dict[str, str]: + return {ranking.template_slug: f"#{ranking.rank} — {ranking.template_name}" for ranking in report.rankings} + + @staticmethod + def _select_ranking( + report: EventComparisonReport, + *, + template_slug: str | None = None, + ) -> EventComparisonRanking: + if not report.rankings: + raise ValueError("Event comparison report has no ranked results") + if template_slug is None: + return report.rankings[0] + for ranking in report.rankings: + if ranking.template_slug == template_slug: + return ranking + raise ValueError(f"Unknown ranked template: {template_slug}") diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index e11c8c6..6f0064f 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -13,7 +13,7 @@ notes: - 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: - - BT-003B + - DEV-DOCKER-001 - PORT-003 - BT-002 - BT-001C @@ -41,14 +41,15 @@ states: - DATA-002A - DATA-001A - OPS-001 + - DEV-DOCKER-001 - PORT-003 - EXEC-001 - EXEC-002 - BT-002 - BT-003 - - BT-003B - BT-001C - in_progress: [] + in_progress: + - BT-003B done: - DATA-001 - DATA-002 diff --git a/docs/roadmap/backlog/BT-003B-event-comparison-drilldown.yaml b/docs/roadmap/backlog/BT-003B-event-comparison-drilldown.yaml deleted file mode 100644 index 413b125..0000000 --- a/docs/roadmap/backlog/BT-003B-event-comparison-drilldown.yaml +++ /dev/null @@ -1,14 +0,0 @@ -id: BT-003B -title: Event Comparison Drilldown -status: backlog -priority: P1 -effort: M -depends_on: - - BT-003A -tags: [backtesting, ui] -summary: Explain why one ranked strategy beat another on the event comparison page. -acceptance_criteria: - - Selecting a ranked strategy shows daily path details. - - UI exposes margin-call days, payoff realized, hedge cost, and final equity. - - Worst LTV point and breach dates are highlighted. - - Browser test verifies drilldown content updates when selecting a ranked result. diff --git a/docs/roadmap/backlog/DEV-DOCKER-001-local-bind-mount-integrity.yaml b/docs/roadmap/backlog/DEV-DOCKER-001-local-bind-mount-integrity.yaml new file mode 100644 index 0000000..c00415f --- /dev/null +++ b/docs/roadmap/backlog/DEV-DOCKER-001-local-bind-mount-integrity.yaml @@ -0,0 +1,17 @@ +id: DEV-DOCKER-001 +title: Local Docker Bind Mount Integrity +status: backlog +priority: P0 +effort: S +depends_on: [] +tags: + - devops + - docker + - local-dev +summary: Restore trustworthy local Docker validation by fixing the current empty bind-mount/import failure for `./app -> /app/app` under the local OrbStack workflow. +acceptance_criteria: + - `docker compose up -d --build` starts the local stack cleanly. + - `docker compose ps` shows the app container healthy instead of restart-looping. + - `docker compose run --rm --entrypoint python app -c 'import app.main'` succeeds. + - Inside the app container, `/app/app` contains the repository's actual application files. + - `/health` and at least one changed route can be validated against the Docker-served app, not only a direct local uvicorn process. diff --git a/docs/roadmap/in-progress/BT-003B-event-comparison-drilldown.yaml b/docs/roadmap/in-progress/BT-003B-event-comparison-drilldown.yaml new file mode 100644 index 0000000..5acfc8c --- /dev/null +++ b/docs/roadmap/in-progress/BT-003B-event-comparison-drilldown.yaml @@ -0,0 +1,20 @@ +id: BT-003B +title: Event Comparison Drilldown +status: in_progress +priority: P1 +effort: M +depends_on: + - BT-003A +tags: + - backtesting + - ui +summary: Explain why one ranked strategy beat another on the event comparison page. +acceptance_criteria: + - Selecting a ranked strategy shows daily path details. + - UI exposes margin-call days, payoff realized, hedge cost, and final equity. + - Worst LTV point and breach dates are highlighted. + - Browser test verifies drilldown content updates when selecting a ranked result. +progress_notes: + - The drilldown UI and service models are implemented in `app/pages/event_comparison.py` and `app/services/event_comparison_ui.py`. + - Focused unit coverage is green and direct local-browser validation against a fresh local uvicorn process succeeded. + - Closure is waiting on the local Docker validation path because `docker compose up -d --build` currently surfaces an environment-specific empty bind-mount/import failure for `./app -> /app/app`. diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 1099b82..ae4f700 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -196,12 +196,24 @@ def test_homepage_and_options_page_render() -> None: page.get_by_role("button", name="Run comparison").click() expect(page.locator("text=Ranked Results").first).to_be_visible(timeout=15000) expect(page.locator("text=Scenario Results").first).to_be_visible(timeout=15000) + expect(page.locator("text=Strategy Drilldown").first).to_be_visible(timeout=15000) expect(page.locator("text=Portfolio Value Paths").first).to_be_visible(timeout=15000) + expect(page.locator("text=Selected strategy: Protective Put ATM").first).to_be_visible(timeout=15000) rerun_event_text = page.locator("body").inner_text(timeout=15000) assert "Baseline series shows the unhedged collateral value path" in rerun_event_text assert "Templates compared" in rerun_event_text and "4" in rerun_event_text + assert "Worst LTV point" in rerun_event_text + assert "Margin threshold breach dates" in rerun_event_text + assert "Daily path details" in rerun_event_text assert "Historical scenario starts undercollateralized:" not in rerun_event_text + page.get_by_label("Strategy drilldown").click() + page.get_by_text("#4 — Protective Put 90%", exact=True).click() + expect(page.locator("text=Selected strategy: Protective Put 90%").first).to_be_visible(timeout=15000) + expect(page.locator("text=Rank #4 · Breached margin threshold").first).to_be_visible(timeout=15000) + expect(page.locator("text=2024-01-08 · 82.6%").first).to_be_visible(timeout=15000) + expect(page.locator("text=$17,939").first).to_be_visible(timeout=15000) + page.get_by_label("Event preset").click() page.get_by_text("GLD January 2024 Drawdown", exact=True).click() expect(page.locator("text=Results out of date").first).to_be_visible(timeout=15000) diff --git a/tests/test_event_comparison_ui.py b/tests/test_event_comparison_ui.py index 4c98736..1c0e4d1 100644 --- a/tests/test_event_comparison_ui.py +++ b/tests/test_event_comparison_ui.py @@ -199,3 +199,61 @@ def test_event_comparison_page_service_builds_chart_model_with_unhedged_referenc 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) + + +def test_event_comparison_page_service_builds_drilldown_for_selected_ranking() -> 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, + ) + drilldown = service.drilldown_model(report, template_slug="protective-put-95pct-12m") + + assert drilldown.rank == 2 + assert drilldown.template_name == "Protective Put 95%" + assert drilldown.margin_call_days_hedged == report.rankings[1].margin_call_days_hedged + assert drilldown.hedge_cost == report.rankings[1].hedge_cost + assert drilldown.final_equity == report.rankings[1].final_equity + assert ( + drilldown.total_option_payoff_realized == report.rankings[1].result.summary_metrics.total_option_payoff_realized + ) + assert drilldown.worst_ltv_date == "2024-01-08" + assert drilldown.rows[0].date == "2024-01-02" + assert drilldown.rows[-1].date == "2024-01-08" + + +def test_event_comparison_page_service_defaults_drilldown_to_top_ranked_strategy() -> 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, + ) + + drilldown = service.drilldown_model(report) + + assert drilldown.rank == 1 + assert drilldown.template_slug == report.rankings[0].template_slug + assert drilldown.template_name == report.rankings[0].template_name + + +def test_event_comparison_page_service_rejects_unknown_drilldown_template_slug() -> None: + service = EventComparisonPageService() + + report = service.run_read_only_comparison( + 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, + ) + + with pytest.raises(ValueError, match="Unknown ranked template"): + service.drilldown_model(report, template_slug="missing-template")