416 lines
16 KiB
Python
416 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from decimal import Decimal, InvalidOperation
|
||
from typing import Any, Mapping
|
||
|
||
from app.domain.backtesting_math import PricePerAsset
|
||
from app.domain.conversions import is_gld_mode
|
||
from app.domain.instruments import gld_ounces_per_share, instrument_metadata
|
||
from app.domain.units import BaseCurrency, Money, PricePerWeight, Weight, WeightUnit, decimal_from_float
|
||
from app.models.portfolio import PortfolioConfig
|
||
|
||
_DEFAULT_CASH_BUFFER = 18_500.0
|
||
_DECIMAL_ZERO = Decimal("0")
|
||
_DECIMAL_ONE = Decimal("1")
|
||
_DECIMAL_HUNDRED = Decimal("100")
|
||
|
||
|
||
def _decimal_ratio(numerator: Decimal, denominator: Decimal) -> Decimal:
|
||
if denominator == 0:
|
||
return _DECIMAL_ZERO
|
||
return numerator / denominator
|
||
|
||
|
||
def _pct_factor(pct: int) -> Decimal:
|
||
return _DECIMAL_ONE + (Decimal(pct) / _DECIMAL_HUNDRED)
|
||
|
||
|
||
def _money_to_float(value: Money) -> float:
|
||
return float(value.amount)
|
||
|
||
|
||
def _decimal_to_float(value: Decimal) -> float:
|
||
return float(value)
|
||
|
||
|
||
def _spot_price(spot_price: float) -> PricePerWeight:
|
||
return PricePerWeight(
|
||
amount=decimal_from_float(spot_price),
|
||
currency=BaseCurrency.USD,
|
||
per_unit=WeightUnit.OUNCE_TROY,
|
||
)
|
||
|
||
|
||
def _gold_weight(gold_ounces: float) -> Weight:
|
||
return Weight(amount=decimal_from_float(gold_ounces), unit=WeightUnit.OUNCE_TROY)
|
||
|
||
|
||
def _safe_quote_price(value: object) -> float:
|
||
try:
|
||
parsed = float(value)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
if parsed <= 0:
|
||
return 0.0
|
||
return parsed
|
||
|
||
|
||
def _strategy_decimal(value: object) -> Decimal | None:
|
||
if value is None or isinstance(value, bool):
|
||
return None
|
||
if isinstance(value, Decimal):
|
||
return value if value.is_finite() else None
|
||
if isinstance(value, int):
|
||
return Decimal(value)
|
||
if isinstance(value, float):
|
||
return decimal_from_float(value)
|
||
if isinstance(value, str):
|
||
stripped = value.strip()
|
||
if not stripped:
|
||
return None
|
||
try:
|
||
parsed = Decimal(stripped)
|
||
except InvalidOperation:
|
||
return None
|
||
return parsed if parsed.is_finite() else None
|
||
return None
|
||
|
||
|
||
def _strategy_downside_put_legs(strategy: Mapping[str, Any], current_spot: Decimal) -> list[tuple[Decimal, Decimal]]:
|
||
raw_legs = strategy.get("downside_put_legs")
|
||
if isinstance(raw_legs, (list, tuple)):
|
||
parsed_legs: list[tuple[Decimal, Decimal]] = []
|
||
for leg in raw_legs:
|
||
if not isinstance(leg, Mapping):
|
||
continue
|
||
weight = _strategy_decimal(leg.get("allocation_weight", leg.get("weight")))
|
||
strike_pct = _strategy_decimal(leg.get("strike_pct"))
|
||
if weight is None or strike_pct is None or weight <= 0 or strike_pct <= 0:
|
||
continue
|
||
parsed_legs.append((weight, current_spot * strike_pct))
|
||
if parsed_legs:
|
||
return parsed_legs
|
||
|
||
protection_floor_pct = _strategy_decimal(strategy.get("protection_floor_pct"))
|
||
if protection_floor_pct is not None and protection_floor_pct > 0:
|
||
return [(_DECIMAL_ONE, current_spot * protection_floor_pct)]
|
||
|
||
absolute_floor = _strategy_decimal(strategy.get("max_drawdown_floor"))
|
||
if absolute_floor is not None and absolute_floor > 0:
|
||
return [(_DECIMAL_ONE, absolute_floor)]
|
||
|
||
return [(_DECIMAL_ONE, current_spot * Decimal("0.95"))]
|
||
|
||
|
||
def _strategy_upside_cap_decimal(strategy: Mapping[str, Any], current_spot: Decimal) -> Decimal | None:
|
||
upside_cap_pct = _strategy_decimal(strategy.get("upside_cap_pct"))
|
||
if upside_cap_pct is not None and upside_cap_pct > 0:
|
||
return current_spot * upside_cap_pct
|
||
|
||
absolute_cap = _strategy_decimal(strategy.get("upside_cap"))
|
||
if absolute_cap is not None and absolute_cap > 0:
|
||
return absolute_cap
|
||
|
||
return None
|
||
|
||
|
||
def _strategy_option_payoff_per_unit(
|
||
strategy: Mapping[str, Any], current_spot: Decimal, scenario_spot: Decimal
|
||
) -> Decimal:
|
||
return sum(
|
||
weight * max(strike_price - scenario_spot, _DECIMAL_ZERO)
|
||
for weight, strike_price in _strategy_downside_put_legs(strategy, current_spot)
|
||
)
|
||
|
||
|
||
def _strategy_upside_cap_effect_per_unit(
|
||
strategy: Mapping[str, Any], current_spot: Decimal, scenario_spot: Decimal
|
||
) -> Decimal:
|
||
cap = _strategy_upside_cap_decimal(strategy, current_spot)
|
||
if cap is None or scenario_spot <= cap:
|
||
return _DECIMAL_ZERO
|
||
return -(scenario_spot - cap)
|
||
|
||
|
||
def strategy_protection_floor_bounds(strategy: Mapping[str, Any], *, current_spot: float) -> tuple[float, float] | None:
|
||
current_spot_decimal = decimal_from_float(current_spot)
|
||
legs = _strategy_downside_put_legs(strategy, current_spot_decimal)
|
||
if not legs:
|
||
return None
|
||
floor_prices = [strike_price for _, strike_price in legs]
|
||
return _decimal_to_float(min(floor_prices)), _decimal_to_float(max(floor_prices))
|
||
|
||
|
||
def strategy_upside_cap_price(strategy: Mapping[str, Any], *, current_spot: float) -> float | None:
|
||
cap = _strategy_upside_cap_decimal(strategy, decimal_from_float(current_spot))
|
||
if cap is None:
|
||
return None
|
||
return _decimal_to_float(cap)
|
||
|
||
|
||
def strategy_benefit_per_unit(strategy: Mapping[str, Any], *, current_spot: float, scenario_spot: float) -> float:
|
||
current_spot_decimal = decimal_from_float(current_spot)
|
||
scenario_spot_decimal = decimal_from_float(scenario_spot)
|
||
cost = _strategy_decimal(strategy.get("estimated_cost")) or _DECIMAL_ZERO
|
||
benefit = (
|
||
_strategy_option_payoff_per_unit(strategy, current_spot_decimal, scenario_spot_decimal)
|
||
+ _strategy_upside_cap_effect_per_unit(strategy, current_spot_decimal, scenario_spot_decimal)
|
||
- cost
|
||
)
|
||
return round(float(benefit), 2)
|
||
|
||
|
||
def resolve_collateral_spot_from_quote(
|
||
quote: Mapping[str, object],
|
||
*,
|
||
fallback_symbol: str | None = None,
|
||
) -> tuple[float, str, str] | None:
|
||
quote_price = _safe_quote_price(quote.get("price"))
|
||
quote_source = str(quote.get("source", "unknown"))
|
||
quote_updated_at = str(quote.get("updated_at", ""))
|
||
quote_symbol = str(quote.get("symbol", fallback_symbol or "")).strip().upper()
|
||
quote_unit = str(quote.get("quote_unit", "")).strip().lower()
|
||
|
||
if quote_price <= 0 or not quote_symbol or quote_unit != "share":
|
||
return None
|
||
|
||
try:
|
||
metadata = instrument_metadata(quote_symbol)
|
||
except ValueError:
|
||
return None
|
||
|
||
converted_spot = metadata.price_per_weight_from_asset_price(
|
||
PricePerAsset(amount=decimal_from_float(quote_price), currency=BaseCurrency.USD, symbol=quote_symbol),
|
||
per_unit=WeightUnit.OUNCE_TROY,
|
||
)
|
||
return _decimal_to_float(converted_spot.amount), quote_source, quote_updated_at
|
||
|
||
|
||
def resolve_portfolio_spot_from_quote(
|
||
config: PortfolioConfig,
|
||
quote: Mapping[str, object],
|
||
*,
|
||
fallback_symbol: str | None = None,
|
||
) -> tuple[float, str, str]:
|
||
"""Resolve spot price from quote based on display mode.
|
||
|
||
In GLD display mode: return GLD share price directly (no conversion)
|
||
In XAU display mode: convert GLD share price to oz-equivalent using expense-adjusted backing
|
||
|
||
Args:
|
||
config: Portfolio configuration with display_mode setting
|
||
quote: Quote data from data service
|
||
fallback_symbol: Fallback symbol if quote lacks symbol
|
||
|
||
Returns:
|
||
Tuple of (spot_price, source, updated_at)
|
||
"""
|
||
display_mode = getattr(config, "display_mode", "XAU")
|
||
|
||
# Try to resolve from quote first
|
||
resolved = resolve_collateral_spot_from_quote(quote, fallback_symbol=fallback_symbol)
|
||
|
||
if resolved is None:
|
||
# No valid quote, use configured entry price
|
||
configured_price = float(config.entry_price or 0.0)
|
||
return configured_price, "configured_entry_price", ""
|
||
|
||
spot_price, source, updated_at = resolved
|
||
|
||
# In GLD mode, return share price directly (no conversion to oz)
|
||
if is_gld_mode(display_mode):
|
||
# For GLD mode, we want the share price, not the converted oz price
|
||
quote_price = _safe_quote_price(quote.get("price"))
|
||
if quote_price > 0:
|
||
return quote_price, source, updated_at
|
||
|
||
# XAU mode: use the converted oz-equivalent price (already done in resolve_collateral_spot_from_quote)
|
||
return spot_price, source, updated_at
|
||
|
||
|
||
def portfolio_snapshot_from_config(
|
||
config: PortfolioConfig | None = None,
|
||
*,
|
||
runtime_spot_price: float | None = None,
|
||
) -> dict[str, float]:
|
||
"""Build portfolio snapshot with display-mode-aware calculations.
|
||
|
||
In GLD mode:
|
||
- gold_units: shares (not oz)
|
||
- spot_price: GLD share price
|
||
- gold_value: shares × share_price
|
||
|
||
In XAU mode:
|
||
- gold_units: oz
|
||
- spot_price: USD/oz
|
||
- gold_value: oz × oz_price
|
||
"""
|
||
if config is None:
|
||
gold_weight = Weight(amount=Decimal("1000"), unit=WeightUnit.OUNCE_TROY)
|
||
spot = PricePerWeight(amount=Decimal("215"), currency=BaseCurrency.USD, per_unit=WeightUnit.OUNCE_TROY)
|
||
loan_amount = Money(amount=Decimal("145000"), currency=BaseCurrency.USD)
|
||
margin_call_ltv = Decimal("0.75")
|
||
hedge_budget = Money(amount=Decimal("8000"), currency=BaseCurrency.USD)
|
||
display_mode = "XAU"
|
||
else:
|
||
display_mode = getattr(config, "display_mode", "XAU")
|
||
resolved_spot = runtime_spot_price if runtime_spot_price is not None else float(config.entry_price or 0.0)
|
||
|
||
if is_gld_mode(display_mode):
|
||
# GLD mode: work with shares directly
|
||
# Use positions if available, otherwise fall back to legacy gold_ounces as shares
|
||
if config.positions:
|
||
# Sum GLD position quantities in shares
|
||
total_shares = Decimal("0")
|
||
for pos in config.positions:
|
||
if pos.underlying == "GLD" and pos.unit == "shares":
|
||
total_shares += pos.quantity
|
||
elif pos.underlying == "GLD" and pos.unit == "oz":
|
||
# Convert oz to shares using current backing
|
||
backing = gld_ounces_per_share(date.today())
|
||
total_shares += pos.quantity / backing
|
||
else:
|
||
# Non-GLD positions: treat as oz and convert to shares
|
||
backing = gld_ounces_per_share(date.today())
|
||
total_shares += pos.quantity / backing
|
||
|
||
gold_weight = Weight(amount=total_shares, unit=WeightUnit.OUNCE_TROY) # Store shares in weight for now
|
||
else:
|
||
# Legacy: treat gold_ounces as oz, convert to shares
|
||
backing = gld_ounces_per_share(date.today())
|
||
shares = Decimal(str(config.gold_ounces or 0.0)) / backing
|
||
gold_weight = Weight(amount=shares, unit=WeightUnit.OUNCE_TROY)
|
||
|
||
spot = PricePerWeight(
|
||
amount=decimal_from_float(resolved_spot), currency=BaseCurrency.USD, per_unit=WeightUnit.OUNCE_TROY
|
||
)
|
||
else:
|
||
# XAU mode: work with oz
|
||
gold_weight = _gold_weight(float(config.gold_ounces or 0.0))
|
||
spot = _spot_price(resolved_spot)
|
||
|
||
loan_amount = Money(amount=decimal_from_float(float(config.loan_amount)), currency=BaseCurrency.USD)
|
||
margin_call_ltv = decimal_from_float(float(config.margin_threshold))
|
||
hedge_budget = Money(amount=decimal_from_float(float(config.monthly_budget)), currency=BaseCurrency.USD)
|
||
|
||
gold_value = gold_weight * spot
|
||
net_equity = gold_value - loan_amount
|
||
ltv_ratio = _decimal_ratio(loan_amount.amount, gold_value.amount)
|
||
margin_call_price = loan_amount.amount / (margin_call_ltv * gold_weight.amount)
|
||
|
||
return {
|
||
"gold_value": _money_to_float(gold_value),
|
||
"loan_amount": _money_to_float(loan_amount),
|
||
"ltv_ratio": _decimal_to_float(ltv_ratio),
|
||
"net_equity": _money_to_float(net_equity),
|
||
"spot_price": _decimal_to_float(spot.amount),
|
||
"gold_units": _decimal_to_float(gold_weight.amount),
|
||
"margin_call_ltv": _decimal_to_float(margin_call_ltv),
|
||
"margin_call_price": _decimal_to_float(margin_call_price),
|
||
"cash_buffer": _DEFAULT_CASH_BUFFER,
|
||
"hedge_budget": _money_to_float(hedge_budget),
|
||
"display_mode": display_mode,
|
||
}
|
||
|
||
|
||
def build_alert_context(
|
||
config: PortfolioConfig,
|
||
*,
|
||
spot_price: float,
|
||
source: str,
|
||
updated_at: str,
|
||
) -> dict[str, float | str]:
|
||
"""Build alert context with display-mode-aware calculations."""
|
||
display_mode = getattr(config, "display_mode", "XAU")
|
||
|
||
if is_gld_mode(display_mode):
|
||
# GLD mode: work with shares
|
||
backing = gld_ounces_per_share(date.today())
|
||
shares = Decimal(str(config.gold_ounces or 0.0)) / backing
|
||
gold_weight = Weight(amount=shares, unit=WeightUnit.OUNCE_TROY)
|
||
else:
|
||
# XAU mode: work with oz
|
||
gold_weight = _gold_weight(float(config.gold_ounces or 0.0))
|
||
|
||
live_spot = _spot_price(spot_price)
|
||
gold_value = gold_weight * live_spot
|
||
loan_amount = Money(amount=decimal_from_float(float(config.loan_amount)), currency=BaseCurrency.USD)
|
||
margin_call_ltv = decimal_from_float(float(config.margin_threshold))
|
||
margin_call_price = (
|
||
loan_amount.amount / (margin_call_ltv * gold_weight.amount) if gold_weight.amount > 0 else _DECIMAL_ZERO
|
||
)
|
||
|
||
return {
|
||
"spot_price": _decimal_to_float(live_spot.amount),
|
||
"gold_units": _decimal_to_float(gold_weight.amount),
|
||
"gold_value": _money_to_float(gold_value),
|
||
"loan_amount": _money_to_float(loan_amount),
|
||
"ltv_ratio": _decimal_to_float(_decimal_ratio(loan_amount.amount, gold_value.amount)),
|
||
"net_equity": _money_to_float(gold_value - loan_amount),
|
||
"margin_call_ltv": _decimal_to_float(margin_call_ltv),
|
||
"margin_call_price": _decimal_to_float(margin_call_price),
|
||
"quote_source": source,
|
||
"quote_updated_at": updated_at,
|
||
"display_mode": display_mode,
|
||
}
|
||
|
||
|
||
def strategy_metrics_from_snapshot(
|
||
strategy: dict[str, Any], scenario_pct: int, snapshot: dict[str, Any]
|
||
) -> dict[str, Any]:
|
||
spot = decimal_from_float(float(snapshot["spot_price"]))
|
||
gold_weight = _gold_weight(float(snapshot["gold_units"]))
|
||
current_spot = PricePerWeight(amount=spot, currency=BaseCurrency.USD, per_unit=WeightUnit.OUNCE_TROY)
|
||
loan_amount = Money(amount=decimal_from_float(float(snapshot["loan_amount"])), currency=BaseCurrency.USD)
|
||
base_equity = Money(amount=decimal_from_float(float(snapshot["net_equity"])), currency=BaseCurrency.USD)
|
||
cost = _strategy_decimal(strategy.get("estimated_cost")) or _DECIMAL_ZERO
|
||
|
||
scenario_prices = [spot * _pct_factor(pct) for pct in range(-25, 30, 5)]
|
||
benefits = [
|
||
strategy_benefit_per_unit(
|
||
strategy,
|
||
current_spot=_decimal_to_float(spot),
|
||
scenario_spot=_decimal_to_float(price),
|
||
)
|
||
for price in scenario_prices
|
||
]
|
||
|
||
scenario_price = spot * _pct_factor(scenario_pct)
|
||
scenario_gold_value = gold_weight * PricePerWeight(
|
||
amount=scenario_price,
|
||
currency=BaseCurrency.USD,
|
||
per_unit=WeightUnit.OUNCE_TROY,
|
||
)
|
||
current_gold_value = gold_weight * current_spot
|
||
unhedged_equity = scenario_gold_value - loan_amount
|
||
scenario_payoff_per_unit = _strategy_option_payoff_per_unit(strategy, spot, scenario_price)
|
||
capped_upside_per_unit = _strategy_upside_cap_effect_per_unit(strategy, spot, scenario_price)
|
||
|
||
option_payoff_cash = Money(amount=gold_weight.amount * scenario_payoff_per_unit, currency=BaseCurrency.USD)
|
||
capped_upside_cash = Money(amount=gold_weight.amount * capped_upside_per_unit, currency=BaseCurrency.USD)
|
||
hedge_cost_cash = Money(amount=gold_weight.amount * cost, currency=BaseCurrency.USD)
|
||
hedged_equity = unhedged_equity + option_payoff_cash + capped_upside_cash - hedge_cost_cash
|
||
|
||
waterfall_steps = [
|
||
("Base equity", round(_money_to_float(base_equity), 2)),
|
||
("Spot move", round(_money_to_float(scenario_gold_value - current_gold_value), 2)),
|
||
("Option payoff", round(_money_to_float(option_payoff_cash), 2)),
|
||
("Call cap", round(_money_to_float(capped_upside_cash), 2)),
|
||
("Hedge cost", round(_money_to_float(-hedge_cost_cash), 2)),
|
||
("Net equity", round(_money_to_float(hedged_equity), 2)),
|
||
]
|
||
|
||
return {
|
||
"strategy": strategy,
|
||
"scenario_pct": scenario_pct,
|
||
"scenario_price": round(float(scenario_price), 2),
|
||
"scenario_series": [
|
||
{"price": round(float(price), 2), "benefit": benefit}
|
||
for price, benefit in zip(scenario_prices, benefits, strict=True)
|
||
],
|
||
"waterfall_steps": waterfall_steps,
|
||
"unhedged_equity": round(_money_to_float(unhedged_equity), 2),
|
||
"hedged_equity": round(_money_to_float(hedged_equity), 2),
|
||
}
|