diff --git a/app/backtesting/__init__.py b/app/backtesting/__init__.py new file mode 100644 index 0000000..01d8bb1 --- /dev/null +++ b/app/backtesting/__init__.py @@ -0,0 +1,5 @@ +"""Backtesting subsystem for historical hedge simulation.""" + +from .engine import SyntheticBacktestEngine + +__all__ = ["SyntheticBacktestEngine"] diff --git a/app/backtesting/engine.py b/app/backtesting/engine.py new file mode 100644 index 0000000..736b90f --- /dev/null +++ b/app/backtesting/engine.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from app.models.backtest import ( + BacktestDailyPoint, + BacktestScenario, + BacktestSummaryMetrics, + TemplateBacktestResult, +) +from app.models.strategy_template import StrategyTemplate, TemplateLeg +from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider + + +@dataclass +class OpenSyntheticPosition: + position_id: str + leg: TemplateLeg + strike: float + expiry: date + quantity: float + + +class SyntheticBacktestEngine: + def __init__(self, provider: SyntheticHistoricalProvider) -> None: + self.provider = provider + + def run_template( + self, + scenario: BacktestScenario, + template: StrategyTemplate, + history: list[DailyClosePoint], + ) -> TemplateBacktestResult: + start_day = history[0] + cash_balance = scenario.initial_portfolio.cash_balance + total_hedge_cost = 0.0 + total_option_payoff_realized = 0.0 + open_positions = self._open_positions(scenario, template, history, start_day) + opening_quotes = [self._mark_position(position, start_day) for position in open_positions] + opening_cost = sum(quote.mark * quote.quantity for quote in opening_quotes) + cash_balance -= opening_cost + total_hedge_cost += opening_cost + + daily_points: list[BacktestDailyPoint] = [] + for day in history: + premium_cashflow = -opening_cost if day.date == start_day.date else 0.0 + realized_option_cashflow = 0.0 + option_market_value = 0.0 + active_position_ids: list[str] = [] + remaining_positions: list[OpenSyntheticPosition] = [] + + for position in open_positions: + if day.date >= position.expiry: + intrinsic = self.provider.intrinsic_value( + option_type=position.leg.option_type, + spot=day.close, + strike=position.strike, + ) + payoff = intrinsic * position.quantity + cash_balance += payoff + realized_option_cashflow += payoff + total_option_payoff_realized += payoff + continue + + quote = self._mark_position(position, day) + option_market_value += quote.mark * position.quantity + active_position_ids.append(position.position_id) + remaining_positions.append(position) + + open_positions = remaining_positions + underlying_value = scenario.initial_portfolio.underlying_units * day.close + net_portfolio_value = underlying_value + option_market_value + cash_balance + ltv_unhedged = scenario.initial_portfolio.loan_amount / underlying_value + ltv_hedged = scenario.initial_portfolio.loan_amount / net_portfolio_value + daily_points.append( + BacktestDailyPoint( + date=day.date, + spot_close=day.close, + underlying_value=underlying_value, + option_market_value=option_market_value, + premium_cashflow=premium_cashflow, + realized_option_cashflow=realized_option_cashflow, + net_portfolio_value=net_portfolio_value, + loan_amount=scenario.initial_portfolio.loan_amount, + ltv_unhedged=ltv_unhedged, + ltv_hedged=ltv_hedged, + margin_call_unhedged=ltv_unhedged >= scenario.initial_portfolio.margin_call_ltv, + margin_call_hedged=ltv_hedged >= scenario.initial_portfolio.margin_call_ltv, + active_position_ids=tuple(active_position_ids), + ) + ) + + summary = BacktestSummaryMetrics( + start_value=scenario.initial_portfolio.start_value, + end_value_unhedged=daily_points[-1].underlying_value, + end_value_hedged_net=daily_points[-1].net_portfolio_value, + total_hedge_cost=total_hedge_cost, + total_option_payoff_realized=total_option_payoff_realized, + max_ltv_unhedged=max(point.ltv_unhedged for point in daily_points), + max_ltv_hedged=max(point.ltv_hedged for point in daily_points), + margin_threshold_breached_unhedged=any(point.margin_call_unhedged for point in daily_points), + margin_threshold_breached_hedged=any(point.margin_call_hedged for point in daily_points), + ) + return TemplateBacktestResult( + template_slug=template.slug, + template_id=template.template_id, + template_version=template.version, + template_name=template.display_name, + summary_metrics=summary, + daily_path=tuple(daily_points), + ) + + def _open_positions( + self, + scenario: BacktestScenario, + template: StrategyTemplate, + history: list[DailyClosePoint], + start_day: DailyClosePoint, + ) -> list[OpenSyntheticPosition]: + positions: list[OpenSyntheticPosition] = [] + for index, leg in enumerate(template.legs, start=1): + expiry = self.provider.resolve_expiry(history, start_day.date, leg.target_expiry_days) + positions.append( + OpenSyntheticPosition( + position_id=f"{template.slug}-position-{index}", + leg=leg, + strike=start_day.close * leg.strike_rule.value, + expiry=expiry, + quantity=scenario.initial_portfolio.underlying_units + * leg.allocation_weight + * leg.target_coverage_pct, + ) + ) + return positions + + def _mark_position(self, position: OpenSyntheticPosition, day: DailyClosePoint): + return self.provider.price_option( + position_id=position.position_id, + leg=position.leg, + spot=day.close, + strike=position.strike, + expiry=position.expiry, + quantity=position.quantity, + valuation_date=day.date, + ) diff --git a/app/models/backtest.py b/app/models/backtest.py new file mode 100644 index 0000000..1d7a7e6 --- /dev/null +++ b/app/models/backtest.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date + + +@dataclass(frozen=True) +class BacktestPortfolioState: + currency: str + underlying_units: float + entry_spot: float + loan_amount: float + margin_call_ltv: float + cash_balance: float = 0.0 + financing_rate: float = 0.0 + + def __post_init__(self) -> None: + if self.currency.upper() != "USD": + raise ValueError("USD is the only supported currency in the MVP") + if self.underlying_units <= 0: + raise ValueError("underlying_units must be positive") + if self.entry_spot <= 0: + raise ValueError("entry_spot must be positive") + if self.loan_amount < 0: + raise ValueError("loan_amount must be non-negative") + if not 0 < self.margin_call_ltv < 1: + raise ValueError("margin_call_ltv must be between 0 and 1") + if self.loan_amount >= self.underlying_units * self.entry_spot: + raise ValueError("loan_amount must be less than initial collateral value") + + @property + def start_value(self) -> float: + return self.underlying_units * self.entry_spot + + +@dataclass(frozen=True) +class TemplateRef: + slug: str + version: int + + def __post_init__(self) -> None: + if not self.slug: + raise ValueError("template slug is required") + if self.version <= 0: + raise ValueError("template version must be positive") + + +@dataclass(frozen=True) +class ProviderRef: + provider_id: str + pricing_mode: str + + def __post_init__(self) -> None: + if not self.provider_id: + raise ValueError("provider_id is required") + if not self.pricing_mode: + raise ValueError("pricing_mode is required") + + +@dataclass(frozen=True) +class BacktestScenario: + scenario_id: str + display_name: str + symbol: str + start_date: date + end_date: date + initial_portfolio: BacktestPortfolioState + template_refs: tuple[TemplateRef, ...] + provider_ref: ProviderRef + + def __post_init__(self) -> None: + if not self.scenario_id: + raise ValueError("scenario_id is required") + if not self.display_name: + raise ValueError("display_name is required") + if not self.symbol: + raise ValueError("symbol is required") + if self.start_date > self.end_date: + raise ValueError("start_date must be on or before end_date") + if not self.template_refs: + raise ValueError("at least one template ref is required") + + +@dataclass(frozen=True) +class BacktestDailyPoint: + date: date + spot_close: float + underlying_value: float + option_market_value: float + premium_cashflow: float + realized_option_cashflow: float + net_portfolio_value: float + loan_amount: float + ltv_unhedged: float + ltv_hedged: float + margin_call_unhedged: bool + margin_call_hedged: bool + active_position_ids: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class BacktestSummaryMetrics: + start_value: float + end_value_unhedged: float + end_value_hedged_net: float + total_hedge_cost: float + total_option_payoff_realized: float + max_ltv_unhedged: float + max_ltv_hedged: float + margin_threshold_breached_unhedged: bool + margin_threshold_breached_hedged: bool + + +@dataclass(frozen=True) +class TemplateBacktestResult: + template_slug: str + template_id: str + template_version: int + template_name: str + summary_metrics: BacktestSummaryMetrics + daily_path: tuple[BacktestDailyPoint, ...] + + +@dataclass(frozen=True) +class BacktestRunResult: + scenario_id: str + template_results: tuple[TemplateBacktestResult, ...] diff --git a/app/services/backtesting/__init__.py b/app/services/backtesting/__init__.py new file mode 100644 index 0000000..b1cc2e1 --- /dev/null +++ b/app/services/backtesting/__init__.py @@ -0,0 +1,6 @@ +"""Backtesting services and historical market-data adapters.""" + +from .historical_provider import SyntheticHistoricalProvider, YFinanceHistoricalPriceSource +from .service import BacktestService + +__all__ = ["BacktestService", "SyntheticHistoricalProvider", "YFinanceHistoricalPriceSource"] diff --git a/app/services/backtesting/historical_provider.py b/app/services/backtesting/historical_provider.py new file mode 100644 index 0000000..d92b754 --- /dev/null +++ b/app/services/backtesting/historical_provider.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Protocol + +from app.models.backtest import ProviderRef + +try: + import yfinance as yf +except ImportError: # pragma: no cover - optional in tests + yf = None + +from app.core.pricing.black_scholes import BlackScholesInputs, black_scholes_price_and_greeks +from app.models.strategy_template import TemplateLeg + + +@dataclass(frozen=True) +class DailyClosePoint: + date: date + close: float + + def __post_init__(self) -> None: + if self.close <= 0: + raise ValueError("close must be positive") + + +@dataclass(frozen=True) +class SyntheticOptionQuote: + position_id: str + leg_id: str + spot: float + strike: float + expiry: date + quantity: float + mark: float + + +class HistoricalPriceSource(Protocol): + def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: + raise NotImplementedError + + +class YFinanceHistoricalPriceSource: + def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: + if yf is None: + raise RuntimeError("yfinance is required to load historical backtest prices") + ticker = yf.Ticker(symbol) + inclusive_end_date = end_date + timedelta(days=1) + history = ticker.history(start=start_date.isoformat(), end=inclusive_end_date.isoformat(), interval="1d") + rows: list[DailyClosePoint] = [] + for index, row in history.iterrows(): + close = row.get("Close") + if close is None: + continue + rows.append(DailyClosePoint(date=index.date(), close=float(close))) + return rows + + +class SyntheticHistoricalProvider: + provider_id = "synthetic_v1" + pricing_mode = "synthetic_bs_mid" + + def __init__( + self, + source: HistoricalPriceSource | None = None, + implied_volatility: float = 0.16, + risk_free_rate: float = 0.045, + ) -> None: + if implied_volatility <= 0: + raise ValueError("implied_volatility must be positive") + self.source = source or YFinanceHistoricalPriceSource() + self.implied_volatility = implied_volatility + self.risk_free_rate = risk_free_rate + + def load_history(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: + rows = self.source.load_daily_closes(symbol, start_date, end_date) + filtered = [row for row in rows if start_date <= row.date <= end_date] + return sorted(filtered, key=lambda row: row.date) + + def validate_provider_ref(self, provider_ref: ProviderRef) -> None: + if provider_ref.provider_id != self.provider_id or provider_ref.pricing_mode != self.pricing_mode: + raise ValueError( + "Unsupported provider/pricing combination for synthetic MVP engine: " + f"{provider_ref.provider_id}/{provider_ref.pricing_mode}" + ) + + def resolve_expiry(self, trading_days: list[DailyClosePoint], as_of_date: date, target_expiry_days: int) -> date: + target_date = date.fromordinal(as_of_date.toordinal() + target_expiry_days) + for day in trading_days: + if day.date >= target_date: + return day.date + return target_date + + def price_option( + self, + *, + position_id: str, + leg: TemplateLeg, + spot: float, + strike: float, + expiry: date, + quantity: float, + valuation_date: date, + ) -> SyntheticOptionQuote: + remaining_days = max(1, expiry.toordinal() - valuation_date.toordinal()) + mark = black_scholes_price_and_greeks( + BlackScholesInputs( + spot=spot, + strike=strike, + time_to_expiry=remaining_days / 365.0, + risk_free_rate=self.risk_free_rate, + volatility=self.implied_volatility, + option_type=leg.option_type, + valuation_date=valuation_date, + ) + ).price + return SyntheticOptionQuote( + position_id=position_id, + leg_id=leg.leg_id, + spot=spot, + strike=strike, + expiry=expiry, + quantity=quantity, + mark=mark, + ) + + @staticmethod + def intrinsic_value(*, option_type: str, spot: float, strike: float) -> float: + if option_type == "put": + return max(strike - spot, 0.0) + if option_type == "call": + return max(spot - strike, 0.0) + raise ValueError(f"Unsupported option type: {option_type}") diff --git a/app/services/backtesting/service.py b/app/services/backtesting/service.py new file mode 100644 index 0000000..efa0b83 --- /dev/null +++ b/app/services/backtesting/service.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from math import isclose + +from app.backtesting.engine import SyntheticBacktestEngine +from app.models.backtest import BacktestRunResult, BacktestScenario +from app.models.strategy_template import StrategyTemplate +from app.services.backtesting.historical_provider import SyntheticHistoricalProvider +from app.services.strategy_templates import StrategyTemplateService + + +class BacktestService: + ENTRY_SPOT_ABS_TOLERANCE = 0.01 + ENTRY_SPOT_REL_TOLERANCE = 1e-6 + + def __init__( + self, + provider: SyntheticHistoricalProvider | None = None, + template_service: StrategyTemplateService | None = None, + ) -> None: + self.provider = provider or SyntheticHistoricalProvider() + self.template_service = template_service or StrategyTemplateService() + self.engine = SyntheticBacktestEngine(self.provider) + + def run_scenario(self, scenario: BacktestScenario) -> BacktestRunResult: + self.provider.validate_provider_ref(scenario.provider_ref) + scenario_symbol = scenario.symbol.strip().upper() + history = self.provider.load_history(scenario_symbol, scenario.start_date, scenario.end_date) + if not history: + raise ValueError("No historical prices found for scenario window") + if history[0].date != scenario.start_date: + raise ValueError( + "Scenario start_date must match the first available historical price point for " + "entry_timing='scenario_start_close'" + ) + if not isclose( + scenario.initial_portfolio.entry_spot, + history[0].close, + rel_tol=self.ENTRY_SPOT_REL_TOLERANCE, + abs_tol=self.ENTRY_SPOT_ABS_TOLERANCE, + ): + raise ValueError( + "initial_portfolio.entry_spot must match the first historical close used for entry " + "when entry_timing='scenario_start_close'" + ) + + template_results = [] + for template_ref in scenario.template_refs: + template = self.template_service.get_template(template_ref.slug) + if template.version != template_ref.version: + raise ValueError( + f"Template version mismatch for {template_ref.slug}: expected {template_ref.version}, got {template.version}" + ) + template_symbol = template.underlying_symbol.strip().upper() + if template_symbol not in {scenario_symbol, "*"}: + raise ValueError(f"Template {template.slug} does not support symbol {scenario_symbol}") + self._validate_template_for_mvp(template) + template_results.append(self.engine.run_template(scenario, template, history)) + + return BacktestRunResult(scenario_id=scenario.scenario_id, template_results=tuple(template_results)) + + @staticmethod + def _validate_template_for_mvp(template: StrategyTemplate) -> None: + if template.contract_mode != "continuous_units": + raise ValueError(f"Unsupported contract_mode for synthetic MVP engine: {template.contract_mode}") + if template.roll_policy.policy_type != "hold_to_expiry": + raise ValueError("Unsupported roll_policy for synthetic MVP engine: " f"{template.roll_policy.policy_type}") + if template.entry_policy.entry_timing != "scenario_start_close": + raise ValueError( + "Unsupported entry_timing for synthetic MVP engine: " f"{template.entry_policy.entry_timing}" + ) + if template.entry_policy.stagger_days is not None: + raise ValueError("Unsupported entry_policy configuration for synthetic MVP engine") diff --git a/tests/test_backtesting.py b/tests/test_backtesting.py new file mode 100644 index 0000000..930a45f --- /dev/null +++ b/tests/test_backtesting.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from datetime import date + +import pandas as pd +import pytest + +from app.models.backtest import BacktestPortfolioState, BacktestScenario, ProviderRef, TemplateRef +from app.models.strategy_template import EntryPolicy, RollPolicy, StrategyTemplate +from app.services.backtesting.historical_provider import ( + DailyClosePoint, + SyntheticHistoricalProvider, + YFinanceHistoricalPriceSource, +) +from app.services.backtesting.service import BacktestService +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) + + +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_synthetic_historical_provider_sorts_and_filters_daily_closes() -> None: + source = FakeHistorySource(FIXTURE_HISTORY) + provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.01) + + series = provider.load_history(symbol="GLD", start_date=date(2024, 1, 3), end_date=date(2024, 1, 5)) + + assert [(point.date.isoformat(), point.close) for point in series] == [ + ("2024-01-03", 96.0), + ("2024-01-04", 92.0), + ("2024-01-05", 88.0), + ] + assert source.calls == [("GLD", date(2024, 1, 3), date(2024, 1, 5))] + + +def _build_scenario( + *, + provider_ref: ProviderRef | None = None, + symbol: str = "GLD", + entry_spot: float = 100.0, +) -> BacktestScenario: + return BacktestScenario( + scenario_id="gld-selloff-1", + display_name="GLD selloff", + symbol=symbol, + start_date=date(2024, 1, 2), + end_date=date(2024, 1, 8), + initial_portfolio=BacktestPortfolioState( + currency="USD", + underlying_units=1000.0, + entry_spot=entry_spot, + loan_amount=68_000.0, + margin_call_ltv=0.75, + ), + template_refs=(TemplateRef(slug="protective-put-atm-12m", version=1),), + provider_ref=provider_ref or ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"), + ) + + +def test_backtest_service_runs_template_backtest_with_daily_points() -> None: + source = FakeHistorySource(FIXTURE_HISTORY) + provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0) + service = BacktestService(provider=provider, template_service=StrategyTemplateService()) + + result = service.run_scenario(_build_scenario()) + + assert result.scenario_id == "gld-selloff-1" + assert len(result.template_results) == 1 + + template_result = result.template_results[0] + summary = template_result.summary_metrics + + assert summary.start_value == 100_000.0 + assert summary.end_value_unhedged == 85_000.0 + assert summary.end_value_hedged_net > summary.end_value_unhedged + assert summary.total_hedge_cost > 0.0 + assert summary.max_ltv_hedged < summary.max_ltv_unhedged + assert summary.margin_threshold_breached_unhedged is True + assert summary.margin_threshold_breached_hedged is False + + assert [point.date.isoformat() for point in template_result.daily_path] == [ + "2024-01-02", + "2024-01-03", + "2024-01-04", + "2024-01-05", + "2024-01-08", + ] + assert template_result.daily_path[0].premium_cashflow < 0.0 + assert template_result.daily_path[-1].margin_call_unhedged is True + assert template_result.daily_path[-1].margin_call_hedged is False + + +def test_backtest_keeps_long_dated_option_open_past_scenario_end() -> None: + source = FakeHistorySource(FIXTURE_HISTORY) + provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0) + service = BacktestService(provider=provider, template_service=StrategyTemplateService()) + + result = service.run_scenario(_build_scenario()) + + template_result = result.template_results[0] + final_day = template_result.daily_path[-1] + + assert final_day.date == date(2024, 1, 8) + assert final_day.realized_option_cashflow == 0.0 + assert final_day.option_market_value > 0.0 + assert final_day.active_position_ids == ("protective-put-atm-12m-position-1",) + assert template_result.summary_metrics.total_option_payoff_realized == 0.0 + + +def test_backtest_rejects_unsupported_provider_pricing_combination() -> None: + source = FakeHistorySource(FIXTURE_HISTORY) + provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0) + service = BacktestService(provider=provider, template_service=StrategyTemplateService()) + scenario = _build_scenario(provider_ref=ProviderRef(provider_id="daily_snapshots_v1", pricing_mode="snapshot_mid")) + + with pytest.raises(ValueError, match="Unsupported provider/pricing combination"): + service.run_scenario(scenario) + + +def test_backtest_rejects_scenario_start_close_when_history_starts_after_scenario_start() -> None: + delayed_history = [row for row in FIXTURE_HISTORY if row.date >= date(2024, 1, 3)] + source = FakeHistorySource(delayed_history) + provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0) + service = BacktestService(provider=provider, template_service=StrategyTemplateService()) + + with pytest.raises(ValueError, match="Scenario start_date must match the first available historical price point"): + service.run_scenario(_build_scenario()) + + +def test_backtest_rejects_mismatched_entry_spot_for_scenario_start_close() -> None: + source = FakeHistorySource(FIXTURE_HISTORY) + provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0) + service = BacktestService(provider=provider, template_service=StrategyTemplateService()) + + with pytest.raises( + ValueError, + match="initial_portfolio.entry_spot must match the first historical close used for entry", + ): + service.run_scenario(_build_scenario(entry_spot=100.02)) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("contract_mode", "listed_contracts", "Unsupported contract_mode"), + ( + "roll_policy", + RollPolicy(policy_type="roll_n_days_before_expiry", days_before_expiry=5), + "Unsupported roll_policy", + ), + ("entry_policy", EntryPolicy(entry_timing="scenario_start_close", stagger_days=1), None), + ], +) +def test_backtest_rejects_unsupported_template_behaviors(field: str, value: object, message: str | None) -> None: + source = FakeHistorySource(FIXTURE_HISTORY) + provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0) + template_service = StrategyTemplateService() + service = BacktestService(provider=provider, template_service=template_service) + template = template_service.get_template("protective-put-atm-12m") + + template_kwargs = { + "template_id": template.template_id, + "slug": template.slug, + "display_name": template.display_name, + "description": template.description, + "template_kind": template.template_kind, + "status": template.status, + "version": template.version, + "underlying_symbol": template.underlying_symbol, + "contract_mode": template.contract_mode, + "legs": template.legs, + "roll_policy": template.roll_policy, + "entry_policy": template.entry_policy, + "tags": template.tags, + "created_at": template.created_at, + "updated_at": template.updated_at, + } + template_kwargs[field] = value + unsupported_template = StrategyTemplate(**template_kwargs) + + if field == "entry_policy": + unsupported_template = StrategyTemplate( + **{ + **template_kwargs, + "entry_policy": EntryPolicy(entry_timing="scenario_start_close", stagger_days=1), + } + ) + with pytest.raises(ValueError, match="Unsupported entry_policy configuration"): + service._validate_template_for_mvp(unsupported_template) + return + + with pytest.raises(ValueError, match=message or "Unsupported"): + service._validate_template_for_mvp(unsupported_template) + + +def test_yfinance_price_source_treats_end_date_inclusively(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str, str, str]] = [] + + class FakeTicker: + def __init__(self, symbol: str) -> None: + self.symbol = symbol + + def history(self, start: str, end: str, interval: str): + calls.append((start, end, interval)) + return pd.DataFrame( + {"Close": [100.0, 101.0]}, + index=pd.to_datetime(["2024-01-04", "2024-01-05"]), + ) + + class FakeYFinance: + Ticker = FakeTicker + + monkeypatch.setattr("app.services.backtesting.historical_provider.yf", FakeYFinance()) + + source = YFinanceHistoricalPriceSource() + rows = source.load_daily_closes(symbol="GLD", start_date=date(2024, 1, 4), end_date=date(2024, 1, 5)) + + assert calls == [("2024-01-04", "2024-01-06", "1d")] + assert [(row.date.isoformat(), row.close) for row in rows] == [ + ("2024-01-04", 100.0), + ("2024-01-05", 101.0), + ]