Files
vault-dash/app/domain/conversions.py

311 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)",
}