File size: 15,260 Bytes
ce4bc73 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 |
"""
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."""
# Create a sample stock position
self.stock_position = StockPosition(
ticker="SPY",
quantity=100,
beta=1.0,
beta_adjusted_exposure=45000.0,
market_exposure=45000.0, # 100 shares * $450
price=450.0, # $450 per share
cost_basis=400.0, # $400 per share cost basis
)
# Create a sample call option position
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, # 1 contract * $10 * 100 shares
strike=460.0,
expiry=expiry_str,
option_type="CALL",
delta=0.5,
delta_exposure=2250.0, # 0.5 * 100 * $450 * 1
notional_value=45000.0, # 100 * $450 * 1
underlying_beta=1.0,
price=10.0, # $10 per contract
cost_basis=8.0, # $8 per contract cost basis
)
# Create a sample put option position
self.put_option = OptionPosition(
ticker="SPY",
position_type="option",
quantity=-2, # Short 2 contracts
beta=1.0,
beta_adjusted_exposure=-1000.0,
market_exposure=-1000.0, # -2 contracts * $5 * 100 shares
strike=440.0,
expiry=expiry_str,
option_type="PUT",
delta=-0.4,
delta_exposure=3600.0, # -0.4 * 100 * $450 * -2
notional_value=90000.0, # 100 * $450 * 2
underlying_beta=1.0,
price=5.0, # $5 per contract
cost_basis=6.0, # $6 per contract cost basis
)
def test_determine_price_range(self):
"""Test price range determination."""
# Test with stock only
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)
# Test with options
price_range = determine_price_range(
[self.stock_position, self.call_option, self.put_option], 450.0
)
self.assertEqual(len(price_range), 2)
# Should include strike prices with margin
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."""
# Calculate P&L for stock position using current price as entry price (default)
pnl_data = calculate_position_pnl(
self.stock_position,
price_range=(400.0, 500.0),
num_points=11, # 400, 410, 420, ..., 500
use_cost_basis=False, # Use current price as entry price
)
# Verify the structure of the result
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)
# Verify P&L calculations for stock
# P&L = (price - entry_price) * quantity
# Entry price is $450 per share (current price)
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)
# Verify mock wasn't called for stock position
mock_calculate_bs_price.assert_not_called()
# Reset mock for the next test
mock_calculate_bs_price.reset_mock()
# Calculate P&L for stock position using cost basis as entry price
pnl_data_cost_basis = calculate_position_pnl(
self.stock_position,
price_range=(400.0, 500.0),
num_points=11, # 400, 410, 420, ..., 500
use_cost_basis=True, # Use cost basis as entry price
)
# Verify P&L calculations for stock using cost basis
# P&L = (price - cost_basis) * quantity
# Cost basis is $400 per share
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
)
# Verify mock wasn't called for stock position
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 the option pricing function for default mode
mock_calculate_bs_price.side_effect = [5.0, 10.0, 15.0, 20.0, 25.0]
# Calculate P&L for call option position using current price as entry price (default)
pnl_data = calculate_position_pnl(
self.call_option,
price_range=(440.0, 480.0),
num_points=5, # 440, 450, 460, 470, 480
use_cost_basis=False, # Use current price as entry price
)
# Verify the structure of the result
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)
# Verify P&L calculations for option
# P&L = (theo_price - entry_price) * quantity * contract_multiplier
# Entry price is $10 per contract (current price), quantity is 1
# Contract multiplier is 100 (each contract controls 100 shares)
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)
# Verify mock was called for option position
self.assertEqual(mock_calculate_bs_price.call_count, 5)
# Reset mock and set new side effect for cost basis mode
mock_calculate_bs_price.reset_mock()
mock_calculate_bs_price.side_effect = [5.0, 10.0, 15.0, 20.0, 25.0]
# Calculate P&L for call option position using cost basis as entry price
pnl_data_cost_basis = calculate_position_pnl(
self.call_option,
price_range=(440.0, 480.0),
num_points=5, # 440, 450, 460, 470, 480
use_cost_basis=True, # Use cost basis as entry price
)
# Verify P&L calculations for option using cost basis
# P&L = (theo_price - cost_basis) * quantity * contract_multiplier
# Cost basis is $8 per contract, quantity is 1
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
)
# Verify mock was called for option position
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 the position P&L calculations for default mode
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": {},
},
]
# Calculate P&L for a strategy with all positions using current price as entry price (default)
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
)
# Verify the structure of the result
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)
# Verify combined P&L calculations
# Combined P&L = sum of individual P&Ls
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)
# Verify mock was called for each position
self.assertEqual(mock_calculate_position_pnl.call_count, 3)
# Reset mock for cost basis mode
mock_calculate_position_pnl.reset_mock()
# Mock the position P&L calculations for cost basis mode
mock_calculate_position_pnl.side_effect = [
{
"price_points": [400.0, 450.0, 500.0],
"pnl_values": [
-3000.0,
2000.0,
7000.0,
], # Different values for cost basis
"position": {},
},
{
"price_points": [400.0, 450.0, 500.0],
"pnl_values": [700.0, 400.0, 100.0], # Different values for cost basis
"position": {},
},
{
"price_points": [400.0, 450.0, 500.0],
"pnl_values": [
800.0,
-200.0,
-1200.0,
], # Different values for cost basis
"position": {},
},
]
# Calculate P&L for a strategy with all positions using cost basis as entry price
pnl_data_cost_basis = calculate_strategy_pnl(
positions, price_range=(400.0, 500.0), num_points=3, use_cost_basis=True
)
# Verify combined P&L calculations for cost basis mode
expected_combined_pnls_cost_basis = [
-1500.0,
2200.0,
5900.0,
] # Different values for cost basis
for i, expected_pnl in enumerate(expected_combined_pnls_cost_basis):
self.assertAlmostEqual(
pnl_data_cost_basis["pnl_values"][i], expected_pnl, places=2
)
# Verify mock was called for each position
self.assertEqual(mock_calculate_position_pnl.call_count, 3)
def test_calculate_breakeven_points(self):
"""Test calculation of breakeven points."""
# Create sample P&L data with a zero crossing
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],
}
# Calculate breakeven points
breakeven_points = calculate_breakeven_points(pnl_data)
# Verify the result - should find 2 breakeven points due to numerical precision
self.assertEqual(len(breakeven_points), 2)
# Both should be close to 450.0
for bp in breakeven_points:
self.assertAlmostEqual(bp, 450.0, places=1)
# Test with multiple zero crossings
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)
# Should find 4 breakeven points due to numerical precision
self.assertEqual(len(breakeven_points), 4)
def test_calculate_max_profit_loss(self):
"""Test calculation of maximum profit and loss."""
# Create sample P&L data
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],
}
# Calculate max profit/loss
max_pl = calculate_max_profit_loss(pnl_data)
# Verify the result
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."""
# Create sample P&L data
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],
}
# Generate summary
summary = summarize_strategy_pnl(pnl_data, 450.0)
# Verify the structure of the result
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)
# Verify specific values
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)
# Should have two breakeven points due to numerical precision
self.assertEqual(len(summary["breakeven_points"]), 2)
# Both should be close to 450.0
for bp in summary["breakeven_points"]:
self.assertAlmostEqual(bp, 450.0, places=1)
# Should have one profitable range
self.assertEqual(len(summary["profitable_ranges"]), 1)
start, end = summary["profitable_ranges"][0]
# The profitable range starts at 475.0 in our implementation
self.assertAlmostEqual(start, 475.0, places=2)
self.assertAlmostEqual(end, 500.0, places=2)
if __name__ == "__main__":
unittest.main()
|