feat(PORTFOLIO-002): add position storage costs
This commit is contained in:
303
tests/test_storage_costs.py
Normal file
303
tests/test_storage_costs.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""Tests for storage cost calculations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
from app.models.position import Position, create_position
|
||||
from app.services.storage_costs import (
|
||||
calculate_annual_storage_cost,
|
||||
calculate_total_storage_cost,
|
||||
get_default_storage_cost_for_underlying,
|
||||
)
|
||||
|
||||
|
||||
class TestCalculateAnnualStorageCost:
|
||||
"""Test calculate_annual_storage_cost function."""
|
||||
|
||||
def test_no_storage_cost_returns_zero(self) -> None:
|
||||
"""Test that position without storage cost returns zero."""
|
||||
pos = create_position(
|
||||
underlying="GLD",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=None,
|
||||
)
|
||||
current_value = Decimal("215000")
|
||||
|
||||
result = calculate_annual_storage_cost(pos, current_value)
|
||||
|
||||
assert result == Decimal("0")
|
||||
|
||||
def test_percentage_annual_cost(self) -> None:
|
||||
"""Test percentage-based annual storage cost."""
|
||||
pos = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("0.12"), # 0.12%
|
||||
storage_cost_period="annual",
|
||||
)
|
||||
current_value = Decimal("215000")
|
||||
|
||||
result = calculate_annual_storage_cost(pos, current_value)
|
||||
|
||||
# 0.12% of 215000 = 258
|
||||
expected = Decimal("258")
|
||||
assert result == expected
|
||||
|
||||
def test_percentage_monthly_cost_annualized(self) -> None:
|
||||
"""Test percentage-based monthly storage cost is annualized."""
|
||||
pos = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("0.01"), # 0.01% per month
|
||||
storage_cost_period="monthly",
|
||||
)
|
||||
current_value = Decimal("215000")
|
||||
|
||||
result = calculate_annual_storage_cost(pos, current_value)
|
||||
|
||||
# 0.01% monthly × 12 = 0.12% annual
|
||||
# 0.12% of 215000 = 258
|
||||
expected = Decimal("258")
|
||||
assert result == expected
|
||||
|
||||
def test_fixed_annual_cost(self) -> None:
|
||||
"""Test fixed annual storage cost."""
|
||||
pos = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("300"), # $300 per year
|
||||
storage_cost_period="annual",
|
||||
)
|
||||
current_value = Decimal("215000")
|
||||
|
||||
result = calculate_annual_storage_cost(pos, current_value)
|
||||
|
||||
assert result == Decimal("300")
|
||||
|
||||
def test_fixed_monthly_cost_annualized(self) -> None:
|
||||
"""Test fixed monthly storage cost is annualized."""
|
||||
pos = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("25"), # $25 per month
|
||||
storage_cost_period="monthly",
|
||||
)
|
||||
current_value = Decimal("215000")
|
||||
|
||||
result = calculate_annual_storage_cost(pos, current_value)
|
||||
|
||||
# $25 × 12 = $300 per year
|
||||
expected = Decimal("300")
|
||||
assert result == expected
|
||||
|
||||
def test_percentage_boundary_at_one(self) -> None:
|
||||
"""Test that basis < 1 is treated as percentage, >= 1 as fixed."""
|
||||
# 0.99 should be treated as percentage
|
||||
pos_pct = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("0.99"),
|
||||
storage_cost_period="annual",
|
||||
)
|
||||
current_value = Decimal("100000")
|
||||
result_pct = calculate_annual_storage_cost(pos_pct, current_value)
|
||||
# 0.99% of 100000 = 990
|
||||
assert result_pct == Decimal("990")
|
||||
|
||||
# 1.00 should be treated as fixed
|
||||
pos_fixed = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("1"),
|
||||
storage_cost_period="annual",
|
||||
)
|
||||
result_fixed = calculate_annual_storage_cost(pos_fixed, current_value)
|
||||
assert result_fixed == Decimal("1")
|
||||
|
||||
|
||||
class TestCalculateTotalStorageCost:
|
||||
"""Test calculate_total_storage_cost function."""
|
||||
|
||||
def test_empty_positions_returns_zero(self) -> None:
|
||||
"""Test that empty position list returns zero."""
|
||||
result = calculate_total_storage_cost([], {})
|
||||
assert result == Decimal("0")
|
||||
|
||||
def test_multiple_positions_summed(self) -> None:
|
||||
"""Test that multiple positions have costs summed."""
|
||||
pos1 = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("50"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("0.12"),
|
||||
storage_cost_period="annual",
|
||||
)
|
||||
pos2 = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("50"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("0.12"),
|
||||
storage_cost_period="annual",
|
||||
)
|
||||
positions = [pos1, pos2]
|
||||
current_values = {
|
||||
str(pos1.id): Decimal("107500"), # 50 × 2150
|
||||
str(pos2.id): Decimal("107500"),
|
||||
}
|
||||
|
||||
result = calculate_total_storage_cost(positions, current_values)
|
||||
|
||||
# Each: 0.12% of 107500 = 129
|
||||
# Total: 129 + 129 = 258
|
||||
expected = Decimal("258")
|
||||
assert result == expected
|
||||
|
||||
def test_mixed_positions_with_and_without_costs(self) -> None:
|
||||
"""Test positions with and without storage costs."""
|
||||
pos_xau = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("0.12"),
|
||||
storage_cost_period="annual",
|
||||
)
|
||||
pos_gld = create_position(
|
||||
underlying="GLD",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=None, # GLD has no storage cost
|
||||
)
|
||||
positions = [pos_xau, pos_gld]
|
||||
current_values = {
|
||||
str(pos_xau.id): Decimal("215000"),
|
||||
str(pos_gld.id): Decimal("215000"),
|
||||
}
|
||||
|
||||
result = calculate_total_storage_cost(positions, current_values)
|
||||
|
||||
# Only XAU position has cost: 0.12% of 215000 = 258
|
||||
expected = Decimal("258")
|
||||
assert result == expected
|
||||
|
||||
|
||||
class TestGetDefaultStorageCostForUnderlying:
|
||||
"""Test get_default_storage_cost_for_underlying function."""
|
||||
|
||||
def test_xau_default(self) -> None:
|
||||
"""Test XAU gets 0.12% annual default."""
|
||||
basis, period = get_default_storage_cost_for_underlying("XAU")
|
||||
|
||||
assert basis == Decimal("0.12")
|
||||
assert period == "annual"
|
||||
|
||||
def test_gld_default(self) -> None:
|
||||
"""Test GLD gets no storage cost (expense ratio in price)."""
|
||||
basis, period = get_default_storage_cost_for_underlying("GLD")
|
||||
|
||||
assert basis is None
|
||||
assert period is None
|
||||
|
||||
def test_gc_f_default(self) -> None:
|
||||
"""Test GC=F gets no storage cost (roll costs deferred)."""
|
||||
basis, period = get_default_storage_cost_for_underlying("GC=F")
|
||||
|
||||
assert basis is None
|
||||
assert period is None
|
||||
|
||||
def test_unknown_underlying_default(self) -> None:
|
||||
"""Test unknown underlying gets no storage cost."""
|
||||
basis, period = get_default_storage_cost_for_underlying("UNKNOWN")
|
||||
|
||||
assert basis is None
|
||||
assert period is None
|
||||
|
||||
|
||||
class TestPositionStorageCostFields:
|
||||
"""Test Position model storage cost fields."""
|
||||
|
||||
def test_position_with_storage_cost_fields(self) -> None:
|
||||
"""Test Position can be created with storage cost fields."""
|
||||
pos = Position(
|
||||
id=uuid4(),
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
unit="oz",
|
||||
entry_price=Decimal("2150"),
|
||||
entry_date="2025-01-01",
|
||||
storage_cost_basis=Decimal("0.12"),
|
||||
storage_cost_period="annual",
|
||||
storage_cost_currency="USD",
|
||||
)
|
||||
|
||||
assert pos.storage_cost_basis == Decimal("0.12")
|
||||
assert pos.storage_cost_period == "annual"
|
||||
assert pos.storage_cost_currency == "USD"
|
||||
|
||||
def test_position_default_storage_cost_fields(self) -> None:
|
||||
"""Test Position defaults for storage cost fields."""
|
||||
pos = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
)
|
||||
|
||||
assert pos.storage_cost_basis is None
|
||||
assert pos.storage_cost_period is None
|
||||
assert pos.storage_cost_currency == "USD"
|
||||
|
||||
def test_position_serialization_with_storage_costs(self) -> None:
|
||||
"""Test Position serialization includes storage cost fields."""
|
||||
pos = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("0.12"),
|
||||
storage_cost_period="annual",
|
||||
)
|
||||
|
||||
data = pos.to_dict()
|
||||
|
||||
assert data["storage_cost_basis"] == "0.12"
|
||||
assert data["storage_cost_period"] == "annual"
|
||||
assert data["storage_cost_currency"] == "USD"
|
||||
|
||||
def test_position_deserialization_with_storage_costs(self) -> None:
|
||||
"""Test Position deserialization restores storage cost fields."""
|
||||
pos = create_position(
|
||||
underlying="XAU",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=Decimal("0.12"),
|
||||
storage_cost_period="annual",
|
||||
)
|
||||
|
||||
data = pos.to_dict()
|
||||
restored = Position.from_dict(data)
|
||||
|
||||
assert restored.storage_cost_basis == Decimal("0.12")
|
||||
assert restored.storage_cost_period == "annual"
|
||||
assert restored.storage_cost_currency == "USD"
|
||||
|
||||
def test_position_serialization_with_null_storage_costs(self) -> None:
|
||||
"""Test Position serialization handles null storage cost fields."""
|
||||
pos = create_position(
|
||||
underlying="GLD",
|
||||
quantity=Decimal("100"),
|
||||
entry_price=Decimal("2150"),
|
||||
storage_cost_basis=None,
|
||||
)
|
||||
|
||||
data = pos.to_dict()
|
||||
|
||||
assert data["storage_cost_basis"] is None
|
||||
assert data["storage_cost_period"] is None
|
||||
assert data["storage_cost_currency"] == "USD"
|
||||
Reference in New Issue
Block a user