feat(DISPLAY-001): add underlying mode switching

This commit is contained in:
Bu5hm4nn
2026-03-28 21:44:32 +01:00
parent 24c74cacbd
commit 20f5086507
6 changed files with 584 additions and 7 deletions

322
app/domain/conversions.py Normal file
View 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",
}

View File

@@ -116,6 +116,10 @@ class PortfolioConfig:
# Underlying instrument selection
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
volatility_spike: float = 0.25
spot_drawdown: float = 7.5
@@ -311,6 +315,7 @@ class PortfolioConfig:
"fallback_source": self.fallback_source,
"refresh_interval": self.refresh_interval,
"underlying": self.underlying,
"display_mode": self.display_mode,
"volatility_spike": self.volatility_spike,
"spot_drawdown": self.spot_drawdown,
"email_alerts": self.email_alerts,
@@ -394,6 +399,7 @@ class PortfolioRepository:
"fallback_source",
"refresh_interval",
"underlying", # optional with default "GLD"
"display_mode", # display mode for underlying units
"volatility_spike",
"spot_drawdown",
"email_alerts",
@@ -491,7 +497,7 @@ class PortfolioRepository:
# Fields that must be present in persisted payloads
# (underlying is optional with default "GLD")
# (positions is optional - legacy configs won't have it)
_REQUIRED_FIELDS = (_PERSISTED_FIELDS - {"underlying"}) - {"positions"}
_REQUIRED_FIELDS = (_PERSISTED_FIELDS - {"underlying", "display_mode"}) - {"positions"}
@classmethod
def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None:

View File

@@ -5,6 +5,7 @@ import logging
from fastapi.responses import RedirectResponse
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.models.workspace import get_workspace_repository
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"
# Get underlying for display
# Get underlying and display mode for display
underlying = "GLD"
display_mode = "GLD"
display_label = "GLD Shares"
if workspace_id:
try:
repo = get_workspace_repository()
config = repo.load_portfolio_config(workspace_id)
underlying = config.underlying or "GLD"
display_mode = config.display_mode or "GLD"
display_label = get_display_mode_label(display_mode)
except Exception:
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(
"Hedge Analysis",
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,
):
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_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"
)
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"):
cards = [
("Start value", f"${portfolio['gold_value']:,.0f}"),
("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}"),
("Margin call LTV", f"{portfolio['margin_call_ltv']:.1%}"),
("Monthly hedge budget", f"${portfolio['hedge_budget']:,.0f}"),

View File

@@ -8,6 +8,7 @@ from fastapi.responses import RedirectResponse
from nicegui import ui
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.models.ltv_history import LtvHistoryRepository
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"
)
# 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(
"Overview",
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"):
ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400")
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")
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")
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 = [
(
"Collateral Spot Price",
f"${portfolio['spot_price']:,.2f}",
f"${portfolio['spot_price']:,.2f}/oz",
spot_caption,
),
(
"Collateral Amount",
f"{format_display_quantity(collateral_qty, collateral_unit)} {collateral_unit}",
f"Gold collateral in {display_label.lower()}",
),
(
"Margin Call Price",
f"${portfolio['margin_call_price']:,.2f}",
f"${portfolio['margin_call_price']:,.2f}/oz",
"Implied trigger level from persisted portfolio settings",
),
(

View File

@@ -8,6 +8,7 @@ from uuid import uuid4
from fastapi.responses import RedirectResponse
from nicegui import ui
from app.domain.conversions import get_display_mode_options
from app.models.portfolio import PortfolioConfig
from app.models.position import Position
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),
refresh_interval=parsed_refresh_interval,
underlying=str(underlying.value),
display_mode=str(display_mode.value),
volatility_spike=float(vol_alert.value),
spot_drawdown=float(price_alert.value),
email_alerts=bool(email_alerts.value),
@@ -257,6 +259,16 @@ def settings_page(workspace_id: str) -> None:
value=config.underlying,
label="Underlying instrument",
).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(
["yfinance", "ibkr", "alpaca"],
value=config.primary_source,
@@ -609,6 +621,7 @@ def settings_page(workspace_id: str) -> None:
primary_source,
fallback_source,
refresh_interval,
display_mode,
):
element.on_value_change(update_calculations)