|
|
""" |
|
|
Tests for the asymptotic analysis function used to detect unbounded profit/loss. |
|
|
""" |
|
|
|
|
|
import datetime |
|
|
import unittest |
|
|
from unittest.mock import patch |
|
|
|
|
|
from src.folio.pnl import analyze_asymptotic_behavior, calculate_max_profit_loss |
|
|
|
|
|
|
|
|
class TestAsymptoticAnalysis(unittest.TestCase): |
|
|
"""Test the asymptotic analysis function for detecting unbounded profit/loss.""" |
|
|
|
|
|
def test_empty_positions(self): |
|
|
"""Test asymptotic analysis with empty positions list.""" |
|
|
result = analyze_asymptotic_behavior([]) |
|
|
self.assertFalse(result["unbounded_profit_high"]) |
|
|
self.assertFalse(result["unbounded_loss_high"]) |
|
|
self.assertFalse(result["unbounded_profit_low"]) |
|
|
self.assertFalse(result["unbounded_loss_low"]) |
|
|
|
|
|
def test_stock_positions(self): |
|
|
"""Test asymptotic analysis with stock positions.""" |
|
|
|
|
|
long_stock = {"position_type": "stock", "quantity": 100, "price": 150} |
|
|
result = analyze_asymptotic_behavior([long_stock]) |
|
|
self.assertTrue(result["unbounded_profit_high"]) |
|
|
self.assertFalse(result["unbounded_loss_high"]) |
|
|
self.assertFalse(result["unbounded_profit_low"]) |
|
|
self.assertTrue(result["unbounded_loss_low"]) |
|
|
|
|
|
|
|
|
short_stock = {"position_type": "stock", "quantity": -100, "price": 150} |
|
|
result = analyze_asymptotic_behavior([short_stock]) |
|
|
self.assertFalse(result["unbounded_profit_high"]) |
|
|
self.assertTrue(result["unbounded_loss_high"]) |
|
|
self.assertTrue(result["unbounded_profit_low"]) |
|
|
self.assertFalse(result["unbounded_loss_low"]) |
|
|
|
|
|
def test_spy_complex_position(self): |
|
|
"""Test asymptotic analysis with a complex SPY position. |
|
|
|
|
|
This test case is based on a real portfolio with SPY positions that should have |
|
|
bounded upside and unbounded downside. |
|
|
""" |
|
|
|
|
|
spy_positions = [ |
|
|
|
|
|
{"position_type": "stock", "quantity": 1, "price": 524.53, "ticker": "SPY"}, |
|
|
|
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 560, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": -40, |
|
|
"price": 12.06, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
|
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 450, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": -10, |
|
|
"price": 9.72, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
|
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 470, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": -30, |
|
|
"price": 12.80, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
|
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 490, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": 10, |
|
|
"price": 16.73, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
|
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 525, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": 30, |
|
|
"price": 27.25, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
] |
|
|
|
|
|
|
|
|
result = analyze_asymptotic_behavior(spy_positions) |
|
|
|
|
|
|
|
|
|
|
|
self.assertFalse( |
|
|
result["unbounded_profit_high"], |
|
|
"SPY position should have bounded profit on the upside", |
|
|
) |
|
|
self.assertTrue( |
|
|
result["unbounded_loss_high"], |
|
|
"SPY position should have unbounded loss on the upside", |
|
|
) |
|
|
self.assertFalse( |
|
|
result["unbounded_profit_low"], |
|
|
"SPY position should have bounded profit on the downside", |
|
|
) |
|
|
self.assertFalse( |
|
|
result["unbounded_loss_low"], |
|
|
"SPY position should have bounded loss on the downside", |
|
|
) |
|
|
|
|
|
def test_spy_complex_position_with_low_threshold(self): |
|
|
"""Test that a low threshold would incorrectly identify unbounded profit/loss.""" |
|
|
|
|
|
spy_positions = [ |
|
|
{"position_type": "stock", "quantity": 1, "price": 524.53, "ticker": "SPY"}, |
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 560, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": -40, |
|
|
"price": 12.06, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 450, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": -10, |
|
|
"price": 9.72, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 470, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": -30, |
|
|
"price": 12.80, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 490, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": 10, |
|
|
"price": 16.73, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 525, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=60), |
|
|
"quantity": 30, |
|
|
"price": 27.25, |
|
|
"ticker": "SPY", |
|
|
}, |
|
|
] |
|
|
|
|
|
|
|
|
with patch("src.folio.pnl.analyze_asymptotic_behavior") as mock_analyze: |
|
|
|
|
|
def modified_analyze(positions): |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
high_price_delta = 0 |
|
|
low_price_delta = 0 |
|
|
|
|
|
for position in positions: |
|
|
|
|
|
if position.get("position_type") == "option": |
|
|
|
|
|
import datetime |
|
|
|
|
|
from src.folio.options import OptionContract |
|
|
|
|
|
|
|
|
expiry_str = position.get("expiration", "2025-01-01") |
|
|
if isinstance(expiry_str, datetime.date): |
|
|
expiry = datetime.datetime.combine( |
|
|
expiry_str, datetime.datetime.min.time() |
|
|
) |
|
|
else: |
|
|
|
|
|
expiry = datetime.datetime.now() + datetime.timedelta( |
|
|
days=365 |
|
|
) |
|
|
|
|
|
OptionContract( |
|
|
underlying=position.get("ticker", ""), |
|
|
expiry=expiry, |
|
|
strike=position.get("strike", 0), |
|
|
option_type=position.get("option_type", "CALL"), |
|
|
quantity=position.get("quantity", 0), |
|
|
current_price=position.get("price", 0), |
|
|
cost_basis=position.get("cost_basis", 0), |
|
|
description=position.get("description", ""), |
|
|
) |
|
|
|
|
|
|
|
|
option_type = position.get("option_type", "") |
|
|
quantity = position.get("quantity", 0) |
|
|
|
|
|
if option_type == "CALL": |
|
|
|
|
|
high_price_delta += 1 * quantity * 100 |
|
|
elif option_type == "PUT": |
|
|
|
|
|
low_price_delta += -1 * quantity * 100 |
|
|
|
|
|
|
|
|
elif position.get("position_type") == "stock": |
|
|
quantity = position.get("quantity", 0) |
|
|
high_price_delta += quantity |
|
|
low_price_delta += quantity |
|
|
|
|
|
|
|
|
delta_threshold = 1.0 |
|
|
|
|
|
return { |
|
|
"unbounded_profit_high": high_price_delta > delta_threshold, |
|
|
"unbounded_loss_high": high_price_delta < -delta_threshold, |
|
|
"unbounded_profit_low": low_price_delta < -delta_threshold, |
|
|
"unbounded_loss_low": low_price_delta > delta_threshold, |
|
|
} |
|
|
|
|
|
|
|
|
mock_analyze.side_effect = modified_analyze |
|
|
|
|
|
|
|
|
result = analyze_asymptotic_behavior(spy_positions) |
|
|
|
|
|
|
|
|
|
|
|
self.assertFalse( |
|
|
result["unbounded_profit_high"], |
|
|
"Even with low threshold, SPY position should have bounded profit on the upside", |
|
|
) |
|
|
self.assertTrue( |
|
|
result["unbounded_loss_high"], |
|
|
"SPY position should have unbounded loss on the upside", |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
@patch("src.folio.options.calculate_black_scholes_delta") |
|
|
def test_option_positions(self, mock_delta): |
|
|
"""Test asymptotic analysis with option positions.""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mock_delta_side_effect(option, price): |
|
|
if option.option_type == "CALL": |
|
|
return 0.99 if price > option.strike * 2 else 0.01 |
|
|
else: |
|
|
return -0.01 if price > option.strike * 2 else -0.99 |
|
|
|
|
|
mock_delta.side_effect = mock_delta_side_effect |
|
|
|
|
|
|
|
|
long_call = { |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 150, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=30), |
|
|
"quantity": 1, |
|
|
"price": 5, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
result = analyze_asymptotic_behavior([long_call]) |
|
|
self.assertTrue(result["unbounded_profit_high"]) |
|
|
self.assertFalse(result["unbounded_loss_high"]) |
|
|
self.assertFalse(result["unbounded_profit_low"]) |
|
|
self.assertFalse(result["unbounded_loss_low"]) |
|
|
|
|
|
|
|
|
short_call = { |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 150, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=30), |
|
|
"quantity": -1, |
|
|
"price": 5, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
result = analyze_asymptotic_behavior([short_call]) |
|
|
self.assertFalse(result["unbounded_profit_high"]) |
|
|
self.assertTrue(result["unbounded_loss_high"]) |
|
|
self.assertFalse(result["unbounded_profit_low"]) |
|
|
self.assertFalse(result["unbounded_loss_low"]) |
|
|
|
|
|
|
|
|
long_put = { |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 150, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=30), |
|
|
"quantity": 1, |
|
|
"price": 5, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
result = analyze_asymptotic_behavior([long_put]) |
|
|
self.assertFalse(result["unbounded_profit_high"]) |
|
|
self.assertFalse(result["unbounded_loss_high"]) |
|
|
self.assertTrue(result["unbounded_profit_low"]) |
|
|
self.assertFalse(result["unbounded_loss_low"]) |
|
|
|
|
|
|
|
|
short_put = { |
|
|
"position_type": "option", |
|
|
"option_type": "PUT", |
|
|
"strike": 150, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=30), |
|
|
"quantity": -1, |
|
|
"price": 5, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
result = analyze_asymptotic_behavior([short_put]) |
|
|
self.assertFalse(result["unbounded_profit_high"]) |
|
|
self.assertFalse(result["unbounded_loss_high"]) |
|
|
self.assertFalse(result["unbounded_profit_low"]) |
|
|
self.assertTrue(result["unbounded_loss_low"]) |
|
|
|
|
|
@patch("src.folio.options.calculate_black_scholes_delta") |
|
|
def test_complex_strategies(self, mock_delta): |
|
|
"""Test asymptotic analysis with complex option strategies.""" |
|
|
|
|
|
|
|
|
def mock_delta_side_effect(option, price): |
|
|
if option.option_type == "CALL": |
|
|
return 0.99 if price > option.strike * 2 else 0.01 |
|
|
else: |
|
|
return -0.01 if price > option.strike * 2 else -0.99 |
|
|
|
|
|
mock_delta.side_effect = mock_delta_side_effect |
|
|
|
|
|
|
|
|
stock = {"position_type": "stock", "quantity": 100, "price": 150} |
|
|
short_call = { |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 160, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=30), |
|
|
"quantity": -1, |
|
|
"price": 5, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
result = analyze_asymptotic_behavior([stock, short_call]) |
|
|
|
|
|
|
|
|
self.assertFalse(result["unbounded_profit_high"]) |
|
|
self.assertFalse(result["unbounded_loss_high"]) |
|
|
self.assertFalse(result["unbounded_profit_low"]) |
|
|
self.assertTrue(result["unbounded_loss_low"]) |
|
|
|
|
|
|
|
|
stock_large = {"position_type": "stock", "quantity": 1000, "price": 150} |
|
|
short_calls = { |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 160, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=30), |
|
|
"quantity": -9, |
|
|
"price": 5, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
result = analyze_asymptotic_behavior([stock_large, short_calls]) |
|
|
|
|
|
|
|
|
self.assertTrue( |
|
|
result["unbounded_profit_high"] |
|
|
) |
|
|
self.assertFalse(result["unbounded_loss_high"]) |
|
|
self.assertFalse(result["unbounded_profit_low"]) |
|
|
self.assertTrue(result["unbounded_loss_low"]) |
|
|
|
|
|
|
|
|
long_call = { |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 150, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=30), |
|
|
"quantity": 1, |
|
|
"price": 5, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
short_call_higher = { |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 160, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=30), |
|
|
"quantity": -1, |
|
|
"price": 2, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
result = analyze_asymptotic_behavior([long_call, short_call_higher]) |
|
|
|
|
|
self.assertFalse(result["unbounded_profit_high"]) |
|
|
self.assertFalse(result["unbounded_loss_high"]) |
|
|
self.assertFalse(result["unbounded_profit_low"]) |
|
|
self.assertFalse(result["unbounded_loss_low"]) |
|
|
|
|
|
def test_fallback_calculation(self): |
|
|
"""Test the fallback calculation when delta calculation fails.""" |
|
|
|
|
|
bad_option = { |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 150, |
|
|
"expiration": "invalid-date", |
|
|
"quantity": 1, |
|
|
"price": 5, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
|
|
|
|
|
|
result = analyze_asymptotic_behavior([bad_option]) |
|
|
self.assertTrue( |
|
|
result["unbounded_profit_high"] |
|
|
) |
|
|
self.assertFalse(result["unbounded_loss_high"]) |
|
|
self.assertFalse(result["unbounded_profit_low"]) |
|
|
self.assertFalse(result["unbounded_loss_low"]) |
|
|
|
|
|
def test_integration_with_max_profit_loss(self): |
|
|
"""Test integration with calculate_max_profit_loss function.""" |
|
|
|
|
|
pnl_data = { |
|
|
"price_points": [100, 125, 150, 175, 200], |
|
|
"pnl_values": [-5, -5, -5, 20, 45], |
|
|
"positions": [ |
|
|
{ |
|
|
"position_type": "option", |
|
|
"option_type": "CALL", |
|
|
"strike": 150, |
|
|
"expiration": datetime.date.today() + datetime.timedelta(days=30), |
|
|
"quantity": 1, |
|
|
"price": 5, |
|
|
"ticker": "AAPL", |
|
|
} |
|
|
], |
|
|
"current_price": 150, |
|
|
} |
|
|
|
|
|
|
|
|
with patch("src.folio.pnl.analyze_asymptotic_behavior") as mock_analyze: |
|
|
mock_analyze.return_value = { |
|
|
"unbounded_profit_high": True, |
|
|
"unbounded_loss_high": False, |
|
|
"unbounded_profit_low": False, |
|
|
"unbounded_loss_low": False, |
|
|
} |
|
|
|
|
|
result = calculate_max_profit_loss(pnl_data) |
|
|
self.assertTrue(result["unbounded_profit"]) |
|
|
self.assertFalse(result["unbounded_loss"]) |
|
|
|
|
|
|
|
|
mock_analyze.assert_called_once_with(pnl_data["positions"]) |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
unittest.main() |
|
|
|