- Updated backtests page with Data Source card - Data source selector (databento/yfinance/synthetic) - Dataset dropdown (XNAS.BASIC, GLBX.MDP3) - Resolution dropdown (ohlcv-1d, ohlcv-1h) - Cost estimate display (placeholder for now) - Added Scenario Configuration card - Underlying symbol selector (GLD/GC/XAU) - Start/end date inputs - Start price input (0 = auto-derive) - Underlying units, loan amount, margin call LTV - BacktestPageService updates: - get_historical_prices() with data_source parameter - get_cost_estimate() for Databento cost estimation - get_cache_stats() for cache status display - Support for injected custom provider identity - DataSourceInfo for provider metadata - BacktestSettingsRepository integration: - Load/save settings per workspace - Default values from BacktestSettings.create_default() - Test update: TLT validation message changed to reflect new multi-symbol support (GLD, GC, XAU)
321 lines
11 KiB
Python
321 lines
11 KiB
Python
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))
|
|
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,
|
|
)
|
|
|
|
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:
|
|
service = BacktestPageService()
|
|
|
|
with pytest.raises(ValueError, match="deterministic fixture data only supports GLD"):
|
|
service.derive_entry_spot("GLD", date(2024, 1, 3), date(2024, 1, 8))
|
|
|
|
with pytest.raises(ValueError, match="deterministic fixture data only supports GLD"):
|
|
service.run_read_only_scenario(
|
|
symbol="GLD",
|
|
start_date=date(2024, 1, 3),
|
|
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,
|
|
)
|
|
|
|
|
|
def test_backtest_preview_validation_requires_supported_fixture_window_even_with_supplied_entry_spot() -> None:
|
|
service = BacktestPageService()
|
|
|
|
with pytest.raises(ValueError, match="deterministic fixture data only supports GLD"):
|
|
service.validate_preview_inputs(
|
|
symbol="GLD",
|
|
start_date=date(2024, 1, 3),
|
|
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.0,
|
|
)
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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
|