from __future__ import annotations from datetime import date from decimal import Decimal import pytest from app.services.backtesting.historical_provider import SyntheticHistoricalProvider from app.services.backtesting.service import BacktestService from app.services.backtesting.ui_service import BacktestPageService from tests.helpers_backtest_sources import StaticBacktestSource def test_backtest_page_service_accepts_decimal_boundary_values() -> None: service = BacktestPageService() result = service.run_read_only_scenario( symbol="GLD", start_date=date(2024, 1, 2), end_date=date(2024, 1, 8), template_slug="protective-put-atm-12m", underlying_units=Decimal("1000.0"), loan_amount=Decimal("68000.0"), margin_call_ltv=Decimal("0.75"), ) assert result.scenario.initial_portfolio.underlying_units == 1000.0 assert result.scenario.initial_portfolio.loan_amount == 68000.0 assert result.scenario.initial_portfolio.margin_call_ltv == 0.75 def test_backtest_page_service_uses_fixture_window_for_deterministic_run() -> None: service = BacktestPageService() entry_spot = service.derive_entry_spot("GLD", date(2024, 1, 2), date(2024, 1, 8), data_source="synthetic") result = service.run_read_only_scenario( symbol="GLD", start_date=date(2024, 1, 2), end_date=date(2024, 1, 8), template_slug="protective-put-atm-12m", underlying_units=1000.0, loan_amount=68000.0, margin_call_ltv=0.75, data_source="synthetic", ) assert entry_spot == 100.0 assert result.scenario.initial_portfolio.entry_spot == 100.0 assert result.run_result.template_results[0].summary_metrics.margin_threshold_breached_unhedged is True assert result.run_result.template_results[0].summary_metrics.margin_threshold_breached_hedged is False assert [point.date.isoformat() for point in result.run_result.template_results[0].daily_path] == [ "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", "2024-01-08", ] def test_backtest_non_default_template_slug_runs_successfully() -> None: service = BacktestPageService() options = service.template_options("GLD") non_default_slug = str(options[1]["slug"]) result = service.run_read_only_scenario( symbol="GLD", start_date=date(2024, 1, 2), end_date=date(2024, 1, 8), template_slug=non_default_slug, underlying_units=1000.0, loan_amount=68000.0, margin_call_ltv=0.75, ) assert result.scenario.template_refs[0].slug == non_default_slug assert result.run_result.template_results[0].template_slug == non_default_slug def test_backtest_page_service_keeps_fixture_history_while_using_caller_portfolio_inputs() -> None: service = BacktestPageService() result = service.run_read_only_scenario( symbol="GLD", start_date=date(2024, 1, 2), end_date=date(2024, 1, 8), template_slug="protective-put-atm-12m", underlying_units=9680.0, loan_amount=222000.0, margin_call_ltv=0.80, ) assert result.entry_spot == 100.0 assert result.scenario.initial_portfolio.underlying_units == 9680.0 assert result.scenario.initial_portfolio.loan_amount == 222000.0 assert result.scenario.initial_portfolio.margin_call_ltv == 0.80 assert [point.date.isoformat() for point in result.run_result.template_results[0].daily_path] == [ "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", "2024-01-08", ] @pytest.mark.parametrize( ("kwargs", "message"), [ ( { "symbol": "", "start_date": date(2024, 1, 2), "end_date": date(2024, 1, 8), "template_slug": "protective-put-atm-12m", "underlying_units": 1000.0, "loan_amount": 68000.0, "margin_call_ltv": 0.75, }, "Symbol is required", ), ( { "symbol": "GLD", "start_date": date(2024, 1, 8), "end_date": date(2024, 1, 2), "template_slug": "protective-put-atm-12m", "underlying_units": 1000.0, "loan_amount": 68000.0, "margin_call_ltv": 0.75, }, "Start date must be on or before end date", ), ( { "symbol": "GLD", "start_date": date(2024, 1, 2), "end_date": date(2024, 1, 8), "template_slug": "protective-put-atm-12m", "underlying_units": 0.0, "loan_amount": 68000.0, "margin_call_ltv": 0.75, }, "Underlying units must be positive", ), ( { "symbol": "TLT", "start_date": date(2024, 1, 2), "end_date": date(2024, 1, 8), "template_slug": "protective-put-atm-12m", "underlying_units": 1000.0, "loan_amount": 68000.0, "margin_call_ltv": 0.75, }, "Backtests support symbols: GLD, GC, XAU", ), ( { "symbol": "GLD", "start_date": date(2024, 1, 2), "end_date": date(2024, 1, 8), "template_slug": "protective-put-atm-12m", "underlying_units": 1000.0, "loan_amount": 145000.0, "margin_call_ltv": 0.75, }, "Historical scenario starts undercollateralized", ), ], ) def test_backtest_page_service_validation_errors_are_user_facing(kwargs: dict[str, object], message: str) -> None: service = BacktestPageService() with pytest.raises(ValueError, match=message): service.run_read_only_scenario(**kwargs) def test_backtest_page_service_fails_closed_outside_seeded_fixture_window() -> None: """Test that fixture data fails for dates outside the seeded window.""" service = BacktestPageService() # Wrong symbol raises error (fixture only supports GLD) with pytest.raises(ValueError, match="deterministic fixture data only supports GLD"): service.derive_entry_spot("AAPL", date(2024, 1, 2), date(2024, 1, 8), data_source="synthetic") # Dates before window start raises error with pytest.raises(ValueError, match="deterministic fixture data only supports the seeded"): service.derive_entry_spot("GLD", date(2024, 1, 1), date(2024, 1, 5), data_source="synthetic") # Dates after window end raises error with pytest.raises(ValueError, match="deterministic fixture data only supports the seeded"): service.derive_entry_spot("GLD", date(2024, 1, 5), date(2024, 1, 10), data_source="synthetic") def test_backtest_preview_validation_requires_supported_fixture_window_even_with_supplied_entry_spot() -> None: """Test that fixture data fails for dates outside the window even with supplied entry spot.""" service = BacktestPageService() # Dates before window start raises error with pytest.raises(ValueError, match="deterministic fixture data only supports the seeded"): service.validate_preview_inputs( symbol="GLD", start_date=date(2024, 1, 1), end_date=date(2024, 1, 5), template_slug="protective-put-atm-12m", underlying_units=1000.0, loan_amount=68000.0, margin_call_ltv=0.75, entry_spot=100.0, data_source="synthetic", ) def test_backtest_preview_validation_accepts_close_supplied_entry_spot() -> None: service = BacktestPageService() entry_spot = service.validate_preview_inputs( symbol="GLD", start_date=date(2024, 1, 2), end_date=date(2024, 1, 8), template_slug="protective-put-atm-12m", underlying_units=1000.0, loan_amount=68000.0, margin_call_ltv=0.75, entry_spot=100.005, data_source="synthetic", ) assert entry_spot == 100.0 def test_backtest_preview_validation_rejects_mismatched_supplied_entry_spot() -> None: service = BacktestPageService() with pytest.raises(ValueError, match="does not match derived historical entry spot"): service.validate_preview_inputs( symbol="GLD", start_date=date(2024, 1, 2), end_date=date(2024, 1, 8), template_slug="protective-put-atm-12m", underlying_units=1000.0, loan_amount=68000.0, margin_call_ltv=0.75, entry_spot=99.0, data_source="synthetic", ) def test_backtest_page_service_does_not_mutate_injected_backtest_service() -> None: provider = SyntheticHistoricalProvider( source=StaticBacktestSource(), implied_volatility=0.2, risk_free_rate=0.01, ) injected_service = BacktestService(provider=provider) page_service = BacktestPageService(backtest_service=injected_service) history = injected_service.provider.load_history("GLD", date(2024, 1, 3), date(2024, 1, 3)) assert history[0].close == 123.0 assert page_service.template_service is injected_service.template_service assert page_service.backtest_service is not injected_service assert page_service.backtest_service.provider.implied_volatility == 0.2 assert page_service.backtest_service.provider.risk_free_rate == 0.01 seeded_history = page_service.backtest_service.provider.load_history("GLD", date(2024, 1, 2), date(2024, 1, 8)) assert seeded_history[0].close == 100.0 def test_backtest_page_service_uses_injected_provider_identity_in_provider_ref() -> None: class CustomProvider(SyntheticHistoricalProvider): provider_id = "custom_v1" pricing_mode = "custom_mode" provider = CustomProvider(source=StaticBacktestSource(), implied_volatility=0.2, risk_free_rate=0.01) injected_service = BacktestService(provider=provider) page_service = BacktestPageService(backtest_service=injected_service) result = page_service.run_read_only_scenario( symbol="GLD", start_date=date(2024, 1, 2), end_date=date(2024, 1, 8), template_slug="protective-put-atm-12m", underlying_units=1000.0, loan_amount=68000.0, margin_call_ltv=0.75, ) assert result.scenario.provider_ref.provider_id == "custom_v1" assert result.scenario.provider_ref.pricing_mode == "custom_mode" def test_backtest_page_service_preserves_injected_provider_behavior_beyond_history_loading() -> None: class CustomProvider(SyntheticHistoricalProvider): provider_id = "custom_v2" pricing_mode = "custom_mode" def price_option_by_type(self, **kwargs: object): quote = super().price_option_by_type(**kwargs) return quote.__class__( position_id=quote.position_id, leg_id=quote.leg_id, spot=quote.spot, strike=quote.strike, expiry=quote.expiry, quantity=quote.quantity, mark=42.0, ) provider = CustomProvider(source=StaticBacktestSource(), implied_volatility=0.2, risk_free_rate=0.01) injected_service = BacktestService(provider=provider) page_service = BacktestPageService(backtest_service=injected_service) result = page_service.run_read_only_scenario( symbol="GLD", start_date=date(2024, 1, 2), end_date=date(2024, 1, 8), template_slug="protective-put-atm-12m", underlying_units=1000.0, loan_amount=68000.0, margin_call_ltv=0.75, ) template_result = result.run_result.template_results[0] assert template_result.daily_path[0].option_market_value == 42000.0 assert template_result.summary_metrics.total_hedge_cost == 42000.0