feat(CORE-001C): type historical unit materialization
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
from app.domain.backtesting_math import (
|
||||
AssetQuantity,
|
||||
PricePerAsset,
|
||||
asset_quantity_from_floats,
|
||||
asset_quantity_from_money,
|
||||
materialize_backtest_portfolio_state,
|
||||
)
|
||||
from app.domain.portfolio_math import (
|
||||
build_alert_context,
|
||||
portfolio_snapshot_from_config,
|
||||
@@ -19,6 +26,11 @@ __all__ = [
|
||||
"Money",
|
||||
"Weight",
|
||||
"PricePerWeight",
|
||||
"AssetQuantity",
|
||||
"PricePerAsset",
|
||||
"asset_quantity_from_money",
|
||||
"asset_quantity_from_floats",
|
||||
"materialize_backtest_portfolio_state",
|
||||
"to_decimal",
|
||||
"decimal_from_float",
|
||||
"portfolio_snapshot_from_config",
|
||||
|
||||
125
app/domain/backtesting_math.py
Normal file
125
app/domain/backtesting_math.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domain.units import BaseCurrency, Money, _coerce_currency, decimal_from_float, to_decimal
|
||||
from app.models.backtest import BacktestPortfolioState
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AssetQuantity:
|
||||
amount: Decimal
|
||||
symbol: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "amount", to_decimal(self.amount))
|
||||
symbol = str(self.symbol).strip().upper()
|
||||
if not symbol:
|
||||
raise ValueError("Asset symbol is required")
|
||||
object.__setattr__(self, "symbol", symbol)
|
||||
|
||||
def __mul__(self, other: object) -> Money:
|
||||
if isinstance(other, PricePerAsset):
|
||||
other.assert_symbol(self.symbol)
|
||||
return Money(amount=self.amount * other.amount, currency=other.currency)
|
||||
return NotImplemented
|
||||
|
||||
def __truediv__(self, other: object) -> AssetQuantity:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, Decimal):
|
||||
return AssetQuantity(amount=self.amount / other, symbol=self.symbol)
|
||||
if isinstance(other, int):
|
||||
return AssetQuantity(amount=self.amount / Decimal(other), symbol=self.symbol)
|
||||
return NotImplemented
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PricePerAsset:
|
||||
amount: Decimal
|
||||
currency: BaseCurrency | str
|
||||
symbol: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
amount = to_decimal(self.amount)
|
||||
if amount < 0:
|
||||
raise ValueError("PricePerAsset amount must be non-negative")
|
||||
object.__setattr__(self, "amount", amount)
|
||||
object.__setattr__(self, "currency", _coerce_currency(self.currency))
|
||||
symbol = str(self.symbol).strip().upper()
|
||||
if not symbol:
|
||||
raise ValueError("Asset symbol is required")
|
||||
object.__setattr__(self, "symbol", symbol)
|
||||
|
||||
def assert_symbol(self, symbol: str) -> PricePerAsset:
|
||||
normalized = str(symbol).strip().upper()
|
||||
if self.symbol != normalized:
|
||||
raise ValueError(f"Asset symbol mismatch: {self.symbol} != {normalized}")
|
||||
return self
|
||||
|
||||
def __mul__(self, other: object) -> Money | PricePerAsset:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, AssetQuantity):
|
||||
other_symbol = str(other.symbol).strip().upper()
|
||||
self.assert_symbol(other_symbol)
|
||||
return Money(amount=self.amount * other.amount, currency=self.currency)
|
||||
if isinstance(other, Decimal):
|
||||
return PricePerAsset(amount=self.amount * other, currency=self.currency, symbol=self.symbol)
|
||||
if isinstance(other, int):
|
||||
return PricePerAsset(amount=self.amount * Decimal(other), currency=self.currency, symbol=self.symbol)
|
||||
return NotImplemented
|
||||
|
||||
def __rmul__(self, other: object) -> PricePerAsset:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, Decimal):
|
||||
return PricePerAsset(amount=self.amount * other, currency=self.currency, symbol=self.symbol)
|
||||
if isinstance(other, int):
|
||||
return PricePerAsset(amount=self.amount * Decimal(other), currency=self.currency, symbol=self.symbol)
|
||||
return NotImplemented
|
||||
|
||||
|
||||
def asset_quantity_from_money(value: Money, spot: PricePerAsset) -> AssetQuantity:
|
||||
value.assert_currency(spot.currency)
|
||||
if spot.amount <= 0:
|
||||
raise ValueError("Spot price per asset must be positive")
|
||||
return AssetQuantity(amount=value.amount / spot.amount, symbol=spot.symbol)
|
||||
|
||||
|
||||
def asset_quantity_from_floats(portfolio_value: float, entry_spot: float, symbol: str) -> float:
|
||||
notional = Money(amount=decimal_from_float(portfolio_value), currency=BaseCurrency.USD)
|
||||
spot = PricePerAsset(amount=decimal_from_float(entry_spot), currency=BaseCurrency.USD, symbol=symbol)
|
||||
return float(asset_quantity_from_money(notional, spot).amount)
|
||||
|
||||
|
||||
def materialize_backtest_portfolio_state(
|
||||
*,
|
||||
symbol: str,
|
||||
underlying_units: float,
|
||||
entry_spot: float,
|
||||
loan_amount: float,
|
||||
margin_call_ltv: float,
|
||||
currency: str = "USD",
|
||||
cash_balance: float = 0.0,
|
||||
financing_rate: float = 0.0,
|
||||
) -> BacktestPortfolioState:
|
||||
normalized_symbol = str(symbol).strip().upper()
|
||||
quantity = AssetQuantity(amount=decimal_from_float(underlying_units), symbol=normalized_symbol)
|
||||
spot = PricePerAsset(
|
||||
amount=decimal_from_float(entry_spot),
|
||||
currency=BaseCurrency.USD,
|
||||
symbol=normalized_symbol,
|
||||
)
|
||||
loan = Money(amount=decimal_from_float(loan_amount), currency=currency)
|
||||
_ = quantity * spot
|
||||
return BacktestPortfolioState(
|
||||
currency=str(loan.currency),
|
||||
underlying_units=float(quantity.amount),
|
||||
entry_spot=float(spot.amount),
|
||||
loan_amount=float(loan.amount),
|
||||
margin_call_ltv=margin_call_ltv,
|
||||
cash_balance=cash_balance,
|
||||
financing_rate=financing_rate,
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from nicegui import ui
|
||||
|
||||
from app.domain.backtesting_math import asset_quantity_from_floats
|
||||
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
||||
from app.pages.common import dashboard_page, render_workspace_recovery
|
||||
from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService
|
||||
@@ -67,7 +68,6 @@ def workspace_backtests_page(workspace_id: str) -> None:
|
||||
_render_backtests_page(workspace_id=workspace_id)
|
||||
|
||||
|
||||
|
||||
def _render_backtests_page(workspace_id: str | None = None) -> None:
|
||||
service = BacktestPageService()
|
||||
template_options = service.template_options("GLD")
|
||||
@@ -77,7 +77,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
||||
config = repo.load_portfolio_config(workspace_id) if workspace_id else None
|
||||
default_entry_spot = service.derive_entry_spot("GLD", date(2024, 1, 2), date(2024, 1, 8))
|
||||
default_units = (
|
||||
float(config.gold_value or 0.0) / default_entry_spot
|
||||
asset_quantity_from_floats(float(config.gold_value or 0.0), default_entry_spot, "GLD")
|
||||
if config and config.gold_value is not None and default_entry_spot > 0
|
||||
else 1000.0
|
||||
)
|
||||
@@ -183,10 +183,25 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
||||
columns=[
|
||||
{"name": "date", "label": "Date", "field": "date", "align": "left"},
|
||||
{"name": "spot_close", "label": "Spot", "field": "spot_close", "align": "right"},
|
||||
{"name": "net_portfolio_value", "label": "Net hedged", "field": "net_portfolio_value", "align": "right"},
|
||||
{"name": "ltv_unhedged", "label": "LTV unhedged", "field": "ltv_unhedged", "align": "right"},
|
||||
{
|
||||
"name": "net_portfolio_value",
|
||||
"label": "Net hedged",
|
||||
"field": "net_portfolio_value",
|
||||
"align": "right",
|
||||
},
|
||||
{
|
||||
"name": "ltv_unhedged",
|
||||
"label": "LTV unhedged",
|
||||
"field": "ltv_unhedged",
|
||||
"align": "right",
|
||||
},
|
||||
{"name": "ltv_hedged", "label": "LTV hedged", "field": "ltv_hedged", "align": "right"},
|
||||
{"name": "margin_call_hedged", "label": "Hedged breach", "field": "margin_call_hedged", "align": "center"},
|
||||
{
|
||||
"name": "margin_call_hedged",
|
||||
"label": "Hedged breach",
|
||||
"field": "margin_call_hedged",
|
||||
"align": "center",
|
||||
},
|
||||
],
|
||||
rows=[
|
||||
{
|
||||
@@ -202,6 +217,20 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
||||
row_key="date",
|
||||
).classes("w-full")
|
||||
|
||||
def refresh_workspace_seeded_units() -> None:
|
||||
if not workspace_id or config is None or config.gold_value is None:
|
||||
return
|
||||
try:
|
||||
entry_spot = service.derive_entry_spot(
|
||||
str(symbol_input.value or "GLD"),
|
||||
parse_iso_date(start_input.value, "Start date"),
|
||||
parse_iso_date(end_input.value, "End date"),
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
return
|
||||
units_input.value = asset_quantity_from_floats(float(config.gold_value), entry_spot, "GLD")
|
||||
entry_spot_hint.set_text(f"Auto-derived entry spot: ${entry_spot:,.2f}")
|
||||
|
||||
def run_backtest() -> None:
|
||||
validation_label.set_text("")
|
||||
try:
|
||||
@@ -224,5 +253,8 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
||||
return
|
||||
render_result(result)
|
||||
|
||||
if workspace_id:
|
||||
start_input.on_value_change(lambda _event: refresh_workspace_seeded_units())
|
||||
end_input.on_value_change(lambda _event: refresh_workspace_seeded_units())
|
||||
run_button.on_click(lambda: run_backtest())
|
||||
run_backtest()
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from nicegui import ui
|
||||
|
||||
from app.domain.backtesting_math import asset_quantity_from_floats
|
||||
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
||||
from app.pages.common import dashboard_page, render_workspace_recovery
|
||||
from app.services.event_comparison_ui import EventComparisonPageService
|
||||
@@ -64,7 +65,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
||||
)
|
||||
default_entry_spot = default_preview.initial_portfolio.entry_spot
|
||||
default_units = (
|
||||
float(config.gold_value or 0.0) / default_entry_spot
|
||||
asset_quantity_from_floats(float(config.gold_value or 0.0), default_entry_spot, "GLD")
|
||||
if config and config.gold_value is not None and default_entry_spot > 0
|
||||
else 1000.0
|
||||
)
|
||||
@@ -138,10 +139,25 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
||||
return
|
||||
template_select.value = list(service.default_template_selection(str(option["slug"])))
|
||||
try:
|
||||
preview_units = float(units_input.value or 0.0)
|
||||
if workspace_id and config is not None and config.gold_value is not None:
|
||||
preview_scenario = service.preview_scenario(
|
||||
preset_slug=str(option["slug"]),
|
||||
template_slugs=selected_template_slugs(),
|
||||
underlying_units=1.0,
|
||||
loan_amount=float(loan_input.value or 0.0),
|
||||
margin_call_ltv=float(ltv_input.value or 0.0),
|
||||
)
|
||||
preview_units = asset_quantity_from_floats(
|
||||
float(config.gold_value),
|
||||
float(preview_scenario.initial_portfolio.entry_spot),
|
||||
"GLD",
|
||||
)
|
||||
units_input.value = preview_units
|
||||
scenario = service.preview_scenario(
|
||||
preset_slug=str(option["slug"]),
|
||||
template_slugs=selected_template_slugs(),
|
||||
underlying_units=float(units_input.value or 0.0),
|
||||
underlying_units=preview_units,
|
||||
loan_amount=float(loan_input.value or 0.0),
|
||||
margin_call_ltv=float(ltv_input.value or 0.0),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from app.domain.backtesting_math import materialize_backtest_portfolio_state
|
||||
from app.models.backtest import (
|
||||
BacktestPortfolioState,
|
||||
BacktestScenario,
|
||||
@@ -93,12 +94,13 @@ class EventComparisonService:
|
||||
preset = self.event_preset_service.get_preset(preset_slug)
|
||||
history = self._load_preset_history(preset)
|
||||
entry_spot = history[0].close
|
||||
initial_portfolio = BacktestPortfolioState(
|
||||
currency=currency,
|
||||
initial_portfolio = materialize_backtest_portfolio_state(
|
||||
symbol=preset.symbol,
|
||||
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,
|
||||
)
|
||||
@@ -124,12 +126,13 @@ class EventComparisonService:
|
||||
raise ValueError("Event comparison requires at least one template slug")
|
||||
|
||||
resolved_history = history or self._load_preset_history(preset)
|
||||
scenario_portfolio = BacktestPortfolioState(
|
||||
currency=initial_portfolio.currency,
|
||||
scenario_portfolio = materialize_backtest_portfolio_state(
|
||||
symbol=preset.symbol,
|
||||
underlying_units=initial_portfolio.underlying_units,
|
||||
entry_spot=resolved_history[0].close,
|
||||
loan_amount=initial_portfolio.loan_amount,
|
||||
margin_call_ltv=initial_portfolio.margin_call_ltv,
|
||||
currency=initial_portfolio.currency,
|
||||
cash_balance=initial_portfolio.cash_balance,
|
||||
financing_rate=initial_portfolio.financing_rate,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from app.domain.backtesting_math import materialize_backtest_portfolio_state
|
||||
from app.models.backtest import (
|
||||
BacktestPortfolioState,
|
||||
BacktestRunResult,
|
||||
BacktestScenario,
|
||||
ProviderRef,
|
||||
@@ -116,6 +116,13 @@ class BacktestPageService:
|
||||
|
||||
template = self.template_service.get_template(template_slug)
|
||||
entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date)
|
||||
initial_portfolio = materialize_backtest_portfolio_state(
|
||||
symbol=normalized_symbol,
|
||||
underlying_units=underlying_units,
|
||||
entry_spot=entry_spot,
|
||||
loan_amount=loan_amount,
|
||||
margin_call_ltv=margin_call_ltv,
|
||||
)
|
||||
scenario = BacktestScenario(
|
||||
scenario_id=(
|
||||
f"{normalized_symbol.lower()}-{start_date.isoformat()}-{end_date.isoformat()}-{template.slug}"
|
||||
@@ -124,13 +131,7 @@ class BacktestPageService:
|
||||
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,
|
||||
),
|
||||
initial_portfolio=initial_portfolio,
|
||||
template_refs=(TemplateRef(slug=template.slug, version=template.version),),
|
||||
provider_ref=ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user