""" 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 (unbounded profit high, unbounded loss low) 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 (unbounded loss high, unbounded profit 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. """ # Create a complex SPY position similar to the real portfolio spy_positions = [ # SPY stock position {"position_type": "stock", "quantity": 1, "price": 524.53, "ticker": "SPY"}, # Short calls (unbounded loss on upside) { "position_type": "option", "option_type": "CALL", "strike": 560, "expiration": datetime.date.today() + datetime.timedelta(days=60), "quantity": -40, "price": 12.06, "ticker": "SPY", }, # Short puts (bounded loss on downside) { "position_type": "option", "option_type": "PUT", "strike": 450, "expiration": datetime.date.today() + datetime.timedelta(days=60), "quantity": -10, "price": 9.72, "ticker": "SPY", }, # Short puts (bounded loss on downside) { "position_type": "option", "option_type": "PUT", "strike": 470, "expiration": datetime.date.today() + datetime.timedelta(days=60), "quantity": -30, "price": 12.80, "ticker": "SPY", }, # Long puts (bounded profit on downside) { "position_type": "option", "option_type": "PUT", "strike": 490, "expiration": datetime.date.today() + datetime.timedelta(days=60), "quantity": 10, "price": 16.73, "ticker": "SPY", }, # Long puts (bounded profit on downside) { "position_type": "option", "option_type": "PUT", "strike": 525, "expiration": datetime.date.today() + datetime.timedelta(days=60), "quantity": 30, "price": 27.25, "ticker": "SPY", }, ] # Test with current implementation (should pass with threshold = 5.0) result = analyze_asymptotic_behavior(spy_positions) # The SPY position should have bounded profit on the upside (false) # and unbounded loss on the upside (true) 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.""" # Create the same SPY position 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", }, ] # Patch the function to use a lower threshold with patch("src.folio.pnl.analyze_asymptotic_behavior") as mock_analyze: # Create a modified version that uses a lower threshold def modified_analyze(positions): # Use extreme price points to approximate infinity and zero # Fixed values instead of using current_price # Calculate the total "effective delta" at these extreme prices high_price_delta = 0 low_price_delta = 0 for position in positions: # For options, use their delta calculation if position.get("position_type") == "option": # Create an OptionContract for delta calculation import datetime from src.folio.options import OptionContract # Parse expiry date 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: # Default to 1 year from now 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", ""), ) # Simplified delta calculation for testing option_type = position.get("option_type", "") quantity = position.get("quantity", 0) if option_type == "CALL": # For calls: delta approaches 1 at high prices, 0 at low prices high_price_delta += 1 * quantity * 100 elif option_type == "PUT": # For puts: delta approaches 0 at high prices, -1 at low prices low_price_delta += -1 * quantity * 100 # For stocks, delta is always 1 (or -1 for short positions) elif position.get("position_type") == "stock": quantity = position.get("quantity", 0) high_price_delta += quantity low_price_delta += quantity # Use a lower threshold that would cause incorrect identification 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, } # Set the mock to use our modified function mock_analyze.side_effect = modified_analyze # Call the function with the SPY positions result = analyze_asymptotic_behavior(spy_positions) # With a lower threshold, we expect incorrect results # The test should still pass because we're asserting the incorrect behavior 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", ) # These might be incorrect with a low threshold # We're not asserting specific values here @patch("src.folio.options.calculate_black_scholes_delta") def test_option_positions(self, mock_delta): """Test asymptotic analysis with option positions.""" # Mock delta values for high and low prices # For calls: delta approaches 1 at high prices, 0 at low prices # For puts: delta approaches 0 at high prices, -1 at low prices def mock_delta_side_effect(option, price): if option.option_type == "CALL": return 0.99 if price > option.strike * 2 else 0.01 else: # PUT return -0.01 if price > option.strike * 2 else -0.99 mock_delta.side_effect = mock_delta_side_effect # Long call (unbounded profit high) 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 (unbounded loss high) 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 (unbounded profit 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 (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.""" # Mock delta values def mock_delta_side_effect(option, price): if option.option_type == "CALL": return 0.99 if price > option.strike * 2 else 0.01 else: # PUT return -0.01 if price > option.strike * 2 else -0.99 mock_delta.side_effect = mock_delta_side_effect # Covered call (long stock + short call) - bounded profit/loss 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]) # Delta at high price: 100 (stock) + (-0.99 * 100) (short call) ≈ 1 # Delta at low price: 100 (stock) + (-0.01 * 100) (short call) ≈ 99 self.assertFalse(result["unbounded_profit_high"]) # Bounded by short call self.assertFalse(result["unbounded_loss_high"]) self.assertFalse(result["unbounded_profit_low"]) self.assertTrue(result["unbounded_loss_low"]) # Stock can go to zero # Nearly-covered call (long 1000 shares + short 9 calls) - unbounded profit 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]) # Delta at high price: 1000 (stock) + (-0.99 * 900) (short calls) ≈ 109 # Delta at low price: 1000 (stock) + (-0.01 * 900) (short calls) ≈ 991 self.assertTrue( result["unbounded_profit_high"] ) # Unbounded profit (more shares than covered) self.assertFalse(result["unbounded_loss_high"]) self.assertFalse(result["unbounded_profit_low"]) self.assertTrue(result["unbounded_loss_low"]) # Stock can go to zero # Vertical call spread (long call + short higher strike call) - bounded profit/loss 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]) # Delta at high price: 0.99 * 100 (long call) + (-0.99 * 100) (short call) ≈ 0 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.""" # Create a position that will cause the delta calculation to fail bad_option = { "position_type": "option", "option_type": "CALL", "strike": 150, "expiration": "invalid-date", # This will cause the date parsing to fail "quantity": 1, "price": 5, "ticker": "AAPL", } # The function should fall back to simplified calculation result = analyze_asymptotic_behavior([bad_option]) self.assertTrue( result["unbounded_profit_high"] ) # Long call has 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.""" # Create test data for a long call (unbounded profit high) 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, } # Patch the analyze_asymptotic_behavior function to return known values 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"]) # Verify the function was called with the right arguments mock_analyze.assert_called_once_with(pnl_data["positions"]) if __name__ == "__main__": unittest.main()