diff --git a/app/domain/conversions.py b/app/domain/conversions.py new file mode 100644 index 0000000..6f4ea85 --- /dev/null +++ b/app/domain/conversions.py @@ -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", + } diff --git a/app/models/portfolio.py b/app/models/portfolio.py index 314e536..368842b 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -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: diff --git a/app/pages/hedge.py b/app/pages/hedge.py index 5022b30..54aaaf5 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -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}"), diff --git a/app/pages/overview.py b/app/pages/overview.py index d4359fd..9e958cf 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -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", ), ( diff --git a/app/pages/settings.py b/app/pages/settings.py index 6b3c91a..49dfb7c 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -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) diff --git a/tests/test_display_mode.py b/tests/test_display_mode.py new file mode 100644 index 0000000..b2aaf1e --- /dev/null +++ b/tests/test_display_mode.py @@ -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"