|
|
""" |
|
|
Tests for P&L calculation functions. |
|
|
""" |
|
|
|
|
|
import datetime |
|
|
import unittest |
|
|
from unittest.mock import patch |
|
|
|
|
|
import numpy as np |
|
|
|
|
|
from src.folio.data_model import OptionPosition, StockPosition |
|
|
from src.folio.pnl import ( |
|
|
calculate_breakeven_points, |
|
|
calculate_max_profit_loss, |
|
|
calculate_position_pnl, |
|
|
calculate_strategy_pnl, |
|
|
determine_price_range, |
|
|
summarize_strategy_pnl, |
|
|
) |
|
|
|
|
|
|
|
|
class TestPnLCalculations(unittest.TestCase): |
|
|
"""Test cases for P&L calculation functions.""" |
|
|
|
|
|
def setUp(self): |
|
|
"""Set up test fixtures.""" |
|
|
|
|
|
self.stock_position = StockPosition( |
|
|
ticker="SPY", |
|
|
quantity=100, |
|
|
beta=1.0, |
|
|
beta_adjusted_exposure=45000.0, |
|
|
market_exposure=45000.0, |
|
|
price=450.0, |
|
|
cost_basis=400.0, |
|
|
) |
|
|
|
|
|
|
|
|
expiry_date = datetime.datetime.now() + datetime.timedelta(days=30) |
|
|
expiry_str = expiry_date.strftime("%Y-%m-%d") |
|
|
self.call_option = OptionPosition( |
|
|
ticker="SPY", |
|
|
position_type="option", |
|
|
quantity=1, |
|
|
beta=1.0, |
|
|
beta_adjusted_exposure=1000.0, |
|
|
market_exposure=1000.0, |
|
|
strike=460.0, |
|
|
expiry=expiry_str, |
|
|
option_type="CALL", |
|
|
delta=0.5, |
|
|
delta_exposure=2250.0, |
|
|
notional_value=45000.0, |
|
|
underlying_beta=1.0, |
|
|
price=10.0, |
|
|
cost_basis=8.0, |
|
|
) |
|
|
|
|
|
|
|
|
self.put_option = OptionPosition( |
|
|
ticker="SPY", |
|
|
position_type="option", |
|
|
quantity=-2, |
|
|
beta=1.0, |
|
|
beta_adjusted_exposure=-1000.0, |
|
|
market_exposure=-1000.0, |
|
|
strike=440.0, |
|
|
expiry=expiry_str, |
|
|
option_type="PUT", |
|
|
delta=-0.4, |
|
|
delta_exposure=3600.0, |
|
|
notional_value=90000.0, |
|
|
underlying_beta=1.0, |
|
|
price=5.0, |
|
|
cost_basis=6.0, |
|
|
) |
|
|
|
|
|
def test_determine_price_range(self): |
|
|
"""Test price range determination.""" |
|
|
|
|
|
price_range = determine_price_range([self.stock_position], 450.0) |
|
|
self.assertEqual(len(price_range), 2) |
|
|
self.assertLess(price_range[0], 450.0) |
|
|
self.assertGreater(price_range[1], 450.0) |
|
|
|
|
|
|
|
|
price_range = determine_price_range( |
|
|
[self.stock_position, self.call_option, self.put_option], 450.0 |
|
|
) |
|
|
self.assertEqual(len(price_range), 2) |
|
|
|
|
|
self.assertLessEqual(price_range[0], 440.0 * 0.8) |
|
|
self.assertGreaterEqual(price_range[1], 460.0 * 1.2) |
|
|
|
|
|
@patch("src.folio.pnl.calculate_bs_price") |
|
|
def test_calculate_position_pnl_stock(self, mock_calculate_bs_price): |
|
|
"""Test P&L calculation for a stock position.""" |
|
|
|
|
|
pnl_data = calculate_position_pnl( |
|
|
self.stock_position, |
|
|
price_range=(400.0, 500.0), |
|
|
num_points=11, |
|
|
use_cost_basis=False, |
|
|
) |
|
|
|
|
|
|
|
|
self.assertIn("price_points", pnl_data) |
|
|
self.assertIn("pnl_values", pnl_data) |
|
|
self.assertEqual(len(pnl_data["price_points"]), 11) |
|
|
self.assertEqual(len(pnl_data["pnl_values"]), 11) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
expected_pnls = [ |
|
|
(price - 450.0) * 100 for price in np.linspace(400.0, 500.0, 11) |
|
|
] |
|
|
for i, expected_pnl in enumerate(expected_pnls): |
|
|
self.assertAlmostEqual(pnl_data["pnl_values"][i], expected_pnl, places=2) |
|
|
|
|
|
|
|
|
mock_calculate_bs_price.assert_not_called() |
|
|
|
|
|
|
|
|
mock_calculate_bs_price.reset_mock() |
|
|
|
|
|
|
|
|
pnl_data_cost_basis = calculate_position_pnl( |
|
|
self.stock_position, |
|
|
price_range=(400.0, 500.0), |
|
|
num_points=11, |
|
|
use_cost_basis=True, |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
expected_pnls_cost_basis = [ |
|
|
(price - 400.0) * 100 for price in np.linspace(400.0, 500.0, 11) |
|
|
] |
|
|
for i, expected_pnl in enumerate(expected_pnls_cost_basis): |
|
|
self.assertAlmostEqual( |
|
|
pnl_data_cost_basis["pnl_values"][i], expected_pnl, places=2 |
|
|
) |
|
|
|
|
|
|
|
|
mock_calculate_bs_price.assert_not_called() |
|
|
|
|
|
@patch("src.folio.pnl.calculate_bs_price") |
|
|
def test_calculate_position_pnl_option(self, mock_calculate_bs_price): |
|
|
"""Test P&L calculation for an option position.""" |
|
|
|
|
|
mock_calculate_bs_price.side_effect = [5.0, 10.0, 15.0, 20.0, 25.0] |
|
|
|
|
|
|
|
|
pnl_data = calculate_position_pnl( |
|
|
self.call_option, |
|
|
price_range=(440.0, 480.0), |
|
|
num_points=5, |
|
|
use_cost_basis=False, |
|
|
) |
|
|
|
|
|
|
|
|
self.assertIn("price_points", pnl_data) |
|
|
self.assertIn("pnl_values", pnl_data) |
|
|
self.assertEqual(len(pnl_data["price_points"]), 5) |
|
|
self.assertEqual(len(pnl_data["pnl_values"]), 5) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
contract_multiplier = 100 |
|
|
expected_pnls = [ |
|
|
(price - 10.0) * 1 * contract_multiplier |
|
|
for price in [5.0, 10.0, 15.0, 20.0, 25.0] |
|
|
] |
|
|
for i, expected_pnl in enumerate(expected_pnls): |
|
|
self.assertAlmostEqual(pnl_data["pnl_values"][i], expected_pnl, places=2) |
|
|
|
|
|
|
|
|
self.assertEqual(mock_calculate_bs_price.call_count, 5) |
|
|
|
|
|
|
|
|
mock_calculate_bs_price.reset_mock() |
|
|
mock_calculate_bs_price.side_effect = [5.0, 10.0, 15.0, 20.0, 25.0] |
|
|
|
|
|
|
|
|
pnl_data_cost_basis = calculate_position_pnl( |
|
|
self.call_option, |
|
|
price_range=(440.0, 480.0), |
|
|
num_points=5, |
|
|
use_cost_basis=True, |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
expected_pnls_cost_basis = [ |
|
|
(price - 8.0) * 1 * contract_multiplier |
|
|
for price in [5.0, 10.0, 15.0, 20.0, 25.0] |
|
|
] |
|
|
for i, expected_pnl in enumerate(expected_pnls_cost_basis): |
|
|
self.assertAlmostEqual( |
|
|
pnl_data_cost_basis["pnl_values"][i], expected_pnl, places=2 |
|
|
) |
|
|
|
|
|
|
|
|
self.assertEqual(mock_calculate_bs_price.call_count, 5) |
|
|
|
|
|
@patch("src.folio.pnl.calculate_position_pnl") |
|
|
def test_calculate_strategy_pnl(self, mock_calculate_position_pnl): |
|
|
"""Test P&L calculation for a strategy (multiple positions).""" |
|
|
|
|
|
mock_calculate_position_pnl.side_effect = [ |
|
|
{ |
|
|
"price_points": [400.0, 450.0, 500.0], |
|
|
"pnl_values": [-4000.0, 1000.0, 6000.0], |
|
|
"position": {}, |
|
|
}, |
|
|
{ |
|
|
"price_points": [400.0, 450.0, 500.0], |
|
|
"pnl_values": [500.0, 200.0, -100.0], |
|
|
"position": {}, |
|
|
}, |
|
|
{ |
|
|
"price_points": [400.0, 450.0, 500.0], |
|
|
"pnl_values": [1000.0, 0.0, -1000.0], |
|
|
"position": {}, |
|
|
}, |
|
|
] |
|
|
|
|
|
|
|
|
positions = [self.stock_position, self.call_option, self.put_option] |
|
|
pnl_data = calculate_strategy_pnl( |
|
|
positions, price_range=(400.0, 500.0), num_points=3, use_cost_basis=False |
|
|
) |
|
|
|
|
|
|
|
|
self.assertIn("price_points", pnl_data) |
|
|
self.assertIn("pnl_values", pnl_data) |
|
|
self.assertIn("individual_pnls", pnl_data) |
|
|
self.assertEqual(len(pnl_data["price_points"]), 3) |
|
|
self.assertEqual(len(pnl_data["pnl_values"]), 3) |
|
|
self.assertEqual(len(pnl_data["individual_pnls"]), 3) |
|
|
|
|
|
|
|
|
|
|
|
expected_combined_pnls = [-2500.0, 1200.0, 4900.0] |
|
|
for i, expected_pnl in enumerate(expected_combined_pnls): |
|
|
self.assertAlmostEqual(pnl_data["pnl_values"][i], expected_pnl, places=2) |
|
|
|
|
|
|
|
|
self.assertEqual(mock_calculate_position_pnl.call_count, 3) |
|
|
|
|
|
|
|
|
mock_calculate_position_pnl.reset_mock() |
|
|
|
|
|
|
|
|
mock_calculate_position_pnl.side_effect = [ |
|
|
{ |
|
|
"price_points": [400.0, 450.0, 500.0], |
|
|
"pnl_values": [ |
|
|
-3000.0, |
|
|
2000.0, |
|
|
7000.0, |
|
|
], |
|
|
"position": {}, |
|
|
}, |
|
|
{ |
|
|
"price_points": [400.0, 450.0, 500.0], |
|
|
"pnl_values": [700.0, 400.0, 100.0], |
|
|
"position": {}, |
|
|
}, |
|
|
{ |
|
|
"price_points": [400.0, 450.0, 500.0], |
|
|
"pnl_values": [ |
|
|
800.0, |
|
|
-200.0, |
|
|
-1200.0, |
|
|
], |
|
|
"position": {}, |
|
|
}, |
|
|
] |
|
|
|
|
|
|
|
|
pnl_data_cost_basis = calculate_strategy_pnl( |
|
|
positions, price_range=(400.0, 500.0), num_points=3, use_cost_basis=True |
|
|
) |
|
|
|
|
|
|
|
|
expected_combined_pnls_cost_basis = [ |
|
|
-1500.0, |
|
|
2200.0, |
|
|
5900.0, |
|
|
] |
|
|
for i, expected_pnl in enumerate(expected_combined_pnls_cost_basis): |
|
|
self.assertAlmostEqual( |
|
|
pnl_data_cost_basis["pnl_values"][i], expected_pnl, places=2 |
|
|
) |
|
|
|
|
|
|
|
|
self.assertEqual(mock_calculate_position_pnl.call_count, 3) |
|
|
|
|
|
def test_calculate_breakeven_points(self): |
|
|
"""Test calculation of breakeven points.""" |
|
|
|
|
|
pnl_data = { |
|
|
"price_points": [400.0, 425.0, 450.0, 475.0, 500.0], |
|
|
"pnl_values": [-1000.0, -500.0, 0.0, 500.0, 1000.0], |
|
|
} |
|
|
|
|
|
|
|
|
breakeven_points = calculate_breakeven_points(pnl_data) |
|
|
|
|
|
|
|
|
self.assertEqual(len(breakeven_points), 2) |
|
|
|
|
|
for bp in breakeven_points: |
|
|
self.assertAlmostEqual(bp, 450.0, places=1) |
|
|
|
|
|
|
|
|
pnl_data = { |
|
|
"price_points": [400.0, 425.0, 450.0, 475.0, 500.0], |
|
|
"pnl_values": [500.0, -500.0, 0.0, -500.0, 500.0], |
|
|
} |
|
|
|
|
|
breakeven_points = calculate_breakeven_points(pnl_data) |
|
|
|
|
|
|
|
|
self.assertEqual(len(breakeven_points), 4) |
|
|
|
|
|
def test_calculate_max_profit_loss(self): |
|
|
"""Test calculation of maximum profit and loss.""" |
|
|
|
|
|
pnl_data = { |
|
|
"price_points": [400.0, 425.0, 450.0, 475.0, 500.0], |
|
|
"pnl_values": [-1000.0, -500.0, 0.0, 1500.0, 1000.0], |
|
|
} |
|
|
|
|
|
|
|
|
max_pl = calculate_max_profit_loss(pnl_data) |
|
|
|
|
|
|
|
|
self.assertEqual(max_pl["max_profit"], 1500.0) |
|
|
self.assertEqual(max_pl["max_profit_price"], 475.0) |
|
|
self.assertEqual(max_pl["max_loss"], -1000.0) |
|
|
self.assertEqual(max_pl["max_loss_price"], 400.0) |
|
|
|
|
|
def test_summarize_strategy_pnl(self): |
|
|
"""Test strategy P&L summary generation.""" |
|
|
|
|
|
pnl_data = { |
|
|
"price_points": [400.0, 425.0, 450.0, 475.0, 500.0], |
|
|
"pnl_values": [-1000.0, -500.0, 0.0, 1500.0, 1000.0], |
|
|
} |
|
|
|
|
|
|
|
|
summary = summarize_strategy_pnl(pnl_data, 450.0) |
|
|
|
|
|
|
|
|
self.assertIn("breakeven_points", summary) |
|
|
self.assertIn("max_profit", summary) |
|
|
self.assertIn("max_loss", summary) |
|
|
self.assertIn("current_pnl", summary) |
|
|
self.assertIn("profitable_ranges", summary) |
|
|
|
|
|
|
|
|
self.assertAlmostEqual(summary["max_profit"], 1500.0, places=2) |
|
|
self.assertAlmostEqual(summary["max_loss"], -1000.0, places=2) |
|
|
self.assertAlmostEqual(summary["current_pnl"], 0.0, places=2) |
|
|
|
|
|
|
|
|
self.assertEqual(len(summary["breakeven_points"]), 2) |
|
|
|
|
|
for bp in summary["breakeven_points"]: |
|
|
self.assertAlmostEqual(bp, 450.0, places=1) |
|
|
|
|
|
|
|
|
self.assertEqual(len(summary["profitable_ranges"]), 1) |
|
|
start, end = summary["profitable_ranges"][0] |
|
|
|
|
|
self.assertAlmostEqual(start, 475.0, places=2) |
|
|
self.assertAlmostEqual(end, 500.0, places=2) |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
unittest.main() |
|
|
|