feat(DISPLAY-002): GLD mode shows real share prices

This commit is contained in:
Bu5hm4nn
2026-03-28 21:45:00 +01:00
parent 24c74cacbd
commit dac0463d55
8 changed files with 800 additions and 40 deletions

298
app/domain/conversions.py Normal file
View File

@@ -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

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Any, Mapping from typing import Any, Mapping
from app.domain.backtesting_math import PricePerAsset 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.domain.units import BaseCurrency, Money, PricePerWeight, Weight, WeightUnit, decimal_from_float
from app.models.portfolio import PortfolioConfig from app.models.portfolio import PortfolioConfig
@@ -191,28 +193,103 @@ def resolve_portfolio_spot_from_quote(
*, *,
fallback_symbol: str | None = None, fallback_symbol: str | None = None,
) -> tuple[float, str, str]: ) -> 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) resolved = resolve_collateral_spot_from_quote(quote, fallback_symbol=fallback_symbol)
if resolved is not None:
return resolved if resolved is None:
# No valid quote, use configured entry price
configured_price = float(config.entry_price or 0.0) configured_price = float(config.entry_price or 0.0)
return configured_price, "configured_entry_price", "" 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( def portfolio_snapshot_from_config(
config: PortfolioConfig | None = None, config: PortfolioConfig | None = None,
*, *,
runtime_spot_price: float | None = None, runtime_spot_price: float | None = None,
) -> dict[str, float]: ) -> 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: if config is None:
gold_weight = Weight(amount=Decimal("1000"), unit=WeightUnit.OUNCE_TROY) gold_weight = Weight(amount=Decimal("1000"), unit=WeightUnit.OUNCE_TROY)
spot = PricePerWeight(amount=Decimal("215"), currency=BaseCurrency.USD, per_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) loan_amount = Money(amount=Decimal("145000"), currency=BaseCurrency.USD)
margin_call_ltv = Decimal("0.75") margin_call_ltv = Decimal("0.75")
hedge_budget = Money(amount=Decimal("8000"), currency=BaseCurrency.USD) hedge_budget = Money(amount=Decimal("8000"), currency=BaseCurrency.USD)
display_mode = "XAU"
else: 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) 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) spot = _spot_price(resolved_spot)
loan_amount = Money(amount=decimal_from_float(float(config.loan_amount)), currency=BaseCurrency.USD) 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_ltv = decimal_from_float(float(config.margin_threshold))
hedge_budget = Money(amount=decimal_from_float(float(config.monthly_budget)), currency=BaseCurrency.USD) 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), "margin_call_price": _decimal_to_float(margin_call_price),
"cash_buffer": _DEFAULT_CASH_BUFFER, "cash_buffer": _DEFAULT_CASH_BUFFER,
"hedge_budget": _money_to_float(hedge_budget), "hedge_budget": _money_to_float(hedge_budget),
"display_mode": display_mode,
} }
@@ -243,7 +321,18 @@ def build_alert_context(
source: str, source: str,
updated_at: str, updated_at: str,
) -> dict[str, float | 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)) gold_weight = _gold_weight(float(config.gold_ounces or 0.0))
live_spot = _spot_price(spot_price) live_spot = _spot_price(spot_price)
gold_value = gold_weight * live_spot gold_value = gold_weight * live_spot
loan_amount = Money(amount=decimal_from_float(float(config.loan_amount)), currency=BaseCurrency.USD) 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), "margin_call_price": _decimal_to_float(margin_call_price),
"quote_source": source, "quote_source": source,
"quote_updated_at": updated_at, "quote_updated_at": updated_at,
"display_mode": display_mode,
} }

View File

