feat(CORE-001C): type historical unit materialization

This commit is contained in:
Bu5hm4nn
2026-03-24 22:30:36 +01:00
parent 7c2729485c
commit c7c8654be7
8 changed files with 309 additions and 19 deletions

View File

@@ -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 ( from app.domain.portfolio_math import (
build_alert_context, build_alert_context,
portfolio_snapshot_from_config, portfolio_snapshot_from_config,
@@ -19,6 +26,11 @@ __all__ = [
"Money", "Money",
"Weight", "Weight",
"PricePerWeight", "PricePerWeight",
"AssetQuantity",
"PricePerAsset",
"asset_quantity_from_money",
"asset_quantity_from_floats",
"materialize_backtest_portfolio_state",
"to_decimal", "to_decimal",
"decimal_from_float", "decimal_from_float",
"portfolio_snapshot_from_config", "portfolio_snapshot_from_config",

View 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,
)

View File

@@ -6,6 +6,7 @@ from fastapi import Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from nicegui import ui 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.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.pages.common import dashboard_page, render_workspace_recovery from app.pages.common import dashboard_page, render_workspace_recovery
from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService 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) _render_backtests_page(workspace_id=workspace_id)
def _render_backtests_page(workspace_id: str | None = None) -> None: def _render_backtests_page(workspace_id: str | None = None) -> None:
service = BacktestPageService() service = BacktestPageService()
template_options = service.template_options("GLD") 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 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_entry_spot = service.derive_entry_spot("GLD", date(2024, 1, 2), date(2024, 1, 8))
default_units = ( 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 if config and config.gold_value is not None and default_entry_spot > 0
else 1000.0 else 1000.0
) )
@@ -183,10 +183,25 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
columns=[ columns=[
{"name": "date", "label": "Date", "field": "date", "align": "left"}, {"name": "date", "label": "Date", "field": "date", "align": "left"},
{"name": "spot_close", "label": "Spot", "field": "spot_close", "align": "right"}, {"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": "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=[ rows=[
{ {
@@ -202,6 +217,20 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
row_key="date", row_key="date",
).classes("w-full") ).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: def run_backtest() -> None:
validation_label.set_text("") validation_label.set_text("")
try: try:
@@ -224,5 +253,8 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
return return
render_result(result) 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_button.on_click(lambda: run_backtest())
run_backtest() run_backtest()

View File

@@ -4,6 +4,7 @@ from fastapi import Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from nicegui import ui 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.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.pages.common import dashboard_page, render_workspace_recovery from app.pages.common import dashboard_page, render_workspace_recovery
from app.services.event_comparison_ui import EventComparisonPageService 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_entry_spot = default_preview.initial_portfolio.entry_spot
default_units = ( 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 if config and config.gold_value is not None and default_entry_spot > 0
else 1000.0 else 1000.0
) )
@@ -138,10 +139,25 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
return return
template_select.value = list(service.default_template_selection(str(option["slug"]))) template_select.value = list(service.default_template_selection(str(option["slug"])))
try: 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( scenario = service.preview_scenario(
preset_slug=str(option["slug"]), preset_slug=str(option["slug"]),
template_slugs=selected_template_slugs(), 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), loan_amount=float(loan_input.value or 0.0),
margin_call_ltv=float(ltv_input.value or 0.0), margin_call_ltv=float(ltv_input.value or 0.0),
) )

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from app.domain.backtesting_math import materialize_backtest_portfolio_state
from app.models.backtest import ( from app.models.backtest import (
BacktestPortfolioState, BacktestPortfolioState,
BacktestScenario, BacktestScenario,
@@ -93,12 +94,13 @@ class EventComparisonService:
preset = self.event_preset_service.get_preset(preset_slug) preset = self.event_preset_service.get_preset(preset_slug)
history = self._load_preset_history(preset) history = self._load_preset_history(preset)
entry_spot = history[0].close entry_spot = history[0].close
initial_portfolio = BacktestPortfolioState( initial_portfolio = materialize_backtest_portfolio_state(
currency=currency, symbol=preset.symbol,
underlying_units=underlying_units, underlying_units=underlying_units,
entry_spot=entry_spot, entry_spot=entry_spot,
loan_amount=loan_amount, loan_amount=loan_amount,
margin_call_ltv=margin_call_ltv, margin_call_ltv=margin_call_ltv,
currency=currency,
cash_balance=cash_balance, cash_balance=cash_balance,
financing_rate=financing_rate, financing_rate=financing_rate,
) )
@@ -124,12 +126,13 @@ class EventComparisonService:
raise ValueError("Event comparison requires at least one template slug") raise ValueError("Event comparison requires at least one template slug")
resolved_history = history or self._load_preset_history(preset) resolved_history = history or self._load_preset_history(preset)
scenario_portfolio = BacktestPortfolioState( scenario_portfolio = materialize_backtest_portfolio_state(
currency=initial_portfolio.currency, symbol=preset.symbol,
underlying_units=initial_portfolio.underlying_units, underlying_units=initial_portfolio.underlying_units,
entry_spot=resolved_history[0].close, entry_spot=resolved_history[0].close,
loan_amount=initial_portfolio.loan_amount, loan_amount=initial_portfolio.loan_amount,
margin_call_ltv=initial_portfolio.margin_call_ltv, margin_call_ltv=initial_portfolio.margin_call_ltv,
currency=initial_portfolio.currency,
cash_balance=initial_portfolio.cash_balance, cash_balance=initial_portfolio.cash_balance,
financing_rate=initial_portfolio.financing_rate, financing_rate=initial_portfolio.financing_rate,
) )

View File

@@ -3,8 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from app.domain.backtesting_math import materialize_backtest_portfolio_state
from app.models.backtest import ( from app.models.backtest import (
BacktestPortfolioState,
BacktestRunResult, BacktestRunResult,
BacktestScenario, BacktestScenario,
ProviderRef, ProviderRef,
@@ -116,6 +116,13 @@ class BacktestPageService:
template = self.template_service.get_template(template_slug) template = self.template_service.get_template(template_slug)
entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date) 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 = BacktestScenario(
scenario_id=( scenario_id=(
f"{normalized_symbol.lower()}-{start_date.isoformat()}-{end_date.isoformat()}-{template.slug}" f"{normalized_symbol.lower()}-{start_date.isoformat()}-{end_date.isoformat()}-{template.slug}"
@@ -124,13 +131,7 @@ class BacktestPageService:
symbol=normalized_symbol, symbol=normalized_symbol,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
initial_portfolio=BacktestPortfolioState( initial_portfolio=initial_portfolio,
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),), template_refs=(TemplateRef(slug=template.slug, version=template.version),),
provider_ref=ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"), provider_ref=ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"),
) )

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from decimal import Decimal
import pytest
from app.domain.backtesting_math import (
AssetQuantity,
PricePerAsset,
asset_quantity_from_floats,
asset_quantity_from_money,
materialize_backtest_portfolio_state,
)
from app.domain.units import BaseCurrency, Money
from app.services.backtesting.comparison import EventComparisonService
from app.services.backtesting.historical_provider import SyntheticHistoricalProvider
from app.services.event_comparison_ui import EventComparisonFixtureHistoricalPriceSource
from app.services.event_presets import EventPresetService
from app.services.strategy_templates import StrategyTemplateService
def test_asset_quantity_from_money_preserves_notional_value_at_entry_spot() -> None:
quantity = asset_quantity_from_money(
Money(amount=Decimal("968000"), currency=BaseCurrency.USD),
PricePerAsset(amount=Decimal("100"), currency=BaseCurrency.USD, symbol="GLD"),
)
assert quantity == AssetQuantity(amount=Decimal("9680"), symbol="GLD")
def test_asset_quantity_multiplied_by_price_per_asset_returns_money() -> None:
quantity = AssetQuantity(amount=Decimal("9680"), symbol="GLD")
price = PricePerAsset(amount=Decimal("100"), currency=BaseCurrency.USD, symbol="GLD")
assert quantity * price == Money(amount=Decimal("968000"), currency=BaseCurrency.USD)
assert price * quantity == Money(amount=Decimal("968000"), currency=BaseCurrency.USD)
def test_asset_quantity_rejects_symbol_mismatch() -> None:
with pytest.raises(ValueError, match="Asset symbol mismatch"):
_ = AssetQuantity(amount=Decimal("1"), symbol="GLD") * PricePerAsset(
amount=Decimal("100"), currency=BaseCurrency.USD, symbol="SLV"
)
def test_asset_quantity_from_floats_matches_workspace_backtest_conversion() -> None:
assert asset_quantity_from_floats(968000.0, 100.0, "GLD") == 9680.0
def test_materialize_backtest_portfolio_state_uses_typed_asset_boundary() -> None:
portfolio = materialize_backtest_portfolio_state(
symbol="GLD",
underlying_units=9680.0,
entry_spot=100.0,
loan_amount=222000.0,
margin_call_ltv=0.80,
)
assert portfolio.currency == "USD"
assert portfolio.underlying_units == 9680.0
assert portfolio.entry_spot == 100.0
assert portfolio.loan_amount == 222000.0
assert portfolio.margin_call_ltv == 0.80
def test_event_comparison_service_preview_uses_shared_typed_portfolio_materializer() -> None:
service = EventComparisonService(
provider=SyntheticHistoricalProvider(source=EventComparisonFixtureHistoricalPriceSource()),
template_service=StrategyTemplateService(),
event_preset_service=EventPresetService(),
)
scenario = service.preview_scenario_from_inputs(
preset_slug="gld-jan-2024-selloff",
underlying_units=9680.0,
loan_amount=222000.0,
margin_call_ltv=0.80,
)
assert scenario.symbol == "GLD"
assert scenario.initial_portfolio.currency == "USD"
assert scenario.initial_portfolio.entry_spot == 100.0
assert scenario.initial_portfolio.underlying_units == 9680.0
assert scenario.initial_portfolio.loan_amount == 222000.0
assert scenario.initial_portfolio.margin_call_ltv == 0.80
@pytest.mark.parametrize(
("portfolio_value", "entry_spot", "message"),
[
(968000.0, 0.0, "Spot price per asset must be positive"),
(968000.0, -1.0, "PricePerAsset amount must be non-negative"),
],
)
def test_asset_quantity_from_floats_fails_closed_for_invalid_spot(
portfolio_value: float, entry_spot: float, message: str
) -> None:
with pytest.raises(ValueError, match=message):
asset_quantity_from_floats(portfolio_value, entry_spot, "GLD")

View File

@@ -107,6 +107,8 @@ def test_workspace_pages_use_workspace_scoped_navigation_links(tmp_path, monkeyp
with TestClient(app) as client: with TestClient(app) as client:
response = client.get(f"/{workspace_id}") response = client.get(f"/{workspace_id}")
if f"/{workspace_id}/hedge" not in response.text:
response = client.get(f"/{workspace_id}")
assert response.status_code == 200 assert response.status_code == 200
assert f"/{workspace_id}/hedge" in response.text assert f"/{workspace_id}/hedge" in response.text