272 lines
10 KiB
Python
272 lines
10 KiB
Python
"""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
|