feat(PORTFOLIO-003): add premium and spread for physical gold positions
This commit is contained in:
271
tests/test_position_costs.py
Normal file
271
tests/test_position_costs.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""Tests for position cost calculations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.position import Position, create_position
|
||||
from app.services.position_costs import (
|
||||
calculate_effective_entry,
|
||||
calculate_effective_exit,
|
||||
calculate_true_pnl,
|
||||
get_default_premium_for_product,
|
||||
)
|
||||
|
||||
|
||||
class TestEffectiveEntry:
|
||||
"""Tests for calculate_effective_entry."""
|
||||
|
||||
def test_no_premium_returns_entry_price(self) -> None:
|
||||
"""Entry price unchanged when no premium."""
|
||||
result = calculate_effective_entry(Decimal("2000"), None)
|
||||
assert result == Decimal("2000")
|
||||
|
||||
def test_zero_premium_returns_entry_price(self) -> None:
|
||||
"""Entry price unchanged with zero premium."""
|
||||
result = calculate_effective_entry(Decimal("2000"), Decimal("0"))
|
||||
assert result == Decimal("2000")
|
||||
|
||||
def test_premium_adds_to_entry(self) -> None:
|
||||
"""Premium adds to entry cost."""
|
||||
# 4% premium on $2000 = $80, total $2080
|
||||
result = calculate_effective_entry(Decimal("2000"), Decimal("0.04"))
|
||||
assert result == Decimal("2080")
|
||||
|
||||
def test_large_premium(self) -> None:
|
||||
"""Large premium (10%) calculated correctly."""
|
||||
result = calculate_effective_entry(Decimal("2000"), Decimal("0.10"))
|
||||
assert result == Decimal("2200")
|
||||
|
||||
|
||||
class TestEffectiveExit:
|
||||
"""Tests for calculate_effective_exit."""
|
||||
|
||||
def test_no_spread_returns_spot(self) -> None:
|
||||
"""Spot unchanged when no spread."""
|
||||
result = calculate_effective_exit(Decimal("2000"), None)
|
||||
assert result == Decimal("2000")
|
||||
|
||||
def test_zero_spread_returns_spot(self) -> None:
|
||||
"""Spot unchanged with zero spread."""
|
||||
result = calculate_effective_exit(Decimal("2000"), Decimal("0"))
|
||||
assert result == Decimal("2000")
|
||||
|
||||
def test_spread_subtracts_from_exit(self) -> None:
|
||||
"""Spread subtracts from exit value."""
|
||||
# 3% spread on $2000 = $60, result $1940
|
||||
result = calculate_effective_exit(Decimal("2000"), Decimal("0.03"))
|
||||
assert result == Decimal("1940")
|
||||
|
||||
def test_large_spread(self) -> None:
|
||||
"""Large spread (5%) calculated correctly."""
|
||||
result = calculate_effective_exit(Decimal("2000"), Decimal("0.05"))
|
||||
assert result == Decimal("1900")
|
||||
|
||||
|
||||
class TestTruePnL:
|
||||
"""Tests for calculate_true_pnl."""
|
||||
|
||||
def test_gld_position_no_premium_spread(self) -> None:
|
||||
"""GLD position with no premium/spread shows paper P&L equals true P&L."""
|
||||
position = create_position(
|
||||
underlying="GLD",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("400"),
|
||||
)
|
||||
result = calculate_true_pnl(position, Decimal("420"))
|
||||
|
||||
# Paper P&L: (420 - 400) * 100 = 2000
|
||||
assert result["paper_pnl"] == 2000.0
|
||||
assert result["true_pnl"] == 2000.0
|
||||
assert result["effective_entry"] == 400.0
|
||||
assert result["effective_exit"] == 420.0
|
||||
|
||||
def test_xau_position_with_premium(self) -> None:
|
||||
"""Physical gold position with premium shows higher effective entry."""
|
||||
position = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("10"), # 10 oz
|
||||
entry_price=Decimal("2000"),
|
||||
purchase_premium=Decimal("0.04"), # 4%
|
||||
)
|
||||
result = calculate_true_pnl(position, Decimal("2200"))
|
||||
|
||||
# Effective entry: 2000 * 1.04 = 2080
|
||||
# Paper P&L: (2200 - 2000) * 10 = 2000
|
||||
# True P&L: (2200 - 2080) * 10 = 1200
|
||||
assert result["paper_pnl"] == 2000.0
|
||||
assert result["true_pnl"] == 1200.0
|
||||
assert result["effective_entry"] == 2080.0
|
||||
assert result["premium_impact"] == 800.0 # 4% of 2000 * 10
|
||||
|
||||
def test_xau_position_with_spread(self) -> None:
|
||||
"""Physical gold position with spread shows lower effective exit."""
|
||||
position = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("10"),
|
||||
entry_price=Decimal("2000"),
|
||||
bid_ask_spread=Decimal("0.03"), # 3%
|
||||
)
|
||||
result = calculate_true_pnl(position, Decimal("2200"))
|
||||
|
||||
# Effective exit: 2200 * 0.97 = 2134
|
||||
# Paper P&L: (2200 - 2000) * 10 = 2000
|
||||
# True P&L: (2134 - 2000) * 10 = 1340
|
||||
assert result["paper_pnl"] == 2000.0
|
||||
assert result["true_pnl"] == 1340.0
|
||||
assert result["effective_exit"] == 2134.0
|
||||
assert result["spread_impact"] == 660.0 # 3% of 2200 * 10
|
||||
|
||||
def test_xau_position_with_both_premium_and_spread(self) -> None:
|
||||
"""Physical gold with both premium and spread."""
|
||||
position = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("10"),
|
||||
entry_price=Decimal("2000"),
|
||||
purchase_premium=Decimal("0.04"), # 4%
|
||||
bid_ask_spread=Decimal("0.03"), # 3%
|
||||
)
|
||||
result = calculate_true_pnl(position, Decimal("2200"))
|
||||
|
||||
# Effective entry: 2000 * 1.04 = 2080
|
||||
# Effective exit: 2200 * 0.97 = 2134
|
||||
# Paper P&L: (2200 - 2000) * 10 = 2000
|
||||
# True P&L: (2134 - 2080) * 10 = 540
|
||||
assert result["paper_pnl"] == 2000.0
|
||||
assert result["true_pnl"] == 540.0
|
||||
assert result["effective_entry"] == 2080.0
|
||||
assert result["effective_exit"] == 2134.0
|
||||
assert result["premium_impact"] == 800.0
|
||||
assert result["spread_impact"] == 660.0
|
||||
|
||||
|
||||
class TestDefaultPremiumForProduct:
|
||||
"""Tests for get_default_premium_for_product."""
|
||||
|
||||
def test_gld_defaults(self) -> None:
|
||||
"""GLD has minimal premium/spread."""
|
||||
premium, spread = get_default_premium_for_product("GLD")
|
||||
assert premium == Decimal("0")
|
||||
assert spread == Decimal("0.001")
|
||||
|
||||
def test_gldm_defaults(self) -> None:
|
||||
"""GLDM has minimal premium/spread."""
|
||||
premium, spread = get_default_premium_for_product("GLDM")
|
||||
assert premium == Decimal("0")
|
||||
assert spread == Decimal("0.001")
|
||||
|
||||
def test_gcf_returns_none(self) -> None:
|
||||
"""GC=F futures have no premium/spread (roll costs handled separately)."""
|
||||
result = get_default_premium_for_product("GC=F")
|
||||
assert result == (None, None)
|
||||
|
||||
def test_xau_default(self) -> None:
|
||||
"""XAU default uses coin defaults."""
|
||||
premium, spread = get_default_premium_for_product("XAU")
|
||||
assert premium == Decimal("0.04")
|
||||
assert spread == Decimal("0.03")
|
||||
|
||||
def test_xau_coin_1oz(self) -> None:
|
||||
"""XAU 1oz coins have 4%/3%."""
|
||||
premium, spread = get_default_premium_for_product("XAU", "coin_1oz")
|
||||
assert premium == Decimal("0.04")
|
||||
assert spread == Decimal("0.03")
|
||||
|
||||
def test_xau_bar_1kg(self) -> None:
|
||||
"""XAU 1kg bars have 1.5%/1.5%."""
|
||||
premium, spread = get_default_premium_for_product("XAU", "bar_1kg")
|
||||
assert premium == Decimal("0.015")
|
||||
assert spread == Decimal("0.015")
|
||||
|
||||
def test_xau_allocated(self) -> None:
|
||||
"""XAU allocated storage has minimal 0.1%/0.3%."""
|
||||
premium, spread = get_default_premium_for_product("XAU", "allocated")
|
||||
assert premium == Decimal("0.001")
|
||||
assert spread == Decimal("0.003")
|
||||
|
||||
def test_unknown_underlying_returns_none(self) -> None:
|
||||
"""Unknown underlying returns None."""
|
||||
result = get_default_premium_for_product("UNKNOWN")
|
||||
assert result == (None, None)
|
||||
|
||||
|
||||
class TestPositionWithPremiumSpread:
|
||||
"""Tests for Position model with premium/spread fields."""
|
||||
|
||||
def test_create_position_with_premium(self) -> None:
|
||||
"""Create position with purchase premium."""
|
||||
position = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("10"),
|
||||
entry_price=Decimal("2000"),
|
||||
purchase_premium=Decimal("0.04"),
|
||||
)
|
||||
assert position.purchase_premium == Decimal("0.04")
|
||||
assert position.bid_ask_spread is None
|
||||
|
||||
def test_create_position_with_spread(self) -> None:
|
||||
"""Create position with bid/ask spread."""
|
||||
position = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("10"),
|
||||
entry_price=Decimal("2000"),
|
||||
bid_ask_spread=Decimal("0.03"),
|
||||
)
|
||||
assert position.bid_ask_spread == Decimal("0.03")
|
||||
assert position.purchase_premium is None
|
||||
|
||||
def test_create_position_with_both(self) -> None:
|
||||
"""Create position with both premium and spread."""
|
||||
position = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("10"),
|
||||
entry_price=Decimal("2000"),
|
||||
purchase_premium=Decimal("0.04"),
|
||||
bid_ask_spread=Decimal("0.03"),
|
||||
)
|
||||
assert position.purchase_premium == Decimal("0.04")
|
||||
assert position.bid_ask_spread == Decimal("0.03")
|
||||
|
||||
def test_position_serialization_with_premium_spread(self) -> None:
|
||||
"""Position serializes premium and spread correctly."""
|
||||
position = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("10"),
|
||||
entry_price=Decimal("2000"),
|
||||
purchase_premium=Decimal("0.04"),
|
||||
bid_ask_spread=Decimal("0.03"),
|
||||
)
|
||||
data = position.to_dict()
|
||||
assert data["purchase_premium"] == "0.04"
|
||||
assert data["bid_ask_spread"] == "0.03"
|
||||
|
||||
def test_position_deserialization_with_premium_spread(self) -> None:
|
||||
"""Position deserializes premium and spread correctly."""
|
||||
data = {
|
||||
"id": "12345678-1234-5678-1234-567812345678",
|
||||
"underlying": "XAU",
|
||||
"quantity": "10",
|
||||
"unit": "oz",
|
||||
"entry_price": "2000",
|
||||
"entry_date": "2024-01-01",
|
||||
"purchase_premium": "0.04",
|
||||
"bid_ask_spread": "0.03",
|
||||
}
|
||||
position = Position.from_dict(data)
|
||||
assert position.purchase_premium == Decimal("0.04")
|
||||
assert position.bid_ask_spread == Decimal("0.03")
|
||||
|
||||
def test_position_deserialization_without_premium_spread(self) -> None:
|
||||
"""Position deserializes without premium and spread (backward compat)."""
|
||||
data = {
|
||||
"id": "12345678-1234-5678-1234-567812345678",
|
||||
"underlying": "GLD",
|
||||
"quantity": "100",
|
||||
"unit": "shares",
|
||||
"entry_price": "400",
|
||||
"entry_date": "2024-01-01",
|
||||
}
|
||||
position = Position.from_dict(data)
|
||||
assert position.purchase_premium is None
|
||||
assert position.bid_ask_spread is None
|
||||
Reference in New Issue
Block a user