from __future__ import annotations from datetime import date, datetime, timezone from pathlib import Path import pytest from app.models.backtest import ( BacktestPortfolioState, BacktestRunResult, BacktestSummaryMetrics, TemplateBacktestResult, ) from app.models.event_preset import EventPreset, EventScenarioOverrides from app.services.backtesting.comparison import EventComparisonService from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider from app.services.event_presets import EventPresetService, FileEventPresetRepository from app.services.strategy_templates import StrategyTemplateService class FakeHistorySource: def __init__(self, rows: list[DailyClosePoint]) -> None: self.rows = rows self.calls: list[tuple[str, date, date]] = [] def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: self.calls.append((symbol, start_date, end_date)) return list(self.rows) class FakeBacktestService: def __init__(self, results: tuple[TemplateBacktestResult, ...]) -> None: self.results = results def run_scenario(self, scenario) -> BacktestRunResult: return BacktestRunResult(scenario_id=scenario.scenario_id, template_results=self.results) FIXTURE_HISTORY = [ DailyClosePoint(date=date(2024, 1, 8), close=85.0), 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), ] def test_file_event_preset_repository_seeds_default_presets(tmp_path: Path) -> None: repository = FileEventPresetRepository(tmp_path / "event_presets.json") presets = repository.list_presets() assert [preset.slug for preset in presets] == [ "gld-jan-2024-selloff", "gld-jan-2024-drawdown", "gld-jan-2024-stress-window", ] assert (tmp_path / "event_presets.json").exists() is True def test_event_comparison_materializes_scenario_from_preset_overrides() -> None: preset = EventPreset( event_preset_id="preset-1", slug="jan-selloff", display_name="January Selloff", symbol="GLD", window_start=date(2024, 1, 3), window_end=date(2024, 1, 5), anchor_date=date(2024, 1, 4), event_type="selloff", tags=("macro",), description="Test event window.", scenario_overrides=EventScenarioOverrides( lookback_days=1, recovery_days=3, default_template_slugs=("protective-put-atm-12m", "protective-put-95pct-12m"), ), created_at=datetime(2026, 3, 24, tzinfo=timezone.utc), ) provider = SyntheticHistoricalProvider( source=FakeHistorySource(FIXTURE_HISTORY), implied_volatility=0.35, risk_free_rate=0.0, ) service = EventComparisonService( provider=provider, template_service=StrategyTemplateService(), event_preset_service=EventPresetService(repository=FileEventPresetRepository()), ) scenario = service.materialize_scenario( preset, initial_portfolio=BacktestPortfolioState( currency="USD", underlying_units=1000.0, entry_spot=130.0, loan_amount=68_000.0, margin_call_ltv=0.75, ), ) assert scenario.scenario_id == "event-jan-selloff" assert scenario.display_name == "January Selloff" assert scenario.start_date == date(2024, 1, 2) assert scenario.end_date == date(2024, 1, 8) assert scenario.initial_portfolio.entry_spot == 100.0 assert scenario.initial_portfolio.start_value == 100_000.0 assert [ref.slug for ref in scenario.template_refs] == [ "protective-put-atm-12m", "protective-put-95pct-12m", ] def test_event_comparison_ranks_templates_by_survival_max_ltv_cost_then_final_equity(tmp_path: Path) -> None: repository = FileEventPresetRepository(tmp_path / "event_presets.json") repository.save_all( [ EventPreset( event_preset_id="preset-1", slug="jan-selloff", display_name="January Selloff", symbol="GLD", window_start=date(2024, 1, 2), window_end=date(2024, 1, 8), anchor_date=date(2024, 1, 4), event_type="selloff", tags=("macro",), description="Test event window.", scenario_overrides=EventScenarioOverrides( default_template_slugs=( "protective-put-atm-12m", "ladder-50-50-atm-95pct-12m", "protective-put-95pct-12m", "ladder-33-33-33-atm-95pct-90pct-12m", "protective-put-90pct-12m", ) ), created_at=datetime(2026, 3, 24, tzinfo=timezone.utc), ) ] ) provider = SyntheticHistoricalProvider( source=FakeHistorySource(FIXTURE_HISTORY), implied_volatility=0.35, risk_free_rate=0.0, ) service = EventComparisonService( provider=provider, template_service=StrategyTemplateService(), event_preset_service=EventPresetService(repository=repository), ) report = service.compare_event( preset_slug="jan-selloff", initial_portfolio=BacktestPortfolioState( currency="USD", underlying_units=1000.0, entry_spot=100.0, loan_amount=68_000.0, margin_call_ltv=0.75, ), ) assert report.event_preset.slug == "jan-selloff" assert [item.template_slug for item in report.rankings] == [ "protective-put-atm-12m", "ladder-50-50-atm-95pct-12m", "protective-put-95pct-12m", "ladder-33-33-33-atm-95pct-90pct-12m", "protective-put-90pct-12m", ] assert [item.rank for item in report.rankings] == [1, 2, 3, 4, 5] assert all(item.survived_margin_call is True for item in report.rankings) assert report.rankings[0].max_ltv_hedged < report.rankings[-1].max_ltv_hedged assert report.rankings[0].hedge_cost > report.rankings[-1].hedge_cost def test_event_comparison_ranking_prioritizes_fewer_hedged_margin_call_days() -> None: provider = SyntheticHistoricalProvider( source=FakeHistorySource(FIXTURE_HISTORY), implied_volatility=0.35, risk_free_rate=0.0, ) summary_with_one_bad_day = BacktestSummaryMetrics( start_value=100_000.0, end_value_unhedged=85_000.0, end_value_hedged_net=90_000.0, total_hedge_cost=1_500.0, total_option_payoff_realized=0.0, max_ltv_unhedged=0.80, max_ltv_hedged=0.77, margin_call_days_unhedged=3, margin_call_days_hedged=1, margin_threshold_breached_unhedged=True, margin_threshold_breached_hedged=True, ) summary_with_two_bad_days = BacktestSummaryMetrics( start_value=100_000.0, end_value_unhedged=85_000.0, end_value_hedged_net=92_000.0, total_hedge_cost=1_000.0, total_option_payoff_realized=0.0, max_ltv_unhedged=0.80, max_ltv_hedged=0.76, margin_call_days_unhedged=3, margin_call_days_hedged=2, margin_threshold_breached_unhedged=True, margin_threshold_breached_hedged=True, ) service = EventComparisonService( provider=provider, template_service=StrategyTemplateService(), event_preset_service=EventPresetService(repository=FileEventPresetRepository()), backtest_service=FakeBacktestService( results=( TemplateBacktestResult( template_slug="one-bad-day", template_id="template-1", template_version=1, template_name="One bad day", summary_metrics=summary_with_one_bad_day, daily_path=(), ), TemplateBacktestResult( template_slug="two-bad-days", template_id="template-2", template_version=1, template_name="Two bad days", summary_metrics=summary_with_two_bad_days, daily_path=(), ), ) ), ) report = service.compare_event( preset_slug="gld-jan-2024-selloff", initial_portfolio=BacktestPortfolioState( currency="USD", underlying_units=1000.0, entry_spot=130.0, loan_amount=68_000.0, margin_call_ltv=0.75, ), template_slugs=("protective-put-atm-12m", "protective-put-95pct-12m"), ) assert [item.template_slug for item in report.rankings] == ["one-bad-day", "two-bad-days"] assert [item.result.summary_metrics.margin_call_days_hedged for item in report.rankings] == [1, 2] def test_event_comparison_requires_template_selection() -> None: preset = EventPreset( event_preset_id="preset-2", slug="empty-templates", display_name="Empty Templates", symbol="GLD", window_start=date(2024, 1, 2), window_end=date(2024, 1, 8), anchor_date=None, event_type="selloff", tags=(), description="No templates configured.", scenario_overrides=EventScenarioOverrides(), created_at=datetime(2026, 3, 24, tzinfo=timezone.utc), ) provider = SyntheticHistoricalProvider( source=FakeHistorySource(FIXTURE_HISTORY), implied_volatility=0.35, risk_free_rate=0.0, ) service = EventComparisonService(provider=provider, template_service=StrategyTemplateService()) with pytest.raises(ValueError, match="at least one template slug"): service.materialize_scenario( preset, initial_portfolio=BacktestPortfolioState( currency="USD", underlying_units=1000.0, entry_spot=100.0, loan_amount=68_000.0, margin_call_ltv=0.75, ), )