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

This commit is contained in:
Bu5hm4nn
2026-03-28 21:59:15 +01:00
8 changed files with 724 additions and 517 deletions

View File

@@ -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)",
}