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...