@@ -116,6 +116,9 @@ class PortfolioConfig:
# Underlying instrument selection # Underlying instrument selection
underlying: str = "GLD" 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 # Alert settings
volatility_spike: float = 0.25 volatility_spike: float = 0.25
spot_drawdown: float = 7.5 spot_drawdown: float = 7.5
@@ -311,6 +314,7 @@ class PortfolioConfig:
"fallback_source": self.fallback_source, "fallback_source": self.fallback_source,
"refresh_interval": self.refresh_interval, "refresh_interval": self.refresh_interval,
"underlying": self.underlying, "underlying": self.underlying,
"display_mode": self.display_mode,
"volatility_spike": self.volatility_spike, "volatility_spike": self.volatility_spike,
"spot_drawdown": self.spot_drawdown, "spot_drawdown": self.spot_drawdown,
"email_alerts": self.email_alerts, "email_alerts": self.email_alerts,
@@ -394,6 +398,7 @@ class PortfolioRepository:
"fallback_source", "fallback_source",
"refresh_interval", "refresh_interval",
"underlying", # optional with default "GLD" "underlying", # optional with default "GLD"
"display_mode", # optional with default "XAU"
"volatility_spike", "volatility_spike",
"spot_drawdown", "spot_drawdown",
"email_alerts", "email_alerts",
@@ -491,7 +496,7 @@ class PortfolioRepository:
# Fields that must be present in persisted payloads # Fields that must be present in persisted payloads
# (underlying is optional with default "GLD") # (underlying is optional with default "GLD")
# (positions is optional - legacy configs won't have it) # (positions is optional - legacy configs won't have it)
_REQUIRED_FIELDS = (_PERSISTED_FIELDS - {"underlying"}) - {"positions"} _REQUIRED_FIELDS = (_PERSISTED_FIELDS - {"underlying", "display_mode"}) - {"positions"}
@classmethod @classmethod
def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None: def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None:

View File

