[Python] Calculations for payoff at expiration and current time (at-now)

Discussão em 'Python' iniciado por Stack, Abril 8, 2025.

  1. Stack

    Stack Membro Participativo

    I am trying to put together a script with Quantlib. It calculates the payoff of an options strategy taking its legs. The result produces three arrays with the underlying prices for the X-axis and the payoff at expiration and as of today.

    The script:

    from dataclasses import dataclass
    from enum import Enum
    from typing import List
    import QuantLib as ql
    from datetime import date
    import numpy as np
    import json
    import sys


    class Direction(Enum):
    LONG = 'LONG'
    SHORT = 'SHORT'


    class OptionSide(Enum):
    CALL = 'CALL'
    PUT = 'PUT'


    @dataclass
    class StrategyLeg:
    side: OptionSide
    size: float
    iv: float
    bid: float
    ask: float
    strike: float
    expiration: date
    direction: Direction
    commission: float


    @dataclass
    class Strategy:
    legs: List[StrategyLeg]


    @dataclass
    class ChartData:
    underlyingPrices: List[float]
    expirationPayoffs: List[float]
    currentPayoffs: List[float]


    def populate_chart_data(
    strat: Strategy,
    today: date,
    underlying: float,
    risk_rate: float,
    div_rate: float
    ) -> ChartData:
    """Main interface that calculates both current and expiration payoffs."""
    price_range = np.linspace(underlying * 0.5, underlying * 1.5, 100)

    expiration_payoffs = [
    _calculate_expiration_payoff(strat, underlying_price, risk_rate, div_rate)
    for underlying_price in price_range
    ]

    current_payoffs = [
    _calculate_current_payoff(
    strat, today, underlying_price,
    risk_rate, div_rate
    )
    for underlying_price in price_range
    ]

    return ChartData(
    underlyingPrices=list(price_range),
    expirationPayoffs=expiration_payoffs,
    currentPayoffs=current_payoffs
    )


    def _calculate_expiration_payoff(strat: Strategy, underlying_price: float, risk_rate: float, div_rate: float) -> float:
    """Calculate strategy payoff at the near-term expiration for a given underlying price."""
    total_payoff = 0.0

    # For calendar spreads, determine the near-term expiration
    expiration_dates = [leg_item.expiration for leg_item in strat.legs]
    near_expiry = min(expiration_dates)

    # Convert near_expiry to QuantLib date for pricing
    ql_near_expiry = ql.Date(near_expiry.day, near_expiry.month, near_expiry.year)
    ql.Settings.instance().evaluationDate = ql_near_expiry

    for leg_item in strat.legs:
    if leg_item.expiration == near_expiry:
    # Near-term leg: Calculate intrinsic value at expiration
    if leg_item.side == OptionSide.CALL:
    intrinsic = max(0.0, underlying_price - leg_item.strike)
    else:
    intrinsic = max(0.0, leg_item.strike - underlying_price)
    position_value = intrinsic * leg_item.size * 100
    else:
    # Far-term leg: Price the option at near_expiry with remaining time
    try:
    option_type = ql.Option.Call if leg_item.side == OptionSide.CALL else ql.Option.Put
    payoff = ql.PlainVanillaPayoff(option_type, leg_item.strike)
    exercise = _create_american_exercise(near_expiry, leg_item.expiration)
    option = ql.VanillaOption(payoff, exercise)

    # Set up pricing parameters
    volatility = ql.BlackConstantVol(ql_near_expiry, ql.NullCalendar(), leg_item.iv, ql.Actual365Fixed())
    spot_handle = ql.QuoteHandle(ql.SimpleQuote(underlying_price))
    risk_free_curve = ql.FlatForward(ql_near_expiry, risk_rate, ql.Actual365Fixed())
    dividend_curve = ql.FlatForward(ql_near_expiry, div_rate, ql.Actual365Fixed())

    process = ql.BlackScholesMertonProcess(
    spot_handle,
    ql.YieldTermStructureHandle(dividend_curve),
    ql.YieldTermStructureHandle(risk_free_curve),
    ql.BlackVolTermStructureHandle(volatility)
    )

    option.setPricingEngine(ql.BinomialVanillaEngine(process, "crr", 100))
    option_value = option.NPV()
    position_value = option_value * leg_item.size * 100
    except Exception as ex:
    raise ValueError(f"Error pricing far-term leg {leg_item}: {str(ex)}")

    # Adjust for direction (short = negative position value)
    if leg_item.direction == Direction.SHORT:
    position_value *= -1

    # Calculate premium impact (positive for short, negative for long)
    premium_impact = (
    leg_item.bid if leg_item.direction == Direction.SHORT else -leg_item.ask) * leg_item.size * 100

    # Add commission impact (always negative, paid when entering position)
    commission_impact = -leg_item.commission * leg_item.size * 100

    total_payoff += position_value + premium_impact + commission_impact

    return total_payoff


    def _calculate_current_payoff(
    strat: Strategy,
    today: date,
    underlying: float,
    risk_rate: float,
    div_rate: float
    ) -> float:
    """Calculate current strategy value using option pricing models."""
    ql_current_date = ql.Date(today.day, today.month, today.year)
    ql.Settings.instance().evaluationDate = ql_current_date

    total_value = 0.0

    for leg_item in strat.legs:
    try:
    # Set up QuantLib option pricing
    option_type = ql.Option.Call if leg_item.side == OptionSide.CALL else ql.Option.Put

    payoff = ql.PlainVanillaPayoff(option_type, leg_item.strike)
    exercise = _create_american_exercise(today, leg_item.expiration)
    option = ql.VanillaOption(payoff, exercise)

    # Rest of the pricing logic remains the same...
    volatility = ql.BlackConstantVol(ql_current_date, ql.NullCalendar(), leg_item.iv, ql.Actual365Fixed())
    spot_handle = ql.QuoteHandle(ql.SimpleQuote(underlying))

    risk_free_curve = ql.FlatForward(ql_current_date, risk_rate, ql.Actual365Fixed())
    dividend_curve = ql.FlatForward(ql_current_date, div_rate, ql.Actual365Fixed())

    process = ql.BlackScholesMertonProcess(
    spot_handle,
    ql.YieldTermStructureHandle(dividend_curve),
    ql.YieldTermStructureHandle(risk_free_curve),
    ql.BlackVolTermStructureHandle(volatility)
    )

    option.setPricingEngine(ql.BinomialVanillaEngine(process, "crr", 100))
    option_value = option.NPV()

    # Calculate premium impact
    premium_impact = (
    leg_item.bid if leg_item.direction == Direction.SHORT else -leg_item.ask) * leg_item.size * 100

    # Calculate commission impact
    commission_impact = -leg_item.commission * leg_item.size * 100

    # Calculate total leg value
    leg_value = option_value * leg_item.size * 100
    if leg_item.direction == Direction.SHORT:
    leg_value *= -1

    total_value += leg_value + premium_impact + commission_impact

    except Exception as ex:
    raise ValueError(f"Error processing leg {leg_item}: {str(ex)}")

    return total_value


    def _create_american_exercise(today: date, expiration_date: date):
    """Safely create American exercise with date validation"""
    ql_current = ql.Date(today.day, today.month, today.year)
    ql_expiration = ql.Date(expiration_date.day, expiration_date.month, expiration_date.year)

    if ql_expiration <= ql_current:
    raise ValueError(f"Expiration date {expiration_date} must be after current date {today}")

    return ql.AmericanExercise(ql_current, ql_expiration)


    if __name__ == "__main__":
    if len(sys.argv) > 1:
    input_json = sys.argv[1]
    else:
    input_json = sys.stdin.read()

    try:
    data = json.loads(input_json)

    # Parse legs with validation
    legs = []
    for i, leg_data in enumerate(data['legs']):
    try:
    # Validate and convert side
    side_str = leg_data['side']
    if side_str not in [OptionSide.CALL.value, OptionSide.PUT.value]:
    raise ValueError(f"Invalid side value '{side_str}' at leg {i}. Must be 'CALL' or 'PUT'.")
    side = OptionSide[side_str]

    # Validate and convert direction
    direction_str = leg_data['direction']
    if direction_str not in [Direction.LONG.value, Direction.SHORT.value]:
    raise ValueError(
    f"Invalid direction value '{direction_str}' at leg {i}. Must be 'LONG' or 'SHORT'.")
    direction = Direction[direction_str]

    leg = StrategyLeg(
    side=side,
    size=float(leg_data['size']),
    iv=float(leg_data['iv']),
    bid=float(leg_data['bid']),
    ask=float(leg_data['ask']),
    strike=float(leg_data['strike']),
    expiration=date.fromisoformat(leg_data['expiration']),
    direction=direction,
    commission=float(leg_data.get('commission')) # Added commission with default 0
    )
    legs.append(leg)
    except KeyError as e:
    raise ValueError(f"Missing or invalid field {str(e)} in leg {i}")
    except ValueError as e:
    raise ValueError(f"Error in leg {i}: {str(e)}")

    strategy = Strategy(legs=legs)

    current_date = date.fromisoformat(data['currentDate'])
    spot_price = float(data['spotPrice'])
    risk_free_rate = float(data['riskFreeRate'])
    dividend_rate = float(data['dividendRate'])

    chart_data = populate_chart_data(
    strategy,
    current_date,
    spot_price,
    risk_free_rate,
    dividend_rate
    )

    result = {
    'underlyingPrices': chart_data.underlyingPrices,
    'expirationPayoffs': chart_data.expirationPayoffs,
    'currentPayoffs': chart_data.currentPayoffs
    }
    print(json.dumps(result))

    except Exception as e:
    print(json.dumps({'error': str(e)}))
    sys.exit(1)



    In case you want to test it with a few strategies

    from datetime import date, timedelta

    from strategyPricer import StrategyLeg, OptionSide, Direction, Strategy

    # Updated base parameters for SPX options
    CURRENT_DATE = date(2023, 11, 1)
    SPOT_PRICE = 4350.0
    RISK_FREE_RATE = 0.05
    DIVIDEND_RATE = 0.014
    EXPIRATION_DATE = CURRENT_DATE + timedelta(days=45)
    NEAR_EXPIRY = CURRENT_DATE + timedelta(days=30)
    FAR_EXPIRY = CURRENT_DATE + timedelta(days=90)
    COMMISSION = 0.50 # Per contract commission


    def print_results(strategy_name: str, result):
    print(f"\n=== {strategy_name} ===")
    for i in range(0, 100, 10):
    print(f"Price: {result.underlyingPrices:.2f}, "
    f"Expiration Payoff: {result.expirationPayoffs:.2f}, "
    f"Current Payoff: {result.currentPayoffs:.2f}")


    def long_call():
    # Buying a call - debit transaction
    ask_price = 87.00
    leg = StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.LONG,
    strike=4350.0,
    expiration=date(2023, 12, 15),
    iv=0.15,
    size=1,
    bid=85.50,
    ask=ask_price,
    commission=COMMISSION
    )
    return Strategy(legs=[leg])


    def short_call():
    # Selling a call - credit transaction
    bid_price = 65.00
    leg = StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.SHORT,
    strike=4400.0,
    expiration=date(2023, 12, 15),
    iv=0.14,
    size=1,
    bid=bid_price,
    ask=67.00,
    commission=COMMISSION
    )
    return Strategy(legs=[leg])


    def bull_call_spread():
    # Debit spread - long leg more expensive than short leg
    long_ask = 87.00
    short_bid = 65.00
    net_debit = long_ask - short_bid # ~22.00 debit

    long_leg = StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.LONG,
    strike=4350.0,
    expiration=date(2023, 12, 15),
    iv=0.15,
    size=1,
    bid=85.50,
    ask=long_ask,
    commission=COMMISSION
    )
    short_leg = StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.SHORT,
    strike=4400.0,
    expiration=date(2023, 12, 15),
    iv=0.14,
    size=1,
    bid=short_bid,
    ask=67.00,
    commission=COMMISSION
    )
    return Strategy(legs=[long_leg, short_leg])


    def bear_put_spread():
    # Debit spread - long leg more expensive than short leg
    long_ask = 94.00
    short_bid = 78.00
    net_debit = long_ask - short_bid # ~16.00 debit

    long_leg = StrategyLeg(
    side=OptionSide.PUT,
    direction=Direction.LONG,
    strike=4350.0,
    expiration=date(2023, 12, 15),
    iv=0.16,
    size=1,
    bid=92.00,
    ask=long_ask,
    commission=COMMISSION
    )
    short_leg = StrategyLeg(
    side=OptionSide.PUT,
    direction=Direction.SHORT,
    strike=4300.0,
    expiration=date(2023, 12, 15),
    iv=0.16,
    size=1,
    bid=short_bid,
    ask=80.00,
    commission=COMMISSION
    )
    return Strategy(legs=[long_leg, short_leg])


    def straddle():
    # Buying both call and put - large debit
    call_ask = 87.00
    put_ask = 94.00
    net_debit = call_ask + put_ask # ~181.00 debit

    call_leg = StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.LONG,
    strike=4350.0,
    expiration=date(2023, 12, 15),
    iv=0.15,
    size=1,
    bid=85.50,
    ask=call_ask,
    commission=COMMISSION
    )
    put_leg = StrategyLeg(
    side=OptionSide.PUT,
    direction=Direction.LONG,
    strike=4350.0,
    expiration=date(2023, 12, 15),
    iv=0.16,
    size=1,
    bid=92.00,
    ask=put_ask,
    commission=COMMISSION
    )
    return Strategy(legs=[call_leg, put_leg])


    def iron_condor():
    # Credit strategy - premium received > premium paid
    call_credit = 65.00 # Short 4400 call
    put_credit = 78.00 # Short 4300 put
    call_debit = 52.00 # Long 4450 call
    put_debit = 67.00 # Long 4250 put
    net_credit = (call_credit + put_credit) - (call_debit + put_debit) # ~24.00 credit

    return Strategy(legs=[
    # Short 4400 Call
    StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.SHORT,
    strike=4400,
    expiration=EXPIRATION_DATE,
    iv=0.14,
    size=1,
    bid=call_credit,
    ask=67.00,
    commission=COMMISSION
    ),
    # Long 4450 Call
    StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.LONG,
    strike=4450,
    expiration=EXPIRATION_DATE,
    iv=0.13,
    size=1,
    bid=50.00,
    ask=call_debit,
    commission=COMMISSION
    ),
    # Short 4300 Put
    StrategyLeg(
    side=OptionSide.PUT,
    direction=Direction.SHORT,
    strike=4300,
    expiration=EXPIRATION_DATE,
    iv=0.16,
    size=1,
    bid=put_credit,
    ask=80.00,
    commission=COMMISSION
    ),
    # Long 4250 Put
    StrategyLeg(
    side=OptionSide.PUT,
    direction=Direction.LONG,
    strike=4250,
    expiration=EXPIRATION_DATE,
    iv=0.17,
    size=1,
    bid=65.00,
    ask=put_debit,
    commission=COMMISSION
    )
    ])


    def iron_butterfly():
    # Typically a small credit strategy
    short_call_bid = 85.50
    short_put_bid = 92.00
    long_call_ask = 67.00
    long_put_ask = 80.00
    net_credit = (short_call_bid + short_put_bid) - (long_call_ask + long_put_ask) # ~30.50 credit

    return Strategy(legs=[
    # Short 4350 Call
    StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.SHORT,
    strike=4350,
    expiration=EXPIRATION_DATE,
    iv=0.15,
    size=1,
    bid=short_call_bid,
    ask=87.00,
    commission=COMMISSION
    ),
    # Long 4400 Call
    StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.LONG,
    strike=4400,
    expiration=EXPIRATION_DATE,
    iv=0.14,
    size=1,
    bid=65.00,
    ask=long_call_ask,
    commission=COMMISSION
    ),
    # Short 4350 Put
    StrategyLeg(
    side=OptionSide.PUT,
    direction=Direction.SHORT,
    strike=4350,
    expiration=EXPIRATION_DATE,
    iv=0.16,
    size=1,
    bid=short_put_bid,
    ask=94.00,
    commission=COMMISSION
    ),
    # Long 4300 Put
    StrategyLeg(
    side=OptionSide.PUT,
    direction=Direction.LONG,
    strike=4300,
    expiration=EXPIRATION_DATE,
    iv=0.16,
    size=1,
    bid=78.00,
    ask=long_put_ask,
    commission=COMMISSION
    )
    ])


    def call_calendar_spread():
    # Near-term short call (we receive the bid price)
    short_bid = 60.00
    # Longer-term long call (we pay the ask price)
    long_ask = 122.00
    net_debit = long_ask - short_bid # 62.00 debit

    return Strategy(legs=[
    # Near-term short call (30 days)
    StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.SHORT,
    strike=4350,
    expiration=NEAR_EXPIRY,
    iv=0.15,
    size=1,
    bid=short_bid, # Credit received
    ask=62.00,
    commission=COMMISSION
    ),
    # Longer-term long call (90 days)
    StrategyLeg(
    side=OptionSide.CALL,
    direction=Direction.LONG,
    strike=4350,
    expiration=FAR_EXPIRY,
    iv=0.18,
    size=1,
    bid=120.00,
    ask=long_ask, # Debit paid
    commission=COMMISSION
    )
    ])



    Plotting helper:

    import matplotlib.pyplot as plt
    from strategyPricer import ChartData


    def plot_chart_data(chart_data: ChartData, filename='payoff_diagram.png'):
    plt.figure(figsize=(10, 6))

    plt.plot(chart_data.underlyingPrices, chart_data.expirationPayoffs,
    label='Expiration', color='blue', linestyle='-')
    plt.plot(chart_data.underlyingPrices, chart_data.currentPayoffs,
    label='Current', color='red', linestyle='--')

    plt.xlabel('Underlying Price')
    plt.ylabel('Payoff')
    plt.title('Payoff Diagram')
    plt.grid(True, linestyle='--', alpha=0.7)
    plt.legend()

    plt.savefig(filename)
    plt.close()


    main.py for calling and plotting

    from PIL import Image

    from plotData import plot_chart_data
    from strategyPricer import populate_chart_data
    from strategies import SPOT_PRICE, RISK_FREE_RATE, DIVIDEND_RATE, long_call, short_call, bull_call_spread, \
    iron_condor, iron_butterfly, CURRENT_DATE, call_calendar_spread

    if __name__ == "__main__":
    result = populate_chart_data(
    strat=call_calendar_spread(),
    today=CURRENT_DATE,
    underlying=SPOT_PRICE,
    risk_rate=RISK_FREE_RATE,
    div_rate=DIVIDEND_RATE
    )

    plot_chart_data(
    chart_data=result
    )

    img = Image.open('payoff_diagram.png')
    img.show()


    The issue is that the current payoff is too optimistic and produces values that are more positive than they should be, i.e. at the time of entering the strategy, the payoff should be negative. It might be the engine I am using. What do you think that it might be causing the issue?

    Continue reading...

Compartilhe esta Página