|
|
"""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.""" |
|
|
|
|
|
expiry_date = datetime(2025, 6, 15) |
|
|
|
|
|
|
|
|
return { |
|
|
|
|
|
"long_call_atm": OptionContract( |
|
|
underlying="SPY", |
|
|
expiry=expiry_date, |
|
|
strike=100.0, |
|
|
option_type="CALL", |
|
|
quantity=1, |
|
|
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, |
|
|
current_price=5.0, |
|
|
description="SPY JUN 15 2025 $100 CALL", |
|
|
), |
|
|
|
|
|
"long_put_atm": OptionContract( |
|
|
underlying="SPY", |
|
|
expiry=expiry_date, |
|
|
strike=100.0, |
|
|
option_type="PUT", |
|
|
quantity=1, |
|
|
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, |
|
|
current_price=5.0, |
|
|
description="SPY JUN 15 2025 $100 PUT", |
|
|
), |
|
|
|
|
|
"long_call_multiple": OptionContract( |
|
|
underlying="SPY", |
|
|
expiry=expiry_date, |
|
|
strike=100.0, |
|
|
option_type="CALL", |
|
|
quantity=3, |
|
|
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, |
|
|
current_price=5.0, |
|
|
description="SPY JUN 15 2025 $100 PUT", |
|
|
), |
|
|
|
|
|
"long_call_itm": OptionContract( |
|
|
underlying="SPY", |
|
|
expiry=expiry_date, |
|
|
strike=90.0, |
|
|
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, |
|
|
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, |
|
|
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, |
|
|
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) |
|
|
""" |
|
|
|
|
|
option.underlying_price = underlying_price |
|
|
|
|
|
|
|
|
delta = calculate_option_delta(option, underlying_price, implied_volatility=iv) |
|
|
|
|
|
|
|
|
|
|
|
exposure = delta * option.notional_value |
|
|
|
|
|
|
|
|
|
|
|
return delta, exposure |
|
|
|
|
|
|
|
|
def test_call_option_exposures(option_fixtures): |
|
|
"""Test that call option exposures are calculated correctly.""" |
|
|
underlying_price = 100.0 |
|
|
|
|
|
|
|
|
long_call = option_fixtures["long_call_atm"] |
|
|
long_delta, long_exposure = calculate_exposure(long_call, underlying_price) |
|
|
|
|
|
|
|
|
assert long_delta > 0, "Long call delta should be positive" |
|
|
|
|
|
assert long_exposure > 0, "Long call exposure should be positive" |
|
|
|
|
|
|
|
|
short_call = option_fixtures["short_call_atm"] |
|
|
short_delta, short_exposure = calculate_exposure(short_call, underlying_price) |
|
|
|
|
|
|
|
|
assert short_delta < 0, "Short call delta should be negative" |
|
|
|
|
|
assert short_exposure < 0, "Short call exposure should be negative" |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
long_put = option_fixtures["long_put_atm"] |
|
|
long_delta, long_exposure = calculate_exposure(long_put, underlying_price) |
|
|
|
|
|
|
|
|
assert long_delta < 0, "Long put delta should be negative" |
|
|
|
|
|
assert long_exposure < 0, "Long put exposure should be negative" |
|
|
|
|
|
|
|
|
short_put = option_fixtures["short_put_atm"] |
|
|
short_delta, short_exposure = calculate_exposure(short_put, underlying_price) |
|
|
|
|
|
|
|
|
assert short_delta > 0, "Short put delta should be positive" |
|
|
|
|
|
assert short_exposure > 0, "Short put exposure should be positive" |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
single_call = option_fixtures["long_call_atm"] |
|
|
_, single_exposure = calculate_exposure(single_call, underlying_price) |
|
|
|
|
|
|
|
|
multiple_call = option_fixtures["long_call_multiple"] |
|
|
_, multiple_exposure = calculate_exposure(multiple_call, underlying_price) |
|
|
|
|
|
|
|
|
expected_ratio = multiple_call.quantity / single_call.quantity |
|
|
actual_ratio = multiple_exposure / single_exposure |
|
|
assert actual_ratio == pytest.approx(expected_ratio, rel=1e-10) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
expiry_date = datetime(2025, 6, 15) |
|
|
underlying_price = 100.0 |
|
|
|
|
|
portfolio = [ |
|
|
|
|
|
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", |
|
|
), |
|
|
|
|
|
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", |
|
|
), |
|
|
|
|
|
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", |
|
|
), |
|
|
|
|
|
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", |
|
|
), |
|
|
] |
|
|
|
|
|
|
|
|
exposures = [] |
|
|
for option in portfolio: |
|
|
_, exposure = calculate_exposure(option, underlying_price) |
|
|
exposures.append(exposure) |
|
|
|
|
|
|
|
|
total_exposure = sum(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" |
|
|
|
|
|
|
|
|
assert total_exposure == pytest.approx(sum(exposures), rel=1e-10) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
from src.folio.options import calculate_option_exposure |
|
|
|
|
|
underlying_price = 100.0 |
|
|
beta = 1.2 |
|
|
|
|
|
|
|
|
long_call = option_fixtures["long_call_atm"] |
|
|
long_call_exposures = calculate_option_exposure(long_call, underlying_price, beta) |
|
|
|
|
|
|
|
|
assert "delta" in long_call_exposures |
|
|
assert "delta_exposure" in long_call_exposures |
|
|
assert "beta_adjusted_exposure" in long_call_exposures |
|
|
|
|
|
|
|
|
delta = long_call_exposures["delta"] |
|
|
delta_exposure = long_call_exposures["delta_exposure"] |
|
|
beta_adjusted_exposure = long_call_exposures["beta_adjusted_exposure"] |
|
|
|
|
|
|
|
|
assert delta > 0, "Delta should be positive for a long call" |
|
|
|
|
|
|
|
|
assert delta_exposure > 0, "Delta exposure should be positive for a long call" |
|
|
|
|
|
|
|
|
assert beta_adjusted_exposure == pytest.approx(delta_exposure * beta) |
|
|
|
|
|
|
|
|
short_call = option_fixtures["short_call_atm"] |
|
|
short_call_exposures = calculate_option_exposure(short_call, underlying_price, beta) |
|
|
|
|
|
|
|
|
assert short_call_exposures["delta"] < 0, ( |
|
|
"Delta should be negative for a short call" |
|
|
) |
|
|
|
|
|
|
|
|
assert short_call_exposures["delta_exposure"] < 0, ( |
|
|
"Delta exposure should be negative for a short call" |
|
|
) |
|
|
|
|
|
|
|
|
long_put = option_fixtures["long_put_atm"] |
|
|
long_put_exposures = calculate_option_exposure(long_put, underlying_price, beta) |
|
|
|
|
|
|
|
|
assert long_put_exposures["delta"] < 0, "Delta should be negative for a long put" |
|
|
|
|
|
|
|
|
assert long_put_exposures["delta_exposure"] < 0, ( |
|
|
"Delta exposure should be negative for a long put" |
|
|
) |
|
|
|
|
|
|
|
|
short_put = option_fixtures["short_put_atm"] |
|
|
short_put_exposures = calculate_option_exposure(short_put, underlying_price, beta) |
|
|
|
|
|
|
|
|
assert short_put_exposures["delta"] > 0, "Delta 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.""" |
|
|
|
|
|
from src.folio.options import process_options |
|
|
|
|
|
|
|
|
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} |
|
|
|
|
|
|
|
|
processed_options = process_options(options_data, prices, betas) |
|
|
|
|
|
|
|
|
assert len(processed_options) == 4, "Should have processed all 4 options" |
|
|
|
|
|
|
|
|
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" |
|
|
) |
|
|
|
|
|
|
|
|
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" |
|
|
) |
|
|
|
|
|
|
|
|
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" |
|
|
) |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
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", |
|
|
"quantity": 1, |
|
|
"price": 10.0, |
|
|
}, |
|
|
] |
|
|
|
|
|
prices = {"SPY": 100.0} |
|
|
|
|
|
|
|
|
processed_options = process_options(options_data, prices) |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
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", |
|
|
"quantity": 1, |
|
|
"price": 5.0, |
|
|
}, |
|
|
] |
|
|
|
|
|
prices = {"SPY": 100.0} |
|
|
|
|
|
|
|
|
processed_options = process_options(options_data, prices) |
|
|
|
|
|
|
|
|
assert len(processed_options) == 1, "Should have processed only the valid option" |
|
|
assert processed_options[0]["ticker"] == "SPY" |
|
|
|