top of page

How to Price Multi-Leg Options Basket with Python

  • Writer: Nikhil Adithyan
    Nikhil Adithyan
  • 3 days ago
  • 10 min read
ree

One of the trickiest parts of working with options is not just pricing a single contract. The real challenge is figuring out how a whole structure behaves when you combine multiple legs. Whether you are building spreads, collars, or something more exotic, you need to know the total premium and the combined Greeks before you ever put the trade on.


A basket approach solves this neatly. Instead of looking at each leg in isolation, you define the full set, price it together, and evaluate the strategy’s risk profile as a whole. In this walkthrough, I priced a simple buffer + cap structure on SPY: a long put at 450 for downside protection, and a short call at 575 to cap the upside.


The focus here is not on complex trading logic or backtesting. The goal is much simpler. How do you calculate the premium and Greeks for a custom multi-leg basket using Python?


SpiderRock Option Basket Calculator

For this walkthrough I am using the SpiderRock Option Basket Calculator, available through their MLink API. The calculator takes multiple option definitions, groups them into a basket, and returns leg-level valuations along with Greeks in a single response.


That design makes it practical to price spreads and multi-leg structures as one trade. Instead of piecing together legs individually and stitching the results, the API handles the heavy lifting and delivers consistent pricing for the entire basket.


In the sections that follow, I will use this API to define a buffer + cap structure on SPY, trigger the pricing engine, and analyze the outputs.


Setup

Before starting with the basket itself, the environment has to be prepared. The workflow needs only a few standard Python packages:


  • requests for interacting with the API

  • pandas for organizing results into tables

  • matplotlib for visualization

  • numpy for numerical work


Along with the packages, there are two URLs to keep in mind. The live server is used when posting new basket definitions and triggering pricing. The delayed server is where results are read back from. An API key is also required, which should be stored securely and never hardcoded in production.



import requests                     
import pandas as pd        
import numpy as np
import time                         
import uuid
from datetime import datetime
import matplotlib.pyplot as plt

API_KEY = "YOUR SPIDERROCK API KEY"

MLINK_PROD_URL = "https://mlink-live.nms.saturn.spiderrockconnect.com/rest/json"
MLINK_DELAYED_URL = 'https://mlink-delay.nms.saturn.spiderrockconnect.com/rest/json'

basket_id = 112345

At this point, the code has everything ready to start defining option legs and sending them for pricing.


Defining the Basket

The structure is a two-leg SPY basket. I define each leg explicitly as an OptionItemDef, keep the same basketNumber and userName across legs, and include a few overrides so the pricing engine uses a consistent set of inputs. The put sits 10 percent below the assumed spot, and the call sits 15 percent above. That is enough to illustrate a buffer and a cap.



def build_option_leg_payload(
    symbol, expiry, strike, cp, 
    basket_id, okey_number, 
    vol=None, uprc=None, rate=None
):
    payload = {
        "header": {
            "mTyp": "OptionItemDef"
        },
        "message": {
            "pkey": {
                "okey": {
                    "at": "EQT",
                    "ts": "NMS",
                    "tk": symbol,
                    "dt": expiry,
                    "xx": strike,
                    "cp": cp
                },
                "okeyNumber": okey_number,
                "basketNumber": basket_id,
                "userName": "indv.nadithyan"
            },
            "exType": "American",
            "exTime": "PM",
            "holidayCalendar": "NYSE",
            "timeMetric": "D252",
            "priceType": "Equity",
            "modelType": "LogNormalExact",
            "incGreeks": "Yes"
        }
    }

    if vol is not None:
        payload["message"]["vol"] = vol
    if uprc is not None:
        payload["message"]["uPrc"] = uprc
    if rate is not None:
        payload["message"]["rate"] = rate

    return payload

params = {
    "apiKey": API_KEY,
    "cmd": "postmsgs",
    "postaction": "I",
    "postmerge": "Y"
}

# Leg 1: Long Put @ 10% below current price (assume SPY @ 500 → strike 450)
put_payload = {
    "header": {
        "mTyp": "OptionItemDef"
    },
    "message": {
        "pkey": {
            "okey": {
                "at": "EQT",
                "ts": "NMS",
                "tk": "SPY",
                "dt": "2025-09-04",
                "xx": 450,
                "cp": "Put"
            },
            "okeyNumber": 1,
            "basketNumber": basket_id,
            "userName": "indv.nadithyan"
        },
        "exType": "American",
        "exTime": "PM",
        "holidayCalendar": "NYSE",
        "timeMetric": "D252",
        "priceType": "Equity",
        "modelType": "LogNormalExact",
        "incGreeks": "Yes",
        "vol": 0.25,
        "uPrc": 500,
        "rate": 0.05
    }
}


