feat(BT-003B): add event comparison drilldown

This commit is contained in:
Bu5hm4nn
2026-03-26 22:05:31 +01:00
parent bdf56ecebe
commit 3c9ff201e1
8 changed files with 329 additions and 21 deletions

View File

@@ -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)) render_selected_summary(entry_spot=float(scenario.initial_portfolio.entry_spot))
chart_model = service.chart_model(report) chart_model = service.chart_model(report)
drilldown_options = service.drilldown_options(report)
initial_drilldown_slug = next(iter(drilldown_options), None)
with result_panel: with result_panel:
with ui.card().classes( with ui.card().classes(
@@ -388,6 +390,132 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
row_key="rank", row_key="rank",
).classes("w-full") ).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( with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
): ):

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date 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.comparison import EventComparisonService
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs
@@ -63,6 +63,35 @@ class EventComparisonChartModel:
series: tuple[EventComparisonChartSeries, ...] 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: class EventComparisonPageService:
def __init__( def __init__(
self, self,
@@ -181,9 +210,9 @@ class EventComparisonPageService:
preview = self.comparison_service.preview_scenario_from_inputs( preview = self.comparison_service.preview_scenario_from_inputs(
preset_slug=preset.slug, preset_slug=preset.slug,
template_slugs=template_slugs, template_slugs=template_slugs,
underlying_units=underlying_units, underlying_units=normalized_inputs.underlying_units,
loan_amount=loan_amount, loan_amount=normalized_inputs.loan_amount,
margin_call_ltv=margin_call_ltv, margin_call_ltv=normalized_inputs.margin_call_ltv,
) )
except ValueError as exc: except ValueError as exc:
if str(exc) == "loan_amount must be less than initial collateral value": 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)) 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}")

View File

@@ -13,7 +13,7 @@ notes:
- Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared. - 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. - Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required.
priority_queue: priority_queue:
- BT-003B - DEV-DOCKER-001
- PORT-003 - PORT-003
- BT-002 - BT-002
- BT-001C - BT-001C
@@ -41,14 +41,15 @@ states:
- DATA-002A - DATA-002A
- DATA-001A - DATA-001A
- OPS-001 - OPS-001
- DEV-DOCKER-001
- PORT-003 - PORT-003
- EXEC-001 - EXEC-001
- EXEC-002 - EXEC-002
- BT-002 - BT-002
- BT-003 - BT-003
- BT-003B
- BT-001C - BT-001C
in_progress: [] in_progress:
- BT-003B
done: done:
- DATA-001 - DATA-001
- DATA-002 - DATA-002

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -196,12 +196,24 @@ def test_homepage_and_options_page_render() -> None:
page.get_by_role("button", name="Run comparison").click() 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=Ranked Results").first).to_be_visible(timeout=15000)
expect(page.locator("text=Scenario 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=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) rerun_event_text = page.locator("body").inner_text(timeout=15000)
assert "Baseline series shows the unhedged collateral value path" in rerun_event_text 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 "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 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_label("Event preset").click()
page.get_by_text("GLD January 2024 Drawdown", exact=True).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) expect(page.locator("text=Results out of date").first).to_be_visible(timeout=15000)

View File

@@ -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[0].name == "Unhedged collateral baseline"
assert chart_model.series[1].name == "Protective Put ATM" assert chart_model.series[1].name == "Protective Put ATM"
assert len(chart_model.series[0].values) == len(chart_model.dates) 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")