151 lines
5.4 KiB
Python
151 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
from app.domain.backtesting_math import PricePerAsset
|
|
from app.domain.instruments import price_per_weight_from_asset_price
|
|
from app.domain.portfolio_math import (
|
|
build_alert_context,
|
|
portfolio_snapshot_from_config,
|
|
resolve_portfolio_spot_from_quote,
|
|
strategy_metrics_from_snapshot,
|
|
)
|
|
from app.domain.units import BaseCurrency, WeightUnit
|
|
from app.models.portfolio import PortfolioConfig
|
|
|
|
|
|
def test_portfolio_snapshot_from_config_preserves_weight_price_and_margin_values() -> None:
|
|
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
|
|
|
snapshot = portfolio_snapshot_from_config(config)
|
|
|
|
assert snapshot["spot_price"] == 4400.0
|
|
assert snapshot["gold_units"] == 220.0
|
|
assert snapshot["gold_value"] == 968000.0
|
|
assert snapshot["net_equity"] == 823000.0
|
|
assert round(snapshot["margin_call_price"], 2) == 878.79
|
|
|
|
|
|
def test_build_alert_context_uses_unit_safe_gold_value_calculation() -> None:
|
|
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
|
|
|
context = build_alert_context(
|
|
config,
|
|
spot_price=4400.0,
|
|
source="configured_entry_price",
|
|
updated_at="",
|
|
)
|
|
|
|
assert context["gold_units"] == 220.0
|
|
assert context["gold_value"] == 968000.0
|
|
assert context["net_equity"] == 823000.0
|
|
assert round(float(context["margin_call_price"]), 2) == 878.79
|
|
|
|
|
|
def test_build_alert_context_accepts_explicit_gld_share_quote_conversion() -> None:
|
|
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
|
share_quote = PricePerAsset(amount=Decimal("404.19"), currency=BaseCurrency.USD, symbol="GLD")
|
|
ounce_spot = price_per_weight_from_asset_price(share_quote, per_unit=WeightUnit.OUNCE_TROY)
|
|
|
|
context = build_alert_context(
|
|
config,
|
|
spot_price=float(ounce_spot.amount),
|
|
source="yfinance",
|
|
updated_at="2026-03-24T00:00:00+00:00",
|
|
)
|
|
|
|
assert context["spot_price"] == 4041.9
|
|
assert context["gold_value"] == 889218.0
|
|
assert context["net_equity"] == 744218.0
|
|
assert context["quote_source"] == "yfinance"
|
|
|
|
|
|
def test_strategy_metrics_from_snapshot_preserves_minus_20pct_protective_put_example() -> None:
|
|
from app.pages.common import strategy_catalog
|
|
|
|
strategy = next(item for item in strategy_catalog() if item["name"] == "protective_put_atm")
|
|
snapshot = {
|
|
"gold_value": 215000.0,
|
|
"loan_amount": 145000.0,
|
|
"net_equity": 70000.0,
|
|
"spot_price": 215.0,
|
|
"gold_units": 1000.0,
|
|
"margin_call_ltv": 0.75,
|
|
"margin_call_price": 193.33,
|
|
"cash_buffer": 18500.0,
|
|
"hedge_budget": 8000.0,
|
|
"ltv_ratio": 145000.0 / 215000.0,
|
|
}
|
|
|
|
metrics = strategy_metrics_from_snapshot(strategy, -20, snapshot)
|
|
|
|
assert metrics["scenario_price"] == 172.0
|
|
assert metrics["unhedged_equity"] == 27000.0
|
|
assert metrics["hedged_equity"] == 58750.0
|
|
assert metrics["waterfall_steps"] == [
|
|
("Base equity", 70000.0),
|
|
("Spot move", -43000.0),
|
|
("Option payoff", 38000.0),
|
|
("Call cap", 0.0),
|
|
("Hedge cost", -6250.0),
|
|
("Net equity", 58750.0),
|
|
]
|
|
|
|
|
|
def test_resolve_portfolio_spot_from_quote_converts_gld_share_to_ozt() -> None:
|
|
"""Hedge/runtime quote resolution should convert GLD share quotes to USD/ozt."""
|
|
from app.models.portfolio import PortfolioConfig
|
|
|
|
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
|
share_quote = {
|
|
"symbol": "GLD",
|
|
"price": 404.19,
|
|
"quote_unit": "share",
|
|
"source": "yfinance",
|
|
"updated_at": "2026-03-25T00:00:00+00:00",
|
|
}
|
|
|
|
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, share_quote)
|
|
|
|
assert spot == 4041.9 # 404.19 / 0.1 = 4041.9 USD/ozt
|
|
assert source == "yfinance"
|
|
assert updated_at == "2026-03-25T00:00:00+00:00"
|
|
|
|
|
|
def test_resolve_portfolio_spot_from_quote_fails_closed_to_configured_price() -> None:
|
|
"""Missing quote_unit should fail closed to configured entry price."""
|
|
from app.models.portfolio import PortfolioConfig
|
|
|
|
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
|
legacy_quote = {
|
|
"symbol": "GLD",
|
|
"price": 404.19,
|
|
"source": "cache",
|
|
"updated_at": "",
|
|
}
|
|
|
|
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, legacy_quote)
|
|
|
|
assert spot == 4400.0 # Falls back to configured price
|
|
assert source == "configured_entry_price"
|
|
assert updated_at == ""
|
|
|
|
|
|
def test_resolve_portfolio_spot_from_quote_fails_closed_for_unsupported_symbol() -> None:
|
|
"""Unsupported symbols without instrument metadata should fail closed."""
|
|
from app.models.portfolio import PortfolioConfig
|
|
|
|
config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0)
|
|
btc_quote = {
|
|
"symbol": "BTC-USD",
|
|
"price": 70000.0,
|
|
"quote_unit": "coin",
|
|
"source": "yfinance",
|
|
"updated_at": "2026-03-25T00:00:00+00:00",
|
|
}
|
|
|
|
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, btc_quote)
|
|
|
|
assert spot == 4400.0 # Falls back to configured price
|
|
assert source == "configured_entry_price"
|