diff --git a/app/domain/backtesting_math.py b/app/domain/backtesting_math.py index 24e8743..bfe6496 100644 --- a/app/domain/backtesting_math.py +++ b/app/domain/backtesting_math.py @@ -3,8 +3,9 @@ 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.domain.units import BaseCurrency, Money, Weight, WeightUnit, _coerce_currency, decimal_from_float, to_decimal from app.models.backtest import BacktestPortfolioState +from app.models.portfolio import PortfolioConfig @dataclass(frozen=True, slots=True) @@ -94,6 +95,23 @@ def asset_quantity_from_floats(portfolio_value: float, entry_spot: float, symbol return float(asset_quantity_from_money(notional, spot).amount) +def asset_quantity_from_workspace_config(config: PortfolioConfig, *, entry_spot: float, symbol: str) -> float: + if config.gold_ounces is not None and config.gold_ounces > 0: + try: + from app.domain.instruments import asset_quantity_from_weight + + quantity = asset_quantity_from_weight( + symbol, + Weight(amount=decimal_from_float(float(config.gold_ounces)), unit=WeightUnit.OUNCE_TROY), + ) + return float(quantity.amount) + except ValueError: + pass + if config.gold_value is not None: + return asset_quantity_from_floats(float(config.gold_value), entry_spot, symbol) + raise ValueError("Workspace config must provide collateral weight or value") + + def materialize_backtest_portfolio_state( *, symbol: str, diff --git a/app/pages/backtests.py b/app/pages/backtests.py index 01d796b..6cfa2e0 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -5,7 +5,7 @@ from datetime import date, datetime from fastapi.responses import RedirectResponse from nicegui import ui -from app.domain.backtesting_math import asset_quantity_from_floats +from app.domain.backtesting_math import asset_quantity_from_workspace_config from app.models.workspace import get_workspace_repository from app.pages.common import dashboard_page, render_workspace_recovery from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService @@ -66,8 +66,8 @@ 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 = ( - 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 + asset_quantity_from_workspace_config(config, entry_spot=default_entry_spot, symbol="GLD") + if config is not None and default_entry_spot > 0 else 1000.0 ) default_loan = float(config.loan_amount) if config else 68000.0 @@ -217,7 +217,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: ) except (ValueError, KeyError): return - units_input.value = asset_quantity_from_floats(float(config.gold_value), entry_spot, "GLD") + units_input.value = asset_quantity_from_workspace_config(config, entry_spot=entry_spot, symbol="GLD") entry_spot_hint.set_text(f"Auto-derived entry spot: ${entry_spot:,.2f}") def run_backtest() -> None: diff --git a/app/pages/event_comparison.py b/app/pages/event_comparison.py index 4db4a70..d273437 100644 --- a/app/pages/event_comparison.py +++ b/app/pages/event_comparison.py @@ -3,7 +3,7 @@ from __future__ import annotations from fastapi.responses import RedirectResponse from nicegui import ui -from app.domain.backtesting_math import asset_quantity_from_floats +from app.domain.backtesting_math import asset_quantity_from_workspace_config from app.models.workspace import get_workspace_repository from app.pages.common import dashboard_page, render_workspace_recovery from app.services.event_comparison_ui import EventComparisonPageService @@ -54,8 +54,8 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: ) default_entry_spot = default_preview.initial_portfolio.entry_spot default_units = ( - 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 + asset_quantity_from_workspace_config(config, entry_spot=default_entry_spot, symbol="GLD") + if config is not None and default_entry_spot > 0 else 1000.0 ) @@ -129,7 +129,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: 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: + if workspace_id and config is not None: preview_scenario = service.preview_scenario( preset_slug=str(option["slug"]), template_slugs=selected_template_slugs(), @@ -137,10 +137,10 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: 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", + preview_units = asset_quantity_from_workspace_config( + config, + entry_spot=float(preview_scenario.initial_portfolio.entry_spot), + symbol="GLD", ) units_input.value = preview_units scenario = service.preview_scenario( diff --git a/tests/test_backtesting_units.py b/tests/test_backtesting_units.py index 8cfd9ef..653d1be 100644 --- a/tests/test_backtesting_units.py +++ b/tests/test_backtesting_units.py @@ -9,9 +9,11 @@ from app.domain.backtesting_math import ( PricePerAsset, asset_quantity_from_floats, asset_quantity_from_money, + asset_quantity_from_workspace_config, materialize_backtest_portfolio_state, ) from app.domain.units import BaseCurrency, Money +from app.models.portfolio import PortfolioConfig from app.services.backtesting.comparison import EventComparisonService from app.services.backtesting.historical_provider import SyntheticHistoricalProvider from app.services.event_comparison_ui import EventComparisonFixtureHistoricalPriceSource @@ -47,6 +49,20 @@ def test_asset_quantity_from_floats_matches_workspace_backtest_conversion() -> N assert asset_quantity_from_floats(968000.0, 100.0, "GLD") == 9680.0 +def test_asset_quantity_from_workspace_config_uses_instrument_weight_conversion_for_gld() -> None: + config = PortfolioConfig( + gold_ounces=220.0, + entry_price=4400.0, + entry_basis_mode="weight", + loan_amount=222000.0, + margin_threshold=0.80, + ) + + quantity = asset_quantity_from_workspace_config(config, entry_spot=100.0, symbol="GLD") + + assert quantity == 2200.0 + + def test_materialize_backtest_portfolio_state_uses_typed_asset_boundary() -> None: portfolio = materialize_backtest_portfolio_state( symbol="GLD", diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 6fb19b0..5b472de 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -119,20 +119,20 @@ def test_homepage_and_options_page_render() -> None: page.screenshot(path=str(ARTIFACTS / "settings.png"), full_page=True) page.goto(f"{workspace_url}/backtests", wait_until="networkidle", timeout=30000) - expect(page.get_by_label("Underlying units")).to_have_value("9680") + expect(page.get_by_label("Underlying units")).to_have_value("2200") expect(page.get_by_label("Loan amount")).to_have_value("222000") expect(page.get_by_label("Margin call LTV")).to_have_value("0.8") backtests_workspace_text = page.locator("body").inner_text(timeout=15000) assert "Scenario Summary" in backtests_workspace_text - assert "$968,000" in backtests_workspace_text + assert "$220,000" in backtests_workspace_text page.goto(f"{workspace_url}/event-comparison", wait_until="networkidle", timeout=30000) - expect(page.get_by_label("Underlying units")).to_have_value("9680") + expect(page.get_by_label("Underlying units")).to_have_value("2200") expect(page.get_by_label("Loan amount")).to_have_value("222000") expect(page.get_by_label("Margin call LTV")).to_have_value("0.8") event_workspace_text = page.locator("body").inner_text(timeout=15000) assert "$222,000" in event_workspace_text - assert "9,680" in event_workspace_text + assert "2,200" in event_workspace_text assert "80.0%" in event_workspace_text page.goto(workspace_url, wait_until="domcontentloaded", timeout=30000) diff --git a/tests/test_workspace.py b/tests/test_workspace.py index a6a26a0..2aa68bd 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -224,7 +224,7 @@ def test_workspace_routes_seed_page_defaults_from_workspace_portfolio_config(tmp assert backtests_response.status_code == 200 assert "Workspace defaults seed underlying units, loan amount, and margin threshold." in backtests_response.text - assert "9680" in backtests_response.text or "9,680" in backtests_response.text + assert "2200" in backtests_response.text or "2,200" in backtests_response.text assert "222000" in backtests_response.text or "222,000" in backtests_response.text assert "0.8" in backtests_response.text or "80.0%" in backtests_response.text @@ -233,8 +233,8 @@ def test_workspace_routes_seed_page_defaults_from_workspace_portfolio_config(tmp assert "Underlying units" in event_response.text assert "Loan amount" in event_response.text assert "222,000" in event_response.text or "222000" in event_response.text - assert "9,680" in event_response.text or "9680" in event_response.text - assert "80.0%" in event_response.text + assert "2,200" in event_response.text or "2200" in event_response.text + assert "80.0%" in event_response.text or "0.8" in event_response.text def test_hedge_page_upgrades_cached_gld_quote_and_uses_converted_spot(tmp_path, monkeypatch) -> None: