folio / tests /test_option_exposure.py
dystomachina's picture
Initial commit for Folio project
ce4bc73
"""Tests for option exposure calculations.
This module focuses specifically on testing that option exposures are calculated correctly
for different option types (calls/puts) and positions (long/short).
"""
from datetime import datetime
import pytest
from src.folio.options import OptionContract, calculate_option_delta
@pytest.fixture
def option_fixtures():
"""Create a set of option fixtures for testing exposures."""
# Use a fixed expiry date for consistent test results
expiry_date = datetime(2025, 6, 15) # About 6 months from now
# Create options with different types and positions
return {
# Call options
"long_call_atm": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=100.0,
option_type="CALL",
quantity=1, # Long position
current_price=5.0,
description="SPY JUN 15 2025 $100 CALL",
),
"short_call_atm": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=100.0,
option_type="CALL",
quantity=-1, # Short position
current_price=5.0,
description="SPY JUN 15 2025 $100 CALL",
),
# Put options
"long_put_atm": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=100.0,
option_type="PUT",
quantity=1, # Long position
current_price=5.0,
description="SPY JUN 15 2025 $100 PUT",
),
"short_put_atm": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=100.0,
option_type="PUT",
quantity=-1, # Short position
current_price=5.0,
description="SPY JUN 15 2025 $100 PUT",
),
# Different quantities
"long_call_multiple": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=100.0,
option_type="CALL",
quantity=3, # Multiple contracts
current_price=5.0,
description="SPY JUN 15 2025 $100 CALL",
),
"short_put_multiple": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=100.0,
option_type="PUT",
quantity=-2, # Multiple contracts
current_price=5.0,
description="SPY JUN 15 2025 $100 PUT",
),
# Different moneyness levels
"long_call_itm": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=90.0, # In-the-money
option_type="CALL",
quantity=1,
current_price=15.0,
description="SPY JUN 15 2025 $90 CALL",
),
"long_call_otm": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=110.0, # Out-of-the-money
option_type="CALL",
quantity=1,
current_price=2.0,
description="SPY JUN 15 2025 $110 CALL",
),
"long_put_itm": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=110.0, # In-the-money
option_type="PUT",
quantity=1,
current_price=15.0,
description="SPY JUN 15 2025 $110 PUT",
),
"long_put_otm": OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=90.0, # Out-of-the-money
option_type="PUT",
quantity=1,
current_price=2.0,
description="SPY JUN 15 2025 $90 PUT",
),
}
def calculate_exposure(option, underlying_price, iv=0.3):
"""Calculate the exposure for an option position.
Args:
option: The option position
underlying_price: The price of the underlying asset
iv: Implied volatility to use for the calculation
Returns:
The calculated exposure (delta * notional value)
"""
# Set the underlying price on the option object to ensure notional_value property works
option.underlying_price = underlying_price
# Calculate delta using Black-Scholes
delta = calculate_option_delta(option, underlying_price, implied_volatility=iv)
# Calculate exposure as delta * notional value
# This is the key calculation we're testing
exposure = delta * option.notional_value
# Print detailed information for debugging
return delta, exposure
def test_call_option_exposures(option_fixtures):
"""Test that call option exposures are calculated correctly."""
underlying_price = 100.0
# Test long call (should have positive delta and positive exposure)
long_call = option_fixtures["long_call_atm"]
long_delta, long_exposure = calculate_exposure(long_call, underlying_price)
# Delta should be positive for a call option
assert long_delta > 0, "Long call delta should be positive"
# Exposure should be positive for a long call
assert long_exposure > 0, "Long call exposure should be positive"
# Test short call (should have negative delta and negative exposure)
short_call = option_fixtures["short_call_atm"]
short_delta, short_exposure = calculate_exposure(short_call, underlying_price)
# Delta should be negative for a short call
assert short_delta < 0, "Short call delta should be negative"
# Exposure should be negative for a short call
assert short_exposure < 0, "Short call exposure should be negative"
# The absolute values of delta and exposure should be the same for long and short
# positions with the same parameters (just opposite signs)
assert abs(long_delta) == pytest.approx(abs(short_delta), rel=1e-10)
assert abs(long_exposure) == pytest.approx(abs(short_exposure), rel=1e-10)
def test_put_option_exposures(option_fixtures):
"""Test that put option exposures are calculated correctly."""
underlying_price = 100.0
# Test long put (should have negative delta and negative exposure)
long_put = option_fixtures["long_put_atm"]
long_delta, long_exposure = calculate_exposure(long_put, underlying_price)
# Delta should be negative for a put option
assert long_delta < 0, "Long put delta should be negative"
# Exposure should be negative for a long put
assert long_exposure < 0, "Long put exposure should be negative"
# Test short put (should have positive delta and positive exposure)
short_put = option_fixtures["short_put_atm"]
short_delta, short_exposure = calculate_exposure(short_put, underlying_price)
# Delta should be positive for a short put
assert short_delta > 0, "Short put delta should be positive"
# Exposure should be positive for a short put
assert short_exposure > 0, "Short put exposure should be positive"
# The absolute values of delta and exposure should be the same for long and short
# positions with the same parameters (just opposite signs)
assert abs(long_delta) == pytest.approx(abs(short_delta), rel=1e-10)
assert abs(long_exposure) == pytest.approx(abs(short_exposure), rel=1e-10)
def test_exposure_scales_with_quantity(option_fixtures):
"""Test that exposure scales linearly with quantity."""
underlying_price = 100.0
# Test with a single contract
single_call = option_fixtures["long_call_atm"]
_, single_exposure = calculate_exposure(single_call, underlying_price)
# Test with multiple contracts
multiple_call = option_fixtures["long_call_multiple"]
_, multiple_exposure = calculate_exposure(multiple_call, underlying_price)
# Exposure should scale linearly with quantity
expected_ratio = multiple_call.quantity / single_call.quantity
actual_ratio = multiple_exposure / single_exposure
assert actual_ratio == pytest.approx(expected_ratio, rel=1e-10)
# Test with short positions
single_put = option_fixtures["short_put_atm"]
_, single_exposure = calculate_exposure(single_put, underlying_price)
multiple_put = option_fixtures["short_put_multiple"]
_, multiple_exposure = calculate_exposure(multiple_put, underlying_price)
# Exposure should scale linearly with quantity
expected_ratio = multiple_put.quantity / single_put.quantity
actual_ratio = multiple_exposure / single_exposure
assert actual_ratio == pytest.approx(expected_ratio, rel=1e-10)
def test_moneyness_affects_delta(option_fixtures):
"""Test that moneyness affects delta as expected."""
underlying_price = 100.0
# For call options:
# - ITM calls should have higher delta
# - OTM calls should have lower delta
itm_call = option_fixtures["long_call_itm"]
atm_call = option_fixtures["long_call_atm"]
otm_call = option_fixtures["long_call_otm"]
itm_delta, _ = calculate_exposure(itm_call, underlying_price)
atm_delta, _ = calculate_exposure(atm_call, underlying_price)
otm_delta, _ = calculate_exposure(otm_call, underlying_price)
assert itm_delta > atm_delta > otm_delta, (
"Call delta should decrease as strike increases"
)
# For put options:
# - ITM puts should have more negative delta
# - OTM puts should have less negative delta
itm_put = option_fixtures["long_put_itm"]
atm_put = option_fixtures["long_put_atm"]
otm_put = option_fixtures["long_put_otm"]
itm_delta, _ = calculate_exposure(itm_put, underlying_price)
atm_delta, _ = calculate_exposure(atm_put, underlying_price)
otm_delta, _ = calculate_exposure(otm_put, underlying_price)
assert itm_delta < atm_delta < otm_delta, (
"Put delta should increase as strike decreases"
)
def test_portfolio_level_exposure():
"""Test that portfolio-level option exposure is calculated correctly."""
# Create a simple portfolio with different option positions
expiry_date = datetime(2025, 6, 15)
underlying_price = 100.0
portfolio = [
# Long call - positive exposure
OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=100.0,
option_type="CALL",
quantity=2,
current_price=5.0,
description="SPY JUN 15 2025 $100 CALL",
),
# Short call - negative exposure
OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=110.0,
option_type="CALL",
quantity=-1,
current_price=2.0,
description="SPY JUN 15 2025 $110 CALL",
),
# Long put - negative exposure
OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=90.0,
option_type="PUT",
quantity=1,
current_price=2.0,
description="SPY JUN 15 2025 $90 PUT",
),
# Short put - positive exposure
OptionContract(
underlying="SPY",
expiry=expiry_date,
strike=80.0,
option_type="PUT",
quantity=-2,
current_price=1.0,
description="SPY JUN 15 2025 $80 PUT",
),
]
# Calculate individual exposures
exposures = []
for option in portfolio:
_, exposure = calculate_exposure(option, underlying_price)
exposures.append(exposure)
# Calculate total exposure
total_exposure = sum(exposures)
# Verify the signs of individual exposures
assert exposures[0] > 0, "Long call should have positive exposure"
assert exposures[1] < 0, "Short call should have negative exposure"
assert exposures[2] < 0, "Long put should have negative exposure"
assert exposures[3] > 0, "Short put should have positive exposure"
# The total exposure should equal the sum of individual exposures
assert total_exposure == pytest.approx(sum(exposures), rel=1e-10)
# Verify that the calculation matches what's done in portfolio.py
# In portfolio.py, the calculation is: delta * option.notional_value
# Let's verify this is correct for each option type and position
# For a long call (positive delta, positive exposure)
long_call = portfolio[0]
long_call_delta = calculate_option_delta(long_call, underlying_price)
long_call_exposure = long_call_delta * long_call.notional_value
assert long_call_exposure > 0, "Long call exposure should be positive"
# For a short call (negative delta, negative exposure)
short_call = portfolio[1]
short_call_delta = calculate_option_delta(short_call, underlying_price)
short_call_exposure = short_call_delta * short_call.notional_value
assert short_call_exposure < 0, "Short call exposure should be negative"
# For a long put (negative delta, negative exposure)
long_put = portfolio[2]
long_put_delta = calculate_option_delta(long_put, underlying_price)
long_put_exposure = long_put_delta * long_put.notional_value
assert long_put_exposure < 0, "Long put exposure should be negative"
# For a short put (positive delta, positive exposure)
short_put = portfolio[3]
short_put_delta = calculate_option_delta(short_put, underlying_price)
short_put_exposure = short_put_delta * short_put.notional_value
assert short_put_exposure > 0, "Short put exposure should be positive"
def test_calculate_option_exposure(option_fixtures):
"""Test the calculate_option_exposure function."""
# Import calculate_option_exposure from options module
from src.folio.options import calculate_option_exposure
underlying_price = 100.0
beta = 1.2
# Test with a long call
long_call = option_fixtures["long_call_atm"]
long_call_exposures = calculate_option_exposure(long_call, underlying_price, beta)
# Verify the keys in the result
assert "delta" in long_call_exposures
assert "delta_exposure" in long_call_exposures
assert "beta_adjusted_exposure" in long_call_exposures
# Verify the values
delta = long_call_exposures["delta"]
delta_exposure = long_call_exposures["delta_exposure"]
beta_adjusted_exposure = long_call_exposures["beta_adjusted_exposure"]
# Delta should be positive for a long call
assert delta > 0, "Delta should be positive for a long call"
# Delta exposure should be positive for a long call
assert delta_exposure > 0, "Delta exposure should be positive for a long call"
# Beta-adjusted exposure should be delta_exposure * beta
assert beta_adjusted_exposure == pytest.approx(delta_exposure * beta)
# Test with a short call
short_call = option_fixtures["short_call_atm"]
short_call_exposures = calculate_option_exposure(short_call, underlying_price, beta)
# Delta should be negative for a short call
assert short_call_exposures["delta"] < 0, (
"Delta should be negative for a short call"
)
# Delta exposure should be negative for a short call
assert short_call_exposures["delta_exposure"] < 0, (
"Delta exposure should be negative for a short call"
)
# Test with a long put
long_put = option_fixtures["long_put_atm"]
long_put_exposures = calculate_option_exposure(long_put, underlying_price, beta)
# Delta should be negative for a long put
assert long_put_exposures["delta"] < 0, "Delta should be negative for a long put"
# Delta exposure should be negative for a long put
assert long_put_exposures["delta_exposure"] < 0, (
"Delta exposure should be negative for a long put"
)
# Test with a short put
short_put = option_fixtures["short_put_atm"]
short_put_exposures = calculate_option_exposure(short_put, underlying_price, beta)
# Delta should be positive for a short put
assert short_put_exposures["delta"] > 0, "Delta should be positive for a short put"
# Delta exposure should be positive for a short put
assert short_put_exposures["delta_exposure"] > 0, (
"Delta exposure should be positive for a short put"
)
def test_process_options():
"""Test the process_options function."""
# Import process_options from options module
from src.folio.options import process_options
# Create test data
options_data = [
{
"description": "SPY JUN 15 2025 $100 CALL",
"quantity": 1,
"price": 5.0,
"symbol": "SPY250615C00100000",
},
{
"description": "SPY JUN 15 2025 $100 CALL",
"quantity": -1,
"price": 5.0,
"symbol": "SPY250615C00100000",
},
{
"description": "SPY JUN 15 2025 $100 PUT",
"quantity": 1,
"price": 5.0,
"symbol": "SPY250615P00100000",
},
{
"description": "SPY JUN 15 2025 $100 PUT",
"quantity": -1,
"price": 5.0,
"symbol": "SPY250615P00100000",
},
]
prices = {"SPY": 100.0}
betas = {"SPY": 1.2}
# Process the options
processed_options = process_options(options_data, prices, betas)
# Verify the results
assert len(processed_options) == 4, "Should have processed all 4 options"
# Check the first option (long call)
long_call = processed_options[0]
assert long_call["ticker"] == "SPY"
assert long_call["option_type"] == "CALL"
assert long_call["quantity"] == 1
assert long_call["delta"] > 0, "Delta should be positive for a long call"
assert long_call["delta_exposure"] > 0, (
"Delta exposure should be positive for a long call"
)
# Check the second option (short call)
short_call = processed_options[1]
assert short_call["ticker"] == "SPY"
assert short_call["option_type"] == "CALL"
assert short_call["quantity"] == -1
assert short_call["delta"] < 0, "Delta should be negative for a short call"
assert short_call["delta_exposure"] < 0, (
"Delta exposure should be negative for a short call"
)
# Check the third option (long put)
long_put = processed_options[2]
assert long_put["ticker"] == "SPY"
assert long_put["option_type"] == "PUT"
assert long_put["quantity"] == 1
assert long_put["delta"] < 0, "Delta should be negative for a long put"
assert long_put["delta_exposure"] < 0, (
"Delta exposure should be negative for a long put"
)
# Check the fourth option (short put)
short_put = processed_options[3]
assert short_put["ticker"] == "SPY"
assert short_put["option_type"] == "PUT"
assert short_put["quantity"] == -1
assert short_put["delta"] > 0, "Delta should be positive for a short put"
assert short_put["delta_exposure"] > 0, (
"Delta exposure should be positive for a short put"
)
def test_process_options_with_missing_price():
"""Test process_options with a missing price."""
# Import process_options from options module
from src.folio.options import process_options
options_data = [
{
"description": "SPY JUN 15 2025 $100 CALL",
"quantity": 1,
"price": 5.0,
},
{
"description": "AAPL JUN 15 2025 $200 CALL", # No price for AAPL
"quantity": 1,
"price": 10.0,
},
]
prices = {"SPY": 100.0} # No price for AAPL
# Process the options
processed_options = process_options(options_data, prices)
# Verify that only the SPY option was processed
assert len(processed_options) == 1, "Should have processed only the SPY option"
assert processed_options[0]["ticker"] == "SPY"
def test_process_options_with_error():
"""Test process_options with an error in the option data."""
# Import process_options from options module
from src.folio.options import process_options
options_data = [
{
"description": "SPY JUN 15 2025 $100 CALL",
"quantity": 1,
"price": 5.0,
},
{
"description": "Invalid option description", # Invalid description
"quantity": 1,
"price": 5.0,
},
]
prices = {"SPY": 100.0}
# Process the options
processed_options = process_options(options_data, prices)
# Verify that only the valid option was processed
assert len(processed_options) == 1, "Should have processed only the valid option"
assert processed_options[0]["ticker"] == "SPY"