"""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")