feat(DISPLAY-001): add underlying mode switching
This commit is contained in:
322
app/domain/conversions.py
Normal file
322
app/domain/conversions.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""Conversion layer for display mode switching.
|
||||||
|
|
||||||
|
Provides functions to convert positions between different display modes:
|
||||||
|
- GLD: shares (for GLD ETF)
|
||||||
|
- XAU_OZ: troy ounces of gold
|
||||||
|
- XAU_G: grams of gold
|
||||||
|
- GCF: gold futures contracts (GC=F)
|
||||||
|
|
||||||
|
All conversions use the position's entry_date for historical accuracy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.domain.instruments import GC_F_OUNCES_PER_CONTRACT, gld_ounces_per_share
|
||||||
|
from app.models.position import Position
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
GRAMS_PER_OUNCE = Decimal("31.1035") # 1 troy oz = 31.1035 grams
|
||||||
|
|
||||||
|
|
||||||
|
def _gld_shares_to_ounces(position: Position, reference_date: date | None = None) -> Decimal:
|
||||||
|
"""Convert GLD shares to troy ounces using historical backing ratio."""
|
||||||
|
if position.underlying != "GLD":
|
||||||
|
raise ValueError(f"Cannot convert non-GLD position ({position.underlying}) to ounces")
|
||||||
|
if position.unit != "shares":
|
||||||
|
raise ValueError(f"GLD position must be in shares, got {position.unit}")
|
||||||
|
|
||||||
|
oz_per_share = gld_ounces_per_share(reference_date or position.entry_date)
|
||||||
|
return position.quantity * oz_per_share
|
||||||
|
|
||||||
|
|
||||||
|
def _gld_shares_to_grams(position: Position, reference_date: date | None = None) -> Decimal:
|
||||||
|
"""Convert GLD shares to grams using historical backing ratio."""
|
||||||
|
ounces = _gld_shares_to_ounces(position, reference_date)
|
||||||
|
return ounces * GRAMS_PER_OUNCE
|
||||||
|
|
||||||
|
|
||||||
|
def _gld_shares_to_contracts(position: Position, reference_date: date | None = None) -> Decimal:
|
||||||
|
"""Convert GLD shares to GC=F contracts."""
|
||||||
|
ounces = _gld_shares_to_ounces(position, reference_date)
|
||||||
|
return ounces / GC_F_OUNCES_PER_CONTRACT
|
||||||
|
|
||||||
|
|
||||||
|
def _ounces_to_gld_shares(ounces: Decimal, reference_date: date | None = None) -> Decimal:
|
||||||
|
"""Convert troy ounces to GLD shares using historical backing ratio."""
|
||||||
|
oz_per_share = gld_ounces_per_share(reference_date or date.today())
|
||||||
|
if oz_per_share <= 0:
|
||||||
|
raise ValueError("GLD ounces per share must be positive")
|
||||||
|
return ounces / oz_per_share
|
||||||
|
|
||||||
|
|
||||||
|
def _grams_to_gld_shares(grams: Decimal, reference_date: date | None = None) -> Decimal:
|
||||||
|
"""Convert grams to GLD shares using historical backing ratio."""
|
||||||
|
ounces = grams / GRAMS_PER_OUNCE
|
||||||
|
return _ounces_to_gld_shares(ounces, reference_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _contracts_to_gld_shares(contracts: Decimal, reference_date: date | None = None) -> Decimal:
|
||||||
|
"""Convert GC=F contracts to GLD shares using historical backing ratio."""
|
||||||
|
ounces = contracts * GC_F_OUNCES_PER_CONTRACT
|
||||||
|
return _ounces_to_gld_shares(ounces, reference_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _xau_oz_to_grams(ounces: Decimal) -> Decimal:
|
||||||
|
"""Convert troy ounces to grams."""
|
||||||
|
return ounces * GRAMS_PER_OUNCE
|
||||||
|
|
||||||
|
|
||||||
|
def _xau_g_to_ounces(grams: Decimal) -> Decimal:
|
||||||
|
"""Convert grams to troy ounces."""
|
||||||
|
return grams / GRAMS_PER_OUNCE
|
||||||
|
|
||||||
|
|
||||||
|
def _xau_oz_to_contracts(ounces: Decimal) -> Decimal:
|
||||||
|
"""Convert troy ounces to GC=F contracts."""
|
||||||
|
return ounces / GC_F_OUNCES_PER_CONTRACT
|
||||||
|
|
||||||
|
|
||||||
|
def _contracts_to_xau_oz(contracts: Decimal) -> Decimal:
|
||||||
|
"""Convert GC=F contracts to troy ounces."""
|
||||||
|
return contracts * GC_F_OUNCES_PER_CONTRACT
|
||||||
|
|
||||||
|
|
||||||
|
def position_to_display_units(
|
||||||
|
position: Position,
|
||||||
|
display_mode: str,
|
||||||
|
reference_date: date | None = None,
|
||||||
|
) -> tuple[Decimal, str]:
|
||||||
|
"""Convert a position to display units based on the selected display mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
position: The position to convert
|
||||||
|
display_mode: One of "GLD", "XAU_OZ", "XAU_G", "GCF"
|
||||||
|
reference_date: Date for historical conversion (defaults to position.entry_date)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (quantity in display units, display unit label)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> from datetime import date
|
||||||
|
>>> from decimal import Decimal
|
||||||
|
>>> from app.models.position import create_position
|
||||||
|
>>> pos = create_position("GLD", Decimal("100"), "shares", Decimal("230"))
|
||||||
|
>>> qty, unit = position_to_display_units(pos, "XAU_OZ")
|
||||||
|
>>> float(qty) > 0 and unit == "oz"
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
ref_date = reference_date or position.entry_date
|
||||||
|
|
||||||
|
if display_mode == "GLD":
|
||||||
|
# Show GLD shares as-is
|
||||||
|
if position.underlying == "GLD" and position.unit == "shares":
|
||||||
|
return position.quantity, "shares"
|
||||||
|
# Convert other formats to GLD shares
|
||||||
|
if position.underlying == "GLD" and position.unit == "oz":
|
||||||
|
shares = _ounces_to_gld_shares(position.quantity, ref_date)
|
||||||
|
return shares, "shares"
|
||||||
|
if position.underlying == "GLD" and position.unit == "g":
|
||||||
|
shares = _grams_to_gld_shares(position.quantity, ref_date)
|
||||||
|
return shares, "shares"
|
||||||
|
if position.underlying == "GC=F" and position.unit == "contracts":
|
||||||
|
shares = _contracts_to_gld_shares(position.quantity, ref_date)
|
||||||
|
return shares, "shares"
|
||||||
|
# XAU positions
|
||||||
|
if position.underlying == "XAU":
|
||||||
|
if position.unit == "oz":
|
||||||
|
shares = _ounces_to_gld_shares(position.quantity, ref_date)
|
||||||
|
return shares, "shares"
|
||||||
|
if position.unit == "g":
|
||||||
|
shares = _grams_to_gld_shares(position.quantity, ref_date)
|
||||||
|
return shares, "shares"
|
||||||
|
|
||||||
|
elif display_mode == "XAU_OZ":
|
||||||
|
# Show everything in troy ounces
|
||||||
|
if position.underlying == "GLD" and position.unit == "shares":
|
||||||
|
ounces = _gld_shares_to_ounces(position, ref_date)
|
||||||
|
return ounces, "oz"
|
||||||
|
if position.underlying == "GLD" and position.unit == "oz":
|
||||||
|
return position.quantity, "oz"
|
||||||
|
if position.underlying == "GLD" and position.unit == "g":
|
||||||
|
return _xau_g_to_ounces(position.quantity), "oz"
|
||||||
|
if position.underlying == "GC=F" and position.unit == "contracts":
|
||||||
|
ounces = position.quantity * GC_F_OUNCES_PER_CONTRACT
|
||||||
|
return ounces, "oz"
|
||||||
|
if position.underlying == "XAU":
|
||||||
|
if position.unit == "oz":
|
||||||
|
return position.quantity, "oz"
|
||||||
|
if position.unit == "g":
|
||||||
|
return _xau_g_to_ounces(position.quantity), "oz"
|
||||||
|
|
||||||
|
elif display_mode == "XAU_G":
|
||||||
|
# Show everything in grams
|
||||||
|
if position.underlying == "GLD" and position.unit == "shares":
|
||||||
|
grams = _gld_shares_to_grams(position, ref_date)
|
||||||
|
return grams, "g"
|
||||||
|
if position.underlying == "GLD" and position.unit == "oz":
|
||||||
|
ounces = position.quantity
|
||||||
|
return _xau_oz_to_grams(ounces), "g"
|
||||||
|
if position.underlying == "GLD" and position.unit == "g":
|
||||||
|
return position.quantity, "g"
|
||||||
|
if position.underlying == "GC=F" and position.unit == "contracts":
|
||||||
|
ounces = position.quantity * GC_F_OUNCES_PER_CONTRACT
|
||||||
|
return _xau_oz_to_grams(ounces), "g"
|
||||||
|
if position.underlying == "XAU":
|
||||||
|
if position.unit == "oz":
|
||||||
|
return _xau_oz_to_grams(position.quantity), "g"
|
||||||
|
if position.unit == "g":
|
||||||
|
return position.quantity, "g"
|
||||||
|
|
||||||
|
elif display_mode == "GCF":
|
||||||
|
# Show everything in GC=F contracts
|
||||||
|
if position.underlying == "GLD" and position.unit == "shares":
|
||||||
|
contracts = _gld_shares_to_contracts(position, ref_date)
|
||||||
|
return contracts, "contracts"
|
||||||
|
if position.underlying == "GLD" and position.unit == "oz":
|
||||||
|
contracts = _xau_oz_to_contracts(position.quantity)
|
||||||
|
return contracts, "contracts"
|
||||||
|
if position.underlying == "GLD" and position.unit == "g":
|
||||||
|
ounces = _xau_g_to_ounces(position.quantity)
|
||||||
|
contracts = _xau_oz_to_contracts(ounces)
|
||||||
|
return contracts, "contracts"
|
||||||
|
if position.underlying == "GC=F" and position.unit == "contracts":
|
||||||
|
return position.quantity, "contracts"
|
||||||
|
if position.underlying == "XAU":
|
||||||
|
if position.unit == "oz":
|
||||||
|
contracts = _xau_oz_to_contracts(position.quantity)
|
||||||
|
return contracts, "contracts"
|
||||||
|
if position.unit == "g":
|
||||||
|
ounces = _xau_g_to_ounces(position.quantity)
|
||||||
|
contracts = _xau_oz_to_contracts(ounces)
|
||||||
|
return contracts, "contracts"
|
||||||
|
|
||||||
|
# Fallback: return as-is
|
||||||
|
return position.quantity, position.unit
|
||||||
|
|
||||||
|
|
||||||
|
def collateral_to_display_units(
|
||||||
|
gold_ounces: float,
|
||||||
|
display_mode: str,
|
||||||
|
reference_date: date | None = None,
|
||||||
|
) -> tuple[float, str]:
|
||||||
|
"""Convert collateral amount to display units.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gold_ounces: Collateral amount in troy ounces
|
||||||
|
display_mode: One of "GLD", "XAU_OZ", "XAU_G", "GCF"
|
||||||
|
reference_date: Date for historical conversion (defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (amount in display units, display unit label)
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
oz_decimal = Decimal(str(gold_ounces))
|
||||||
|
ref_date = reference_date or date.today()
|
||||||
|
|
||||||
|
if display_mode == "GLD":
|
||||||
|
shares = _ounces_to_gld_shares(oz_decimal, ref_date)
|
||||||
|
return float(shares), "shares"
|
||||||
|
elif display_mode == "XAU_OZ":
|
||||||
|
return gold_ounces, "oz"
|
||||||
|
elif display_mode == "XAU_G":
|
||||||
|
grams = _xau_oz_to_grams(oz_decimal)
|
||||||
|
return float(grams), "g"
|
||||||
|
elif display_mode == "GCF":
|
||||||
|
contracts = _xau_oz_to_contracts(oz_decimal)
|
||||||
|
return float(contracts), "contracts"
|
||||||
|
|
||||||
|
return gold_ounces, "oz"
|
||||||
|
|
||||||
|
|
||||||
|
def price_per_display_unit(
|
||||||
|
price_per_oz: float,
|
||||||
|
display_mode: str,
|
||||||
|
reference_date: date | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Convert price per ounce to price per display unit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
price_per_oz: Price per troy ounce in USD
|
||||||
|
display_mode: One of "GLD", "XAU_OZ", "XAU_G", "GCF"
|
||||||
|
reference_date: Date for historical conversion (defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Price per display unit in USD
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
price = Decimal(str(price_per_oz))
|
||||||
|
ref_date = reference_date or date.today()
|
||||||
|
|
||||||
|
if display_mode == "GLD":
|
||||||
|
# Price per GLD share = price per oz * oz per share
|
||||||
|
oz_per_share = gld_ounces_per_share(ref_date)
|
||||||
|
return float(price * oz_per_share)
|
||||||
|
elif display_mode == "XAU_OZ":
|
||||||
|
return price_per_oz
|
||||||
|
elif display_mode == "XAU_G":
|
||||||
|
# Price per gram = price per oz / grams per oz
|
||||||
|
return float(price / GRAMS_PER_OUNCE)
|
||||||
|
elif display_mode == "GCF":
|
||||||
|
# Price per contract = price per oz * 100 oz
|
||||||
|
return float(price * GC_F_OUNCES_PER_CONTRACT)
|
||||||
|
|
||||||
|
return price_per_oz
|
||||||
|
|
||||||
|
|
||||||
|
def format_display_quantity(quantity: float, unit: str) -> str:
|
||||||
|
"""Format a quantity with appropriate precision for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quantity: Numeric quantity
|
||||||
|
unit: Unit label ("shares", "oz", "g", "contracts")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with appropriate precision
|
||||||
|
"""
|
||||||
|
if unit == "shares":
|
||||||
|
return f"{quantity:,.0f}"
|
||||||
|
elif unit == "contracts":
|
||||||
|
return f"{quantity:,.0f}"
|
||||||
|
elif unit == "oz":
|
||||||
|
return f"{quantity:,.4f}"
|
||||||
|
elif unit == "g":
|
||||||
|
return f"{quantity:,.2f}"
|
||||||
|
else:
|
||||||
|
return f"{quantity:,.4f}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_display_mode_label(display_mode: str) -> str:
|
||||||
|
"""Get human-readable label for display mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
display_mode: One of "GLD", "XAU_OZ", "XAU_G", "GCF"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable label
|
||||||
|
"""
|
||||||
|
labels = {
|
||||||
|
"GLD": "GLD Shares",
|
||||||
|
"XAU_OZ": "Gold (Troy Ounces)",
|
||||||
|
"XAU_G": "Gold (Grams)",
|
||||||
|
"GCF": "GC=F Contracts",
|
||||||
|
}
|
||||||
|
return labels.get(display_mode, display_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def get_display_mode_options() -> dict[str, str]:
|
||||||
|
"""Get display mode options for UI selector.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping mode codes to display labels
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"GLD": "GLD Shares",
|
||||||
|
"XAU_OZ": "Gold Ounces (troy)",
|
||||||
|
"XAU_G": "Gold Grams",
|
||||||
|
"GCF": "GC=F Contracts",
|
||||||
|
}
|
||||||
@@ -116,6 +116,10 @@ class PortfolioConfig:
|
|||||||
# Underlying instrument selection
|
# Underlying instrument selection
|
||||||
underlying: str = "GLD"
|
underlying: str = "GLD"
|
||||||
|
|
||||||
|
# Display mode for underlying units
|
||||||
|
# Options: "GLD" (shares), "XAU_OZ" (troy ounces), "XAU_G" (grams), "GCF" (futures contracts)
|
||||||
|
display_mode: str = "GLD"
|
||||||
|
|
||||||
# Alert settings
|
# Alert settings
|
||||||
volatility_spike: float = 0.25
|
volatility_spike: float = 0.25
|
||||||
spot_drawdown: float = 7.5
|
spot_drawdown: float = 7.5
|
||||||
@@ -311,6 +315,7 @@ class PortfolioConfig:
|
|||||||
"fallback_source": self.fallback_source,
|
"fallback_source": self.fallback_source,
|
||||||
"refresh_interval": self.refresh_interval,
|
"refresh_interval": self.refresh_interval,
|
||||||
"underlying": self.underlying,
|
"underlying": self.underlying,
|
||||||
|
"display_mode": self.display_mode,
|
||||||
"volatility_spike": self.volatility_spike,
|
"volatility_spike": self.volatility_spike,
|
||||||
"spot_drawdown": self.spot_drawdown,
|
"spot_drawdown": self.spot_drawdown,
|
||||||
"email_alerts": self.email_alerts,
|
"email_alerts": self.email_alerts,
|
||||||
@@ -394,6 +399,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
|
||||||
"volatility_spike",
|
"volatility_spike",
|
||||||
"spot_drawdown",
|
"spot_drawdown",
|
||||||
"email_alerts",
|
"email_alerts",
|
||||||
@@ -491,7 +497,7 @@ class PortfolioRepository:
|
|||||||
# Fields that must be present in persisted payloads
|
# Fields that must be present in persisted payloads
|
||||||
# (underlying is optional with default "GLD")
|
# (underlying is optional with default "GLD")
|
||||||
# (positions is optional - legacy configs won't have it)
|
# (positions is optional - legacy configs won't have it)
|
||||||
_REQUIRED_FIELDS = (_PERSISTED_FIELDS - {"underlying"}) - {"positions"}
|
_REQUIRED_FIELDS = (_PERSISTED_FIELDS - {"underlying", "display_mode"}) - {"positions"}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None:
|
def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 (
|
||||||
@@ -121,16 +122,25 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
|
|||||||
)
|
)
|
||||||
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 for display
|
# Get underlying and display mode 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}.",
|
||||||
@@ -138,7 +148,10 @@ 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(f"Active underlying: {underlying}").classes("text-sm text-slate-500 dark:text-slate-400")
|
ui.label(
|
||||||
|
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",
|
||||||
@@ -249,11 +262,15 @@ 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}/oz"),
|
||||||
("Weight", f"{portfolio['gold_units']:,.0f} oz"),
|
("Weight", f"{format_display_quantity(collateral_qty, collateral_unit)} {collateral_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}"),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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
|
||||||
@@ -198,6 +199,13 @@ async def overview_page(workspace_id: str) -> None:
|
|||||||
else "Configured entry price fallback in USD/ozt"
|
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",
|
||||||
f"Portfolio health, LTV risk, and quick strategy guidance for the current {underlying}-backed loan.",
|
f"Portfolio health, LTV risk, and quick strategy guidance for the current {underlying}-backed loan.",
|
||||||
@@ -207,7 +215,9 @@ 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} · Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}"
|
f"Active underlying: {underlying} · Display mode: {display_label} · "
|
||||||
|
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(
|
||||||
@@ -312,15 +322,24 @@ async def overview_page(workspace_id: str) -> None:
|
|||||||
):
|
):
|
||||||
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")
|
||||||
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",
|
"Collateral Spot Price",
|
||||||
f"${portfolio['spot_price']:,.2f}",
|
f"${portfolio['spot_price']:,.2f}/oz",
|
||||||
spot_caption,
|
spot_caption,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"Collateral Amount",
|
||||||
|
f"{format_display_quantity(collateral_qty, collateral_unit)} {collateral_unit}",
|
||||||
|
f"Gold collateral in {display_label.lower()}",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"Margin Call Price",
|
"Margin Call Price",
|
||||||
f"${portfolio['margin_call_price']:,.2f}",
|
f"${portfolio['margin_call_price']:,.2f}/oz",
|
||||||
"Implied trigger level from persisted portfolio settings",
|
"Implied trigger level from persisted portfolio settings",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from uuid import uuid4
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
from app.domain.conversions import get_display_mode_options
|
||||||
from app.models.portfolio import PortfolioConfig
|
from app.models.portfolio import PortfolioConfig
|
||||||
from app.models.position import Position
|
from app.models.position import Position
|
||||||
from app.models.workspace import get_workspace_repository
|
from app.models.workspace import get_workspace_repository
|
||||||
@@ -126,6 +127,7 @@ def settings_page(workspace_id: str) -> None:
|
|||||||
fallback_source=str(fallback_source.value),
|
fallback_source=str(fallback_source.value),
|
||||||
refresh_interval=parsed_refresh_interval,
|
refresh_interval=parsed_refresh_interval,
|
||||||
underlying=str(underlying.value),
|
underlying=str(underlying.value),
|
||||||
|
display_mode=str(display_mode.value),
|
||||||
volatility_spike=float(vol_alert.value),
|
volatility_spike=float(vol_alert.value),
|
||||||
spot_drawdown=float(price_alert.value),
|
spot_drawdown=float(price_alert.value),
|
||||||
email_alerts=bool(email_alerts.value),
|
email_alerts=bool(email_alerts.value),
|
||||||
@@ -257,6 +259,16 @@ def settings_page(workspace_id: str) -> None:
|
|||||||
value=config.underlying,
|
value=config.underlying,
|
||||||
label="Underlying instrument",
|
label="Underlying instrument",
|
||||||
).classes("w-full")
|
).classes("w-full")
|
||||||
|
|
||||||
|
display_mode = ui.select(
|
||||||
|
get_display_mode_options(),
|
||||||
|
value=config.display_mode,
|
||||||
|
label="Display Mode",
|
||||||
|
).classes("w-full")
|
||||||
|
ui.label("Choose how to display positions and collateral values.").classes(
|
||||||
|
"text-xs text-slate-500 dark:text-slate-400 -mt-2"
|
||||||
|
)
|
||||||
|
|
||||||
primary_source = ui.select(
|
primary_source = ui.select(
|
||||||
["yfinance", "ibkr", "alpaca"],
|
["yfinance", "ibkr", "alpaca"],
|
||||||
value=config.primary_source,
|
value=config.primary_source,
|
||||||
@@ -609,6 +621,7 @@ def settings_page(workspace_id: str) -> None:
|
|||||||
primary_source,
|
primary_source,
|
||||||
fallback_source,
|
fallback_source,
|
||||||
refresh_interval,
|
refresh_interval,
|
||||||
|
display_mode,
|
||||||
):
|
):
|
||||||
element.on_value_change(update_calculations)
|
element.on_value_change(update_calculations)
|
||||||
|
|
||||||
|
|||||||
200
tests/test_display_mode.py
Normal file
200
tests/test_display_mode.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""Tests for display mode switching functionality."""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.conversions import (
|
||||||
|
collateral_to_display_units,
|
||||||
|
format_display_quantity,
|
||||||
|
get_display_mode_label,
|
||||||
|
get_display_mode_options,
|
||||||
|
position_to_display_units,
|
||||||
|
)
|
||||||
|
from app.models.position import create_position
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisplayModeOptions:
|
||||||
|
"""Test display mode option helpers."""
|
||||||
|
|
||||||
|
def test_get_display_mode_options(self):
|
||||||
|
"""Test display mode options dictionary."""
|
||||||
|
options = get_display_mode_options()
|
||||||
|
assert "GLD" in options
|
||||||
|
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):
|
||||||
|
"""Test display mode label lookup."""
|
||||||
|
assert get_display_mode_label("GLD") == "GLD Shares"
|
||||||
|
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:
|
||||||
|
"""Test quantity formatting for display."""
|
||||||
|
|
||||||
|
def test_format_shares(self):
|
||||||
|
"""Test share formatting (no decimals)."""
|
||||||
|
assert format_display_quantity(100.0, "shares") == "100"
|
||||||
|
assert format_display_quantity(1234.56, "shares") == "1,235"
|
||||||
|
assert format_display_quantity(1000000.0, "shares") == "1,000,000"
|
||||||
|
|
||||||
|
def test_format_contracts(self):
|
||||||
|
"""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):
|
||||||
|
"""Test ounce formatting (4 decimals)."""
|
||||||
|
assert format_display_quantity(100.0, "oz") == "100.0000"
|
||||||
|
assert format_display_quantity(9.1576, "oz") == "9.1576"
|
||||||
|
assert format_display_quantity(0.123456, "oz") == "0.1235"
|
||||||
|
|
||||||
|
def test_format_grams(self):
|
||||||
|
"""Test gram formatting (2 decimals)."""
|
||||||
|
assert format_display_quantity(100.0, "g") == "100.00"
|
||||||
|
assert format_display_quantity(284.83, "g") == "284.83"
|
||||||
|
assert format_display_quantity(31103.5, "g") == "31,103.50"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPositionToDisplayUnits:
|
||||||
|
"""Test position conversion to display units."""
|
||||||
|
|
||||||
|
def test_gld_shares_in_gld_mode(self):
|
||||||
|
"""Test GLD shares displayed as-is in GLD mode."""
|
||||||
|
pos = create_position("GLD", Decimal("100"), "shares", Decimal("230"))
|
||||||
|
qty, unit = position_to_display_units(pos, "GLD")
|
||||||
|
assert qty == Decimal("100")
|
||||||
|
assert unit == "shares"
|
||||||
|
|
||||||
|
def test_gld_shares_in_xau_oz_mode(self):
|
||||||
|
"""Test GLD shares converted to ounces in XAU_OZ mode."""
|
||||||
|
pos = create_position("GLD", Decimal("100"), "shares", Decimal("230"))
|
||||||
|
qty, unit = position_to_display_units(pos, "XAU_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:
|
||||||
|
"""Test collateral conversion to display units."""
|
||||||
|
|
||||||
|
def test_collateral_in_gld_mode(self):
|
||||||
|
"""Test collateral converted to 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):
|
||||||
|
"""Test collateral stays in ounces."""
|
||||||
|
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):
|
||||||
|
"""Test collateral converted to grams."""
|
||||||
|
qty, unit = collateral_to_display_units(1000.0, "XAU_G")
|
||||||
|
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:
|
||||||
|
"""Test PortfolioConfig display_mode field."""
|
||||||
|
|
||||||
|
def test_default_display_mode(self):
|
||||||
|
"""Test default display mode is GLD."""
|
||||||
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
|
||||||
|
config = PortfolioConfig()
|
||||||
|
assert config.display_mode == "GLD"
|
||||||
|
|
||||||
|
def test_custom_display_mode(self):
|
||||||
|
"""Test custom display mode."""
|
||||||
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
|
||||||
|
config = PortfolioConfig(display_mode="XAU_OZ")
|
||||||
|
assert config.display_mode == "XAU_OZ"
|
||||||
|
|
||||||
|
def test_display_mode_in_to_dict(self):
|
||||||
|
"""Test display_mode is included in to_dict."""
|
||||||
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
|
||||||
|
config = PortfolioConfig(display_mode="XAU_G")
|
||||||
|
config_dict = config.to_dict()
|
||||||
|
assert "display_mode" in config_dict
|
||||||
|
assert config_dict["display_mode"] == "XAU_G"
|
||||||
|
|
||||||
|
def test_display_mode_in_from_dict(self):
|
||||||
|
"""Test display_mode is restored from dict."""
|
||||||
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
|
||||||
|
config_dict = {
|
||||||
|
"gold_value": 215000.0,
|
||||||
|
"entry_price": 2150.0,
|
||||||
|
"gold_ounces": 100.0,
|
||||||
|
"display_mode": "GCF",
|
||||||
|
}
|
||||||
|
config = PortfolioConfig.from_dict(config_dict)
|
||||||
|
assert config.display_mode == "GCF"
|
||||||
Reference in New Issue
Block a user