|
|
"""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.""" |
|
|
|
|
|
sample_file_path = os.path.join( |
|
|
"src", "folio", "assets", "sample-portfolio.csv" |
|
|
) |
|
|
|
|
|
|
|
|
assert os.path.exists(sample_file_path), ( |
|
|
f"Sample file not found: {sample_file_path}" |
|
|
) |
|
|
|
|
|
|
|
|
portfolio_data = pd.read_csv(sample_file_path) |
|
|
|
|
|
|
|
|
assert portfolio_data is not None |
|
|
assert len(portfolio_data) > 0 |
|
|
|
|
|
|
|
|
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" |
|
|
) |
|
|
|
|
|
|
|
|
assert "AAPL" in portfolio_data["Symbol"].values |
|
|
assert "SPAXX**" in portfolio_data["Symbol"].values |
|
|
|
|
|
|
|
|
option_entries = portfolio_data[portfolio_data["Symbol"].str.contains("-")] |
|
|
assert len(option_entries) > 0, "No option entries found in portfolio data" |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
stock_position = StockPosition( |
|
|
ticker="AAPL", |
|
|
quantity=1500, |
|
|
beta=1.2, |
|
|
market_exposure=33825.0, |
|
|
beta_adjusted_exposure=40590.0, |
|
|
) |
|
|
|
|
|
|
|
|
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, |
|
|
notional_value=49500.0, |
|
|
underlying_beta=1.2, |
|
|
) |
|
|
|
|
|
|
|
|
portfolio_group = PortfolioGroup( |
|
|
ticker="AAPL", |
|
|
stock_position=stock_position, |
|
|
option_positions=[option_position], |
|
|
net_exposure=32670.0, |
|
|
beta=1.2, |
|
|
beta_adjusted_exposure=39270.0, |
|
|
total_delta_exposure=-1155.0, |
|
|
options_delta_exposure=-1155.0, |
|
|
) |
|
|
|
|
|
|
|
|
cash_position = StockPosition( |
|
|
ticker="SPAXX**", |
|
|
quantity=1, |
|
|
beta=0.0, |
|
|
market_exposure=12345670.0, |
|
|
beta_adjusted_exposure=0.0, |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
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={}, |
|
|
) |
|
|
|
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
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={}, |
|
|
) |
|
|
|
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
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. |
|
|
""" |
|
|
|
|
|
long_beta_adjusted = 14400.0 |
|
|
short_beta_adjusted = -7200.0 |
|
|
expected_net = 7200.0 |
|
|
|
|
|
result = calculate_beta_adjusted_net_exposure( |
|
|
long_beta_adjusted, short_beta_adjusted |
|
|
) |
|
|
assert result == expected_net |
|
|
|
|
|
|
|
|
assert calculate_beta_adjusted_net_exposure(0.0, 0.0) == 0.0 |
|
|
|
|
|
|
|
|
assert calculate_beta_adjusted_net_exposure(-1000.0, -500.0) == -1500.0 |
|
|
|
|
|
|
|
|
large_long = 1_000_000.0 |
|
|
large_short = -500_000.0 |
|
|
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.""" |
|
|
|
|
|
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, |
|
|
underlying_beta=1.0, |
|
|
price=5.0, |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
expected_market_value = ( |
|
|
10 * 5.0 * 100 |
|
|
) |
|
|
assert option_position.market_value == expected_market_value |
|
|
assert option_position.market_value == 5000.0 |
|
|
|
|
|
|
|
|
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, |
|
|
underlying_beta=1.0, |
|
|
price=2.0, |
|
|
) |
|
|
|
|
|
|
|
|
expected_short_value = ( |
|
|
-5 * 2.0 * 100 |
|
|
) |
|
|
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.""" |
|
|
|
|
|
mock_data_fetcher = mocker.MagicMock() |
|
|
mock_df = pd.DataFrame({"Close": [150.0]}) |
|
|
mock_data_fetcher.fetch_data.return_value = mock_df |
|
|
|
|
|
|
|
|
stock_position = StockPosition( |
|
|
ticker="AAPL", |
|
|
quantity=100, |
|
|
beta=1.2, |
|
|
market_exposure=14000.0, |
|
|
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, |
|
|
) |
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
from src.folio.portfolio import update_portfolio_prices |
|
|
|
|
|
timestamp = update_portfolio_prices([portfolio_group], mock_data_fetcher) |
|
|
|
|
|
|
|
|
assert timestamp is not None |
|
|
assert isinstance(timestamp, str) |
|
|
|
|
|
|
|
|
assert stock_position.price == 150.0 |
|
|
assert option_position.price == 150.0 |
|
|
|
|
|
|
|
|
assert stock_position.market_exposure == 15000.0 |
|
|
assert stock_position.beta_adjusted_exposure == 18000.0 |
|
|
|
|
|
|
|
|
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_update_prices = mocker.patch( |
|
|
"src.folio.portfolio.update_portfolio_prices", |
|
|
return_value="2025-04-08T12:34:56.789012", |
|
|
) |
|
|
|
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
portfolio_groups = [mocker.MagicMock()] |
|
|
current_summary = mocker.MagicMock() |
|
|
|
|
|
|
|
|
from src.folio.portfolio import update_portfolio_summary_with_prices |
|
|
|
|
|
updated_summary = update_portfolio_summary_with_prices( |
|
|
portfolio_groups, current_summary |
|
|
) |
|
|
|
|
|
|
|
|
assert updated_summary.price_updated_at == "2025-04-08T12:34:56.789012" |
|
|
|
|
|
|
|
|
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). |
|
|
""" |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
from src.folio.portfolio import process_portfolio_data |
|
|
|
|
|
|
|
|
groups, summary, cash_like = process_portfolio_data(df) |
|
|
|
|
|
|
|
|
assert len(groups) > 0, "No portfolio groups were created" |
|
|
|
|
|
|
|
|
spy_group = None |
|
|
for group in groups: |
|
|
if group.ticker == "SPY": |
|
|
spy_group = group |
|
|
break |
|
|
|
|
|
|
|
|
assert spy_group is not None, "SPY group was not created" |
|
|
|
|
|
|
|
|
assert len(spy_group.option_positions) == 3, ( |
|
|
"SPY group should have 3 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" |
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
assert spy_group.option_positions[0].quantity == -30 |
|
|
assert spy_group.option_positions[1].quantity == -30 |
|
|
assert spy_group.option_positions[2].quantity == 30 |
|
|
|