311 lines
10 KiB
Python
311 lines
10 KiB
Python
"""Display mode conversion utilities for GLD/XAU views.
|
||
|
||
This module handles conversion between GLD share prices and physical gold prices
|
||
based on the user's display mode preference.
|
||
|
||
Key insight:
|
||
- In GLD mode: show share prices directly, no conversion to oz
|
||
- In XAU mode: convert GLD shares to oz-equivalent using expense-adjusted backing
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.domain.instruments import gld_ounces_per_share
|
||
from app.models.position import Position
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DisplayContext:
|
||
"""Context for display mode conversions.
|
||
|
||
Attributes:
|
||
mode: Display mode ("GLD" for shares, "XAU" for physical gold)
|
||
reference_date: Date for historical conversion lookups
|
||
gld_ounces_per_share: GLD backing ratio for the reference date
|
||
"""
|
||
|
||
mode: str
|
||
reference_date: date | None = None
|
||
gld_ounces_per_share: Decimal | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
if self.mode not in {"GLD", "XAU"}:
|
||
raise ValueError(f"Invalid display mode: {self.mode!r}")
|
||
|
||
@classmethod
|
||
def create(cls, mode: str, reference_date: date | None = None) -> "DisplayContext":
|
||
"""Create a display context with computed GLD backing ratio."""
|
||
gld_backing = None
|
||
if mode == "XAU" and reference_date is not None:
|
||
gld_backing = gld_ounces_per_share(reference_date)
|
||
return cls(mode=mode, reference_date=reference_date, gld_ounces_per_share=gld_backing)
|
||
|
||
|
||
def is_gld_mode(display_mode: str) -> bool:
|
||
"""Check if display mode is GLD (share view)."""
|
||
return display_mode == "GLD"
|
||
|
||
|
||
def is_xau_mode(display_mode: str) -> bool:
|
||
"""Check if display mode is XAU (physical gold view)."""
|
||
return display_mode == "XAU"
|
||
|
||
|
||
def convert_position_to_display(
|
||
position: Position,
|
||
display_mode: str,
|
||
reference_date: date | None = None,
|
||
) -> tuple[Decimal, str, Decimal]:
|
||
"""Convert a position to display units based on display mode.
|
||
|
||
Args:
|
||
position: Position to convert
|
||
display_mode: "GLD" for shares, "XAU" for physical gold
|
||
reference_date: Date for historical conversion (for GLD->XAU)
|
||
|
||
Returns:
|
||
Tuple of (display_quantity, display_unit, display_entry_price)
|
||
|
||
Examples:
|
||
>>> # GLD position in GLD mode: show as-is
|
||
>>> from datetime import date
|
||
>>> from decimal import Decimal
|
||
>>> from app.models.position import create_position
|
||
>>> pos = create_position(
|
||
... underlying="GLD",
|
||
... quantity=Decimal("100"),
|
||
... unit="shares",
|
||
... entry_price=Decimal("400"),
|
||
... entry_date=date.today(),
|
||
... )
|
||
>>> qty, unit, price = convert_position_to_display(pos, "GLD")
|
||
>>> qty, unit, price
|
||
(Decimal('100'), 'shares', Decimal('400'))
|
||
|
||
>>> # GLD position in XAU mode: convert to oz
|
||
>>> qty, unit, price = convert_position_to_display(pos, "XAU", date.today())
|
||
>>> # qty will be shares * oz_per_share
|
||
"""
|
||
if display_mode == "GLD":
|
||
# GLD mode: show shares directly
|
||
if position.underlying == "GLD":
|
||
return position.quantity, position.unit, position.entry_price
|
||
# Non-GLD positions in GLD mode: would need conversion (not implemented yet)
|
||
return position.quantity, position.unit, position.entry_price
|
||
|
||
elif display_mode == "XAU":
|
||
# XAU mode: convert to physical gold ounces
|
||
if position.underlying == "GLD":
|
||
# Convert GLD shares to oz using expense-adjusted backing
|
||
backing = gld_ounces_per_share(reference_date or position.entry_date)
|
||
display_qty = position.quantity * backing
|
||
display_price = position.entry_price / backing # Price per oz
|
||
return display_qty, "oz", display_price
|
||
# XAU positions already in oz
|
||
return position.quantity, position.unit, position.entry_price
|
||
|
||
else:
|
||
raise ValueError(f"Unsupported display mode: {display_mode!r}")
|
||
|
||
|
||
def convert_price_to_display(
|
||
price: Decimal,
|
||
from_unit: str,
|
||
to_mode: str,
|
||
reference_date: date | None = None,
|
||
) -> tuple[Decimal, str]:
|
||
"""Convert a price to display mode units.
|
||
|
||
Args:
|
||
price: Price value to convert
|
||
from_unit: Source unit ("shares" or "oz")
|
||
to_mode: Target display mode ("GLD" or "XAU")
|
||
reference_date: Date for historical conversion
|
||
|
||
Returns:
|
||
Tuple of (converted_price, display_unit)
|
||
"""
|
||
if to_mode == "GLD":
|
||
if from_unit == "shares":
|
||
return price, "shares"
|
||
elif from_unit == "oz":
|
||
# Convert oz price to share price
|
||
backing = gld_ounces_per_share(reference_date or date.today())
|
||
return price * backing, "shares"
|
||
|
||
elif to_mode == "XAU":
|
||
if from_unit == "oz":
|
||
return price, "oz"
|
||
elif from_unit == "shares":
|
||
# Convert share price to oz price
|
||
backing = gld_ounces_per_share(reference_date or date.today())
|
||
return price / backing, "oz"
|
||
|
||
raise ValueError(f"Unsupported conversion: {from_unit} -> {to_mode}")
|
||
|
||
|
||
def convert_quantity_to_display(
|
||
quantity: Decimal,
|
||
from_unit: str,
|
||
to_mode: str,
|
||
reference_date: date | None = None,
|
||
) -> tuple[Decimal, str]:
|
||
"""Convert a quantity to display mode units.
|
||
|
||
Args:
|
||
quantity: Quantity value to convert
|
||
from_unit: Source unit ("shares" or "oz")
|
||
to_mode: Target display mode ("GLD" or "XAU")
|
||
reference_date: Date for historical conversion
|
||
|
||
Returns:
|
||
Tuple of (converted_quantity, display_unit)
|
||
"""
|
||
if to_mode == "GLD":
|
||
if from_unit == "shares":
|
||
return quantity, "shares"
|
||
elif from_unit == "oz":
|
||
# Convert oz to shares (inverse of backing)
|
||
backing = gld_ounces_per_share(reference_date or date.today())
|
||
return quantity / backing, "shares"
|
||
|
||
elif to_mode == "XAU":
|
||
if from_unit == "oz":
|
||
return quantity, "oz"
|
||
elif from_unit == "shares":
|
||
# Convert shares to oz using backing
|
||
backing = gld_ounces_per_share(reference_date or date.today())
|
||
return quantity * backing, "oz"
|
||
|
||
raise ValueError(f"Unsupported conversion: {from_unit} -> {to_mode}")
|
||
|
||
|
||
def get_display_unit_label(underlying: str, display_mode: str) -> str:
|
||
"""Get the display unit label for a position based on display mode.
|
||
|
||
Args:
|
||
underlying: Position underlying symbol
|
||
display_mode: Display mode ("GLD" or "XAU")
|
||
|
||
Returns:
|
||
Unit label string ("shares", "oz", etc.)
|
||
"""
|
||
if underlying == "GLD":
|
||
if display_mode == "GLD":
|
||
return "shares"
|
||
else: # XAU mode
|
||
return "oz"
|
||
elif underlying in ("XAU", "GC=F"):
|
||
return "oz" if display_mode == "XAU" else "oz" # Physical gold always in oz
|
||
|
||
return "units"
|
||
|
||
|
||
def calculate_position_value_in_display_mode(
|
||
quantity: Decimal,
|
||
unit: str,
|
||
current_price: Decimal,
|
||
price_unit: str,
|
||
display_mode: str,
|
||
reference_date: date | None = None,
|
||
) -> Decimal:
|
||
"""Calculate position value in display mode.
|
||
|
||
Args:
|
||
quantity: Position quantity
|
||
unit: Position unit
|
||
current_price: Current market price
|
||
price_unit: Price unit ("shares" or "oz")
|
||
display_mode: Display mode ("GLD" or "XAU")
|
||
reference_date: Date for conversion
|
||
|
||
Returns:
|
||
Position value in USD
|
||
"""
|
||
if display_mode == "GLD" and unit == "shares":
|
||
# GLD mode: shares × share_price
|
||
return quantity * current_price
|
||
elif display_mode == "XAU" and unit == "oz":
|
||
# XAU mode: oz × oz_price
|
||
return quantity * current_price
|
||
elif display_mode == "GLD" and unit == "oz":
|
||
# Convert oz to shares, then calculate
|
||
backing = gld_ounces_per_share(reference_date or date.today())
|
||
shares = quantity / backing
|
||
share_price = current_price * backing
|
||
return shares * share_price
|
||
elif display_mode == "XAU" and unit == "shares":
|
||
# Convert shares to oz, then calculate
|
||
backing = gld_ounces_per_share(reference_date or date.today())
|
||
oz = quantity * backing
|
||
oz_price = current_price / backing
|
||
return oz * oz_price
|
||
|
||
# Fallback: direct multiplication
|
||
return quantity * current_price
|
||
|
||
|
||
def calculate_pnl_in_display_mode(
|
||
quantity: Decimal,
|
||
unit: str,
|
||
entry_price: Decimal,
|
||
current_price: Decimal,
|
||
display_mode: str,
|
||
reference_date: date | None = None,
|
||
) -> Decimal:
|
||
"""Calculate P&L in display mode.
|
||
|
||
Args:
|
||
quantity: Position quantity
|
||
unit: Position unit
|
||
entry_price: Entry price per unit
|
||
current_price: Current price per unit
|
||
display_mode: Display mode ("GLD" or "XAU")
|
||
reference_date: Date for conversion
|
||
|
||
Returns:
|
||
P&L in USD
|
||
"""
|
||
if display_mode == "GLD" and unit == "shares":
|
||
# GLD mode: (current_share_price - entry_share_price) × shares
|
||
return (current_price - entry_price) * quantity
|
||
elif display_mode == "XAU" and unit == "oz":
|
||
# XAU mode: (current_oz_price - entry_oz_price) × oz
|
||
return (current_price - entry_price) * quantity
|
||
elif display_mode == "GLD" and unit == "oz":
|
||
# Convert to share basis
|
||
backing = gld_ounces_per_share(reference_date or date.today())
|
||
shares = quantity * backing # oz → shares (wait, this is wrong)
|
||
# Actually: if we have oz, we need to convert to shares
|
||
# shares = oz / backing
|
||
shares = quantity / backing
|
||
share_entry = entry_price / backing
|
||
share_current = current_price / backing
|
||
return (share_current - share_entry) * shares
|
||
elif display_mode == "XAU" and unit == "shares":
|
||
# Convert to oz basis
|
||
backing = gld_ounces_per_share(reference_date or date.today())
|
||
oz = quantity * backing
|
||
oz_entry = entry_price * backing
|
||
oz_current = current_price * backing
|
||
return (oz_current - oz_entry) * oz
|
||
|
||
# Fallback
|
||
return (current_price - entry_price) * quantity
|
||
|
||
|
||
def get_display_mode_options() -> dict[str, str]:
|
||
"""Return available display mode options for the settings UI.
|
||
|
||
Returns:
|
||
Dict mapping mode value to display label for NiceGUI select.
|
||
"""
|
||
return {
|
||
"GLD": "GLD Shares (show share prices directly)",
|
||
"XAU": "Physical Gold (oz) (convert to gold ounces)",
|
||
}
|