feat(BT-001A): add backtest scenario runner page
This commit is contained in:
141
app/services/backtesting/ui_service.py
Normal file
141
app/services/backtesting/ui_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from app.models.backtest import (
|
||||
BacktestPortfolioState,
|
||||
BacktestRunResult,
|
||||
BacktestScenario,
|
||||
ProviderRef,
|
||||
TemplateRef,
|
||||
)
|
||||
from app.services.backtesting.historical_provider import (
|
||||
DailyClosePoint,
|
||||
HistoricalPriceSource,
|
||||
YFinanceHistoricalPriceSource,
|
||||
)
|
||||
from app.services.backtesting.service import BacktestService
|
||||
from app.services.strategy_templates import StrategyTemplateService
|
||||
|
||||
SUPPORTED_BACKTEST_PAGE_SYMBOL = "GLD"
|
||||
|
||||
DETERMINISTIC_UI_FIXTURE_HISTORY: tuple[DailyClosePoint, ...] = (
|
||||
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),
|
||||
DailyClosePoint(date=date(2024, 1, 8), close=85.0),
|
||||
)
|
||||
|
||||
|
||||
class FixtureFallbackHistoricalPriceSource:
|
||||
def __init__(self, fallback: HistoricalPriceSource | None = None) -> None:
|
||||
self.fallback = fallback or YFinanceHistoricalPriceSource()
|
||||
|
||||
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||
normalized_symbol = symbol.strip().upper()
|
||||
if (
|
||||
normalized_symbol == SUPPORTED_BACKTEST_PAGE_SYMBOL
|
||||
and start_date == date(2024, 1, 2)
|
||||
and end_date == date(2024, 1, 8)
|
||||
):
|
||||
return list(DETERMINISTIC_UI_FIXTURE_HISTORY)
|
||||
return self.fallback.load_daily_closes(normalized_symbol, start_date, end_date)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BacktestPageRunResult:
|
||||
scenario: BacktestScenario
|
||||
run_result: BacktestRunResult
|
||||
entry_spot: float
|
||||
|
||||
|
||||
class BacktestPageService:
|
||||
def __init__(
|
||||
self,
|
||||
backtest_service: BacktestService | None = None,
|
||||
template_service: StrategyTemplateService | None = None,
|
||||
) -> None:
|
||||
self.template_service = template_service or StrategyTemplateService()
|
||||
self.backtest_service = backtest_service or BacktestService(
|
||||
template_service=self.template_service,
|
||||
provider=None,
|
||||
)
|
||||
if backtest_service is None:
|
||||
provider = self.backtest_service.provider
|
||||
provider.source = FixtureFallbackHistoricalPriceSource(provider.source)
|
||||
|
||||
def template_options(self, symbol: str = "GLD") -> list[dict[str, str | int]]:
|
||||
return [
|
||||
{
|
||||
"label": template.display_name,
|
||||
"slug": template.slug,
|
||||
"version": template.version,
|
||||
"description": template.description,
|
||||
}
|
||||
for template in self.template_service.list_active_templates(symbol)
|
||||
]
|
||||
|
||||
def derive_entry_spot(self, symbol: str, start_date: date, end_date: date) -> float:
|
||||
history = self.backtest_service.provider.load_history(symbol.strip().upper(), start_date, end_date)
|
||||
if not history:
|
||||
raise ValueError("No historical prices found for scenario window")
|
||||
if history[0].date != start_date:
|
||||
raise ValueError(
|
||||
"Scenario start date must match the first available historical close for entry-at-start backtests"
|
||||
)
|
||||
return history[0].close
|
||||
|
||||
def run_read_only_scenario(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
template_slug: str,
|
||||
underlying_units: float,
|
||||
loan_amount: float,
|
||||
margin_call_ltv: float,
|
||||
) -> BacktestPageRunResult:
|
||||
normalized_symbol = symbol.strip().upper()
|
||||
if not normalized_symbol:
|
||||
raise ValueError("Symbol is required")
|
||||
if normalized_symbol != SUPPORTED_BACKTEST_PAGE_SYMBOL:
|
||||
raise ValueError("BT-001A backtests are currently limited to GLD on this page")
|
||||
if start_date > end_date:
|
||||
raise ValueError("Start date must be on or before end date")
|
||||
if underlying_units <= 0:
|
||||
raise ValueError("Underlying units must be positive")
|
||||
if loan_amount < 0:
|
||||
raise ValueError("Loan amount must be non-negative")
|
||||
if not 0 < margin_call_ltv < 1:
|
||||
raise ValueError("Margin call LTV must be between 0 and 1")
|
||||
if not template_slug:
|
||||
raise ValueError("Template selection is required")
|
||||
|
||||
template = self.template_service.get_template(template_slug)
|
||||
entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date)
|
||||
scenario = BacktestScenario(
|
||||
scenario_id=(
|
||||
f"{normalized_symbol.lower()}-{start_date.isoformat()}-{end_date.isoformat()}-{template.slug}"
|
||||
),
|
||||
display_name=f"{normalized_symbol} backtest {start_date.isoformat()} → {end_date.isoformat()}",
|
||||
symbol=normalized_symbol,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
initial_portfolio=BacktestPortfolioState(
|
||||
currency="USD",
|
||||
underlying_units=underlying_units,
|
||||
entry_spot=entry_spot,
|
||||
loan_amount=loan_amount,
|
||||
margin_call_ltv=margin_call_ltv,
|
||||
),
|
||||
template_refs=(TemplateRef(slug=template.slug, version=template.version),),
|
||||
provider_ref=ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"),
|
||||
)
|
||||
return BacktestPageRunResult(
|
||||
scenario=scenario,
|
||||
run_result=self.backtest_service.run_scenario(scenario),
|
||||
entry_spot=entry_spot,
|
||||
)
|
||||
Reference in New Issue
Block a user