feat(DISPLAY-002): GLD mode shows real share prices
This commit is contained in:
@@ -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:
|
This module handles conversion between GLD share prices and physical gold prices
|
||||||
- GLD: shares (for GLD ETF)
|
based on the user's display mode preference.
|
||||||
- XAU_OZ: troy ounces of gold
|
|
||||||
- XAU_G: grams of gold
|
|
||||||
- GCF: gold futures contracts (GC=F)
|
|
||||||
|
|
||||||
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 __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
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
|
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:
|
def is_gld_mode(display_mode: str) -> bool:
|
||||||
"""Convert GLD shares to troy ounces using historical backing ratio."""
|
"""Check if display mode is GLD (share view)."""
|
||||||
if position.underlying != "GLD":
|
return display_mode == "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 _gld_shares_to_grams(position: Position, reference_date: date | None = None) -> Decimal:
|
def is_xau_mode(display_mode: str) -> bool:
|
||||||
"""Convert GLD shares to grams using historical backing ratio."""
|
"""Check if display mode is XAU (physical gold view)."""
|
||||||
ounces = _gld_shares_to_ounces(position, reference_date)
|
return display_mode == "XAU"
|
||||||
return ounces * GRAMS_PER_OUNCE
|
|
||||||
|
|
||||||
|
|
||||||
def _gld_shares_to_contracts(position: Position, reference_date: date | None = None) -> Decimal:
|
def convert_position_to_display(
|
||||||
"""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(
|
|
||||||
position: Position,
|
position: Position,
|
||||||
display_mode: str,
|
display_mode: str,
|
||||||
reference_date: date | None = None,
|
reference_date: date | None = None,
|
||||||
) -> tuple[Decimal, str]:
|
) -> tuple[Decimal, str, Decimal]:
|
||||||
"""Convert a position to display units based on the selected display mode.
|
"""Convert a position to display units based on display mode.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
position: The position to convert
|
position: Position to convert
|
||||||
display_mode: One of "GLD", "XAU_OZ", "XAU_G", "GCF"
|
display_mode: "GLD" for shares, "XAU" for physical gold
|
||||||
reference_date: Date for historical conversion (defaults to position.entry_date)
|
reference_date: Date for historical conversion (for GLD->XAU)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (quantity in display units, display unit label)
|
Tuple of (display_quantity, display_unit, display_entry_price)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
>>> # GLD position in GLD mode: show as-is
|
||||||
>>> from datetime import date
|
>>> from datetime import date
|
||||||
>>> from decimal import Decimal
|
>>> from decimal import Decimal
|
||||||
>>> from app.models.position import create_position
|
>>> from app.models.position import create_position
|
||||||
>>> pos = create_position("GLD", Decimal("100"), "shares", Decimal("230"))
|
>>> pos = create_position(
|
||||||
>>> qty, unit = position_to_display_units(pos, "XAU_OZ")
|
... underlying="GLD",
|
||||||
>>> float(qty) > 0 and unit == "oz"
|
... quantity=Decimal("100"),
|
||||||
True
|
... unit="shares",
|
||||||
"""
|
... entry_price=Decimal("400"),
|
||||||
ref_date = reference_date or position.entry_date
|
... 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":
|
if display_mode == "GLD":
|
||||||
# Show GLD shares as-is
|
# GLD mode: show shares directly
|
||||||
if position.underlying == "GLD" and position.unit == "shares":
|
if position.underlying == "GLD":
|
||||||
return position.quantity, "shares"
|
return position.quantity, position.unit, position.entry_price
|
||||||
# Convert other formats to GLD shares
|
# Non-GLD positions in GLD mode: would need conversion (not implemented yet)
|
||||||
if position.underlying == "GLD" and position.unit == "oz":
|
return position.quantity, position.unit, position.entry_price
|
||||||
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"
|
|
||||||
|
|
||||||
elif display_mode == "XAU_OZ":
|
elif display_mode == "XAU":
|
||||||
# Show everything in troy ounces
|
# XAU mode: convert to physical gold ounces
|
||||||
if position.underlying == "GLD" and position.unit == "shares":
|
if position.underlying == "GLD":
|
||||||
ounces = _gld_shares_to_ounces(position, ref_date)
|
# Convert GLD shares to oz using expense-adjusted backing
|
||||||
return ounces, "oz"
|
backing = gld_ounces_per_share(reference_date or position.entry_date)
|
||||||
if position.underlying == "GLD" and position.unit == "oz":
|
display_qty = position.quantity * backing
|
||||||
return position.quantity, "oz"
|
display_price = position.entry_price / backing # Price per oz
|
||||||
if position.underlying == "GLD" and position.unit == "g":
|
return display_qty, "oz", display_price
|
||||||
return _xau_g_to_ounces(position.quantity), "oz"
|
# XAU positions already in oz
|
||||||
if position.underlying == "GC=F" and position.unit == "contracts":
|
return position.quantity, position.unit, position.entry_price
|
||||||
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_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:
|
else:
|
||||||
return f"{quantity:,.4f}"
|
raise ValueError(f"Unsupported display mode: {display_mode!r}")
|
||||||
|
|
||||||
|
|
||||||
def get_display_mode_label(display_mode: str) -> str:
|
def convert_price_to_display(
|
||||||
"""Get human-readable label for display mode.
|
price: Decimal,
|
||||||
|
from_unit: str,
|
||||||
|
to_mode: str,
|
||||||
|
reference_date: date | None = None,
|
||||||
|
) -> tuple[Decimal, str]:
|
||||||
|
"""Convert a price to display mode units.
|
||||||
|
|
||||||
Args:
|
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:
|
Returns:
|
||||||
Human-readable label
|
Tuple of (converted_price, display_unit)
|
||||||
"""
|
"""
|
||||||
labels = {
|
if to_mode == "GLD":
|
||||||
"GLD": "GLD Shares",
|
if from_unit == "shares":
|
||||||
"XAU_OZ": "Gold (Troy Ounces)",
|
return price, "shares"
|
||||||
"XAU_G": "Gold (Grams)",
|
elif from_unit == "oz":
|
||||||
"GCF": "GC=F Contracts",
|
# Convert oz price to share price
|
||||||
}
|
backing = gld_ounces_per_share(reference_date or date.today())
|
||||||
return labels.get(display_mode, display_mode)
|
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]:
|
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:
|
Returns:
|
||||||
Dictionary mapping mode codes to display labels
|
Dict mapping mode value to display label for NiceGUI select.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"GLD": "GLD Shares",
|
"GLD": "GLD Shares (show share prices directly)",
|
||||||
"XAU_OZ": "Gold Ounces (troy)",
|
"XAU": "Physical Gold (oz) (convert to gold ounces)",
|
||||||
"XAU_G": "Gold Grams",
|
|
||||||
"GCF": "GC=F Contracts",
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +193,40 @@ 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:
|
||||||
configured_price = float(config.entry_price or 0.0)
|
# No valid quote, use configured entry price
|
||||||
return configured_price, "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(
|
def portfolio_snapshot_from_config(
|
||||||
@@ -203,16 +234,62 @@ def portfolio_snapshot_from_config(
|
|||||||
*,
|
*,
|
||||||
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)
|
||||||
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)
|
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]:
|
||||||
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)
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,9 +116,8 @@ class PortfolioConfig:
|
|||||||
# Underlying instrument selection
|
# Underlying instrument selection
|
||||||
underlying: str = "GLD"
|
underlying: str = "GLD"
|
||||||
|
|
||||||
# Display mode for underlying units
|
# Display mode: how to show positions (GLD shares vs physical gold)
|
||||||
# Options: "GLD" (shares), "XAU_OZ" (troy ounces), "XAU_G" (grams), "GCF" (futures contracts)
|
display_mode: str = "XAU" # "GLD" for share view, "XAU" for physical gold view
|
||||||
display_mode: str = "GLD"
|
|
||||||
|
|
||||||
# Alert settings
|
# Alert settings
|
||||||
volatility_spike: float = 0.25
|
volatility_spike: float = 0.25
|
||||||
@@ -399,7 +398,7 @@ class PortfolioRepository:
|
|||||||
"fallback_source",
|
"fallback_source",
|
||||||
"refresh_interval",
|
"refresh_interval",
|
||||||
"underlying", # optional with default "GLD"
|
"underlying", # optional with default "GLD"
|
||||||
"display_mode", # display mode for underlying units
|
"display_mode", # optional with default "XAU"
|
||||||
"volatility_spike",
|
"volatility_spike",
|
||||||
"spot_drawdown",
|
"spot_drawdown",
|
||||||
"email_alerts",
|
"email_alerts",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import logging
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from app.domain.conversions import collateral_to_display_units, format_display_quantity, get_display_mode_label
|
|
||||||
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
|
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
|
||||||
from app.models.workspace import get_workspace_repository
|
from app.models.workspace import get_workspace_repository
|
||||||
from app.pages.common import (
|
from app.pages.common import (
|
||||||
@@ -114,33 +113,33 @@ 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"
|
||||||
|
|
||||||
# Get underlying and display mode for display
|
# Get underlying for display
|
||||||
underlying = "GLD"
|
underlying = "GLD"
|
||||||
display_mode = "GLD"
|
|
||||||
display_label = "GLD Shares"
|
|
||||||
if workspace_id:
|
if workspace_id:
|
||||||
try:
|
try:
|
||||||
repo = get_workspace_repository()
|
repo = get_workspace_repository()
|
||||||
config = repo.load_portfolio_config(workspace_id)
|
config = repo.load_portfolio_config(workspace_id)
|
||||||
underlying = config.underlying or "GLD"
|
underlying = config.underlying or "GLD"
|
||||||
display_mode = config.display_mode or "GLD"
|
|
||||||
display_label = get_display_mode_label(display_mode)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Compute display unit values
|
|
||||||
collateral_qty, collateral_unit = collateral_to_display_units(
|
|
||||||
float(portfolio.get("gold_units", 0.0)), display_mode
|
|
||||||
)
|
|
||||||
|
|
||||||
with dashboard_page(
|
with dashboard_page(
|
||||||
"Hedge Analysis",
|
"Hedge Analysis",
|
||||||
f"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts for {underlying}.",
|
f"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts for {underlying}.",
|
||||||
@@ -148,10 +147,7 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
):
|
):
|
||||||
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
|
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
|
||||||
ui.label(
|
ui.label(f"Active underlying: {underlying}").classes("text-sm text-slate-500 dark:text-slate-400")
|
||||||
f"Active underlying: {underlying} · Display mode: {display_label} · "
|
|
||||||
f"Collateral: {format_display_quantity(collateral_qty, collateral_unit)} {collateral_unit}"
|
|
||||||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
|
||||||
|
|
||||||
left_pane, right_pane = split_page_panes(
|
left_pane, right_pane = split_page_panes(
|
||||||
left_testid="hedge-left-pane",
|
left_testid="hedge-left-pane",
|
||||||
@@ -255,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")
|
||||||
@@ -262,15 +269,11 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
|
|||||||
"text-sm text-slate-500 dark:text-slate-400"
|
"text-sm text-slate-500 dark:text-slate-400"
|
||||||
)
|
)
|
||||||
ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300")
|
ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300")
|
||||||
# Compute display unit values for summary
|
|
||||||
collateral_qty, collateral_unit = collateral_to_display_units(
|
|
||||||
float(portfolio.get("gold_units", 0.0)), display_mode
|
|
||||||
)
|
|
||||||
with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
|
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"{format_display_quantity(collateral_qty, collateral_unit)} {collateral_unit}"),
|
("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}"),
|
||||||
@@ -287,8 +290,8 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
|
|||||||
ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
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}"),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from fastapi.responses import RedirectResponse
|
|||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from app.components import PortfolioOverview
|
from app.components import PortfolioOverview
|
||||||
from app.domain.conversions import collateral_to_display_units, format_display_quantity, get_display_mode_label
|
|
||||||
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
|
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
|
||||||
from app.models.ltv_history import LtvHistoryRepository
|
from app.models.ltv_history import LtvHistoryRepository
|
||||||
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
||||||
@@ -184,27 +183,38 @@ async def overview_page(workspace_id: str) -> None:
|
|||||||
ltv_chart_models = ()
|
ltv_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":
|
||||||
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:
|
else:
|
||||||
quote_status = (
|
if display_mode == "GLD":
|
||||||
f"Live quote source: {portfolio['quote_source']} · "
|
quote_status = (
|
||||||
f"GLD share quote converted to ozt-equivalent spot · "
|
f"Live quote source: {portfolio['quote_source']} (GLD share price) · "
|
||||||
f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}"
|
f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
quote_status = (
|
||||||
|
f"Live quote source: {portfolio['quote_source']} · "
|
||||||
|
f"GLD share quote converted to ozt-equivalent spot · "
|
||||||
|
f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if display_mode == "GLD":
|
||||||
|
spot_caption = (
|
||||||
|
f"{symbol} share price via {portfolio['quote_source']}"
|
||||||
|
if portfolio["quote_source"] != "configured_entry_price"
|
||||||
|
else "Configured GLD share entry price"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
spot_caption = (
|
||||||
|
f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}"
|
||||||
|
if portfolio["quote_source"] != "configured_entry_price"
|
||||||
|
else "Configured entry price fallback in USD/ozt"
|
||||||
)
|
)
|
||||||
|
|
||||||
spot_caption = (
|
|
||||||
f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}"
|
|
||||||
if portfolio["quote_source"] != "configured_entry_price"
|
|
||||||
else "Configured entry price fallback in USD/ozt"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Compute display mode values
|
|
||||||
display_mode = config.display_mode or "GLD"
|
|
||||||
display_label = get_display_mode_label(display_mode)
|
|
||||||
collateral_display_qty, collateral_display_unit = collateral_to_display_units(
|
|
||||||
float(config.gold_ounces or 0.0), display_mode
|
|
||||||
)
|
|
||||||
|
|
||||||
with dashboard_page(
|
with dashboard_page(
|
||||||
"Overview",
|
"Overview",
|
||||||
@@ -215,9 +225,7 @@ async def overview_page(workspace_id: str) -> None:
|
|||||||
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
|
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
|
||||||
ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400")
|
ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||||
ui.label(
|
ui.label(
|
||||||
f"Active underlying: {underlying} · Display mode: {display_label} · "
|
f"Active underlying: {underlying} · Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}"
|
||||||
f"Collateral: {format_display_quantity(collateral_display_qty, collateral_display_unit)} {collateral_display_unit} · "
|
|
||||||
f"Loan ${config.loan_amount:,.0f}"
|
|
||||||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||||
|
|
||||||
left_pane, right_pane = split_page_panes(
|
left_pane, right_pane = split_page_panes(
|
||||||
@@ -321,25 +329,27 @@ async def overview_page(workspace_id: str) -> None:
|
|||||||
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
"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"):
|
||||||
# Compute display unit values for snapshot
|
|
||||||
collateral_qty, collateral_unit = collateral_to_display_units(
|
|
||||||
float(portfolio["gold_units"]), display_mode
|
|
||||||
)
|
|
||||||
summary_cards = [
|
summary_cards = [
|
||||||
(
|
(
|
||||||
"Collateral Spot Price",
|
spot_label,
|
||||||
f"${portfolio['spot_price']:,.2f}/oz",
|
f"${portfolio['spot_price']:,.2f}{spot_unit}",
|
||||||
spot_caption,
|
spot_caption,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Collateral Amount",
|
margin_label,
|
||||||
f"{format_display_quantity(collateral_qty, collateral_unit)} {collateral_unit}",
|
f"${portfolio['margin_call_price']:,.2f}",
|
||||||
f"Gold collateral in {display_label.lower()}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Margin Call Price",
|
|
||||||
f"${portfolio['margin_call_price']:,.2f}/oz",
|
|
||||||
"Implied trigger level from persisted portfolio settings",
|
"Implied trigger level from persisted portfolio settings",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -250,6 +250,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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,200 +1,301 @@
|
|||||||
"""Tests for display mode switching functionality."""
|
"""Tests for DISPLAY-002: GLD Mode Shows Real GLD Pricing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.domain.conversions import (
|
from app.domain.conversions import (
|
||||||
collateral_to_display_units,
|
convert_position_to_display,
|
||||||
format_display_quantity,
|
convert_price_to_display,
|
||||||
get_display_mode_label,
|
convert_quantity_to_display,
|
||||||
get_display_mode_options,
|
get_display_unit_label,
|
||||||
position_to_display_units,
|
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
|
from app.models.position import create_position
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayModeOptions:
|
class TestDisplayModeHelpers:
|
||||||
"""Test display mode option helpers."""
|
"""Test display mode helper functions."""
|
||||||
|
|
||||||
def test_get_display_mode_options(self):
|
def test_is_gld_mode(self) -> None:
|
||||||
"""Test display mode options dictionary."""
|
assert is_gld_mode("GLD") is True
|
||||||
options = get_display_mode_options()
|
assert is_gld_mode("XAU") is False
|
||||||
assert "GLD" in options
|
assert is_gld_mode("gld") is False # Case sensitive
|
||||||
assert "XAU_OZ" in options
|
|
||||||
assert "XAU_G" in options
|
|
||||||
assert "GCF" in options
|
|
||||||
assert options["GLD"] == "GLD Shares"
|
|
||||||
assert options["XAU_OZ"] == "Gold Ounces (troy)"
|
|
||||||
assert options["XAU_G"] == "Gold Grams"
|
|
||||||
assert options["GCF"] == "GC=F Contracts"
|
|
||||||
|
|
||||||
def test_get_display_mode_label(self):
|
def test_is_xau_mode(self) -> None:
|
||||||
"""Test display mode label lookup."""
|
assert is_xau_mode("XAU") is True
|
||||||
assert get_display_mode_label("GLD") == "GLD Shares"
|
assert is_xau_mode("GLD") is False
|
||||||
assert get_display_mode_label("XAU_OZ") == "Gold (Troy Ounces)"
|
|
||||||
assert get_display_mode_label("XAU_G") == "Gold (Grams)"
|
|
||||||
assert get_display_mode_label("GCF") == "GC=F Contracts"
|
|
||||||
assert get_display_mode_label("UNKNOWN") == "UNKNOWN"
|
|
||||||
|
|
||||||
|
|
||||||
class TestFormatDisplayQuantity:
|
class TestConvertPositionToDisplay:
|
||||||
"""Test quantity formatting for display."""
|
"""Test position conversion based on display mode."""
|
||||||
|
|
||||||
def test_format_shares(self):
|
def test_gld_position_in_gld_mode_shows_shares(self) -> None:
|
||||||
"""Test share formatting (no decimals)."""
|
"""GLD position in GLD mode: show as-is in shares."""
|
||||||
assert format_display_quantity(100.0, "shares") == "100"
|
pos = create_position(
|
||||||
assert format_display_quantity(1234.56, "shares") == "1,235"
|
underlying="GLD",
|
||||||
assert format_display_quantity(1000000.0, "shares") == "1,000,000"
|
quantity=Decimal("100"),
|
||||||
|
unit="shares",
|
||||||
|
entry_price=Decimal("400"),
|
||||||
|
entry_date=date.today(),
|
||||||
|
)
|
||||||
|
|
||||||
def test_format_contracts(self):
|
qty, unit, price = convert_position_to_display(pos, "GLD")
|
||||||
"""Test contract formatting (no decimals)."""
|
|
||||||
assert format_display_quantity(10.0, "contracts") == "10"
|
|
||||||
assert format_display_quantity(1234.56, "contracts") == "1,235"
|
|
||||||
|
|
||||||
def test_format_ounces(self):
|
assert qty == Decimal("100")
|
||||||
"""Test ounce formatting (4 decimals)."""
|
assert unit == "shares"
|
||||||
assert format_display_quantity(100.0, "oz") == "100.0000"
|
assert price == Decimal("400")
|
||||||
assert format_display_quantity(9.1576, "oz") == "9.1576"
|
|
||||||
assert format_display_quantity(0.123456, "oz") == "0.1235"
|
|
||||||
|
|
||||||
def test_format_grams(self):
|
def test_gld_position_in_xau_mode_converts_to_oz(self) -> None:
|
||||||
"""Test gram formatting (2 decimals)."""
|
"""GLD position in XAU mode: convert to oz using expense-adjusted backing."""
|
||||||
assert format_display_quantity(100.0, "g") == "100.00"
|
pos = create_position(
|
||||||
assert format_display_quantity(284.83, "g") == "284.83"
|
underlying="GLD",
|
||||||
assert format_display_quantity(31103.5, "g") == "31,103.50"
|
quantity=Decimal("100"),
|
||||||
|
unit="shares",
|
||||||
|
entry_price=Decimal("400"),
|
||||||
|
entry_date=date.today(),
|
||||||
|
)
|
||||||
|
|
||||||
|
qty, unit, price = convert_position_to_display(pos, "XAU", date.today())
|
||||||
|
|
||||||
|
# Should convert to oz
|
||||||
|
assert unit == "oz"
|
||||||
|
backing = gld_ounces_per_share(date.today())
|
||||||
|
expected_qty = Decimal("100") * backing
|
||||||
|
assert abs(float(qty) - float(expected_qty)) < 0.0001
|
||||||
|
# Price should be per oz (share price / backing)
|
||||||
|
expected_price = Decimal("400") / backing
|
||||||
|
assert abs(float(price) - float(expected_price)) < 0.01
|
||||||
|
|
||||||
|
def test_xau_position_in_xau_mode_shows_oz(self) -> None:
|
||||||
|
"""Physical gold position in XAU mode: show as-is in oz."""
|
||||||
|
pos = create_position(
|
||||||
|
underlying="XAU",
|
||||||
|
quantity=Decimal("50"),
|
||||||
|
unit="oz",
|
||||||
|
entry_price=Decimal("2000"),
|
||||||
|
entry_date=date.today(),
|
||||||
|
)
|
||||||
|
|
||||||
|
qty, unit, price = convert_position_to_display(pos, "XAU")
|
||||||
|
|
||||||
|
assert qty == Decimal("50")
|
||||||
|
assert unit == "oz"
|
||||||
|
assert price == Decimal("2000")
|
||||||
|
|
||||||
|
|
||||||
class TestPositionToDisplayUnits:
|
class TestConvertPriceToDisplay:
|
||||||
"""Test position conversion to display units."""
|
"""Test price conversion based on display mode."""
|
||||||
|
|
||||||
def test_gld_shares_in_gld_mode(self):
|
def test_share_price_to_gld_mode(self) -> None:
|
||||||
"""Test GLD shares displayed as-is in GLD mode."""
|
"""Share price in GLD mode: no conversion."""
|
||||||
pos = create_position("GLD", Decimal("100"), "shares", Decimal("230"))
|
price, unit = convert_price_to_display(Decimal("400"), "shares", "GLD")
|
||||||
qty, unit = position_to_display_units(pos, "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 qty == Decimal("100")
|
||||||
assert unit == "shares"
|
assert unit == "shares"
|
||||||
|
|
||||||
def test_gld_shares_in_xau_oz_mode(self):
|
def test_shares_to_xau_mode(self) -> None:
|
||||||
"""Test GLD shares converted to ounces in XAU_OZ mode."""
|
"""Shares in XAU mode: convert to oz."""
|
||||||
pos = create_position("GLD", Decimal("100"), "shares", Decimal("230"))
|
shares = Decimal("100")
|
||||||
qty, unit = position_to_display_units(pos, "XAU_OZ")
|
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"
|
assert unit == "oz"
|
||||||
# Should be ~9.16 oz for 100 shares (depends on date)
|
|
||||||
assert Decimal("9") < qty < Decimal("10")
|
|
||||||
|
|
||||||
def test_gld_shares_in_xau_g_mode(self):
|
|
||||||
"""Test GLD shares converted to grams in XAU_G mode."""
|
|
||||||
pos = create_position("GLD", Decimal("100"), "shares", Decimal("230"))
|
|
||||||
qty, unit = position_to_display_units(pos, "XAU_G")
|
|
||||||
assert unit == "g"
|
|
||||||
# Should be ~285 g for 100 shares
|
|
||||||
assert Decimal("280") < qty < Decimal("290")
|
|
||||||
|
|
||||||
def test_gld_shares_in_gcf_mode(self):
|
|
||||||
"""Test GLD shares converted to contracts in GCF mode."""
|
|
||||||
pos = create_position("GLD", Decimal("1000"), "shares", Decimal("230"))
|
|
||||||
qty, unit = position_to_display_units(pos, "GCF")
|
|
||||||
assert unit == "contracts"
|
|
||||||
# Should be ~0.92 contracts for 1000 shares
|
|
||||||
assert Decimal("0.9") < qty < Decimal("1.0")
|
|
||||||
|
|
||||||
def test_xau_oz_in_gld_mode(self):
|
|
||||||
"""Test XAU ounces converted to GLD shares in GLD mode."""
|
|
||||||
pos = create_position("XAU", Decimal("10"), "oz", Decimal("2150"))
|
|
||||||
qty, unit = position_to_display_units(pos, "GLD")
|
|
||||||
assert unit == "shares"
|
|
||||||
# 10 oz should be ~109 shares
|
|
||||||
assert Decimal("100") < qty < Decimal("120")
|
|
||||||
|
|
||||||
def test_xau_oz_in_xau_g_mode(self):
|
|
||||||
"""Test XAU ounces converted to grams in XAU_G mode."""
|
|
||||||
pos = create_position("XAU", Decimal("10"), "oz", Decimal("2150"))
|
|
||||||
qty, unit = position_to_display_units(pos, "XAU_G")
|
|
||||||
assert unit == "g"
|
|
||||||
# 10 oz = 311.035 g
|
|
||||||
assert qty == Decimal("311.035")
|
|
||||||
|
|
||||||
def test_gcf_contracts_in_xau_oz_mode(self):
|
|
||||||
"""Test GC=F contracts converted to ounces in XAU_OZ mode."""
|
|
||||||
pos = create_position("GC=F", Decimal("1"), "contracts", Decimal("215000"))
|
|
||||||
qty, unit = position_to_display_units(pos, "XAU_OZ")
|
|
||||||
assert unit == "oz"
|
|
||||||
# 1 contract = 100 oz
|
|
||||||
assert qty == Decimal("100")
|
|
||||||
|
|
||||||
def test_gcf_contracts_in_gld_mode(self):
|
|
||||||
"""Test GC=F contracts converted to GLD shares in GLD mode."""
|
|
||||||
pos = create_position("GC=F", Decimal("1"), "contracts", Decimal("215000"))
|
|
||||||
qty, unit = position_to_display_units(pos, "GLD")
|
|
||||||
assert unit == "shares"
|
|
||||||
# 100 oz should be ~1092 shares
|
|
||||||
assert Decimal("1000") < qty < Decimal("1100")
|
|
||||||
|
|
||||||
|
|
||||||
class TestCollateralToDisplayUnits:
|
class TestGetDisplayUnitLabel:
|
||||||
"""Test collateral conversion to display units."""
|
"""Test display unit label helper."""
|
||||||
|
|
||||||
def test_collateral_in_gld_mode(self):
|
def test_gld_underlying_in_gld_mode(self) -> None:
|
||||||
"""Test collateral converted to GLD shares."""
|
assert get_display_unit_label("GLD", "GLD") == "shares"
|
||||||
qty, unit = collateral_to_display_units(1000.0, "GLD")
|
|
||||||
assert unit == "shares"
|
|
||||||
assert qty > 10000 # Should be ~10920 shares
|
|
||||||
|
|
||||||
def test_collateral_in_xau_oz_mode(self):
|
def test_gld_underlying_in_xau_mode(self) -> None:
|
||||||
"""Test collateral stays in ounces."""
|
assert get_display_unit_label("GLD", "XAU") == "oz"
|
||||||
qty, unit = collateral_to_display_units(1000.0, "XAU_OZ")
|
|
||||||
assert unit == "oz"
|
|
||||||
assert qty == 1000.0
|
|
||||||
|
|
||||||
def test_collateral_in_xau_g_mode(self):
|
def test_xau_underlying(self) -> None:
|
||||||
"""Test collateral converted to grams."""
|
assert get_display_unit_label("XAU", "GLD") == "oz"
|
||||||
qty, unit = collateral_to_display_units(1000.0, "XAU_G")
|
assert get_display_unit_label("XAU", "XAU") == "oz"
|
||||||
assert unit == "g"
|
|
||||||
assert qty == pytest.approx(31103.5, rel=0.01)
|
|
||||||
|
|
||||||
def test_collateral_in_gcf_mode(self):
|
|
||||||
"""Test collateral converted to contracts."""
|
|
||||||
qty, unit = collateral_to_display_units(1000.0, "GCF")
|
|
||||||
assert unit == "contracts"
|
|
||||||
assert qty == pytest.approx(10.0, rel=0.01)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPortfolioConfigDisplayMode:
|
class TestPortfolioSnapshotWithDisplayMode:
|
||||||
"""Test PortfolioConfig display_mode field."""
|
"""Test portfolio snapshot respects display mode."""
|
||||||
|
|
||||||
def test_default_display_mode(self):
|
def test_gld_mode_snapshot_uses_shares(self) -> None:
|
||||||
"""Test default display mode is GLD."""
|
"""GLD mode: snapshot shows shares and share price."""
|
||||||
from app.models.portfolio import PortfolioConfig
|
config = PortfolioConfig(
|
||||||
|
entry_price=2150.0,
|
||||||
|
gold_ounces=100.0,
|
||||||
|
entry_basis_mode="weight",
|
||||||
|
loan_amount=145000.0,
|
||||||
|
display_mode="GLD",
|
||||||
|
)
|
||||||
|
|
||||||
config = PortfolioConfig()
|
snapshot = portfolio_snapshot_from_config(config, runtime_spot_price=400.0)
|
||||||
assert config.display_mode == "GLD"
|
|
||||||
|
|
||||||
def test_custom_display_mode(self):
|
# In GLD mode, spot_price should be the share price (400.0)
|
||||||
"""Test custom display mode."""
|
assert snapshot["spot_price"] == 400.0
|
||||||
from app.models.portfolio import PortfolioConfig
|
# gold_units should be in shares (converted from oz using backing)
|
||||||
|
backing = float(gld_ounces_per_share(date.today()))
|
||||||
|
expected_shares = 100.0 / backing
|
||||||
|
assert abs(snapshot["gold_units"] - expected_shares) < 0.01
|
||||||
|
# gold_value = shares × share_price
|
||||||
|
expected_value = expected_shares * 400.0
|
||||||
|
assert abs(snapshot["gold_value"] - expected_value) < 0.01
|
||||||
|
assert snapshot["display_mode"] == "GLD"
|
||||||
|
|
||||||
config = PortfolioConfig(display_mode="XAU_OZ")
|
def test_xau_mode_snapshot_uses_oz(self) -> None:
|
||||||
assert config.display_mode == "XAU_OZ"
|
"""XAU mode: snapshot shows oz and oz price."""
|
||||||
|
config = PortfolioConfig(
|
||||||
|
entry_price=2150.0,
|
||||||
|
gold_ounces=100.0,
|
||||||
|
entry_basis_mode="weight",
|
||||||
|
loan_amount=145000.0,
|
||||||
|
display_mode="XAU",
|
||||||
|
)
|
||||||
|
|
||||||
def test_display_mode_in_to_dict(self):
|
snapshot = portfolio_snapshot_from_config(config, runtime_spot_price=2150.0)
|
||||||
"""Test display_mode is included in to_dict."""
|
|
||||||
from app.models.portfolio import PortfolioConfig
|
|
||||||
|
|
||||||
config = PortfolioConfig(display_mode="XAU_G")
|
# In XAU mode, spot_price should be oz price (2150.0)
|
||||||
config_dict = config.to_dict()
|
assert snapshot["spot_price"] == 2150.0
|
||||||
assert "display_mode" in config_dict
|
# gold_units should be in oz
|
||||||
assert config_dict["display_mode"] == "XAU_G"
|
assert snapshot["gold_units"] == 100.0
|
||||||
|
# gold_value = oz × oz_price
|
||||||
|
assert snapshot["gold_value"] == 215000.0
|
||||||
|
assert snapshot["display_mode"] == "XAU"
|
||||||
|
|
||||||
def test_display_mode_in_from_dict(self):
|
|
||||||
"""Test display_mode is restored from dict."""
|
|
||||||
from app.models.portfolio import PortfolioConfig
|
|
||||||
|
|
||||||
config_dict = {
|
class TestResolvePortfolioSpotFromQuote:
|
||||||
"gold_value": 215000.0,
|
"""Test spot price resolution respects display mode."""
|
||||||
"entry_price": 2150.0,
|
|
||||||
"gold_ounces": 100.0,
|
def test_gld_mode_returns_share_price_directly(self) -> None:
|
||||||
"display_mode": "GCF",
|
"""GLD mode: return GLD share price without conversion."""
|
||||||
|
config = PortfolioConfig(
|
||||||
|
entry_price=2150.0,
|
||||||
|
gold_ounces=100.0,
|
||||||
|
display_mode="GLD",
|
||||||
|
)
|
||||||
|
|
||||||
|
quote = {
|
||||||
|
"symbol": "GLD",
|
||||||
|
"price": 404.19,
|
||||||
|
"quote_unit": "share",
|
||||||
|
"source": "yfinance",
|
||||||
|
"updated_at": "2026-03-24T00:00:00+00:00",
|
||||||
}
|
}
|
||||||
config = PortfolioConfig.from_dict(config_dict)
|
|
||||||
assert config.display_mode == "GCF"
|
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, quote)
|
||||||
|
|
||||||
|
# In GLD mode, should return share price directly
|
||||||
|
assert spot == 404.19
|
||||||
|
assert source == "yfinance"
|
||||||
|
|
||||||
|
def test_xau_mode_converts_to_oz_price(self) -> None:
|
||||||
|
"""XAU mode: convert GLD share price to oz-equivalent."""
|
||||||
|
config = PortfolioConfig(
|
||||||
|
entry_price=2150.0,
|
||||||
|
gold_ounces=100.0,
|
||||||
|
display_mode="XAU",
|
||||||
|
)
|
||||||
|
|
||||||
|
quote = {
|
||||||
|
"symbol": "GLD",
|
||||||
|
"price": 404.19,
|
||||||
|
"quote_unit": "share",
|
||||||
|
"source": "yfinance",
|
||||||
|
"updated_at": "2026-03-24T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, quote)
|
||||||
|
|
||||||
|
# In XAU mode, should convert to oz price
|
||||||
|
backing = float(gld_ounces_per_share(date.today()))
|
||||||
|
expected_spot = 404.19 / backing
|
||||||
|
assert abs(spot - expected_spot) < 0.01
|
||||||
|
assert source == "yfinance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildAlertContextWithDisplayMode:
|
||||||
|
"""Test alert context respects display mode."""
|
||||||
|
|
||||||
|
def test_gld_mode_context_uses_shares(self) -> None:
|
||||||
|
"""GLD mode: alert context shows shares."""
|
||||||
|
config = PortfolioConfig(
|
||||||
|
entry_price=2150.0,
|
||||||
|
gold_ounces=100.0,
|
||||||
|
loan_amount=145000.0,
|
||||||
|
display_mode="GLD",
|
||||||
|
)
|
||||||
|
|
||||||
|
context = build_alert_context(
|
||||||
|
config,
|
||||||
|
spot_price=400.0, # Share price
|
||||||
|
source="yfinance",
|
||||||
|
updated_at="2026-03-24T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should show shares, not oz
|
||||||
|
backing = float(gld_ounces_per_share(date.today()))
|
||||||
|
expected_shares = 100.0 / backing
|
||||||
|
assert abs(context["gold_units"] - expected_shares) < 0.01
|
||||||
|
assert context["display_mode"] == "GLD"
|
||||||
|
|
||||||
|
def test_xau_mode_context_uses_oz(self) -> None:
|
||||||
|
"""XAU mode: alert context shows oz."""
|
||||||
|
config = PortfolioConfig(
|
||||||
|
entry_price=2150.0,
|
||||||
|
gold_ounces=100.0,
|
||||||
|
loan_amount=145000.0,
|
||||||
|
display_mode="XAU",
|
||||||
|
)
|
||||||
|
|
||||||
|
context = build_alert_context(
|
||||||
|
config,
|
||||||
|
spot_price=2150.0,
|
||||||
|
source="yfinance",
|
||||||
|
updated_at="2026-03-24T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context["gold_units"] == 100.0
|
||||||
|
assert context["display_mode"] == "XAU"
|
||||||
|
|||||||
Reference in New Issue
Block a user