feat(CORE-001D): close remaining boundary cleanup slices
This commit is contained in:
@@ -13,6 +13,7 @@ from app.models.backtest import (
|
||||
)
|
||||
from app.models.event_preset import EventPreset
|
||||
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
|
||||
from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs
|
||||
from app.services.backtesting.service import BacktestService
|
||||
from app.services.event_presets import EventPresetService
|
||||
from app.services.strategy_templates import StrategyTemplateService
|
||||
@@ -64,16 +65,24 @@ class EventComparisonService:
|
||||
financing_rate: float = 0.0,
|
||||
provider_ref: ProviderRef | None = None,
|
||||
) -> EventComparisonReport:
|
||||
preset = self.event_preset_service.get_preset(preset_slug)
|
||||
scenario = self.preview_scenario_from_inputs(
|
||||
preset_slug=preset_slug,
|
||||
normalized_inputs = normalize_historical_scenario_inputs(
|
||||
underlying_units=underlying_units,
|
||||
loan_amount=loan_amount,
|
||||
margin_call_ltv=margin_call_ltv,
|
||||
template_slugs=template_slugs,
|
||||
currency=currency,
|
||||
cash_balance=cash_balance,
|
||||
financing_rate=financing_rate,
|
||||
)
|
||||
preset = self.event_preset_service.get_preset(preset_slug)
|
||||
scenario = self.preview_scenario_from_inputs(
|
||||
preset_slug=preset_slug,
|
||||
underlying_units=normalized_inputs.underlying_units,
|
||||
loan_amount=normalized_inputs.loan_amount,
|
||||
margin_call_ltv=normalized_inputs.margin_call_ltv,
|
||||
template_slugs=template_slugs,
|
||||
currency=normalized_inputs.currency,
|
||||
cash_balance=normalized_inputs.cash_balance,
|
||||
financing_rate=normalized_inputs.financing_rate,
|
||||
provider_ref=provider_ref,
|
||||
)
|
||||
return self._compare_materialized_event(preset=preset, scenario=scenario)
|
||||
@@ -91,19 +100,27 @@ class EventComparisonService:
|
||||
financing_rate: float = 0.0,
|
||||
provider_ref: ProviderRef | None = None,
|
||||
) -> BacktestScenario:
|
||||
preset = self.event_preset_service.get_preset(preset_slug)
|
||||
history = self._load_preset_history(preset)
|
||||
entry_spot = history[0].close
|
||||
initial_portfolio = materialize_backtest_portfolio_state(
|
||||
symbol=preset.symbol,
|
||||
normalized_inputs = normalize_historical_scenario_inputs(
|
||||
underlying_units=underlying_units,
|
||||
entry_spot=entry_spot,
|
||||
loan_amount=loan_amount,
|
||||
margin_call_ltv=margin_call_ltv,
|
||||
currency=currency,
|
||||
cash_balance=cash_balance,
|
||||
financing_rate=financing_rate,
|
||||
)
|
||||
preset = self.event_preset_service.get_preset(preset_slug)
|
||||
history = self._load_preset_history(preset)
|
||||
entry_spot = history[0].close
|
||||
initial_portfolio = materialize_backtest_portfolio_state(
|
||||
symbol=preset.symbol,
|
||||
underlying_units=normalized_inputs.underlying_units,
|
||||
entry_spot=entry_spot,
|
||||
loan_amount=normalized_inputs.loan_amount,
|
||||
margin_call_ltv=normalized_inputs.margin_call_ltv,
|
||||
currency=normalized_inputs.currency,
|
||||
cash_balance=normalized_inputs.cash_balance,
|
||||
financing_rate=normalized_inputs.financing_rate,
|
||||
)
|
||||
return self.materialize_scenario(
|
||||
preset,
|
||||
initial_portfolio=initial_portfolio,
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from math import isfinite
|
||||
from typing import Protocol
|
||||
|
||||
from app.models.backtest import ProviderRef
|
||||
@@ -35,6 +36,24 @@ class SyntheticOptionQuote:
|
||||
quantity: float
|
||||
mark: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("position_id", "leg_id"):
|
||||
value = getattr(self, field_name)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field_name} is required")
|
||||
for field_name in ("spot", "strike", "quantity", "mark"):
|
||||
value = getattr(self, field_name)
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool) or not isfinite(float(value)):
|
||||
raise TypeError(f"{field_name} must be a finite number")
|
||||
if self.spot <= 0:
|
||||
raise ValueError("spot must be positive")
|
||||
if self.strike <= 0:
|
||||
raise ValueError("strike must be positive")
|
||||
if self.quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
if self.mark < 0:
|
||||
raise ValueError("mark must be non-negative")
|
||||
|
||||
|
||||
class HistoricalPriceSource(Protocol):
|
||||
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||
@@ -42,6 +61,17 @@ class HistoricalPriceSource(Protocol):
|
||||
|
||||
|
||||
class YFinanceHistoricalPriceSource:
|
||||
@staticmethod
|
||||
def _normalize_daily_close_row(*, row_date: object, close: object) -> DailyClosePoint | None:
|
||||
if close is None:
|
||||
return None
|
||||
if not hasattr(row_date, "date"):
|
||||
raise TypeError(f"historical row date must support .date(), got {type(row_date)!r}")
|
||||
normalized_close = float(close)
|
||||
if not isfinite(normalized_close):
|
||||
raise ValueError("historical close must be finite")
|
||||
return DailyClosePoint(date=row_date.date(), close=normalized_close)
|
||||
|
||||
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")
|
||||
@@ -50,10 +80,9 @@ class YFinanceHistoricalPriceSource:
|
||||
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)))
|
||||
point = self._normalize_daily_close_row(row_date=index, close=row.get("Close"))
|
||||
if point is not None:
|
||||
rows.append(point)
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
51
app/services/backtesting/input_normalization.py
Normal file
51
app/services/backtesting/input_normalization.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.services.boundary_values import boundary_decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NormalizedHistoricalScenarioInputs:
|
||||
underlying_units: float
|
||||
loan_amount: float
|
||||
margin_call_ltv: float
|
||||
currency: str = "USD"
|
||||
cash_balance: float = 0.0
|
||||
financing_rate: float = 0.0
|
||||
|
||||
|
||||
def normalize_historical_scenario_inputs(
|
||||
*,
|
||||
underlying_units: object,
|
||||
loan_amount: object,
|
||||
margin_call_ltv: object,
|
||||
currency: object = "USD",
|
||||
cash_balance: object = 0.0,
|
||||
financing_rate: object = 0.0,
|
||||
) -> NormalizedHistoricalScenarioInputs:
|
||||
normalized_currency = str(currency).strip().upper()
|
||||
if not normalized_currency:
|
||||
raise ValueError("Currency is required")
|
||||
|
||||
units = float(boundary_decimal(underlying_units, field_name="underlying_units"))
|
||||
normalized_loan_amount = float(boundary_decimal(loan_amount, field_name="loan_amount"))
|
||||
ltv = float(boundary_decimal(margin_call_ltv, field_name="margin_call_ltv"))
|
||||
normalized_cash_balance = float(boundary_decimal(cash_balance, field_name="cash_balance"))
|
||||
normalized_financing_rate = float(boundary_decimal(financing_rate, field_name="financing_rate"))
|
||||
|
||||
if units <= 0:
|
||||
raise ValueError("Underlying units must be positive")
|
||||
if normalized_loan_amount < 0:
|
||||
raise ValueError("Loan amount must be non-negative")
|
||||
if not 0 < ltv < 1:
|
||||
raise ValueError("Margin call LTV must be between 0 and 1")
|
||||
|
||||
return NormalizedHistoricalScenarioInputs(
|
||||
underlying_units=units,
|
||||
loan_amount=normalized_loan_amount,
|
||||
margin_call_ltv=ltv,
|
||||
currency=normalized_currency,
|
||||
cash_balance=normalized_cash_balance,
|
||||
financing_rate=normalized_financing_rate,
|
||||
)
|
||||
@@ -18,6 +18,7 @@ from app.services.backtesting.historical_provider import (
|
||||
SyntheticHistoricalProvider,
|
||||
SyntheticOptionQuote,
|
||||
)
|
||||
from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs
|
||||
from app.services.backtesting.service import BacktestService
|
||||
from app.services.strategy_templates import StrategyTemplateService
|
||||
|
||||
@@ -149,12 +150,11 @@ class BacktestPageService:
|
||||
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")
|
||||
normalized_inputs = normalize_historical_scenario_inputs(
|
||||
underlying_units=underlying_units,
|
||||
loan_amount=loan_amount,
|
||||
margin_call_ltv=margin_call_ltv,
|
||||
)
|
||||
if not template_slug:
|
||||
raise ValueError("Template selection is required")
|
||||
|
||||
@@ -169,7 +169,11 @@ class BacktestPageService:
|
||||
raise ValueError(
|
||||
f"Supplied entry spot ${entry_spot:,.2f} does not match derived historical entry spot ${derived_entry_spot:,.2f}"
|
||||
)
|
||||
_validate_initial_collateral(underlying_units, derived_entry_spot, loan_amount)
|
||||
_validate_initial_collateral(
|
||||
normalized_inputs.underlying_units,
|
||||
derived_entry_spot,
|
||||
normalized_inputs.loan_amount,
|
||||
)
|
||||
return derived_entry_spot
|
||||
|
||||
def run_read_only_scenario(
|
||||
@@ -193,13 +197,18 @@ class BacktestPageService:
|
||||
loan_amount=loan_amount,
|
||||
margin_call_ltv=margin_call_ltv,
|
||||
)
|
||||
normalized_inputs = normalize_historical_scenario_inputs(
|
||||
underlying_units=underlying_units,
|
||||
loan_amount=loan_amount,
|
||||
margin_call_ltv=margin_call_ltv,
|
||||
)
|
||||
template = self.template_service.get_template(template_slug)
|
||||
initial_portfolio = materialize_backtest_portfolio_state(
|
||||
symbol=normalized_symbol,
|
||||
underlying_units=underlying_units,
|
||||
underlying_units=normalized_inputs.underlying_units,
|
||||
entry_spot=entry_spot,
|
||||
loan_amount=loan_amount,
|
||||
margin_call_ltv=margin_call_ltv,
|
||||
loan_amount=normalized_inputs.loan_amount,
|
||||
margin_call_ltv=normalized_inputs.margin_call_ltv,
|
||||
)
|
||||
scenario = BacktestScenario(
|
||||
scenario_id=(
|
||||
|
||||
Reference in New Issue
Block a user