feat(BT-003A): add event comparison page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
238
app/pages/event_comparison.py
Normal file
238
app/pages/event_comparison.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
164
app/services/event_comparison_ui.py
Normal file
164
app/services/event_comparison_ui.py
Normal file
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
130
tests/test_event_comparison_ui.py
Normal file
130
tests/test_event_comparison_ui.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user