@@ -113,11 +113,20 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
"scenario_pct": 0, "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": 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: else:
spot_label = ( 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" 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: def render_summary() -> None:
metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio) metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio)
strategy = metrics["strategy"] 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() summary.clear()
with summary: with summary:
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") 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"): with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
cards = [ cards = [
("Start value", f"${portfolio['gold_value']:,.0f}"), ("Start value", f"${portfolio['gold_value']:,.0f}"),
("Start price", f"${portfolio['spot_price']:,.2f}/oz"), ("Start price", f"${portfolio['spot_price']:,.2f}{price_unit}"),
("Weight", f"{portfolio['gold_units']:,.0f} oz"), ("Weight", f"{portfolio['gold_units']:,.0f} {weight_unit}"),
("Loan amount", f"${portfolio['loan_amount']:,.0f}"), ("Loan amount", f"${portfolio['loan_amount']:,.0f}"),
("Margin call LTV", f"{portfolio['margin_call_ltv']:.1%}"), ("Margin call LTV", f"{portfolio['margin_call_ltv']:.1%}"),
("Monthly hedge budget", f"${portfolio['hedge_budget']:,.0f}"), ("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") 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"): with ui.grid(columns=2).classes("w-full gap-4 max-md:grid-cols-1"):
result_cards = [ result_cards = [
("Scenario spot", f"${metrics['scenario_price']:,.2f}"), ("Scenario spot", f"${metrics['scenario_price']:,.2f}{price_unit}"),
("Hedge cost", f"${float(strategy.get('estimated_cost', 0.0)):,.2f}/oz"), ("Hedge cost", f"${float(strategy.get('estimated_cost', 0.0)):,.2f}{hedge_cost_unit}"),
("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"), ("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"),
("Hedged equity", f"${metrics['hedged_equity']:,.0f}"), ("Hedged equity", f"${metrics['hedged_equity']:,.0f}"),
("Net hedge benefit", f"${metrics['hedged_equity'] - metrics['unhedged_equity']:,.0f}"), ("Net hedge benefit", f"${metrics['hedged_equity'] - metrics['unhedged_equity']:,.0f}"),

View File

@@ -183,8 +183,19 @@ async def overview_page(workspace_id: str) -> None:
ltv_chart_models = () ltv_chart_models = ()
ltv_history_csv = "" ltv_history_csv = ""
ltv_history_notice = "Historical LTV is temporarily unavailable due to a storage error." 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": if portfolio["quote_source"] == "configured_entry_price":
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" quote_status = "Live quote source: configured entry price fallback · Last updated Unavailable"
else:
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: else:
quote_status = ( quote_status = (
f"Live quote source: {portfolio['quote_source']} · " f"Live quote source: {portfolio['quote_source']} · "
@@ -192,6 +203,13 @@ async def overview_page(workspace_id: str) -> None:
f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" 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 = ( spot_caption = (
f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}" f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}"
if portfolio["quote_source"] != "configured_entry_price" if portfolio["quote_source"] != "configured_entry_price"
@@ -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" "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") 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"): with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
summary_cards = [ summary_cards = [
( (
"Collateral Spot Price", spot_label,
f"${portfolio['spot_price']:,.2f}", f"${portfolio['spot_price']:,.2f}{spot_unit}",
spot_caption, spot_caption,
), ),
( (
"Margin Call Price", margin_label,
f"${portfolio['margin_call_price']:,.2f}", f"${portfolio['margin_call_price']:,.2f}",
"Implied trigger level from persisted portfolio settings", "Implied trigger level from persisted portfolio settings",
), ),

View File

@@ -126,6 +126,7 @@ def settings_page(workspace_id: str) -> None:
fallback_source=str(fallback_source.value), fallback_source=str(fallback_source.value),
refresh_interval=parsed_refresh_interval, refresh_interval=parsed_refresh_interval,
underlying=str(underlying.value), underlying=str(underlying.value),
display_mode=str(display_mode.value),
volatility_spike=float(vol_alert.value), volatility_spike=float(vol_alert.value),
spot_drawdown=float(price_alert.value), spot_drawdown=float(price_alert.value),
email_alerts=bool(email_alerts.value), email_alerts=bool(email_alerts.value),
@@ -248,6 +249,21 @@ def settings_page(workspace_id: str) -> None:
with ui.card().classes( with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "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") ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
underlying = ui.select( underlying = ui.select(
{ {

View File

@@ -1,6 +1,6 @@
id: DISPLAY-002 id: DISPLAY-002
title: GLD Mode Shows Real GLD Pricing title: GLD Mode Shows Real GLD Pricing
status: backlog status: done
priority: P0 priority: P0
size: S size: S
depends_on: depends_on:
@@ -15,13 +15,14 @@ acceptance_criteria:
- LTV calculated using GLD position value as collateral - LTV calculated using GLD position value as collateral
- Options strike selection in GLD mode uses GLD share prices directly - Options strike selection in GLD mode uses GLD share prices directly
- Hedge cost shown as $/share or $/position, not $/oz - Hedge cost shown as $/share or $/position, not $/oz
notes: implementation_notes:
- This is the key insight: GLD mode should NOT convert to gold - Added `display_mode` field to PortfolioConfig (default: "XAU")
- User bought shares, they think in shares, they should see share metrics - Created app/domain/conversions.py for display mode conversion utilities
- GLD backing decay is irrelevant in GLD mode (baked into price) - Updated portfolio_math.py to respect display_mode in spot resolution and snapshots
- Only when switching to XAU mode is conversion needed - Updated overview.py to show share prices/units in GLD mode
implementation_hints: - Updated hedge.py to show share prices/units in GLD mode
- GLD mode short-circuits conversion logic - Updated settings.py to allow display mode selection
- Overview in GLD mode: position value = shares × GLD_price - Added tests/test_display_mode.py with comprehensive test coverage
- Hedge in GLD mode: strike = GLD_share_price × strike_pct - Default display mode remains "XAU" to preserve existing behavior
- Add `position.is_gld()` helper to check display treatment - GLD mode short-circuits conversion logic, showing share prices directly
completed: 2026-03-28

301
tests/test_display_mode.py Normal file
View File

@@ -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"