126 lines
4.7 KiB
Python
126 lines
4.7 KiB
Python
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,
|
|
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
|
|
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_asset_quantity_from_workspace_config_uses_instrument_weight_conversion_for_gld() -> None:
|
|
"""GLD shares are calculated using expense-adjusted backing (~0.0916 oz/share in 2026)."""
|
|
from datetime import date
|
|
|
|
from app.domain.instruments import gld_ounces_per_share
|
|
|
|
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")
|
|
|
|
# 220 oz / 0.091576... oz/share ≈ 2402.37 shares (NOT 2200 with old 0.1 ratio)
|
|
current_backing = float(gld_ounces_per_share(date.today()))
|
|
expected_shares = 220.0 / current_backing
|
|
assert abs(quantity - expected_shares) < 0.0001
|
|
# Verify it's more than the old 2200 shares
|
|
assert quantity > 2200.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")
|