diff --git a/app/domain/conversions.py b/app/domain/conversions.py new file mode 100644 index 0000000..822f513 --- /dev/null +++ b/app/domain/conversions.py @@ -0,0 +1,298 @@ +"""Display mode conversion utilities for GLD/XAU views. + +This module handles conversion between GLD share prices and physical gold prices +based on the user's display mode preference. + +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 gld_ounces_per_share +from app.models.position import Position + + +@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 is_gld_mode(display_mode: str) -> bool: + """Check if display mode is GLD (share view).""" + return display_mode == "GLD" + + +def is_xau_mode(display_mode: str) -> bool: + """Check if display mode is XAU (physical gold view).""" + return display_mode == "XAU" + + +def convert_position_to_display( + position: Position, + display_mode: str, + reference_date: date | None = None, +) -> tuple[Decimal, str, Decimal]: + """Convert a position to display units based on display mode. + + Args: + 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 (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( + ... 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": + # 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": + # 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 + + else: + raise ValueError(f"Unsupported display mode: {display_mode!r}") + + +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: + 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: + Tuple of (converted_price, display_unit) + """ + 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 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 314e536..df971d4 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -116,6 +116,9 @@ class PortfolioConfig: # Underlying instrument selection underlying: 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 spot_drawdown: float = 7.5 @@ -311,6 +314,7 @@ class PortfolioConfig: "fallback_source": self.fallback_source, "refresh_interval": self.refresh_interval, "underlying": self.underlying, + "display_mode": self.display_mode, "volatility_spike": self.volatility_spike, "spot_drawdown": self.spot_drawdown, "email_alerts": self.email_alerts, @@ -394,6 +398,7 @@ class PortfolioRepository: "fallback_source", "refresh_interval", "underlying", # optional with default "GLD" + "display_mode", # optional with default "XAU" "volatility_spike", "spot_drawdown", "email_alerts", @@ -491,7 +496,7 @@ class PortfolioRepository: # Fields that must be present in persisted payloads # (underlying is optional with default "GLD") # (positions is optional - legacy configs won't have it) - _REQUIRED_FIELDS = (_PERSISTED_FIELDS - {"underlying"}) - {"positions"} + _REQUIRED_FIELDS = (_PERSISTED_FIELDS - {"underlying", "display_mode"}) - {"positions"} @classmethod def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None: diff --git a/app/pages/hedge.py b/app/pages/hedge.py index 5022b30..cc4e56d 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -113,11 +113,20 @@ 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" @@ -242,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") @@ -252,8 +272,8 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: 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"{portfolio['gold_units']:,.0f} oz"), + ("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}"), @@ -270,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 d4359fd..683fd20 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -183,20 +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']))}" + ) - 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" - ) + 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" + ) with dashboard_page( "Overview", @@ -311,15 +329,26 @@ 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"): summary_cards = [ ( - "Collateral Spot Price", - f"${portfolio['spot_price']:,.2f}", + spot_label, + f"${portfolio['spot_price']:,.2f}{spot_unit}", spot_caption, ), ( - "Margin Call Price", + 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 6b3c91a..55b07fb 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -126,6 +126,7 @@ def settings_page(workspace_id: str) -> None: fallback_source=str(fallback_source.value), refresh_interval=parsed_refresh_interval, underlying=str(underlying.value), + display_mode=str(display_mode.value), volatility_spike=float(vol_alert.value), spot_drawdown=float(price_alert.value), email_alerts=bool(email_alerts.value), @@ -248,6 +249,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 new file mode 100644 index 0000000..b539b95 --- /dev/null +++ b/tests/test_display_mode.py @@ -0,0 +1,301 @@ +"""Tests for DISPLAY-002: GLD Mode Shows Real GLD Pricing.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from app.domain.conversions import ( + 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 TestDisplayModeHelpers: + """Test display mode helper functions.""" + + 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_is_xau_mode(self) -> None: + assert is_xau_mode("XAU") is True + assert is_xau_mode("GLD") is False + + +class TestConvertPositionToDisplay: + """Test position conversion based on display mode.""" + + 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(), + ) + + qty, unit, price = convert_position_to_display(pos, "GLD") + + assert qty == Decimal("100") + assert unit == "shares" + assert price == Decimal("400") + + 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 TestConvertPriceToDisplay: + """Test price conversion based on display mode.""" + + 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_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" + + +class TestGetDisplayUnitLabel: + """Test display unit label helper.""" + + def test_gld_underlying_in_gld_mode(self) -> None: + assert get_display_unit_label("GLD", "GLD") == "shares" + + def test_gld_underlying_in_xau_mode(self) -> None: + assert get_display_unit_label("GLD", "XAU") == "oz" + + def test_xau_underlying(self) -> None: + assert get_display_unit_label("XAU", "GLD") == "oz" + assert get_display_unit_label("XAU", "XAU") == "oz" + + +class TestPortfolioSnapshotWithDisplayMode: + """Test portfolio snapshot respects display mode.""" + + 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", + ) + + snapshot = portfolio_snapshot_from_config(config, runtime_spot_price=400.0) + + # 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" + + 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", + ) + + snapshot = portfolio_snapshot_from_config(config, runtime_spot_price=2150.0) + + # 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" + + +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", + } + + 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"