In Part 2, we trained a neural network to price options using the Cox-Ross-Rubinstein (CRR) model. It learned to predict the fair value of an option using inputs like strike, time to expiry, interest rate, and volatility.
Now, we’ll use that trained model to do something powerful:
Scan live option chains and identify mispriced vertical call spreads — a core strategy for directional or volatility-based options trading.
This is where the theory becomes practice.
A link to this notebook and the other notebooks can be found here on GitHub
What We’re Building
We’ll use real option chain data and our neural network to:
- Fetch calls for a symbol (e.g.
PLTR) - Predict the model price for each option using the trained neural net
- Construct all possible vertical call spreads
- Compare the market spread vs the model spread
- Flag spreads with significant mispricing
Our neural net becomes a custom pricing engine — one that can flag trading opportunities based on model-vs-market discrepancies.
Step 1: Pull the Option Chain
We use Alpaca’s broker API to get the latest option quotes. We filter down to call options and compute the mid price (average of bid and ask) for each.
Step 2: Calculate Time to Expiration
Since option values depend heavily on time, we compute T (in years) for each row. We align everything to U.S. market time (Eastern).
Step 3: Predict Model Prices Using Your Trained NN
We load the trained model and predict each call option’s fair value using our NN.
from datetime import datetime
import pandas as pd
import numpy as np
import pytz
# Pull option chain and filter calls
symbol = ticker_symbol # set this variable earlier, e.g., "PLTR" or "AAPL"
option_chain_raw = fetch_option_chain(symbol, option_type="call", limit=250)
df_options = option_chain_to_df(option_chain_raw)
df_calls_live = df_calls(df_options)
# Clean and prepare
df_calls_live.dropna(subset=["implied_volatility", "bid_price", "ask_price", "expiration_date"], inplace=True)
df_calls_live["mid_price"] = (df_calls_live["bid_price"] + df_calls_live["ask_price"]) / 2
# Convert expiration_date to timezone-aware EST datetime
eastern = pytz.timezone("US/Eastern")
df_calls_live["expiration_date"] = pd.to_datetime(df_calls_live["expiration_date"], format="%y%m%d").dt.tz_localize(eastern)
# Get current time in EST
today = pd.Timestamp.now(tz="US/Eastern")
# Calculate T (time to expiration in years)
df_calls_live["T"] = (df_calls_live["expiration_date"] - today).dt.days / 365
# Define model price prediction using row-specific T
def predict_nn_price(row, S, r):
inputs = np.array([[S, row['strike_price'], row['T'], r, row['implied_volatility']]])
inputs_scaled = scaler.transform(inputs)
return model.predict(inputs_scaled)[0]
# Fetch latest price and apply predictions
S = fetch_latest_underlying_price(ticker_symbol)
r = 0.05
df_calls_live["model_price"] = df_calls_live.apply(lambda row: predict_nn_price(row, S, r), axis=1)
# Preview
df_calls_live.head()Step 4: Build All Vertical Call Spreads
We iterate through all combinations of call options with different strikes (but same expiry) and calculate:
- Market spread value = difference in mid prices
- Model spread value = difference in model prices
- Mispricing = market value – model value
spreads = []
# Add DTE if not already in DataFrame
df_calls_live["DTE"] = (df_calls_live["T"] * 365).round().astype(int)
# Sort calls by strike
calls = df_calls_live.sort_values("strike_price").reset_index(drop=True)
# Construct all vertical call spreads
for i in range(len(calls)):
for j in range(i + 1, len(calls)):
c1 = calls.iloc[i]
c2 = calls.iloc[j]
spread_width = c2["strike_price"] - c1["strike_price"]
market_spread = c1["mid_price"] - c2["mid_price"]
model_spread = c1["model_price"] - c2["model_price"]
mispricing = market_spread - model_spread
spreads.append({
"long_strike": c1["strike_price"],
"short_strike": c2["strike_price"],
"spread_width": spread_width,
"market_spread_value": market_spread,
"model_spread_value": model_spread,
"mispricing": mispricing,
"DTE": c1["DTE"] # assume same expiry
})
# Convert to DataFrame
df_spreads = pd.DataFrame(spreads)
# Add calculated metrics
df_opportunities["mispricing_pct"] = df_opportunities["mispricing"] / df_opportunities["model_spread_value"]
df_opportunities["max_profit"] = df_opportunities["spread_width"] - df_opportunities["market_spread_value"]
df_opportunities["max_loss"] = df_opportunities["market_spread_value"]
# Preview
df_opportunities.head()Step 5: Filter for Actionable Trades
We now flag verticals that look significantly mispriced — say, $0.10+ — and fall in a reasonable DTE range.
threshold = 0.10
df_opportunities = df_spreads[
(np.abs(df_spreads["mispricing"]) > threshold) &
(df_spreads["DTE"] >= 10) & (df_spreads["DTE"] <= 60) &
(df_spreads["spread_width"] >= 1) &
(df_spreads["market_spread_value"] > 0)
].copy()
df_opportunities.sort_values("mispricing", ascending=False, inplace=True)
df_opportunities.head()Example Output
| Long | Short | Spread Width | Market Value | Model Value | Mispricing | DTE |
|---|---|---|---|---|---|---|
| 20.5 | 33.0 | 12.5 | 7.37 | 1.99 | +5.38 | 17 |
| 20.0 | 23.5 | 3.5 | 3.30 | 6.95 | −3.65 | 24 |
- Positive mispricing → market is overpaying → consider selling the spread
- Negative mispricing → market is underpaying → consider buying the spread
Key Takeaways
- Neural nets trained on CRR prices can be used to screen live options data
- You can compute model vs market spreads instantly and spot edge
- This approach is scalable across tickers and DTE ranges
A link to this notebook can be found here on GitHub