285 lines
10 KiB
Python
285 lines
10 KiB
Python
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,
|
|
),
|
|
)
|