diff --git a/app/domain/__init__.py b/app/domain/__init__.py index 2fec095..18ddc27 100644 --- a/app/domain/__init__.py +++ b/app/domain/__init__.py @@ -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", diff --git a/app/domain/backtesting_math.py b/app/domain/backtesting_math.py new file mode 100644 index 0000000..24e8743 --- /dev/null +++ b/app/domain/backtesting_math.py @@ -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, + ) diff --git a/app/pages/backtests.py b/app/pages/backtests.py index 0ae9eec..a7e0e80 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -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() diff --git a/app/pages/event_comparison.py b/app/pages/event_comparison.py index 2958430..7baaa03 100644 --- a/app/pages/event_comparison.py +++ b/app/pages/event_comparison.py @@ -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), ) diff --git a/app/services/backtesting/comparison.py b/app/services/backtesting/comparison.py index 57fd917..49b44f4 100644 --- a/app/services/backtesting/comparison.py +++ b/app/services/backtesting/comparison.py @@ -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, ) diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py index 31d3970..adea4df 100644 --- a/app/services/backtesting/ui_service.py +++ b/app/services/backtesting/ui_service.py @@ -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"), ) diff --git a/tests/test_backtesting_units.py b/tests/test_backtesting_units.py new file mode 100644 index 0000000..8cfd9ef --- /dev/null +++ b/tests/test_backtesting_units.py @@ -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") diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 110f289..39b7318 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -107,6 +107,8 @@ def test_workspace_pages_use_workspace_scoped_navigation_links(tmp_path, monkeyp with TestClient(app) as client: 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 f"/{workspace_id}/hedge" in response.text