"""Tests for the portfolio loading and processing functionality. This module tests the functionality in src/folio/portfolio.py for loading and processing portfolio data from CSV files. """ import os import pandas as pd from src.folio.data_model import ( ExposureBreakdown, OptionPosition, PortfolioGroup, PortfolioSummary, StockPosition, ) from src.folio.portfolio import calculate_beta_adjusted_net_exposure class TestPortfolioLoading: """Tests for portfolio loading functionality.""" def test_load_sample_portfolio(self): """Test loading the sample portfolio data from CSV file.""" # Use the sample portfolio file for testing sample_file_path = os.path.join( "src", "folio", "assets", "sample-portfolio.csv" ) # Ensure the file exists assert os.path.exists(sample_file_path), ( f"Sample file not found: {sample_file_path}" ) # Load the portfolio data using pandas directly portfolio_data = pd.read_csv(sample_file_path) # Verify the data was loaded correctly assert portfolio_data is not None assert len(portfolio_data) > 0 # Check for expected columns expected_columns = [ "Symbol", "Description", "Quantity", "Last Price", "Current Value", "Percent Of Account", "Type", ] for column in expected_columns: assert column in portfolio_data.columns, ( f"Column {column} not found in portfolio data" ) # Check for specific entries from the sample file assert "AAPL" in portfolio_data["Symbol"].values assert "SPAXX**" in portfolio_data["Symbol"].values # Check for option entries option_entries = portfolio_data[portfolio_data["Symbol"].str.contains("-")] assert len(option_entries) > 0, "No option entries found in portfolio data" # Check for cash entries cash_entries = portfolio_data[portfolio_data["Type"] == "Cash"] assert len(cash_entries) > 0, "No cash entries found in portfolio data" def test_process_portfolio_data_with_mocks(self): """Test processing portfolio data into position objects using mocks.""" # Since we can't easily mock the process_portfolio_data function due to its complexity, # we'll test it indirectly by verifying we can create the objects it would create # Create a stock position stock_position = StockPosition( ticker="AAPL", quantity=1500, beta=1.2, market_exposure=33825.0, beta_adjusted_exposure=40590.0, ) # Create an option position option_position = OptionPosition( ticker="AAPL", position_type="option", quantity=-15, beta=1.2, market_exposure=-1100.0, beta_adjusted_exposure=-1320.0, strike=220.0, expiry="2025-04-17", option_type="CALL", delta=0.7, delta_exposure=-1155.0, # -15 * 0.7 * 110 (notional per contract) notional_value=49500.0, # -15 * 100 * 220 (strike) underlying_beta=1.2, ) # Create a portfolio group portfolio_group = PortfolioGroup( ticker="AAPL", stock_position=stock_position, option_positions=[option_position], net_exposure=32670.0, # 33825 - 1155 beta=1.2, beta_adjusted_exposure=39270.0, # 40590 - 1320 total_delta_exposure=-1155.0, options_delta_exposure=-1155.0, ) # Create a cash position cash_position = StockPosition( ticker="SPAXX**", quantity=1, beta=0.0, market_exposure=12345670.0, beta_adjusted_exposure=0.0, ) # Verify the objects were created correctly assert portfolio_group.ticker == "AAPL" assert portfolio_group.stock_position is not None assert portfolio_group.stock_position.quantity == 1500 assert portfolio_group.stock_position.market_exposure == 33825.0 # Check the option position assert len(portfolio_group.option_positions) == 1 option = portfolio_group.option_positions[0] assert option.ticker == "AAPL" assert option.quantity == -15 assert option.strike == 220.0 assert option.option_type == "CALL" assert option.expiry == "2025-04-17" # Check the cash position assert cash_position.ticker == "SPAXX**" assert cash_position.market_exposure == 12345670.0 def test_calculate_portfolio_summary_with_mocks(self): """Test calculating portfolio summary from position data using mocks.""" # Create empty exposure breakdowns for testing empty_exposure = ExposureBreakdown( stock_exposure=0.0, stock_beta_adjusted=0.0, option_delta_exposure=0.0, option_beta_adjusted=0.0, total_exposure=0.0, total_beta_adjusted=0.0, description="Empty exposure", formula="N/A", components={}, ) # Create a portfolio summary directly summary = PortfolioSummary( net_market_exposure=32670.0, portfolio_beta=1.2, long_exposure=empty_exposure, short_exposure=empty_exposure, options_exposure=empty_exposure, short_percentage=3.4, cash_like_positions=[], cash_like_value=12345670.0, cash_like_count=1, cash_percentage=97.4, portfolio_estimate_value=12378340.0, # 32670 + 12345670 ) # Verify the summary was created correctly assert isinstance(summary, PortfolioSummary) assert summary.net_market_exposure == 32670.0 assert summary.portfolio_beta == 1.2 assert summary.cash_like_value == 12345670.0 assert summary.cash_like_count == 1 assert summary.portfolio_estimate_value == 12378340.0 assert summary.cash_percentage == 97.4 assert summary.short_percentage == 3.4 # Check the exposure breakdowns assert isinstance(summary.long_exposure, ExposureBreakdown) assert summary.long_exposure.stock_exposure == 0.0 assert summary.long_exposure.total_exposure == 0.0 assert isinstance(summary.short_exposure, ExposureBreakdown) assert summary.short_exposure.option_delta_exposure == 0.0 assert summary.short_exposure.total_exposure == 0.0 assert isinstance(summary.options_exposure, ExposureBreakdown) assert summary.options_exposure.option_delta_exposure == 0.0 assert summary.options_exposure.total_exposure == 0.0 def test_empty_portfolio_summary(self): """Test creating an empty portfolio summary.""" # Create empty exposure breakdowns for testing empty_exposure = ExposureBreakdown( stock_exposure=0.0, stock_beta_adjusted=0.0, option_delta_exposure=0.0, option_beta_adjusted=0.0, total_exposure=0.0, total_beta_adjusted=0.0, description="Empty exposure", formula="N/A", components={}, ) # Create an empty portfolio summary directly summary = PortfolioSummary( net_market_exposure=0.0, portfolio_beta=0.0, long_exposure=empty_exposure, short_exposure=empty_exposure, options_exposure=empty_exposure, short_percentage=0.0, cash_like_positions=[], cash_like_value=0.0, cash_like_count=0, cash_percentage=0.0, portfolio_estimate_value=0.0, ) # Verify the summary for empty portfolio assert summary.net_market_exposure == 0.0 assert summary.portfolio_beta == 0.0 assert summary.cash_like_value == 0.0 assert summary.cash_like_count == 0 assert summary.portfolio_estimate_value == 0.0 assert summary.cash_percentage == 0.0 assert summary.short_percentage == 0.0 class TestPortfolioUtilityFunctions: """Tests for utility functions in the portfolio module.""" def test_calculate_beta_adjusted_net_exposure(self): """Test the calculate_beta_adjusted_net_exposure function. This function tests that the beta-adjusted net exposure is correctly calculated by adding the long and short beta-adjusted exposures, where short is represented as a negative value. """ # Test with positive long and negative short values long_beta_adjusted = 14400.0 short_beta_adjusted = -7200.0 # Negative value for short exposure expected_net = 7200.0 result = calculate_beta_adjusted_net_exposure( long_beta_adjusted, short_beta_adjusted ) assert result == expected_net # Test with zero values assert calculate_beta_adjusted_net_exposure(0.0, 0.0) == 0.0 # Test with negative long value and negative short value assert calculate_beta_adjusted_net_exposure(-1000.0, -500.0) == -1500.0 # Test with large values large_long = 1_000_000.0 large_short = -500_000.0 # Negative value for short exposure assert ( calculate_beta_adjusted_net_exposure(large_long, large_short) == 500_000.0 ) class TestOptionMarketValue: """Tests for option market value calculation.""" def test_option_market_value_calculation(self): """Test that option market value is calculated correctly with 100x multiplier.""" # Create an option position option_position = OptionPosition( ticker="SPY", position_type="option", quantity=10, beta=1.0, beta_adjusted_exposure=1000.0, market_exposure=1000.0, strike=450.0, expiry="2025-06-20", option_type="CALL", delta=0.5, delta_exposure=1000.0, notional_value=450000.0, # 10 contracts * 100 shares * $450 strike underlying_beta=1.0, price=5.0, # $5 per share ) # Verify the market value is calculated with 100x multiplier # Market value should be: quantity * price * 100 expected_market_value = ( 10 * 5.0 * 100 ) # 10 contracts * $5 * 100 shares per contract assert option_position.market_value == expected_market_value assert option_position.market_value == 5000.0 # Test with negative quantity (short position) short_option = OptionPosition( ticker="SPY", position_type="option", quantity=-5, beta=1.0, beta_adjusted_exposure=-500.0, market_exposure=-500.0, strike=450.0, expiry="2025-06-20", option_type="PUT", delta=-0.5, delta_exposure=-500.0, notional_value=225000.0, # 5 contracts * 100 shares * $450 strike underlying_beta=1.0, price=2.0, # $2 per share ) # Verify the market value for short position expected_short_value = ( -5 * 2.0 * 100 ) # -5 contracts * $2 * 100 shares per contract assert short_option.market_value == expected_short_value assert short_option.market_value == -1000.0 class TestPriceUpdates: """Tests for portfolio price update functionality.""" def test_update_portfolio_prices(self, mocker): """Test updating prices for portfolio positions.""" # Create mock data fetcher mock_data_fetcher = mocker.MagicMock() mock_df = pd.DataFrame({"Close": [150.0]}) mock_data_fetcher.fetch_data.return_value = mock_df # Create test portfolio groups stock_position = StockPosition( ticker="AAPL", quantity=100, beta=1.2, market_exposure=14000.0, # Old price: $140 beta_adjusted_exposure=16800.0, price=140.0, ) option_position = OptionPosition( ticker="AAPL", position_type="option", quantity=1, beta=1.2, market_exposure=500.0, beta_adjusted_exposure=600.0, strike=150.0, expiry="2025-01-17", option_type="CALL", delta=0.5, delta_exposure=50.0, notional_value=15000.0, underlying_beta=1.2, price=5.0, # Old price ) portfolio_group = PortfolioGroup( ticker="AAPL", stock_position=stock_position, option_positions=[option_position], net_exposure=14500.0, beta=1.2, beta_adjusted_exposure=17400.0, total_delta_exposure=50.0, options_delta_exposure=50.0, ) # Update prices from src.folio.portfolio import update_portfolio_prices timestamp = update_portfolio_prices([portfolio_group], mock_data_fetcher) # Verify the timestamp is returned assert timestamp is not None assert isinstance(timestamp, str) # Verify the prices were updated assert stock_position.price == 150.0 assert option_position.price == 150.0 # Verify market exposure was updated for stock position assert stock_position.market_exposure == 15000.0 # 100 shares * $150 assert stock_position.beta_adjusted_exposure == 18000.0 # $15000 * 1.2 # Verify the data fetcher was called correctly mock_data_fetcher.fetch_data.assert_called_with("AAPL", period="1d") def test_update_portfolio_summary_with_prices(self, mocker): """Test updating the portfolio summary with the latest prices.""" # Mock the update_portfolio_prices function mock_update_prices = mocker.patch( "src.folio.portfolio.update_portfolio_prices", return_value="2025-04-08T12:34:56.789012", ) # Mock the calculate_portfolio_summary function mock_summary = PortfolioSummary( net_market_exposure=10000.0, portfolio_beta=1.2, long_exposure=ExposureBreakdown( stock_exposure=10000.0, stock_beta_adjusted=12000.0, option_delta_exposure=0.0, option_beta_adjusted=0.0, total_exposure=10000.0, total_beta_adjusted=12000.0, description="Long Exposure", formula="", components={}, ), short_exposure=ExposureBreakdown( stock_exposure=0.0, stock_beta_adjusted=0.0, option_delta_exposure=0.0, option_beta_adjusted=0.0, total_exposure=0.0, total_beta_adjusted=0.0, description="Short Exposure", formula="", components={}, ), options_exposure=ExposureBreakdown( stock_exposure=0.0, stock_beta_adjusted=0.0, option_delta_exposure=0.0, option_beta_adjusted=0.0, total_exposure=0.0, total_beta_adjusted=0.0, description="Options Exposure", formula="", components={}, ), short_percentage=0.0, cash_like_positions=[], cash_like_value=0.0, cash_like_count=0, cash_percentage=0.0, portfolio_estimate_value=10000.0, ) mocker.patch( "src.folio.portfolio.calculate_portfolio_summary", return_value=mock_summary, ) # Create test portfolio groups portfolio_groups = [mocker.MagicMock()] current_summary = mocker.MagicMock() # Update the portfolio summary with prices from src.folio.portfolio import update_portfolio_summary_with_prices updated_summary = update_portfolio_summary_with_prices( portfolio_groups, current_summary ) # Verify the price_updated_at timestamp was set assert updated_summary.price_updated_at == "2025-04-08T12:34:56.789012" # Verify the update_portfolio_prices function was called mock_update_prices.assert_called_once_with(portfolio_groups, None) def test_process_orphaned_options(self): """Test that options without a matching stock position are properly processed. This test verifies that the portfolio processing logic correctly handles options without a matching stock position (like SPY options without a SPY stock position). """ # Create a test DataFrame with only option positions (no matching stock) data = { "Symbol": ["-SPY250620C580", "-SPY250620P470", "-SPY250620P525"], "Description": [ "SPY JUN 20 2025 $580 CALL", "SPY JUN 20 2025 $470 PUT", "SPY JUN 20 2025 $525 PUT", ], "Quantity": [-30, -30, 30], "Last Price": [5.37, 7.29, 17.76], "Current Value": [-16110.00, -21870.00, 53280.00], "Percent Of Account": [-0.60, -0.81, 1.98], "Type": ["Margin", "Margin", "Margin"], } df = pd.DataFrame(data) # Import the function directly in the test to avoid circular imports from src.folio.portfolio import process_portfolio_data # Process the portfolio data groups, summary, cash_like = process_portfolio_data(df) # Verify that a group was created for the SPY options assert len(groups) > 0, "No portfolio groups were created" # Find the SPY group spy_group = None for group in groups: if group.ticker == "SPY": spy_group = group break # Verify that the SPY group exists assert spy_group is not None, "SPY group was not created" # Verify that the SPY group has options but no stock position assert len(spy_group.option_positions) == 3, ( "SPY group should have 3 option positions" ) # Verify the option positions assert spy_group.option_positions[0].ticker == "SPY" assert spy_group.option_positions[1].ticker == "SPY" assert spy_group.option_positions[2].ticker == "SPY" # Verify the option types call_count = sum( 1 for opt in spy_group.option_positions if opt.option_type == "CALL" ) put_count = sum( 1 for opt in spy_group.option_positions if opt.option_type == "PUT" ) assert call_count == 1, "Should have 1 CALL option" assert put_count == 2, "Should have 2 PUT options" # Verify the quantities assert spy_group.option_positions[0].quantity == -30 assert spy_group.option_positions[1].quantity == -30 assert spy_group.option_positions[2].quantity == 30