# Leg 2: Short Call @ 15% above current price → strike 575
call_payload = {
    "header": {
        "mTyp": "OptionItemDef"
    },
    "message": {
        "pkey": {
            "okey": {
                "at": "EQT",
                "ts": "NMS",
                "tk": "SPY",
                "dt": "2025-09-04",
                "xx": 575,
                "cp": "Call"
            },
            "okeyNumber": 2,
            "basketNumber": basket_id,
            "userName": "indv.nadithyan"
        },
        "exType": "American",
        "exTime": "PM",
        "holidayCalendar": "NYSE",
        "timeMetric": "D252",
        "priceType": "Equity",
        "modelType": "LogNormalExact",
        "incGreeks": "Yes",
        "vol": 0.25,
        "uPrc": 500,
        "rate": 0.05
    }
}

# Send both legs
response = requests.post(MLINK_PROD_URL, params=params, json=put_payload)
requests.post(MLINK_PROD_URL, params=params, json=call_payload)

Each leg is sent as an OptionItemDef with a primary key under message.pkey. The okey object identifies the contract by asset type, venue, symbol, expiry, strike, and side.


The fields okeyNumber, basketNumber, and userName associate the leg with a specific basket for a specific user, and they let multiple legs be grouped for pricing. I include pricing overrides for volatility, underlying price, and rate so the valuation is consistent across legs.


The request parameters use cmd=postmsgs with an insert action so both legs are registered on the live endpoint.


Verifying the Basket Posts

After sending both legs, I run a quick read on the delayed server to confirm they are recorded. The check filters by my username and returns the OptionItemDef rows that were just posted.



MLINK_DELAYED_URL = 'https://mlink-delay.nms.saturn.spiderrockconnect.com/rest/json'

params_check = {
    "apiKey": API_KEY,
    "cmd": "getmsgs",
    "msgType": "OptionItemDef",
    "where": "userName:eq:indv.nadithyan"
}

response = requests.get(MLINK_DELAYED_URL, params=params_check)
print(response.status_code)
print(response.json())

The call uses cmd=getmsgs with msgType=OptionItemDef on the delayed endpoint. The where clause filters by userName so the response lists only my legs. A 200 status with two OptionItemDef messages and a QueryResult of Ok is enough to proceed.


Trigger Pricing

With both legs posted, I ask the engine to price the basket. This is a single GetOptionBasket call. If the server has everything it needs, the response will include OptionItemCalc messages for each leg.



get_basket_payload = {
    "header": {
        "mTyp": "GetOptionBasket"
    },
    "message": {
        "basketNumber": '0000-0000-0001-B6D9'
    }
}

calc_response = requests.post(MLINK_PROD_URL, params=params, json=get_basket_payload).json()
print(calc_response)

The payload carries only the basket identifier. I post to the live endpoint with the same params used for inserts. The server runs the calculation for every leg in that basket and returns a JSON list. In many cases the list already contains the pricing outputs for each leg, which removes the need to poll the delayed server.


ree

The list contains two OptionItemCalc entries, one for the 450 put and one for the 575 call. Each message includes the option price and a full set of Greeks along with diagnostics like pricerModel and timestamp. The final QueryResult reports result: Ok, which confirms the request succeeded. Since the pricing messages are already present in this response, I can parse them directly into a DataFrame in the next section.


Data Analysis

Now that the engine has priced the basket, the rest is just cleanup and interpretation. I pull the OptionItemCalc messages out of the response, turn them into a tidy DataFrame, then roll everything up at the basket level before plotting.


1. Retrieve and Parse Results

The pricing response contained several messages, but the ones that matter are the OptionItemCalc entries. Each of these corresponds to one leg in the basket, with the premium, Greeks, and diagnostics included. To work with this properly in Python, the first step is to extract those messages and convert them into a clean DataFrame.



calc_msgs = [
    row["message"] for row in calc_response
    if isinstance(row, dict) and row.get("header", {}).get("mTyp") == "OptionItemCalc"
]

def _okey_str(okey):
    return f"{okey.get('tk')}-{okey.get('ts')}-{okey.get('at')}-{okey.get('dt')}-{okey.get('xx')}-{okey.get('cp')}"

rows = []
for m in calc_msgs:
    okey = m.get("okey", {})
    rows.append({
        "okeyNumber": m.get("okeyNumber"),
        "symbol": okey.get("tk"),
        "expiry": okey.get("dt"),
        "strike": okey.get("xx"),
        "cp": okey.get("cp"),
        "okey": _okey_str(okey),
        "basketNumber": m.get("basketNumber"),
        "price": m.get("price"),
        "delta": m.get("delta"),
        "gamma": m.get("gamma"),
        "theta": m.get("theta"),
        "vega": m.get("vega"),
        "volga": m.get("volga"),
        "vanna": m.get("vanna"),
        "rho": m.get("rho"),
        "phi": m.get("phi"),
        "deDecay": m.get("deDecay"),
        "effStrike": m.get("effStrike"),
        "vol": m.get("vol"),
        "uPrc": m.get("uPrc"),
        "years": m.get("years"),
        "rate": m.get("rate"),
        "modelType": m.get("modelType"),
        "pricerModel": m.get("pricerModel"),
        "timestamp": m.get("timestamp"),
        "error": m.get("error"),
    })

