302 lines
10 KiB
Python
302 lines
10 KiB
Python
"""Tests for DISPLAY-002: GLD Mode Shows Real GLD Pricing."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.domain.conversions import (
|
||
convert_position_to_display,
|
||
convert_price_to_display,
|
||
convert_quantity_to_display,
|
||
get_display_unit_label,
|
||
is_gld_mode,
|
||
is_xau_mode,
|
||
)
|
||
from app.domain.instruments import gld_ounces_per_share
|
||
from app.domain.portfolio_math import (
|
||
build_alert_context,
|
||
portfolio_snapshot_from_config,
|
||
resolve_portfolio_spot_from_quote,
|
||
)
|
||
from app.models.portfolio import PortfolioConfig
|
||
from app.models.position import create_position
|
||
|
||
|
||
class TestDisplayModeHelpers:
|
||
"""Test display mode helper functions."""
|
||
|
||
def test_is_gld_mode(self) -> None:
|
||
assert is_gld_mode("GLD") is True
|
||
assert is_gld_mode("XAU") is False
|
||
assert is_gld_mode("gld") is False # Case sensitive
|
||
|
||
def test_is_xau_mode(self) -> None:
|
||
assert is_xau_mode("XAU") is True
|
||
assert is_xau_mode("GLD") is False
|
||
|
||
|
||
class TestConvertPositionToDisplay:
|
||
"""Test position conversion based on display mode."""
|
||
|
||
def test_gld_position_in_gld_mode_shows_shares(self) -> None:
|
||
"""GLD position in GLD mode: show as-is in shares."""
|
||
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")
|
||
|
||
assert qty == Decimal("100")
|
||
assert unit == "shares"
|
||
assert price == Decimal("400")
|
||
|
||
def test_gld_position_in_xau_mode_converts_to_oz(self) -> None:
|
||
"""GLD position in XAU mode: convert to oz using expense-adjusted backing."""
|
||
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, "XAU", date.today())
|
||
|
||
# Should convert to oz
|
||
assert unit == "oz"
|
||
backing = gld_ounces_per_share(date.today())
|
||
expected_qty = Decimal("100") * backing
|
||
assert abs(float(qty) - float(expected_qty)) < 0.0001
|
||
# Price should be per oz (share price / backing)
|
||
expected_price = Decimal("400") / backing
|
||
assert abs(float(price) - float(expected_price)) < 0.01
|
||
|
||
def test_xau_position_in_xau_mode_shows_oz(self) -> None:
|
||
"""Physical gold position in XAU mode: show as-is in oz."""
|
||
pos = create_position(
|
||
underlying="XAU",
|
||
quantity=Decimal("50"),
|
||
unit="oz",
|
||
entry_price=Decimal("2000"),
|
||
entry_date=date.today(),
|
||
)
|
||
|
||
qty, unit, price = convert_position_to_display(pos, "XAU")
|
||
|
||
assert qty == Decimal("50")
|
||
assert unit == "oz"
|
||
assert price == Decimal("2000")
|
||
|
||
|
||
class TestConvertPriceToDisplay:
|
||
"""Test price conversion based on display mode."""
|
||
|
||
def test_share_price_to_gld_mode(self) -> None:
|
||
"""Share price in GLD mode: no conversion."""
|
||
price, unit = convert_price_to_display(Decimal("400"), "shares", "GLD")
|
||
assert price == Decimal("400")
|
||
assert unit == "shares"
|
||
|
||
def test_oz_price_to_gld_mode(self) -> None:
|
||
"""Oz price in GLD mode: convert to share price."""
|
||
oz_price = Decimal("4400")
|
||
price, unit = convert_price_to_display(oz_price, "oz", "GLD", date.today())
|
||
|
||
backing = gld_ounces_per_share(date.today())
|
||
# Share price = oz price × backing
|
||
expected = oz_price * backing
|
||
assert abs(float(price) - float(expected)) < 0.01
|
||
assert unit == "shares"
|
||
|
||
def test_share_price_to_xau_mode(self) -> None:
|
||
"""Share price in XAU mode: convert to oz price."""
|
||
share_price = Decimal("400")
|
||
price, unit = convert_price_to_display(share_price, "shares", "XAU", date.today())
|
||
|
||
backing = gld_ounces_per_share(date.today())
|
||
# Oz price = share price / backing
|
||
expected = share_price / backing
|
||
assert abs(float(price) - float(expected)) < 1.0
|
||
assert unit == "oz"
|
||
|
||
|
||
class TestConvertQuantityToDisplay:
|
||
"""Test quantity conversion based on display mode."""
|
||
|
||
def test_shares_to_gld_mode(self) -> None:
|
||
"""Shares in GLD mode: no conversion."""
|
||
qty, unit = convert_quantity_to_display(Decimal("100"), "shares", "GLD")
|
||
assert qty == Decimal("100")
|
||
assert unit == "shares"
|
||
|
||
def test_shares_to_xau_mode(self) -> None:
|
||
"""Shares in XAU mode: convert to oz."""
|
||
shares = Decimal("100")
|
||
qty, unit = convert_quantity_to_display(shares, "shares", "XAU", date.today())
|
||
|
||
backing = gld_ounces_per_share(date.today())
|
||
expected = shares * backing
|
||
assert abs(float(qty) - float(expected)) < 0.0001
|
||
assert unit == "oz"
|
||
|
||
|
||
class TestGetDisplayUnitLabel:
|
||
"""Test display unit label helper."""
|
||
|
||
def test_gld_underlying_in_gld_mode(self) -> None:
|
||
assert get_display_unit_label("GLD", "GLD") == "shares"
|
||
|
||
def test_gld_underlying_in_xau_mode(self) -> None:
|
||
assert get_display_unit_label("GLD", "XAU") == "oz"
|
||
|
||
def test_xau_underlying(self) -> None:
|
||
assert get_display_unit_label("XAU", "GLD") == "oz"
|
||
assert get_display_unit_label("XAU", "XAU") == "oz"
|
||
|
||
|
||
class TestPortfolioSnapshotWithDisplayMode:
|
||
"""Test portfolio snapshot respects display mode."""
|
||
|
||
def test_gld_mode_snapshot_uses_shares(self) -> None:
|
||
"""GLD mode: snapshot shows shares and share price."""
|
||
config = PortfolioConfig(
|
||
entry_price=2150.0,
|
||
gold_ounces=100.0,
|
||
entry_basis_mode="weight",
|
||
loan_amount=145000.0,
|
||
display_mode="GLD",
|
||
)
|
||
|
||
snapshot = portfolio_snapshot_from_config(config, runtime_spot_price=400.0)
|
||
|
||
# In GLD mode, spot_price should be the share price (400.0)
|
||
assert snapshot["spot_price"] == 400.0
|
||
# gold_units should be in shares (converted from oz using backing)
|
||
backing = float(gld_ounces_per_share(date.today()))
|
||
expected_shares = 100.0 / backing
|
||
assert abs(snapshot["gold_units"] - expected_shares) < 0.01
|
||
# gold_value = shares × share_price
|
||
expected_value = expected_shares * 400.0
|
||
assert abs(snapshot["gold_value"] - expected_value) < 0.01
|
||
assert snapshot["display_mode"] == "GLD"
|
||
|
||
def test_xau_mode_snapshot_uses_oz(self) -> None:
|
||
"""XAU mode: snapshot shows oz and oz price."""
|
||
config = PortfolioConfig(
|
||
entry_price=2150.0,
|
||
gold_ounces=100.0,
|
||
entry_basis_mode="weight",
|
||
loan_amount=145000.0,
|
||
display_mode="XAU",
|
||
)
|
||
|
||
snapshot = portfolio_snapshot_from_config(config, runtime_spot_price=2150.0)
|
||
|
||
# In XAU mode, spot_price should be oz price (2150.0)
|
||
assert snapshot["spot_price"] == 2150.0
|
||
# gold_units should be in oz
|
||
assert snapshot["gold_units"] == 100.0
|
||
# gold_value = oz × oz_price
|
||
assert snapshot["gold_value"] == 215000.0
|
||
assert snapshot["display_mode"] == "XAU"
|
||
|
||
|
||
class TestResolvePortfolioSpotFromQuote:
|
||
"""Test spot price resolution respects display mode."""
|
||
|
||
def test_gld_mode_returns_share_price_directly(self) -> None:
|
||
"""GLD mode: return GLD share price without conversion."""
|
||
config = PortfolioConfig(
|
||
entry_price=2150.0,
|
||
gold_ounces=100.0,
|
||
display_mode="GLD",
|
||
)
|
||
|
||
quote = {
|
||
"symbol": "GLD",
|
||
"price": 404.19,
|
||
"quote_unit": "share",
|
||
"source": "yfinance",
|
||
"updated_at": "2026-03-24T00:00:00+00:00",
|
||
}
|
||
|
||
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, quote)
|
||
|
||
# In GLD mode, should return share price directly
|
||
assert spot == 404.19
|
||
assert source == "yfinance"
|
||
|
||
def test_xau_mode_converts_to_oz_price(self) -> None:
|
||
"""XAU mode: convert GLD share price to oz-equivalent."""
|
||
config = PortfolioConfig(
|
||
entry_price=2150.0,
|
||
gold_ounces=100.0,
|
||
display_mode="XAU",
|
||
)
|
||
|
||
quote = {
|
||
"symbol": "GLD",
|
||
"price": 404.19,
|
||
"quote_unit": "share",
|
||
"source": "yfinance",
|
||
"updated_at": "2026-03-24T00:00:00+00:00",
|
||
}
|
||
|
||
spot, source, updated_at = resolve_portfolio_spot_from_quote(config, quote)
|
||
|
||
# In XAU mode, should convert to oz price
|
||
backing = float(gld_ounces_per_share(date.today()))
|
||
expected_spot = 404.19 / backing
|
||
assert abs(spot - expected_spot) < 0.01
|
||
assert source == "yfinance"
|
||
|
||
|
||
class TestBuildAlertContextWithDisplayMode:
|
||
"""Test alert context respects display mode."""
|
||
|
||
def test_gld_mode_context_uses_shares(self) -> None:
|
||
"""GLD mode: alert context shows shares."""
|
||
config = PortfolioConfig(
|
||
entry_price=2150.0,
|
||
gold_ounces=100.0,
|
||
loan_amount=145000.0,
|
||
display_mode="GLD",
|
||
)
|
||
|
||
context = build_alert_context(
|
||
config,
|
||
spot_price=400.0, # Share price
|
||
source="yfinance",
|
||
updated_at="2026-03-24T00:00:00+00:00",
|
||
)
|
||
|
||
# Should show shares, not oz
|
||
backing = float(gld_ounces_per_share(date.today()))
|
||
expected_shares = 100.0 / backing
|
||
assert abs(context["gold_units"] - expected_shares) < 0.01
|
||
assert context["display_mode"] == "GLD"
|
||
|
||
def test_xau_mode_context_uses_oz(self) -> None:
|
||
"""XAU mode: alert context shows oz."""
|
||
config = PortfolioConfig(
|
||
entry_price=2150.0,
|
||
gold_ounces=100.0,
|
||
loan_amount=145000.0,
|
||
display_mode="XAU",
|
||
)
|
||
|
||
context = build_alert_context(
|
||
config,
|
||
spot_price=2150.0,
|
||
source="yfinance",
|
||
updated_at="2026-03-24T00:00:00+00:00",
|
||
)
|
||
|
||
assert context["gold_units"] == 100.0
|
||
assert context["display_mode"] == "XAU"
|