971 lines
34 KiB
Python
971 lines
34 KiB
Python
"""Tests for Portfolio State module.
|
|
|
|
Issue #29: [PORT-28] Portfolio state - holdings, cash, mark-to-market
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Dict, List, Optional
|
|
|
|
from tradingagents.portfolio import (
|
|
Currency,
|
|
HoldingType,
|
|
Holding,
|
|
CashBalance,
|
|
PortfolioSnapshot,
|
|
PortfolioState,
|
|
PriceProvider,
|
|
ExchangeRateProvider,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Test Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
class MockPriceProvider:
|
|
"""Mock price provider for testing."""
|
|
|
|
def __init__(self, prices: Optional[Dict[str, Decimal]] = None):
|
|
self._prices = prices or {}
|
|
|
|
def set_price(self, symbol: str, price: Decimal) -> None:
|
|
self._prices[symbol] = price
|
|
|
|
def get_price(self, symbol: str) -> Optional[Decimal]:
|
|
return self._prices.get(symbol)
|
|
|
|
def get_prices(self, symbols: List[str]) -> Dict[str, Decimal]:
|
|
return {s: self._prices[s] for s in symbols if s in self._prices}
|
|
|
|
|
|
class MockExchangeRateProvider:
|
|
"""Mock exchange rate provider for testing."""
|
|
|
|
def __init__(self, rates: Optional[Dict[tuple, Decimal]] = None):
|
|
self._rates = rates or {}
|
|
|
|
def set_rate(self, from_curr: Currency, to_curr: Currency, rate: Decimal) -> None:
|
|
self._rates[(from_curr, to_curr)] = rate
|
|
|
|
def get_rate(self, from_currency: Currency, to_currency: Currency) -> Optional[Decimal]:
|
|
return self._rates.get((from_currency, to_currency))
|
|
|
|
|
|
@pytest.fixture
|
|
def price_provider():
|
|
"""Create a mock price provider."""
|
|
return MockPriceProvider({
|
|
"AAPL": Decimal("175.00"),
|
|
"GOOGL": Decimal("140.00"),
|
|
"MSFT": Decimal("380.00"),
|
|
})
|
|
|
|
|
|
@pytest.fixture
|
|
def exchange_rate_provider():
|
|
"""Create a mock exchange rate provider."""
|
|
provider = MockExchangeRateProvider()
|
|
provider.set_rate(Currency.EUR, Currency.USD, Decimal("1.10"))
|
|
provider.set_rate(Currency.GBP, Currency.USD, Decimal("1.27"))
|
|
provider.set_rate(Currency.AUD, Currency.USD, Decimal("0.65"))
|
|
provider.set_rate(Currency.JPY, Currency.USD, Decimal("0.0067"))
|
|
return provider
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_holding():
|
|
"""Create a sample holding."""
|
|
return Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150.00"),
|
|
current_price=Decimal("175.00"),
|
|
currency=Currency.USD,
|
|
asset_class="equity",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def empty_portfolio():
|
|
"""Create an empty portfolio."""
|
|
return PortfolioState(base_currency=Currency.USD)
|
|
|
|
|
|
@pytest.fixture
|
|
def funded_portfolio():
|
|
"""Create a portfolio with cash."""
|
|
portfolio = PortfolioState(base_currency=Currency.USD)
|
|
portfolio.add_cash(Currency.USD, Decimal("100000"))
|
|
return portfolio
|
|
|
|
|
|
# =============================================================================
|
|
# Holding Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestHolding:
|
|
"""Test Holding dataclass."""
|
|
|
|
def test_holding_creation(self, sample_holding):
|
|
"""Test basic holding creation."""
|
|
assert sample_holding.symbol == "AAPL"
|
|
assert sample_holding.quantity == Decimal("100")
|
|
assert sample_holding.avg_cost == Decimal("150.00")
|
|
assert sample_holding.current_price == Decimal("175.00")
|
|
|
|
def test_holding_type_long(self):
|
|
"""Test long holding type detection."""
|
|
holding = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("160"),
|
|
)
|
|
assert holding.holding_type == HoldingType.LONG
|
|
|
|
def test_holding_type_short(self):
|
|
"""Test short holding type detection."""
|
|
holding = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("-100"),
|
|
avg_cost=Decimal("160"),
|
|
current_price=Decimal("150"),
|
|
)
|
|
assert holding.holding_type == HoldingType.SHORT
|
|
|
|
def test_abs_quantity(self):
|
|
"""Test absolute quantity calculation."""
|
|
long_holding = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("160"),
|
|
)
|
|
short_holding = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("-100"),
|
|
avg_cost=Decimal("160"),
|
|
current_price=Decimal("150"),
|
|
)
|
|
assert long_holding.abs_quantity == Decimal("100")
|
|
assert short_holding.abs_quantity == Decimal("100")
|
|
|
|
def test_cost_basis(self, sample_holding):
|
|
"""Test cost basis calculation."""
|
|
# 100 shares * $150 = $15,000
|
|
assert sample_holding.cost_basis == Decimal("15000.00")
|
|
|
|
def test_market_value(self, sample_holding):
|
|
"""Test market value calculation."""
|
|
# 100 shares * $175 = $17,500
|
|
assert sample_holding.market_value == Decimal("17500.00")
|
|
|
|
def test_unrealized_pnl_long_profit(self, sample_holding):
|
|
"""Test unrealized P&L for profitable long position."""
|
|
# (175 - 150) * 100 = $2,500 profit
|
|
assert sample_holding.unrealized_pnl == Decimal("2500.00")
|
|
|
|
def test_unrealized_pnl_long_loss(self):
|
|
"""Test unrealized P&L for losing long position."""
|
|
holding = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("175"),
|
|
current_price=Decimal("150"),
|
|
)
|
|
# (150 - 175) * 100 = -$2,500 loss
|
|
assert holding.unrealized_pnl == Decimal("-2500")
|
|
|
|
def test_unrealized_pnl_short_profit(self):
|
|
"""Test unrealized P&L for profitable short position."""
|
|
holding = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("-100"),
|
|
avg_cost=Decimal("175"),
|
|
current_price=Decimal("150"),
|
|
)
|
|
# (175 - 150) * 100 = $2,500 profit (price went down)
|
|
assert holding.unrealized_pnl == Decimal("2500")
|
|
|
|
def test_unrealized_pnl_short_loss(self):
|
|
"""Test unrealized P&L for losing short position."""
|
|
holding = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("-100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
)
|
|
# (150 - 175) * 100 = -$2,500 loss (price went up)
|
|
assert holding.unrealized_pnl == Decimal("-2500")
|
|
|
|
def test_unrealized_pnl_percent(self, sample_holding):
|
|
"""Test unrealized P&L percentage."""
|
|
# 2500 / 15000 * 100 = 16.67%
|
|
assert sample_holding.unrealized_pnl_percent == Decimal("16.67")
|
|
|
|
def test_unrealized_pnl_percent_zero_cost(self):
|
|
"""Test unrealized P&L percent with zero cost basis."""
|
|
holding = Holding(
|
|
symbol="FREE",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("0"),
|
|
current_price=Decimal("10"),
|
|
)
|
|
assert holding.unrealized_pnl_percent == Decimal("0")
|
|
|
|
def test_is_profitable(self, sample_holding):
|
|
"""Test is_profitable property."""
|
|
assert sample_holding.is_profitable is True
|
|
|
|
losing = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("200"),
|
|
current_price=Decimal("150"),
|
|
)
|
|
assert losing.is_profitable is False
|
|
|
|
def test_update_price(self, sample_holding):
|
|
"""Test price update creates new holding."""
|
|
new_price = Decimal("180.00")
|
|
updated = sample_holding.update_price(new_price)
|
|
|
|
assert updated is not sample_holding # New instance
|
|
assert updated.current_price == new_price
|
|
assert updated.symbol == sample_holding.symbol
|
|
assert updated.quantity == sample_holding.quantity
|
|
assert updated.avg_cost == sample_holding.avg_cost
|
|
|
|
|
|
# =============================================================================
|
|
# CashBalance Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestCashBalance:
|
|
"""Test CashBalance dataclass."""
|
|
|
|
def test_cash_balance_creation(self):
|
|
"""Test basic cash balance creation."""
|
|
balance = CashBalance(
|
|
currency=Currency.USD,
|
|
available=Decimal("10000"),
|
|
reserved=Decimal("500"),
|
|
)
|
|
assert balance.currency == Currency.USD
|
|
assert balance.available == Decimal("10000")
|
|
assert balance.reserved == Decimal("500")
|
|
assert balance.total == Decimal("10500")
|
|
|
|
def test_deposit(self):
|
|
"""Test depositing cash."""
|
|
balance = CashBalance(currency=Currency.USD, available=Decimal("1000"))
|
|
new_balance = balance.deposit(Decimal("500"))
|
|
|
|
assert new_balance.available == Decimal("1500")
|
|
assert balance.available == Decimal("1000") # Original unchanged
|
|
|
|
def test_deposit_negative_amount(self):
|
|
"""Test that negative deposit raises error."""
|
|
balance = CashBalance(currency=Currency.USD, available=Decimal("1000"))
|
|
with pytest.raises(ValueError, match="non-negative"):
|
|
balance.deposit(Decimal("-100"))
|
|
|
|
def test_withdraw(self):
|
|
"""Test withdrawing cash."""
|
|
balance = CashBalance(currency=Currency.USD, available=Decimal("1000"))
|
|
new_balance = balance.withdraw(Decimal("500"))
|
|
|
|
assert new_balance.available == Decimal("500")
|
|
|
|
def test_withdraw_insufficient_funds(self):
|
|
"""Test withdrawal with insufficient funds."""
|
|
balance = CashBalance(currency=Currency.USD, available=Decimal("100"))
|
|
with pytest.raises(ValueError, match="Insufficient"):
|
|
balance.withdraw(Decimal("500"))
|
|
|
|
def test_withdraw_negative_amount(self):
|
|
"""Test that negative withdrawal raises error."""
|
|
balance = CashBalance(currency=Currency.USD, available=Decimal("1000"))
|
|
with pytest.raises(ValueError, match="non-negative"):
|
|
balance.withdraw(Decimal("-100"))
|
|
|
|
def test_reserve(self):
|
|
"""Test reserving cash."""
|
|
balance = CashBalance(currency=Currency.USD, available=Decimal("1000"))
|
|
new_balance = balance.reserve(Decimal("300"))
|
|
|
|
assert new_balance.available == Decimal("700")
|
|
assert new_balance.reserved == Decimal("300")
|
|
assert new_balance.total == Decimal("1000")
|
|
|
|
def test_reserve_insufficient(self):
|
|
"""Test reserving more than available."""
|
|
balance = CashBalance(currency=Currency.USD, available=Decimal("100"))
|
|
with pytest.raises(ValueError, match="Insufficient"):
|
|
balance.reserve(Decimal("500"))
|
|
|
|
def test_release(self):
|
|
"""Test releasing reserved cash."""
|
|
balance = CashBalance(
|
|
currency=Currency.USD,
|
|
available=Decimal("700"),
|
|
reserved=Decimal("300"),
|
|
)
|
|
new_balance = balance.release(Decimal("200"))
|
|
|
|
assert new_balance.available == Decimal("900")
|
|
assert new_balance.reserved == Decimal("100")
|
|
|
|
def test_release_too_much(self):
|
|
"""Test releasing more than reserved."""
|
|
balance = CashBalance(
|
|
currency=Currency.USD,
|
|
available=Decimal("1000"),
|
|
reserved=Decimal("100"),
|
|
)
|
|
with pytest.raises(ValueError, match="Insufficient reserved"):
|
|
balance.release(Decimal("500"))
|
|
|
|
|
|
# =============================================================================
|
|
# PortfolioState Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestPortfolioState:
|
|
"""Test PortfolioState class."""
|
|
|
|
def test_portfolio_creation(self, empty_portfolio):
|
|
"""Test basic portfolio creation."""
|
|
assert empty_portfolio.base_currency == Currency.USD
|
|
assert empty_portfolio.num_holdings == 0
|
|
assert empty_portfolio.total_value == Decimal("0")
|
|
|
|
def test_add_cash(self, empty_portfolio):
|
|
"""Test adding cash."""
|
|
empty_portfolio.add_cash(Currency.USD, Decimal("10000"))
|
|
|
|
assert empty_portfolio.total_cash == Decimal("10000")
|
|
balance = empty_portfolio.get_cash(Currency.USD)
|
|
assert balance.available == Decimal("10000")
|
|
|
|
def test_add_cash_multiple_currencies(self, empty_portfolio):
|
|
"""Test adding cash in multiple currencies."""
|
|
empty_portfolio.add_cash(Currency.USD, Decimal("10000"))
|
|
empty_portfolio.add_cash(Currency.EUR, Decimal("5000"))
|
|
|
|
# Without exchange rate provider, EUR converts at 1:1
|
|
assert empty_portfolio.total_cash == Decimal("15000")
|
|
|
|
def test_withdraw_cash(self, funded_portfolio):
|
|
"""Test withdrawing cash."""
|
|
funded_portfolio.withdraw_cash(Currency.USD, Decimal("25000"))
|
|
|
|
balance = funded_portfolio.get_cash(Currency.USD)
|
|
assert balance.available == Decimal("75000")
|
|
|
|
def test_reserve_cash(self, funded_portfolio):
|
|
"""Test reserving cash."""
|
|
funded_portfolio.reserve_cash(Currency.USD, Decimal("10000"))
|
|
|
|
balance = funded_portfolio.get_cash(Currency.USD)
|
|
assert balance.available == Decimal("90000")
|
|
assert balance.reserved == Decimal("10000")
|
|
assert funded_portfolio.total_reserved_cash == Decimal("10000")
|
|
|
|
def test_release_cash(self, funded_portfolio):
|
|
"""Test releasing reserved cash."""
|
|
funded_portfolio.reserve_cash(Currency.USD, Decimal("10000"))
|
|
funded_portfolio.release_cash(Currency.USD, Decimal("5000"))
|
|
|
|
balance = funded_portfolio.get_cash(Currency.USD)
|
|
assert balance.available == Decimal("95000")
|
|
assert balance.reserved == Decimal("5000")
|
|
|
|
def test_add_holding(self, funded_portfolio, sample_holding):
|
|
"""Test adding a holding."""
|
|
funded_portfolio.add_holding(sample_holding)
|
|
|
|
assert funded_portfolio.num_holdings == 1
|
|
retrieved = funded_portfolio.get_holding("AAPL")
|
|
assert retrieved is not None
|
|
assert retrieved.symbol == "AAPL"
|
|
assert retrieved.quantity == Decimal("100")
|
|
|
|
def test_add_to_existing_holding(self, funded_portfolio):
|
|
"""Test adding to an existing holding (average cost)."""
|
|
# Add first lot: 100 @ $150
|
|
holding1 = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("160"),
|
|
)
|
|
funded_portfolio.add_holding(holding1)
|
|
|
|
# Add second lot: 100 @ $170
|
|
holding2 = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("170"),
|
|
current_price=Decimal("160"),
|
|
)
|
|
funded_portfolio.add_holding(holding2)
|
|
|
|
# Should have 200 shares at average cost of $160
|
|
retrieved = funded_portfolio.get_holding("AAPL")
|
|
assert retrieved is not None
|
|
assert retrieved.quantity == Decimal("200")
|
|
# (100 * 150 + 100 * 170) / 200 = 32000 / 200 = 160
|
|
assert retrieved.avg_cost == Decimal("160")
|
|
|
|
def test_close_position(self, funded_portfolio):
|
|
"""Test closing a position completely."""
|
|
# Add 100 shares
|
|
holding1 = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("160"),
|
|
)
|
|
funded_portfolio.add_holding(holding1)
|
|
|
|
# Sell 100 shares (net 0)
|
|
holding2 = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("-100"),
|
|
avg_cost=Decimal("160"),
|
|
current_price=Decimal("160"),
|
|
)
|
|
funded_portfolio.add_holding(holding2)
|
|
|
|
# Position should be closed
|
|
assert funded_portfolio.get_holding("AAPL") is None
|
|
assert funded_portfolio.num_holdings == 0
|
|
|
|
def test_remove_holding(self, funded_portfolio, sample_holding):
|
|
"""Test removing a holding."""
|
|
funded_portfolio.add_holding(sample_holding)
|
|
assert funded_portfolio.num_holdings == 1
|
|
|
|
removed = funded_portfolio.remove_holding("AAPL")
|
|
assert removed is not None
|
|
assert removed.symbol == "AAPL"
|
|
assert funded_portfolio.num_holdings == 0
|
|
|
|
def test_remove_nonexistent_holding(self, funded_portfolio):
|
|
"""Test removing a holding that doesn't exist."""
|
|
removed = funded_portfolio.remove_holding("NOTREAL")
|
|
assert removed is None
|
|
|
|
def test_update_price(self, funded_portfolio, sample_holding):
|
|
"""Test updating price of a holding."""
|
|
funded_portfolio.add_holding(sample_holding)
|
|
|
|
success = funded_portfolio.update_price("AAPL", Decimal("180.00"))
|
|
assert success is True
|
|
|
|
holding = funded_portfolio.get_holding("AAPL")
|
|
assert holding.current_price == Decimal("180.00")
|
|
|
|
def test_update_price_nonexistent(self, funded_portfolio):
|
|
"""Test updating price of nonexistent holding."""
|
|
success = funded_portfolio.update_price("NOTREAL", Decimal("100"))
|
|
assert success is False
|
|
|
|
def test_update_all_prices(self, price_provider):
|
|
"""Test updating all prices from provider."""
|
|
portfolio = PortfolioState(
|
|
base_currency=Currency.USD,
|
|
price_provider=price_provider,
|
|
)
|
|
portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("150"),
|
|
))
|
|
portfolio.add_holding(Holding(
|
|
symbol="GOOGL",
|
|
quantity=Decimal("50"),
|
|
avg_cost=Decimal("130"),
|
|
current_price=Decimal("130"),
|
|
))
|
|
|
|
results = portfolio.update_all_prices()
|
|
|
|
assert results["AAPL"] is True
|
|
assert results["GOOGL"] is True
|
|
assert portfolio.get_holding("AAPL").current_price == Decimal("175.00")
|
|
assert portfolio.get_holding("GOOGL").current_price == Decimal("140.00")
|
|
|
|
def test_total_holdings_value(self, funded_portfolio):
|
|
"""Test total holdings value calculation."""
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
))
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="GOOGL",
|
|
quantity=Decimal("50"),
|
|
avg_cost=Decimal("130"),
|
|
current_price=Decimal("140"),
|
|
))
|
|
|
|
# AAPL: 100 * 175 = 17500
|
|
# GOOGL: 50 * 140 = 7000
|
|
# Total: 24500
|
|
assert funded_portfolio.total_holdings_value == Decimal("24500.00")
|
|
|
|
def test_total_value(self, funded_portfolio):
|
|
"""Test total portfolio value (holdings + cash)."""
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
))
|
|
|
|
# Cash: 100000
|
|
# Holdings: 17500
|
|
# Total: 117500
|
|
assert funded_portfolio.total_value == Decimal("117500.00")
|
|
|
|
def test_total_unrealized_pnl(self, funded_portfolio):
|
|
"""Test total unrealized P&L."""
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
))
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="GOOGL",
|
|
quantity=Decimal("50"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("140"),
|
|
))
|
|
|
|
# AAPL: (175 - 150) * 100 = 2500
|
|
# GOOGL: (140 - 150) * 50 = -500
|
|
# Total: 2000
|
|
assert funded_portfolio.total_unrealized_pnl == Decimal("2000.00")
|
|
|
|
def test_total_cost_basis(self, funded_portfolio):
|
|
"""Test total cost basis."""
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
))
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="GOOGL",
|
|
quantity=Decimal("50"),
|
|
avg_cost=Decimal("130"),
|
|
current_price=Decimal("140"),
|
|
))
|
|
|
|
# AAPL: 100 * 150 = 15000
|
|
# GOOGL: 50 * 130 = 6500
|
|
# Total: 21500
|
|
assert funded_portfolio.total_cost_basis == Decimal("21500.00")
|
|
|
|
def test_concentration(self, funded_portfolio):
|
|
"""Test position concentration."""
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("200"),
|
|
))
|
|
|
|
# Holdings: 20000, Cash: 100000, Total: 120000
|
|
# AAPL concentration: 20000 / 120000 * 100 = 16.67%
|
|
assert funded_portfolio.get_concentration("AAPL") == Decimal("16.67")
|
|
|
|
def test_concentration_nonexistent(self, funded_portfolio):
|
|
"""Test concentration for nonexistent holding."""
|
|
assert funded_portfolio.get_concentration("NOTREAL") == Decimal("0")
|
|
|
|
def test_allocations(self, funded_portfolio):
|
|
"""Test getting allocations for all holdings."""
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("100"), # 10000
|
|
))
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="GOOGL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("130"),
|
|
current_price=Decimal("100"), # 10000
|
|
))
|
|
|
|
# Total: 100000 cash + 20000 holdings = 120000
|
|
allocations = funded_portfolio.get_allocations()
|
|
|
|
assert len(allocations) == 2
|
|
# Each holding is 10000 / 120000 * 100 = 8.33%
|
|
assert allocations["AAPL"] == Decimal("8.33")
|
|
assert allocations["GOOGL"] == Decimal("8.33")
|
|
|
|
def test_asset_class_breakdown(self, funded_portfolio):
|
|
"""Test asset class breakdown."""
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("100"),
|
|
current_price=Decimal("100"),
|
|
asset_class="equity",
|
|
))
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="SPY",
|
|
quantity=Decimal("50"),
|
|
avg_cost=Decimal("400"),
|
|
current_price=Decimal("400"),
|
|
asset_class="etf",
|
|
))
|
|
|
|
# AAPL: 10000 (equity)
|
|
# SPY: 20000 (etf)
|
|
# Total holdings: 30000
|
|
breakdown = funded_portfolio.get_asset_class_breakdown()
|
|
|
|
# Equity: 10000 / 30000 * 100 = 33.33%
|
|
# ETF: 20000 / 30000 * 100 = 66.67%
|
|
assert breakdown["equity"] == Decimal("33.33")
|
|
assert breakdown["etf"] == Decimal("66.67")
|
|
|
|
|
|
# =============================================================================
|
|
# Multi-Currency Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestMultiCurrency:
|
|
"""Test multi-currency functionality."""
|
|
|
|
def test_holdings_in_different_currencies(self, exchange_rate_provider):
|
|
"""Test holdings in different currencies."""
|
|
portfolio = PortfolioState(
|
|
base_currency=Currency.USD,
|
|
exchange_rate_provider=exchange_rate_provider,
|
|
)
|
|
|
|
# USD holding
|
|
portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
currency=Currency.USD,
|
|
))
|
|
|
|
# EUR holding (converted at 1.10)
|
|
portfolio.add_holding(Holding(
|
|
symbol="ASML",
|
|
quantity=Decimal("50"),
|
|
avg_cost=Decimal("500"),
|
|
current_price=Decimal("600"),
|
|
currency=Currency.EUR,
|
|
))
|
|
|
|
# AAPL: 100 * 175 = 17500 USD
|
|
# ASML: 50 * 600 = 30000 EUR * 1.10 = 33000 USD
|
|
# Total: 50500 USD
|
|
assert portfolio.total_holdings_value == Decimal("50500.00")
|
|
|
|
def test_cash_in_different_currencies(self, exchange_rate_provider):
|
|
"""Test cash in different currencies."""
|
|
portfolio = PortfolioState(
|
|
base_currency=Currency.USD,
|
|
exchange_rate_provider=exchange_rate_provider,
|
|
)
|
|
|
|
portfolio.add_cash(Currency.USD, Decimal("10000"))
|
|
portfolio.add_cash(Currency.EUR, Decimal("5000")) # 5000 * 1.10 = 5500 USD
|
|
portfolio.add_cash(Currency.GBP, Decimal("2000")) # 2000 * 1.27 = 2540 USD
|
|
|
|
# 10000 + 5500 + 2540 = 18040
|
|
assert portfolio.total_cash == Decimal("18040.00")
|
|
|
|
def test_currency_exposure(self, exchange_rate_provider):
|
|
"""Test currency exposure calculation."""
|
|
portfolio = PortfolioState(
|
|
base_currency=Currency.USD,
|
|
exchange_rate_provider=exchange_rate_provider,
|
|
)
|
|
|
|
portfolio.add_cash(Currency.USD, Decimal("10000"))
|
|
portfolio.add_holding(Holding(
|
|
symbol="ASML",
|
|
quantity=Decimal("10"),
|
|
avg_cost=Decimal("500"),
|
|
current_price=Decimal("1000"),
|
|
currency=Currency.EUR,
|
|
))
|
|
|
|
# EUR holding: 10 * 1000 = 10000 EUR * 1.10 = 11000 USD
|
|
# Total: 10000 USD + 11000 USD = 21000 USD
|
|
exposure = portfolio.get_currency_exposure()
|
|
|
|
# USD: 10000 / 21000 * 100 = 47.62%
|
|
# EUR: 11000 / 21000 * 100 = 52.38%
|
|
assert exposure[Currency.USD] == Decimal("47.62")
|
|
assert exposure[Currency.EUR] == Decimal("52.38")
|
|
|
|
def test_exchange_rate_same_currency(self, exchange_rate_provider):
|
|
"""Test exchange rate for same currency is 1."""
|
|
portfolio = PortfolioState(
|
|
base_currency=Currency.USD,
|
|
exchange_rate_provider=exchange_rate_provider,
|
|
)
|
|
|
|
rate = portfolio.get_exchange_rate(Currency.USD, Currency.USD)
|
|
assert rate == Decimal("1")
|
|
|
|
|
|
# =============================================================================
|
|
# Snapshot Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestPortfolioSnapshot:
|
|
"""Test portfolio snapshot functionality."""
|
|
|
|
def test_create_snapshot(self, funded_portfolio, sample_holding):
|
|
"""Test creating a portfolio snapshot."""
|
|
funded_portfolio.add_holding(sample_holding)
|
|
|
|
snapshot = funded_portfolio.create_snapshot()
|
|
|
|
assert snapshot is not None
|
|
assert isinstance(snapshot.timestamp, datetime)
|
|
assert len(snapshot.holdings) == 1
|
|
assert snapshot.total_portfolio_value == funded_portfolio.total_value
|
|
|
|
def test_snapshot_immutability(self, funded_portfolio, sample_holding):
|
|
"""Test that snapshot is independent of portfolio changes."""
|
|
funded_portfolio.add_holding(sample_holding)
|
|
snapshot = funded_portfolio.create_snapshot()
|
|
|
|
original_value = snapshot.total_portfolio_value
|
|
|
|
# Modify portfolio
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="GOOGL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("140"),
|
|
current_price=Decimal("140"),
|
|
))
|
|
|
|
# Snapshot should be unchanged
|
|
assert snapshot.total_portfolio_value == original_value
|
|
|
|
def test_get_snapshots(self, funded_portfolio):
|
|
"""Test getting all snapshots."""
|
|
funded_portfolio.create_snapshot()
|
|
funded_portfolio.create_snapshot()
|
|
funded_portfolio.create_snapshot()
|
|
|
|
snapshots = funded_portfolio.get_snapshots()
|
|
assert len(snapshots) == 3
|
|
|
|
def test_get_latest_snapshot(self, funded_portfolio):
|
|
"""Test getting latest snapshot."""
|
|
funded_portfolio.add_cash(Currency.USD, Decimal("1000"))
|
|
funded_portfolio.create_snapshot(metadata={"version": 1})
|
|
|
|
funded_portfolio.add_cash(Currency.USD, Decimal("2000"))
|
|
funded_portfolio.create_snapshot(metadata={"version": 2})
|
|
|
|
latest = funded_portfolio.get_latest_snapshot()
|
|
assert latest.metadata["version"] == 2
|
|
|
|
def test_get_latest_snapshot_empty(self, empty_portfolio):
|
|
"""Test getting latest snapshot when none exist."""
|
|
assert empty_portfolio.get_latest_snapshot() is None
|
|
|
|
def test_clear_snapshots(self, funded_portfolio):
|
|
"""Test clearing all snapshots."""
|
|
funded_portfolio.create_snapshot()
|
|
funded_portfolio.create_snapshot()
|
|
|
|
count = funded_portfolio.clear_snapshots()
|
|
|
|
assert count == 2
|
|
assert len(funded_portfolio.get_snapshots()) == 0
|
|
|
|
def test_snapshot_properties(self, funded_portfolio, sample_holding):
|
|
"""Test snapshot properties."""
|
|
funded_portfolio.add_holding(sample_holding)
|
|
snapshot = funded_portfolio.create_snapshot()
|
|
|
|
assert snapshot.num_holdings == 1
|
|
assert "AAPL" in snapshot.symbols
|
|
assert snapshot.get_holding("AAPL") is not None
|
|
assert snapshot.get_cash(Currency.USD) == Decimal("100000")
|
|
|
|
|
|
# =============================================================================
|
|
# Serialization Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestSerialization:
|
|
"""Test serialization and deserialization."""
|
|
|
|
def test_to_dict(self, funded_portfolio, sample_holding):
|
|
"""Test converting portfolio to dictionary."""
|
|
funded_portfolio.add_holding(sample_holding)
|
|
|
|
data = funded_portfolio.to_dict()
|
|
|
|
assert data["base_currency"] == "USD"
|
|
assert "AAPL" in data["holdings"]
|
|
assert data["holdings"]["AAPL"]["quantity"] == "100"
|
|
assert data["summary"]["num_holdings"] == 1
|
|
|
|
def test_from_dict(self, funded_portfolio, sample_holding):
|
|
"""Test creating portfolio from dictionary."""
|
|
funded_portfolio.add_holding(sample_holding)
|
|
data = funded_portfolio.to_dict()
|
|
|
|
restored = PortfolioState.from_dict(data)
|
|
|
|
assert restored.base_currency == Currency.USD
|
|
assert restored.num_holdings == 1
|
|
holding = restored.get_holding("AAPL")
|
|
assert holding is not None
|
|
assert holding.quantity == Decimal("100")
|
|
|
|
def test_round_trip(self, funded_portfolio):
|
|
"""Test full serialization round trip."""
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
))
|
|
funded_portfolio.add_cash(Currency.EUR, Decimal("5000"))
|
|
funded_portfolio.reserve_cash(Currency.USD, Decimal("10000"))
|
|
|
|
data = funded_portfolio.to_dict()
|
|
restored = PortfolioState.from_dict(data)
|
|
|
|
assert restored.total_value == funded_portfolio.total_value
|
|
assert restored.total_holdings_value == funded_portfolio.total_holdings_value
|
|
assert restored.total_unrealized_pnl == funded_portfolio.total_unrealized_pnl
|
|
|
|
|
|
# =============================================================================
|
|
# Edge Cases and Error Handling
|
|
# =============================================================================
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Test edge cases and error handling."""
|
|
|
|
def test_empty_portfolio_metrics(self, empty_portfolio):
|
|
"""Test metrics on empty portfolio."""
|
|
assert empty_portfolio.total_value == Decimal("0")
|
|
assert empty_portfolio.total_holdings_value == Decimal("0")
|
|
assert empty_portfolio.total_cash == Decimal("0")
|
|
assert empty_portfolio.total_unrealized_pnl == Decimal("0")
|
|
assert empty_portfolio.get_allocations() == {}
|
|
|
|
def test_zero_quantity_holding(self):
|
|
"""Test holding with zero quantity."""
|
|
holding = Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("0"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
)
|
|
assert holding.cost_basis == Decimal("0")
|
|
assert holding.market_value == Decimal("0")
|
|
assert holding.unrealized_pnl == Decimal("0")
|
|
|
|
def test_symbols_property(self, funded_portfolio):
|
|
"""Test getting list of symbols."""
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
))
|
|
funded_portfolio.add_holding(Holding(
|
|
symbol="GOOGL",
|
|
quantity=Decimal("50"),
|
|
avg_cost=Decimal("140"),
|
|
current_price=Decimal("140"),
|
|
))
|
|
|
|
symbols = funded_portfolio.symbols
|
|
assert len(symbols) == 2
|
|
assert "AAPL" in symbols
|
|
assert "GOOGL" in symbols
|
|
|
|
def test_last_updated_tracking(self, empty_portfolio):
|
|
"""Test last_updated is updated on changes."""
|
|
assert empty_portfolio.last_updated is None
|
|
|
|
empty_portfolio.add_cash(Currency.USD, Decimal("1000"))
|
|
first_update = empty_portfolio.last_updated
|
|
assert first_update is not None
|
|
|
|
empty_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("10"),
|
|
avg_cost=Decimal("100"),
|
|
current_price=Decimal("100"),
|
|
))
|
|
second_update = empty_portfolio.last_updated
|
|
assert second_update >= first_update
|
|
|
|
def test_no_price_provider(self, empty_portfolio):
|
|
"""Test update_all_prices with no provider."""
|
|
empty_portfolio.add_holding(Holding(
|
|
symbol="AAPL",
|
|
quantity=Decimal("100"),
|
|
avg_cost=Decimal("150"),
|
|
current_price=Decimal("175"),
|
|
))
|
|
|
|
results = empty_portfolio.update_all_prices()
|
|
assert results == {}
|
|
|
|
def test_holdings_property_returns_copy(self, funded_portfolio, sample_holding):
|
|
"""Test that holdings property returns a copy."""
|
|
funded_portfolio.add_holding(sample_holding)
|
|
|
|
holdings1 = funded_portfolio.holdings
|
|
holdings2 = funded_portfolio.holdings
|
|
|
|
assert holdings1 is not holdings2
|
|
assert holdings1["AAPL"] is holdings2["AAPL"] # Same Holding objects
|
|
|
|
def test_cash_balances_property_returns_copy(self, funded_portfolio):
|
|
"""Test that cash_balances property returns a copy."""
|
|
balances1 = funded_portfolio.cash_balances
|
|
balances2 = funded_portfolio.cash_balances
|
|
|
|
assert balances1 is not balances2
|
|
|
|
def test_get_cash_creates_balance(self, empty_portfolio):
|
|
"""Test that get_cash creates a balance if it doesn't exist."""
|
|
balance = empty_portfolio.get_cash(Currency.GBP)
|
|
|
|
assert balance.currency == Currency.GBP
|
|
assert balance.available == Decimal("0")
|
|
assert balance.reserved == Decimal("0")
|