diff --git a/app/domain/conversions.py b/app/domain/conversions.py index 6f4ea85..b4e612d 100644 --- a/app/domain/conversions.py +++ b/app/domain/conversions.py @@ -1,322 +1,310 @@ -"""Conversion layer for display mode switching. +"""Display mode conversion utilities for GLD/XAU views. -Provides functions to convert positions between different display modes: -- GLD: shares (for GLD ETF) -- XAU_OZ: troy ounces of gold -- XAU_G: grams of gold -- GCF: gold futures contracts (GC=F) +This module handles conversion between GLD share prices and physical gold prices +based on the user's display mode preference. -All conversions use the position's entry_date for historical accuracy. +Key insight: +- In GLD mode: show share prices directly, no conversion to oz +- In XAU mode: convert GLD shares to oz-equivalent using expense-adjusted backing """ from __future__ import annotations +from dataclasses import dataclass from datetime import date from decimal import Decimal -from app.domain.instruments import GC_F_OUNCES_PER_CONTRACT, gld_ounces_per_share +from app.domain.instruments import gld_ounces_per_share from app.models.position import Position -# Constants -GRAMS_PER_OUNCE = Decimal("31.1035") # 1 troy oz = 31.1035 grams + +@dataclass(frozen=True) +class DisplayContext: + """Context for display mode conversions. + + Attributes: + mode: Display mode ("GLD" for shares, "XAU" for physical gold) + reference_date: Date for historical conversion lookups + gld_ounces_per_share: GLD backing ratio for the reference date + """ + + mode: str + reference_date: date | None = None + gld_ounces_per_share: Decimal | None = None + + def __post_init__(self) -> None: + if self.mode not in {"GLD", "XAU"}: + raise ValueError(f"Invalid display mode: {self.mode!r}") + + @classmethod + def create(cls, mode: str, reference_date: date | None = None) -> "DisplayContext": + """Create a display context with computed GLD backing ratio.""" + gld_backing = None + if mode == "XAU" and reference_date is not None: + gld_backing = gld_ounces_per_share(reference_date) + return cls(mode=mode, reference_date=reference_date, gld_ounces_per_share=gld_backing) -def _gld_shares_to_ounces(position: Position, reference_date: date | None = None) -> Decimal: - """Convert GLD shares to troy ounces using historical backing ratio.""" - if position.underlying != "GLD": - raise ValueError(f"Cannot convert non-GLD position ({position.underlying}) to ounces") - if position.unit != "shares": - raise ValueError(f"GLD position must be in shares, got {position.unit}") - - oz_per_share = gld_ounces_per_share(reference_date or position.entry_date) - return position.quantity * oz_per_share +def is_gld_mode(display_mode: str) -> bool: + """Check if display mode is GLD (share view).""" + return display_mode == "GLD" -def _gld_shares_to_grams(position: Position, reference_date: date | None = None) -> Decimal: - """Convert GLD shares to grams using historical backing ratio.""" - ounces = _gld_shares_to_ounces(position, reference_date) - return ounces * GRAMS_PER_OUNCE +def is_xau_mode(display_mode: str) -> bool: + """Check if display mode is XAU (physical gold view).""" + return display_mode == "XAU" -def _gld_shares_to_contracts(position: Position, reference_date: date | None = None) -> Decimal: - """Convert GLD shares to GC=F contracts.""" - ounces = _gld_shares_to_ounces(position, reference_date) - return ounces / GC_F_OUNCES_PER_CONTRACT - - -def _ounces_to_gld_shares(ounces: Decimal, reference_date: date | None = None) -> Decimal: - """Convert troy ounces to GLD shares using historical backing ratio.""" - oz_per_share = gld_ounces_per_share(reference_date or date.today()) - if oz_per_share <= 0: - raise ValueError("GLD ounces per share must be positive") - return ounces / oz_per_share - - -def _grams_to_gld_shares(grams: Decimal, reference_date: date | None = None) -> Decimal: - """Convert grams to GLD shares using historical backing ratio.""" - ounces = grams / GRAMS_PER_OUNCE - return _ounces_to_gld_shares(ounces, reference_date) - - -def _contracts_to_gld_shares(contracts: Decimal, reference_date: date | None = None) -> Decimal: - """Convert GC=F contracts to GLD shares using historical backing ratio.""" - ounces = contracts * GC_F_OUNCES_PER_CONTRACT - return _ounces_to_gld_shares(ounces, reference_date) - - -def _xau_oz_to_grams(ounces: Decimal) -> Decimal: - """Convert troy ounces to grams.""" - return ounces * GRAMS_PER_OUNCE - - -def _xau_g_to_ounces(grams: Decimal) -> Decimal: - """Convert grams to troy ounces.""" - return grams / GRAMS_PER_OUNCE - - -def _xau_oz_to_contracts(ounces: Decimal) -> Decimal: - """Convert troy ounces to GC=F contracts.""" - return ounces / GC_F_OUNCES_PER_CONTRACT - - -def _contracts_to_xau_oz(contracts: Decimal) -> Decimal: - """Convert GC=F contracts to troy ounces.""" - return contracts * GC_F_OUNCES_PER_CONTRACT - - -def position_to_display_units( +def convert_position_to_display( position: Position, display_mode: str, reference_date: date | None = None, -) -> tuple[Decimal, str]: - """Convert a position to display units based on the selected display mode. +) -> tuple[Decimal, str, Decimal]: + """Convert a position to display units based on display mode. Args: - position: The position to convert - display_mode: One of "GLD", "XAU_OZ", "XAU_G", "GCF" - reference_date: Date for historical conversion (defaults to position.entry_date) + position: Position to convert + display_mode: "GLD" for shares, "XAU" for physical gold + reference_date: Date for historical conversion (for GLD->XAU) Returns: - Tuple of (quantity in display units, display unit label) + Tuple of (display_quantity, display_unit, display_entry_price) Examples: + >>> # GLD position in GLD mode: show as-is >>> from datetime import date >>> from decimal import Decimal >>> from app.models.position import create_position - >>> pos = create_position("GLD", Decimal("100"), "shares", Decimal("230")) - >>> qty, unit = position_to_display_units(pos, "XAU_OZ") - >>> float(qty) > 0 and unit == "oz" - True - """ - ref_date = reference_date or position.entry_date + >>> pos = create_position( + ... underlying="GLD", + ... quantity=Decimal("100"), + ... unit="shares", + ... entry_price=Decimal("400"), + ... entry_date=date.today(), + ... ) + >>> qty, unit, price = convert_position_to_display(pos, "GLD") + >>> qty, unit, price + (Decimal('100'), 'shares', Decimal('400')) + >>> # GLD position in XAU mode: convert to oz + >>> qty, unit, price = convert_position_to_display(pos, "XAU", date.today()) + >>> # qty will be shares * oz_per_share + """ if display_mode == "GLD": - # Show GLD shares as-is - if position.underlying == "GLD" and position.unit == "shares": - return position.quantity, "shares" - # Convert other formats to GLD shares - if position.underlying == "GLD" and position.unit == "oz": - shares = _ounces_to_gld_shares(position.quantity, ref_date) - return shares, "shares" - if position.underlying == "GLD" and position.unit == "g": - shares = _grams_to_gld_shares(position.quantity, ref_date) - return shares, "shares" - if position.underlying == "GC=F" and position.unit == "contracts": - shares = _contracts_to_gld_shares(position.quantity, ref_date) - return shares, "shares" - # XAU positions - if position.underlying == "XAU": - if position.unit == "oz": - shares = _ounces_to_gld_shares(position.quantity, ref_date) - return shares, "shares" - if position.unit == "g": - shares = _grams_to_gld_shares(position.quantity, ref_date) - return shares, "shares" + # GLD mode: show shares directly + if position.underlying == "GLD": + return position.quantity, position.unit, position.entry_price + # Non-GLD positions in GLD mode: would need conversion (not implemented yet) + return position.quantity, position.unit, position.entry_price - elif display_mode == "XAU_OZ": - # Show everything in troy ounces - if position.underlying == "GLD" and position.unit == "shares": - ounces = _gld_shares_to_ounces(position, ref_date) - return ounces, "oz" - if position.underlying == "GLD" and position.unit == "oz": - return position.quantity, "oz" - if position.underlying == "GLD" and position.unit == "g": - return _xau_g_to_ounces(position.quantity), "oz" - if position.underlying == "GC=F" and position.unit == "contracts": - ounces = position.quantity * GC_F_OUNCES_PER_CONTRACT - return ounces, "oz" - if position.underlying == "XAU": - if position.unit == "oz": - return position.quantity, "oz" - if position.unit == "g": - return _xau_g_to_ounces(position.quantity), "oz" + elif display_mode == "XAU": + # XAU mode: convert to physical gold ounces + if position.underlying == "GLD": + # Convert GLD shares to oz using expense-adjusted backing + backing = gld_ounces_per_share(reference_date or position.entry_date) + display_qty = position.quantity * backing + display_price = position.entry_price / backing # Price per oz + return display_qty, "oz", display_price + # XAU positions already in oz + return position.quantity, position.unit, position.entry_price - elif display_mode == "XAU_G": - # Show everything in grams - if position.underlying == "GLD" and position.unit == "shares": - grams = _gld_shares_to_grams(position, ref_date) - return grams, "g" - if position.underlying == "GLD" and position.unit == "oz": - ounces = position.quantity - return _xau_oz_to_grams(ounces), "g" - if position.underlying == "GLD" and position.unit == "g": - return position.quantity, "g" - if position.underlying == "GC=F" and position.unit == "contracts": - ounces = position.quantity * GC_F_OUNCES_PER_CONTRACT - return _xau_oz_to_grams(ounces), "g" - if position.underlying == "XAU": - if position.unit == "oz": - return _xau_oz_to_grams(position.quantity), "g" - if position.unit == "g": - return position.quantity, "g" - - elif display_mode == "GCF": - # Show everything in GC=F contracts - if position.underlying == "GLD" and position.unit == "shares": - contracts = _gld_shares_to_contracts(position, ref_date) - return contracts, "contracts" - if position.underlying == "GLD" and position.unit == "oz": - contracts = _xau_oz_to_contracts(position.quantity) - return contracts, "contracts" - if position.underlying == "GLD" and position.unit == "g": - ounces = _xau_g_to_ounces(position.quantity) - contracts = _xau_oz_to_contracts(ounces) - return contracts, "contracts" - if position.underlying == "GC=F" and position.unit == "contracts": - return position.quantity, "contracts" - if position.underlying == "XAU": - if position.unit == "oz": - contracts = _xau_oz_to_contracts(position.quantity) - return contracts, "contracts" - if position.unit == "g": - ounces = _xau_g_to_ounces(position.quantity) - contracts = _xau_oz_to_contracts(ounces) - return contracts, "contracts" - - # Fallback: return as-is - return position.quantity, position.unit - - -def collateral_to_display_units( - gold_ounces: float, - display_mode: str, - reference_date: date | None = None, -) -> tuple[float, str]: - """Convert collateral amount to display units. - - Args: - gold_ounces: Collateral amount in troy ounces - display_mode: One of "GLD", "XAU_OZ", "XAU_G", "GCF" - reference_date: Date for historical conversion (defaults to today) - - Returns: - Tuple of (amount in display units, display unit label) - """ - from decimal import Decimal - - oz_decimal = Decimal(str(gold_ounces)) - ref_date = reference_date or date.today() - - if display_mode == "GLD": - shares = _ounces_to_gld_shares(oz_decimal, ref_date) - return float(shares), "shares" - elif display_mode == "XAU_OZ": - return gold_ounces, "oz" - elif display_mode == "XAU_G": - grams = _xau_oz_to_grams(oz_decimal) - return float(grams), "g" - elif display_mode == "GCF": - contracts = _xau_oz_to_contracts(oz_decimal) - return float(contracts), "contracts" - - return gold_ounces, "oz" - - -def price_per_display_unit( - price_per_oz: float, - display_mode: str, - reference_date: date | None = None, -) -> float: - """Convert price per ounce to price per display unit. - - Args: - price_per_oz: Price per troy ounce in USD - display_mode: One of "GLD", "XAU_OZ", "XAU_G", "GCF" - reference_date: Date for historical conversion (defaults to today) - - Returns: - Price per display unit in USD - """ - from decimal import Decimal - - price = Decimal(str(price_per_oz)) - ref_date = reference_date or date.today() - - if display_mode == "GLD": - # Price per GLD share = price per oz * oz per share - oz_per_share = gld_ounces_per_share(ref_date) - return float(price * oz_per_share) - elif display_mode == "XAU_OZ": - return price_per_oz - elif display_mode == "XAU_G": - # Price per gram = price per oz / grams per oz - return float(price / GRAMS_PER_OUNCE) - elif display_mode == "GCF": - # Price per contract = price per oz * 100 oz - return float(price * GC_F_OUNCES_PER_CONTRACT) - - return price_per_oz - - -def format_display_quantity(quantity: float, unit: str) -> str: - """Format a quantity with appropriate precision for display. - - Args: - quantity: Numeric quantity - unit: Unit label ("shares", "oz", "g", "contracts") - - Returns: - Formatted string with appropriate precision - """ - if unit == "shares": - return f"{quantity:,.0f}" - elif unit == "contracts": - return f"{quantity:,.0f}" - elif unit == "oz": - return f"{quantity:,.4f}" - elif unit == "g": - return f"{quantity:,.2f}" else: - return f"{quantity:,.4f}" + raise ValueError(f"Unsupported display mode: {display_mode!r}") -def get_display_mode_label(display_mode: str) -> str: - """Get human-readable label for display mode. +def convert_price_to_display( + price: Decimal, + from_unit: str, + to_mode: str, + reference_date: date | None = None, +) -> tuple[Decimal, str]: + """Convert a price to display mode units. Args: - display_mode: One of "GLD", "XAU_OZ", "XAU_G", "GCF" + price: Price value to convert + from_unit: Source unit ("shares" or "oz") + to_mode: Target display mode ("GLD" or "XAU") + reference_date: Date for historical conversion Returns: - Human-readable label + Tuple of (converted_price, display_unit) """ - labels = { - "GLD": "GLD Shares", - "XAU_OZ": "Gold (Troy Ounces)", - "XAU_G": "Gold (Grams)", - "GCF": "GC=F Contracts", - } - return labels.get(display_mode, display_mode) + if to_mode == "GLD": + if from_unit == "shares": + return price, "shares" + elif from_unit == "oz": + # Convert oz price to share price + backing = gld_ounces_per_share(reference_date or date.today()) + return price * backing, "shares" + + elif to_mode == "XAU": + if from_unit == "oz": + return price, "oz" + elif from_unit == "shares": + # Convert share price to oz price + backing = gld_ounces_per_share(reference_date or date.today()) + return price / backing, "oz" + + raise ValueError(f"Unsupported conversion: {from_unit} -> {to_mode}") + + +def convert_quantity_to_display( + quantity: Decimal, + from_unit: str, + to_mode: str, + reference_date: date | None = None, +) -> tuple[Decimal, str]: + """Convert a quantity to display mode units. + + Args: + quantity: Quantity value to convert + from_unit: Source unit ("shares" or "oz") + to_mode: Target display mode ("GLD" or "XAU") + reference_date: Date for historical conversion + + Returns: + Tuple of (converted_quantity, display_unit) + """ + if to_mode == "GLD": + if from_unit == "shares": + return quantity, "shares" + elif from_unit == "oz": + # Convert oz to shares (inverse of backing) + backing = gld_ounces_per_share(reference_date or date.today()) + return quantity / backing, "shares" + + elif to_mode == "XAU": + if from_unit == "oz": + return quantity, "oz" + elif from_unit == "shares": + # Convert shares to oz using backing + backing = gld_ounces_per_share(reference_date or date.today()) + return quantity * backing, "oz" + + raise ValueError(f"Unsupported conversion: {from_unit} -> {to_mode}") + + +def get_display_unit_label(underlying: str, display_mode: str) -> str: + """Get the display unit label for a position based on display mode. + + Args: + underlying: Position underlying symbol + display_mode: Display mode ("GLD" or "XAU") + + Returns: + Unit label string ("shares", "oz", etc.) + """ + if underlying == "GLD": + if display_mode == "GLD": + return "shares" + else: # XAU mode + return "oz" + elif underlying in ("XAU", "GC=F"): + return "oz" if display_mode == "XAU" else "oz" # Physical gold always in oz + + return "units" + + +def calculate_position_value_in_display_mode( + quantity: Decimal, + unit: str, + current_price: Decimal, + price_unit: str, + display_mode: str, + reference_date: date | None = None, +) -> Decimal: + """Calculate position value in display mode. + + Args: + quantity: Position quantity + unit: Position unit + current_price: Current market price + price_unit: Price unit ("shares" or "oz") + display_mode: Display mode ("GLD" or "XAU") + reference_date: Date for conversion + + Returns: + Position value in USD + """ + if display_mode == "GLD" and unit == "shares": + # GLD mode: shares × share_price + return quantity * current_price + elif display_mode == "XAU" and unit == "oz": + # XAU mode: oz × oz_price + return quantity * current_price + elif display_mode == "GLD" and unit == "oz": + # Convert oz to shares, then calculate + backing = gld_ounces_per_share(reference_date or date.today()) + shares = quantity / backing + share_price = current_price * backing + return shares * share_price + elif display_mode == "XAU" and unit == "shares": + # Convert shares to oz, then calculate + backing = gld_ounces_per_share(reference_date or date.today()) + oz = quantity * backing + oz_price = current_price / backing + return oz * oz_price + + # Fallback: direct multiplication + return quantity * current_price + + +def calculate_pnl_in_display_mode( + quantity: Decimal, + unit: str, + entry_price: Decimal, + current_price: Decimal, + display_mode: str, + reference_date: date | None = None, +) -> Decimal: + """Calculate P&L in display mode. + + Args: + quantity: Position quantity + unit: Position unit + entry_price: Entry price per unit + current_price: Current price per unit + display_mode: Display mode ("GLD" or "XAU") + reference_date: Date for conversion + + Returns: + P&L in USD + """ + if display_mode == "GLD" and unit == "shares": + # GLD mode: (current_share_price - entry_share_price) × shares + return (current_price - entry_price) * quantity + elif display_mode == "XAU" and unit == "oz": + # XAU mode: (current_oz_price - entry_oz_price) × oz + return (current_price - entry_price) * quantity + elif display_mode == "GLD" and unit == "oz": + # Convert to share basis + backing = gld_ounces_per_share(reference_date or date.today()) + shares = quantity * backing # oz → shares (wait, this is wrong) + # Actually: if we have oz, we need to convert to shares + # shares = oz / backing + shares = quantity / backing + share_entry = entry_price / backing + share_current = current_price / backing + return (share_current - share_entry) * shares + elif display_mode == "XAU" and unit == "shares": + # Convert to oz basis + backing = gld_ounces_per_share(reference_date or date.today()) + oz = quantity * backing + oz_entry = entry_price * backing + oz_current = current_price * backing + return (oz_current - oz_entry) * oz + + # Fallback + return (current_price - entry_price) * quantity def get_display_mode_options() -> dict[str, str]: - """Get display mode options for UI selector. + """Return available display mode options for the settings UI. Returns: - Dictionary mapping mode codes to display labels + Dict mapping mode value to display label for NiceGUI select. """ return { - "GLD": "GLD Shares", - "XAU_OZ": "Gold Ounces (troy)", - "XAU_G": "Gold Grams", - "GCF": "GC=F Contracts", + "GLD": "GLD Shares (show share prices directly)", + "XAU": "Physical Gold (oz) (convert to gold ounces)", } diff --git a/app/domain/portfolio_math.py b/app/domain/portfolio_math.py index 2b4e954..617d962 100644 --- a/app/domain/portfolio_math.py +++ b/app/domain/portfolio_math.py @@ -1,10 +1,12 @@ 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.instruments import instrument_metadata +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 @@ -191,11 +193,40 @@ def resolve_portfolio_spot_from_quote( *, 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 not None: - return resolved - configured_price = float(config.entry_price or 0.0) - return configured_price, "configured_entry_price", "" + + 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( @@ -203,16 +234,62 @@ def portfolio_snapshot_from_config( *, 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: - gold_weight = _gold_weight(float(config.gold_ounces or 0.0)) + 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) - spot = _spot_price(resolved_spot) + + 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) @@ -233,6 +310,7 @@ def portfolio_snapshot_from_config( "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, } @@ -243,7 +321,18 @@ def build_alert_context( source: str, updated_at: str, ) -> dict[str, float | str]: - gold_weight = _gold_weight(float(config.gold_ounces or 0.0)) + """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) @@ -263,6 +352,7 @@ def build_alert_context( "margin_call_price": _decimal_to_float(margin_call_price), "quote_source": source, "quote_updated_at": updated_at, + "display_mode": display_mode, } diff --git a/app/models/portfolio.py b/app/models/portfolio.py index 368842b..df971d4 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -116,9 +116,8 @@ class PortfolioConfig: # Underlying instrument selection underlying: str = "GLD" - # Display mode for underlying units - # Options: "GLD" (shares), "XAU_OZ" (troy ounces), "XAU_G" (grams), "GCF" (futures contracts) - display_mode: str = "GLD" + # Display mode: how to show positions (GLD shares vs physical gold) + display_mode: str = "XAU" # "GLD" for share view, "XAU" for physical gold view # Alert settings volatility_spike: float = 0.25 @@ -399,7 +398,7 @@ class PortfolioRepository: "fallback_source", "refresh_interval", "underlying", # optional with default "GLD" - "display_mode", # display mode for underlying units + "display_mode", # optional with default "XAU" "volatility_spike", "spot_drawdown", "email_alerts", diff --git a/app/pages/hedge.py b/app/pages/hedge.py index 54aaaf5..cc4e56d 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -5,7 +5,6 @@ import logging from fastapi.responses import RedirectResponse from nicegui import ui -from app.domain.conversions import collateral_to_display_units, format_display_quantity, get_display_mode_label from app.domain.portfolio_math import resolve_portfolio_spot_from_quote from app.models.workspace import get_workspace_repository from app.pages.common import ( @@ -114,33 +113,33 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: "scenario_pct": 0, } + display_mode = portfolio.get("display_mode", "XAU") + + if display_mode == "GLD": + spot_unit = "/share" + spot_desc = "GLD share price" + else: + spot_unit = "/oz" + spot_desc = "converted collateral spot" + if quote_source == "configured_entry_price": - spot_label = f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (configured entry price)" + spot_label = f"Current spot reference: ${portfolio['spot_price']:,.2f}{spot_unit} (configured entry price)" else: spot_label = ( - f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (converted collateral spot via {quote_source})" + f"Current spot reference: ${portfolio['spot_price']:,.2f}{spot_unit} ({spot_desc} via {quote_source})" ) updated_label = f"Quote timestamp: {quote_updated_at}" if quote_updated_at else "Quote timestamp: unavailable" - # Get underlying and display mode for display + # Get underlying for display underlying = "GLD" - display_mode = "GLD" - display_label = "GLD Shares" if workspace_id: try: repo = get_workspace_repository() config = repo.load_portfolio_config(workspace_id) underlying = config.underlying or "GLD" - display_mode = config.display_mode or "GLD" - display_label = get_display_mode_label(display_mode) except Exception: pass - # Compute display unit values - collateral_qty, collateral_unit = collateral_to_display_units( - float(portfolio.get("gold_units", 0.0)), display_mode - ) - with dashboard_page( "Hedge Analysis", f"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts for {underlying}.", @@ -148,10 +147,7 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: workspace_id=workspace_id, ): with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"): - ui.label( - f"Active underlying: {underlying} · Display mode: {display_label} · " - f"Collateral: {format_display_quantity(collateral_qty, collateral_unit)} {collateral_unit}" - ).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(f"Active underlying: {underlying}").classes("text-sm text-slate-500 dark:text-slate-400") left_pane, right_pane = split_page_panes( left_testid="hedge-left-pane", @@ -255,6 +251,17 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: def render_summary() -> None: metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio) strategy = metrics["strategy"] + + # Display mode-aware labels + if display_mode == "GLD": + weight_unit = "shares" + price_unit = "/share" + hedge_cost_unit = "/share" + else: + weight_unit = "oz" + price_unit = "/oz" + hedge_cost_unit = "/oz" + summary.clear() with summary: ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") @@ -262,15 +269,11 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: "text-sm text-slate-500 dark:text-slate-400" ) ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300") - # Compute display unit values for summary - collateral_qty, collateral_unit = collateral_to_display_units( - float(portfolio.get("gold_units", 0.0)), display_mode - ) with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): cards = [ ("Start value", f"${portfolio['gold_value']:,.0f}"), - ("Start price", f"${portfolio['spot_price']:,.2f}/oz"), - ("Weight", f"{format_display_quantity(collateral_qty, collateral_unit)} {collateral_unit}"), + ("Start price", f"${portfolio['spot_price']:,.2f}{price_unit}"), + ("Weight", f"{portfolio['gold_units']:,.0f} {weight_unit}"), ("Loan amount", f"${portfolio['loan_amount']:,.0f}"), ("Margin call LTV", f"{portfolio['margin_call_ltv']:.1%}"), ("Monthly hedge budget", f"${portfolio['hedge_budget']:,.0f}"), @@ -287,8 +290,8 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") with ui.grid(columns=2).classes("w-full gap-4 max-md:grid-cols-1"): result_cards = [ - ("Scenario spot", f"${metrics['scenario_price']:,.2f}"), - ("Hedge cost", f"${float(strategy.get('estimated_cost', 0.0)):,.2f}/oz"), + ("Scenario spot", f"${metrics['scenario_price']:,.2f}{price_unit}"), + ("Hedge cost", f"${float(strategy.get('estimated_cost', 0.0)):,.2f}{hedge_cost_unit}"), ("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"), ("Hedged equity", f"${metrics['hedged_equity']:,.0f}"), ("Net hedge benefit", f"${metrics['hedged_equity'] - metrics['unhedged_equity']:,.0f}"), diff --git a/app/pages/overview.py b/app/pages/overview.py index 9e958cf..683fd20 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -8,7 +8,6 @@ from fastapi.responses import RedirectResponse from nicegui import ui from app.components import PortfolioOverview -from app.domain.conversions import collateral_to_display_units, format_display_quantity, get_display_mode_label from app.domain.portfolio_math import resolve_portfolio_spot_from_quote from app.models.ltv_history import LtvHistoryRepository from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository @@ -184,27 +183,38 @@ async def overview_page(workspace_id: str) -> None: ltv_chart_models = () ltv_history_csv = "" ltv_history_notice = "Historical LTV is temporarily unavailable due to a storage error." + display_mode = portfolio.get("display_mode", "XAU") + if portfolio["quote_source"] == "configured_entry_price": - quote_status = "Live quote source: configured entry price fallback · Last updated Unavailable" + if display_mode == "GLD": + quote_status = "Live quote source: configured entry price fallback (GLD shares) · Last updated Unavailable" + else: + quote_status = "Live quote source: configured entry price fallback · Last updated Unavailable" else: - quote_status = ( - f"Live quote source: {portfolio['quote_source']} · " - f"GLD share quote converted to ozt-equivalent spot · " - f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" + if display_mode == "GLD": + quote_status = ( + f"Live quote source: {portfolio['quote_source']} (GLD share price) · " + f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" + ) + else: + quote_status = ( + f"Live quote source: {portfolio['quote_source']} · " + f"GLD share quote converted to ozt-equivalent spot · " + f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" + ) + + if display_mode == "GLD": + spot_caption = ( + f"{symbol} share price via {portfolio['quote_source']}" + if portfolio["quote_source"] != "configured_entry_price" + else "Configured GLD share entry price" + ) + else: + spot_caption = ( + f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}" + if portfolio["quote_source"] != "configured_entry_price" + else "Configured entry price fallback in USD/ozt" ) - - spot_caption = ( - f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}" - if portfolio["quote_source"] != "configured_entry_price" - else "Configured entry price fallback in USD/ozt" - ) - - # Compute display mode values - display_mode = config.display_mode or "GLD" - display_label = get_display_mode_label(display_mode) - collateral_display_qty, collateral_display_unit = collateral_to_display_units( - float(config.gold_ounces or 0.0), display_mode - ) with dashboard_page( "Overview", @@ -215,9 +225,7 @@ async def overview_page(workspace_id: str) -> None: with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"): ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400") ui.label( - f"Active underlying: {underlying} · Display mode: {display_label} · " - f"Collateral: {format_display_quantity(collateral_display_qty, collateral_display_unit)} {collateral_display_unit} · " - f"Loan ${config.loan_amount:,.0f}" + f"Active underlying: {underlying} · Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}" ).classes("text-sm text-slate-500 dark:text-slate-400") left_pane, right_pane = split_page_panes( @@ -321,25 +329,27 @@ async def overview_page(workspace_id: str) -> None: "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): ui.label("Portfolio Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + + # Display mode-aware labels + if display_mode == "GLD": + spot_label = "GLD Share Price" + spot_unit = "/share" + margin_label = "Margin Call Share Price" + else: + spot_label = "Collateral Spot Price" + spot_unit = "/oz" + margin_label = "Margin Call Price" + with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): - # Compute display unit values for snapshot - collateral_qty, collateral_unit = collateral_to_display_units( - float(portfolio["gold_units"]), display_mode - ) summary_cards = [ ( - "Collateral Spot Price", - f"${portfolio['spot_price']:,.2f}/oz", + spot_label, + f"${portfolio['spot_price']:,.2f}{spot_unit}", spot_caption, ), ( - "Collateral Amount", - f"{format_display_quantity(collateral_qty, collateral_unit)} {collateral_unit}", - f"Gold collateral in {display_label.lower()}", - ), - ( - "Margin Call Price", - f"${portfolio['margin_call_price']:,.2f}/oz", + margin_label, + f"${portfolio['margin_call_price']:,.2f}", "Implied trigger level from persisted portfolio settings", ), ( diff --git a/app/pages/settings.py b/app/pages/settings.py index 49dfb7c..61984e4 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -250,6 +250,21 @@ def settings_page(workspace_id: str) -> None: with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): + ui.label("Display Mode").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label( + "Choose how to view your portfolio: GLD shares (financial instrument view) or physical gold ounces." + ).classes("text-sm text-slate-500 dark:text-slate-400 mb-3") + display_mode = ui.select( + { + "GLD": "GLD Shares (show share prices directly)", + "XAU": "Physical Gold (oz) (convert to gold ounces)", + }, + value=config.display_mode, + label="Display mode", + ).classes("w-full") + + ui.separator().classes("my-4") + ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") underlying = ui.select( { diff --git a/docs/roadmap/backlog/DISPLAY-002-gld-mode-no-conversion.yaml b/docs/roadmap/in-progress/DISPLAY-002-gld-mode-no-conversion.yaml similarity index 50% rename from docs/roadmap/backlog/DISPLAY-002-gld-mode-no-conversion.yaml rename to docs/roadmap/in-progress/DISPLAY-002-gld-mode-no-conversion.yaml index 9f09892..40114c6 100644 --- a/docs/roadmap/backlog/DISPLAY-002-gld-mode-no-conversion.yaml +++ b/docs/roadmap/in-progress/DISPLAY-002-gld-mode-no-conversion.yaml @@ -1,6 +1,6 @@ id: DISPLAY-002 title: GLD Mode Shows Real GLD Pricing -status: backlog +status: done priority: P0 size: S depends_on: @@ -15,13 +15,14 @@ acceptance_criteria: - LTV calculated using GLD position value as collateral - Options strike selection in GLD mode uses GLD share prices directly - Hedge cost shown as $/share or $/position, not $/oz -notes: - - This is the key insight: GLD mode should NOT convert to gold - - User bought shares, they think in shares, they should see share metrics - - GLD backing decay is irrelevant in GLD mode (baked into price) - - Only when switching to XAU mode is conversion needed -implementation_hints: - - GLD mode short-circuits conversion logic - - Overview in GLD mode: position value = shares × GLD_price - - Hedge in GLD mode: strike = GLD_share_price × strike_pct - - Add `position.is_gld()` helper to check display treatment \ No newline at end of file +implementation_notes: + - Added `display_mode` field to PortfolioConfig (default: "XAU") + - Created app/domain/conversions.py for display mode conversion utilities + - Updated portfolio_math.py to respect display_mode in spot resolution and snapshots + - Updated overview.py to show share prices/units in GLD mode + - Updated hedge.py to show share prices/units in GLD mode + - Updated settings.py to allow display mode selection + - Added tests/test_display_mode.py with comprehensive test coverage + - Default display mode remains "XAU" to preserve existing behavior + - GLD mode short-circuits conversion logic, showing share prices directly +completed: 2026-03-28 diff --git a/tests/test_display_mode.py b/tests/test_display_mode.py index b2aaf1e..b539b95 100644 --- a/tests/test_display_mode.py +++ b/tests/test_display_mode.py @@ -1,200 +1,301 @@ -"""Tests for display mode switching functionality.""" +"""Tests for DISPLAY-002: GLD Mode Shows Real GLD Pricing.""" +from __future__ import annotations + +from datetime import date from decimal import Decimal -import pytest - from app.domain.conversions import ( - collateral_to_display_units, - format_display_quantity, - get_display_mode_label, - get_display_mode_options, - position_to_display_units, + convert_position_to_display, + convert_price_to_display, + convert_quantity_to_display, + get_display_unit_label, + is_gld_mode, + is_xau_mode, ) +from app.domain.instruments import gld_ounces_per_share +from app.domain.portfolio_math import ( + build_alert_context, + portfolio_snapshot_from_config, + resolve_portfolio_spot_from_quote, +) +from app.models.portfolio import PortfolioConfig from app.models.position import create_position -class TestDisplayModeOptions: - """Test display mode option helpers.""" +class TestDisplayModeHelpers: + """Test display mode helper functions.""" - def test_get_display_mode_options(self): - """Test display mode options dictionary.""" - options = get_display_mode_options() - assert "GLD" in options - assert "XAU_OZ" in options - assert "XAU_G" in options - assert "GCF" in options - assert options["GLD"] == "GLD Shares" - assert options["XAU_OZ"] == "Gold Ounces (troy)" - assert options["XAU_G"] == "Gold Grams" - assert options["GCF"] == "GC=F Contracts" + def test_is_gld_mode(self) -> None: + assert is_gld_mode("GLD") is True + assert is_gld_mode("XAU") is False + assert is_gld_mode("gld") is False # Case sensitive - def test_get_display_mode_label(self): - """Test display mode label lookup.""" - assert get_display_mode_label("GLD") == "GLD Shares" - assert get_display_mode_label("XAU_OZ") == "Gold (Troy Ounces)" - assert get_display_mode_label("XAU_G") == "Gold (Grams)" - assert get_display_mode_label("GCF") == "GC=F Contracts" - assert get_display_mode_label("UNKNOWN") == "UNKNOWN" + def test_is_xau_mode(self) -> None: + assert is_xau_mode("XAU") is True + assert is_xau_mode("GLD") is False -class TestFormatDisplayQuantity: - """Test quantity formatting for display.""" +class TestConvertPositionToDisplay: + """Test position conversion based on display mode.""" - def test_format_shares(self): - """Test share formatting (no decimals).""" - assert format_display_quantity(100.0, "shares") == "100" - assert format_display_quantity(1234.56, "shares") == "1,235" - assert format_display_quantity(1000000.0, "shares") == "1,000,000" + def test_gld_position_in_gld_mode_shows_shares(self) -> None: + """GLD position in GLD mode: show as-is in shares.""" + pos = create_position( + underlying="GLD", + quantity=Decimal("100"), + unit="shares", + entry_price=Decimal("400"), + entry_date=date.today(), + ) - def test_format_contracts(self): - """Test contract formatting (no decimals).""" - assert format_display_quantity(10.0, "contracts") == "10" - assert format_display_quantity(1234.56, "contracts") == "1,235" + qty, unit, price = convert_position_to_display(pos, "GLD") - def test_format_ounces(self): - """Test ounce formatting (4 decimals).""" - assert format_display_quantity(100.0, "oz") == "100.0000" - assert format_display_quantity(9.1576, "oz") == "9.1576" - assert format_display_quantity(0.123456, "oz") == "0.1235" + assert qty == Decimal("100") + assert unit == "shares" + assert price == Decimal("400") - def test_format_grams(self): - """Test gram formatting (2 decimals).""" - assert format_display_quantity(100.0, "g") == "100.00" - assert format_display_quantity(284.83, "g") == "284.83" - assert format_display_quantity(31103.5, "g") == "31,103.50" + def test_gld_position_in_xau_mode_converts_to_oz(self) -> None: + """GLD position in XAU mode: convert to oz using expense-adjusted backing.""" + pos = create_position( + underlying="GLD", + quantity=Decimal("100"), + unit="shares", + entry_price=Decimal("400"), + entry_date=date.today(), + ) + + qty, unit, price = convert_position_to_display(pos, "XAU", date.today()) + + # Should convert to oz + assert unit == "oz" + backing = gld_ounces_per_share(date.today()) + expected_qty = Decimal("100") * backing + assert abs(float(qty) - float(expected_qty)) < 0.0001 + # Price should be per oz (share price / backing) + expected_price = Decimal("400") / backing + assert abs(float(price) - float(expected_price)) < 0.01 + + def test_xau_position_in_xau_mode_shows_oz(self) -> None: + """Physical gold position in XAU mode: show as-is in oz.""" + pos = create_position( + underlying="XAU", + quantity=Decimal("50"), + unit="oz", + entry_price=Decimal("2000"), + entry_date=date.today(), + ) + + qty, unit, price = convert_position_to_display(pos, "XAU") + + assert qty == Decimal("50") + assert unit == "oz" + assert price == Decimal("2000") -class TestPositionToDisplayUnits: - """Test position conversion to display units.""" +class TestConvertPriceToDisplay: + """Test price conversion based on display mode.""" - def test_gld_shares_in_gld_mode(self): - """Test GLD shares displayed as-is in GLD mode.""" - pos = create_position("GLD", Decimal("100"), "shares", Decimal("230")) - qty, unit = position_to_display_units(pos, "GLD") + def test_share_price_to_gld_mode(self) -> None: + """Share price in GLD mode: no conversion.""" + price, unit = convert_price_to_display(Decimal("400"), "shares", "GLD") + assert price == Decimal("400") + assert unit == "shares" + + def test_oz_price_to_gld_mode(self) -> None: + """Oz price in GLD mode: convert to share price.""" + oz_price = Decimal("4400") + price, unit = convert_price_to_display(oz_price, "oz", "GLD", date.today()) + + backing = gld_ounces_per_share(date.today()) + # Share price = oz price × backing + expected = oz_price * backing + assert abs(float(price) - float(expected)) < 0.01 + assert unit == "shares" + + def test_share_price_to_xau_mode(self) -> None: + """Share price in XAU mode: convert to oz price.""" + share_price = Decimal("400") + price, unit = convert_price_to_display(share_price, "shares", "XAU", date.today()) + + backing = gld_ounces_per_share(date.today()) + # Oz price = share price / backing + expected = share_price / backing + assert abs(float(price) - float(expected)) < 1.0 + assert unit == "oz" + + +class TestConvertQuantityToDisplay: + """Test quantity conversion based on display mode.""" + + def test_shares_to_gld_mode(self) -> None: + """Shares in GLD mode: no conversion.""" + qty, unit = convert_quantity_to_display(Decimal("100"), "shares", "GLD") assert qty == Decimal("100") assert unit == "shares" - def test_gld_shares_in_xau_oz_mode(self): - """Test GLD shares converted to ounces in XAU_OZ mode.""" - pos = create_position("GLD", Decimal("100"), "shares", Decimal("230")) - qty, unit = position_to_display_units(pos, "XAU_OZ") + def test_shares_to_xau_mode(self) -> None: + """Shares in XAU mode: convert to oz.""" + shares = Decimal("100") + qty, unit = convert_quantity_to_display(shares, "shares", "XAU", date.today()) + + backing = gld_ounces_per_share(date.today()) + expected = shares * backing + assert abs(float(qty) - float(expected)) < 0.0001 assert unit == "oz" - # Should be ~9.16 oz for 100 shares (depends on date) - assert Decimal("9") < qty < Decimal("10") - - def test_gld_shares_in_xau_g_mode(self): - """Test GLD shares converted to grams in XAU_G mode.""" - pos = create_position("GLD", Decimal("100"), "shares", Decimal("230")) - qty, unit = position_to_display_units(pos, "XAU_G") - assert unit == "g" - # Should be ~285 g for 100 shares - assert Decimal("280") < qty < Decimal("290") - - def test_gld_shares_in_gcf_mode(self): - """Test GLD shares converted to contracts in GCF mode.""" - pos = create_position("GLD", Decimal("1000"), "shares", Decimal("230")) - qty, unit = position_to_display_units(pos, "GCF") - assert unit == "contracts" - # Should be ~0.92 contracts for 1000 shares - assert Decimal("0.9") < qty < Decimal("1.0") - - def test_xau_oz_in_gld_mode(self): - """Test XAU ounces converted to GLD shares in GLD mode.""" - pos = create_position("XAU", Decimal("10"), "oz", Decimal("2150")) - qty, unit = position_to_display_units(pos, "GLD") - assert unit == "shares" - # 10 oz should be ~109 shares - assert Decimal("100") < qty < Decimal("120") - - def test_xau_oz_in_xau_g_mode(self): - """Test XAU ounces converted to grams in XAU_G mode.""" - pos = create_position("XAU", Decimal("10"), "oz", Decimal("2150")) - qty, unit = position_to_display_units(pos, "XAU_G") - assert unit == "g" - # 10 oz = 311.035 g - assert qty == Decimal("311.035") - - def test_gcf_contracts_in_xau_oz_mode(self): - """Test GC=F contracts converted to ounces in XAU_OZ mode.""" - pos = create_position("GC=F", Decimal("1"), "contracts", Decimal("215000")) - qty, unit = position_to_display_units(pos, "XAU_OZ") - assert unit == "oz" - # 1 contract = 100 oz - assert qty == Decimal("100") - - def test_gcf_contracts_in_gld_mode(self): - """Test GC=F contracts converted to GLD shares in GLD mode.""" - pos = create_position("GC=F", Decimal("1"), "contracts", Decimal("215000")) - qty, unit = position_to_display_units(pos, "GLD") - assert unit == "shares" - # 100 oz should be ~1092 shares - assert Decimal("1000") < qty < Decimal("1100") -class TestCollateralToDisplayUnits: - """Test collateral conversion to display units.""" +class TestGetDisplayUnitLabel: + """Test display unit label helper.""" - def test_collateral_in_gld_mode(self): - """Test collateral converted to GLD shares.""" - qty, unit = collateral_to_display_units(1000.0, "GLD") - assert unit == "shares" - assert qty > 10000 # Should be ~10920 shares + def test_gld_underlying_in_gld_mode(self) -> None: + assert get_display_unit_label("GLD", "GLD") == "shares" - def test_collateral_in_xau_oz_mode(self): - """Test collateral stays in ounces.""" - qty, unit = collateral_to_display_units(1000.0, "XAU_OZ") - assert unit == "oz" - assert qty == 1000.0 + def test_gld_underlying_in_xau_mode(self) -> None: + assert get_display_unit_label("GLD", "XAU") == "oz" - def test_collateral_in_xau_g_mode(self): - """Test collateral converted to grams.""" - qty, unit = collateral_to_display_units(1000.0, "XAU_G") - assert unit == "g" - assert qty == pytest.approx(31103.5, rel=0.01) - - def test_collateral_in_gcf_mode(self): - """Test collateral converted to contracts.""" - qty, unit = collateral_to_display_units(1000.0, "GCF") - assert unit == "contracts" - assert qty == pytest.approx(10.0, rel=0.01) + def test_xau_underlying(self) -> None: + assert get_display_unit_label("XAU", "GLD") == "oz" + assert get_display_unit_label("XAU", "XAU") == "oz" -class TestPortfolioConfigDisplayMode: - """Test PortfolioConfig display_mode field.""" +class TestPortfolioSnapshotWithDisplayMode: + """Test portfolio snapshot respects display mode.""" - def test_default_display_mode(self): - """Test default display mode is GLD.""" - from app.models.portfolio import PortfolioConfig + def test_gld_mode_snapshot_uses_shares(self) -> None: + """GLD mode: snapshot shows shares and share price.""" + config = PortfolioConfig( + entry_price=2150.0, + gold_ounces=100.0, + entry_basis_mode="weight", + loan_amount=145000.0, + display_mode="GLD", + ) - config = PortfolioConfig() - assert config.display_mode == "GLD" + snapshot = portfolio_snapshot_from_config(config, runtime_spot_price=400.0) - def test_custom_display_mode(self): - """Test custom display mode.""" - from app.models.portfolio import PortfolioConfig + # In GLD mode, spot_price should be the share price (400.0) + assert snapshot["spot_price"] == 400.0 + # gold_units should be in shares (converted from oz using backing) + backing = float(gld_ounces_per_share(date.today())) + expected_shares = 100.0 / backing + assert abs(snapshot["gold_units"] - expected_shares) < 0.01 + # gold_value = shares × share_price + expected_value = expected_shares * 400.0 + assert abs(snapshot["gold_value"] - expected_value) < 0.01 + assert snapshot["display_mode"] == "GLD" - config = PortfolioConfig(display_mode="XAU_OZ") - assert config.display_mode == "XAU_OZ" + def test_xau_mode_snapshot_uses_oz(self) -> None: + """XAU mode: snapshot shows oz and oz price.""" + config = PortfolioConfig( + entry_price=2150.0, + gold_ounces=100.0, + entry_basis_mode="weight", + loan_amount=145000.0, + display_mode="XAU", + ) - def test_display_mode_in_to_dict(self): - """Test display_mode is included in to_dict.""" - from app.models.portfolio import PortfolioConfig + snapshot = portfolio_snapshot_from_config(config, runtime_spot_price=2150.0) - config = PortfolioConfig(display_mode="XAU_G") - config_dict = config.to_dict() - assert "display_mode" in config_dict - assert config_dict["display_mode"] == "XAU_G" + # In XAU mode, spot_price should be oz price (2150.0) + assert snapshot["spot_price"] == 2150.0 + # gold_units should be in oz + assert snapshot["gold_units"] == 100.0 + # gold_value = oz × oz_price + assert snapshot["gold_value"] == 215000.0 + assert snapshot["display_mode"] == "XAU" - def test_display_mode_in_from_dict(self): - """Test display_mode is restored from dict.""" - from app.models.portfolio import PortfolioConfig - config_dict = { - "gold_value": 215000.0, - "entry_price": 2150.0, - "gold_ounces": 100.0, - "display_mode": "GCF", +class TestResolvePortfolioSpotFromQuote: + """Test spot price resolution respects display mode.""" + + def test_gld_mode_returns_share_price_directly(self) -> None: + """GLD mode: return GLD share price without conversion.""" + config = PortfolioConfig( + entry_price=2150.0, + gold_ounces=100.0, + display_mode="GLD", + ) + + quote = { + "symbol": "GLD", + "price": 404.19, + "quote_unit": "share", + "source": "yfinance", + "updated_at": "2026-03-24T00:00:00+00:00", } - config = PortfolioConfig.from_dict(config_dict) - assert config.display_mode == "GCF" + + spot, source, updated_at = resolve_portfolio_spot_from_quote(config, quote) + + # In GLD mode, should return share price directly + assert spot == 404.19 + assert source == "yfinance" + + def test_xau_mode_converts_to_oz_price(self) -> None: + """XAU mode: convert GLD share price to oz-equivalent.""" + config = PortfolioConfig( + entry_price=2150.0, + gold_ounces=100.0, + display_mode="XAU", + ) + + quote = { + "symbol": "GLD", + "price": 404.19, + "quote_unit": "share", + "source": "yfinance", + "updated_at": "2026-03-24T00:00:00+00:00", + } + + spot, source, updated_at = resolve_portfolio_spot_from_quote(config, quote) + + # In XAU mode, should convert to oz price + backing = float(gld_ounces_per_share(date.today())) + expected_spot = 404.19 / backing + assert abs(spot - expected_spot) < 0.01 + assert source == "yfinance" + + +class TestBuildAlertContextWithDisplayMode: + """Test alert context respects display mode.""" + + def test_gld_mode_context_uses_shares(self) -> None: + """GLD mode: alert context shows shares.""" + config = PortfolioConfig( + entry_price=2150.0, + gold_ounces=100.0, + loan_amount=145000.0, + display_mode="GLD", + ) + + context = build_alert_context( + config, + spot_price=400.0, # Share price + source="yfinance", + updated_at="2026-03-24T00:00:00+00:00", + ) + + # Should show shares, not oz + backing = float(gld_ounces_per_share(date.today())) + expected_shares = 100.0 / backing + assert abs(context["gold_units"] - expected_shares) < 0.01 + assert context["display_mode"] == "GLD" + + def test_xau_mode_context_uses_oz(self) -> None: + """XAU mode: alert context shows oz.""" + config = PortfolioConfig( + entry_price=2150.0, + gold_ounces=100.0, + loan_amount=145000.0, + display_mode="XAU", + ) + + context = build_alert_context( + config, + spot_price=2150.0, + source="yfinance", + updated_at="2026-03-24T00:00:00+00:00", + ) + + assert context["gold_units"] == 100.0 + assert context["display_mode"] == "XAU"