calc_df = pd.DataFrame(rows).sort_values("okeyNumber").reset_index(drop=True)

cols = [
    "okeyNumber","symbol","cp","strike","expiry","okey","basketNumber",
    "price","delta","gamma","theta","vega","volga","vanna","rho","phi","deDecay",
    "effStrike","uPrc","vol","years","rate","modelType","pricerModel","timestamp","error"
]
calc_df = calc_df[cols]

calc_df

The code loops through the response, filters out only OptionItemCalc entries, and then flattens the nested okey object into readable fields. The result is organized into a DataFrame with stable column order, which makes it much easier to explore each leg side by side.


Here is the output:


ree

Both rows line up with the basket I defined earlier. The first row is the long put at 450 and the second is the short call at 575. Each includes its premium and Greeks, along with inputs such as volatility and underlying price. This is the raw material for all the analysis that follows, since every calculation of net Greeks or P&L will roll up from these numbers.


2. Basket aggregation

The leg table is useful, but the trade is the basket. I roll the leg premiums and Greeks into basket totals using the intended quantities. For this structure, I keep the put long and the call short, both with the standard equity multiplier of 100.



def aggregate_basket(calc_df: pd.DataFrame, qty_map: dict, multiplier: int = 100):
    df = calc_df.copy()
    df["qty"] = df["okeyNumber"].map(qty_map).fillna(0).astype(float)
    greek_cols = ["price","delta","gamma","theta","vega","volga","vanna","rho","phi","deDecay"]
    for c in greek_cols:
        if c not in df.columns: df[c] = 0.0

    df["contribution_premium"] = df["price"] * df["qty"] * multiplier
    for g in ["delta","gamma","theta","vega","volga","vanna","rho","phi","deDecay"]:
        df[f"contribution_{g}"] = df[g] * df["qty"] * multiplier

    totals = {
        "net_premium": df["contribution_premium"].sum(),
        "net_delta": df["contribution_delta"].sum(),
        "net_gamma": df["contribution_gamma"].sum(),
        "net_theta": df["contribution_theta"].sum(),
        "net_vega": df["contribution_vega"].sum(),
        "net_volga": df["contribution_volga"].sum(),
        "net_vanna": df["contribution_vanna"].sum(),
        "net_rho": df["contribution_rho"].sum(),
        "net_phi": df["contribution_phi"].sum(),
        "net_deDecay": df["contribution_deDecay"].sum(),
        "multiplier": multiplier,
    }
    return df, pd.Series(totals, name="basket_totals")

# Example: buffer+cap (long put 1, short call -1)
qty = {1: +1, 2: -1}  # {okeyNumber: qty}
legs_with_contrib, basket_totals = aggregate_basket(calc_df, qty, multiplier=100)

legs_with_contrib[[
    "okeyNumber","symbol","cp","strike","expiry","qty","price",
    "contribution_premium","delta","theta","vega",
    "contribution_delta","contribution_theta","contribution_vega"
]]

basket_totals

I map a quantity to each okeyNumber, then multiply each leg’s price and Greeks by both the quantity and the contract multiplier. That produces per leg contributions for premium and for each Greek. The totals dictionary collapses those contributions to the basket level. The same function works for any number of legs, since the aggregation is column driven.


This is the output:



net_premium      1.159212
net_delta       -0.229000
net_gamma        0.033000
net_theta       -0.768000
net_vega         0.478000
net_volga        0.146000
net_vanna       -0.084000
net_rho         -0.026000
net_phi          0.026000
net_deDecay      0.142000
multiplier     100.000000

The basket costs a little over one dollar per contract set, which is tiny relative to the payoff scale. Delta is slightly negative, so the protection leg dominates near spot. Theta is negative, which is the carry you pay for the hedge. Vega is positive, so the basket benefits if volatility picks up. The remaining Greeks are small and in line with a shallow out-of-the-money structure.


3. Payoff Plot

Once the basket is priced, the next natural step is to see how it behaves at expiry. A payoff diagram makes this explicit by showing the profit or loss across a range of underlying prices. I sweep SPY from 20 percent below to 20 percent above spot and calculate the payoff of each leg, then aggregate them into both a gross payoff and a net P&L that includes premium.



qty = {1: +1, 2: -1}  
mult = 100

def leg_payoff(S, K, cp, q, mult=100):
    if cp == "Put":
        base = np.maximum(K - S, 0.0)     
    else:  # "Call"
        base = np.maximum(S - K, 0.0)     
    return q * mult * base                 

S0 = float(calc_df["uPrc"].iloc[0])
S = np.linspace(S0*0.8, S0*1.2, 201)

payoff = np.zeros_like(S)
for _, r in calc_df.iterrows():
    q = qty.get(int(r["okeyNumber"]), 0)
    payoff += leg_payoff(S, float(r["strike"]), r["cp"], q, mult)

net_premium = float((calc_df["price"] * calc_df["okeyNumber"].map(qty).fillna(0) * mult).sum())
pnl = payoff - net_premium

plt.figure(figsize=(8,5))
plt.axhline(0, lw=1, color="black")
plt.axvline(S0, lw=1, ls="--", color="gray")
plt.plot(S, payoff, label="Gross Payoff (no premium)")
plt.plot(S, pnl, label="Net P&L (after premium)")
plt.title("Buffer + Cap Payoff at Expiry")
plt.xlabel("Underlying Price at Expiry")
plt.ylabel("Payoff / P&L (per contract set)")
plt.legend(); plt.grid(True); plt.show()

Here’s the output:


ree

The structure is doing exactly what it was built for. Between the two strikes, the payoff is flat; neither leg is active. Below 450, the long put kicks in, giving downside protection. Above 575, the short call loses value, capping upside. The red line (net P&L) sits slightly below the blue line (gross payoff) due to the upfront premium. Since that premium is small, the two lines almost overlap, which is consistent with the earlier basket totals.


4. Greeks profile

Seeing the payoff is helpful, but it does not tell you how the position will react to small moves right now. For that you want the net Greeks. I aggregate the leg Greeks using the same quantities and the standard contract multiplier, then plot them so the exposures are obvious at a glance.



calc_df["qty"] = calc_df["okeyNumber"].map(qty).fillna(0)

mult = 100  

def agg(col):
    return float((calc_df[col] * calc_df["qty"] * mult).sum())

net_greeks = {
    "Delta": agg("delta"),
    "Gamma": agg("gamma"),
    "Theta": agg("theta"),
    "Vega":  agg("vega"),
    "Rho":   agg("rho"),
}

plt.figure(figsize=(6,4))
bars = plt.bar(net_greeks.keys(), net_greeks.values(), color=["steelblue","steelblue","tomato","seagreen","slategray"])
plt.axhline(0, color="black", lw=1)
plt.title("Net Greeks Profile (per contract set)")
plt.ylabel("Sensitivity")
plt.grid(axis="y", linestyle="--", alpha=0.6)
plt.show()

net_greeks

I map quantities into the leg table, multiply each leg Greek by both quantity and multiplier, and sum by column. The result is a small dictionary with the basket’s net Delta, Gamma, Theta, Vega, and Rho. The bar chart is only a convenience layer so the relative sizes are easy to compare.


This is the output:


ree

This basket is slightly short the underlying, which fits the idea of buying protection. Time decay is negative, which is the cost of carry for the hedge. Vega is positive, so the basket benefits when volatility rises. Gamma is small but positive, which reflects the convexity coming from the put. Rho is close to zero here and can be treated as a secondary effect.


From Legs to Basket

At this point, the workflow is complete. I started by defining each leg with OptionItemDef, grouped them into a basket, and triggered SpiderRock’s engine to run the calculations.


The OptionItemCalc output gave me everything I needed: leg-level prices, Greeks, and diagnostics. From there, rolling it up at the basket level showed how the position behaves as a single trade rather than two disconnected legs.


The payoff and Greeks profiles made the exposures clear, and the numbers lined up with the intuition behind the buffer + cap idea.


Conclusion

Pricing a basket of options is not just about calculating premiums. The real value comes from seeing how the legs work together as a single trade. By treating the basket as one unit, the exposures are clearer, the risks easier to interpret, and the payoff more intuitive than if each leg were analyzed in isolation.


The buffer + cap was a simple example, yet the same process extends to more complex multi-leg structures. Once the results are in a DataFrame, you can aggregate Greeks, run sensitivity checks, and plot payoffs with the same ease regardless of how many legs are involved. That flexibility is what makes basket-level pricing a practical tool in day-to-day strategy design.


What made this walkthrough straightforward was having SpiderRock’s API handle the heavy lifting. Instead of building pricing logic from scratch, the calculations came back directly as leg-level and basket-level outputs, leaving the analysis to focus on interpretation rather than implementation. It is a subtle but important distinction: the workflow lets you spend less time coding a model, and more time testing ideas and evaluating risk.

Bring information-rich articles and research works straight to your inbox (it's not that hard). 

Thanks for subscribing!

© 2023 by InsightBig. Powered and secured by Wix

bottom of page