Build a Real-Time Options Dashboard in an Afternoon with Python
- Nikhil Adithyan
- 24 hours ago
- 11 min read
A lean guide to deploying volatility surfaces and stress scenarios

Lately, for an internal project, a need for an options monitoring tool came up. As always, the real struggle isn’t execution but the time spent building it. Automating a daily 10-minute task in a few hours is great. Doing it in six months is useless.
Many early-stage teams spend months setting up data pipelines, cleaning datasets, and wiring basic infrastructure. By the time a prototype is ready, the original problem you wanted to validate has already shifted. What I really needed at this stage was not a perfect system but a working one that proves the tool is useful.
The goal was simple: I needed a small tool to check derivatives and monitor IV skew for any ticker in real time. Using Python, Streamlit, and market data from SpiderRock, I was able to do it in hours. Not a demo. Not a blueprint. But a first version that works.
Project Architecture
To build a responsive and interactive application, I used Streamlit. It’s a Python library built for rapid web app development, which makes it especially well-suited for proof-of-concept work and early-stage prototypes.
When designing the app, the first step is to think at a high level: how many pages are really needed? What are the core features? What data is required? How should users interact with the product? Since this was a single-feature project, I chose to keep things simple with a one-page layout and structured the project accordingly.
Project/
app.py
utils/
metrics.py
plots.py
data.py
Using Streamlit
Streamlit offers clear, well-structured documentation, and its simple syntax and unified front- and back-end philosophy enable rapid development.
This makes it an excellent choice for building a first product version quickly, especially for entrepreneurs and small startups. With a single terminal command, you can launch the app on your local network and start testing immediately.
st.set_page_config(
page_title="Welcome to Streamlit",
page_icon="👋",
)
st.sidebar.success("Congrats.")
streamlit run app.py

What the app is for
This tool came with very specific requirements. For any given ticker, maturity, and strike, I needed the ability to retrieve option market prices and immediately compute key outputs such as Greeks, Volatility Surface, Risk Scenario Outcomes, and Volatility Smile. The focus was on fast access to actionable information, without unnecessary complexity.
As with any analytical tool, raw numbers alone aren’t enough. The data needed to be presented through clear, intuitive visuals so insights could be extracted at a glance. Usability was a priority: the interface had to make exploration easy, even for users who weren’t deeply technical.
To keep development fast and maintainable, the implementation was deliberately lightweight. The entire application relies on just four Python scripts:
app.py, which serves as the entry point and launches the Streamlit interface.
data.py, responsible for fetching and preparing market data.
metrics.py, which handles Greeks and risk scenarios.
plots.py, which generates the visualizations of the volatility skew and volatility skew.
This modular structure made it easy to iterate quickly while keeping responsibilities clearly separated.
Fetching Data Using the SpiderRock MLink API
The SpiderRock MLink API provides a straightforward and efficient way to access a broad range of options data. This simplicity, combined with the depth and reliability of the dataset, is why I chose SpiderRock as the data provider for this project.
At this stage, the focus was deliberately narrow. Rather than attempting to cover the full options surface, I concentrated on extracting key option metrics such as delta, gamma, and implied volatility for at-the-money (ATM) options only. This approach reduced complexity while still delivering the most relevant information for monitoring volatility skew and option behavior in real time.
The code below shows the Python implementation used to fetch and prepare the data I used. Specifically, it walks through the data.py script, which handles the connection to the SpiderRock MLink API, queries the required option contracts, and formats the output so it can be easily consumed by the rest of the application.
import requests
API_KEY = "YOUR API KEY"
MLINK_REST_URL = "https://mlink-live.nms.saturn.spiderrockconnect.com/rest/json"
def fetch_live_implied_quote_df(api_key: str, where: str, limit: int = 100):
params = {
"apiKey": api_key,
"cmd": "getmsgs",
"msgType": "LiveImpliedQuote",
"where": where,
"limit": limit,
}
response = requests.get(MLINK_REST_URL, params=params)
response = response.json()
return response
Selecting the ticker and the maturity
To allow users to specify the ticker of interest and select the maturity and strike of the option, I implemented a simple and intuitive input mechanism directly in the interface. The goal was to make parameter selection frictionless so users could quickly explore different underlyings without navigating unnecessary complexity.
The following code snippet shows how this functionality is implemented in practice, forming the entry point for user-driven data exploration within the app. This is the first part of theapp.py script.
import streamlit as st
import utils.data as data
import utils.plots as plots
import utils.metrics as metrics
st.set_page_config(
page_title="Option Monitoring Tool",
layout="wide",
initial_sidebar_state="collapsed"
)
st.title("Option Monitoring Tool")
st.write("Ticker Informations")
ticker = st.text_input("Ticker").strip()
if not ticker:
st.write("Please select a ticker")
st.stop()
else:
st.write('Option Informations')
col1, col2, col3 = st.columns([1, 1, 1])
MAX_LIMIT = 1000
response = data.fetch_live_implied_quote_df(f"ticker:eq:{ticker}", limit=MAX_LIMIT)
possible_maturities = sorted(list(set([response[t]['message']['years'] for t in range(0, MAX_LIMIT)])))
possible_maturities_rounded = [round(t * 365.25, 3) for t in possible_maturities]
mapping = {rounded:original for rounded, original in zip(possible_maturities_rounded, possible_maturities)}
with col1:
maturity = st.selectbox(label='Select Maturity (days)', options=possible_maturities_rounded)
with col2:
option_type = st.selectbox("Call or Put", ['Call', 'Put'],)
subset = [response[i]['message'] for i in range(0, MAX_LIMIT) if response[i]["message"]["years"] == mapping[maturity] ]
subset = [sub for sub in subset if sub.get("pkey").get('okey').get('cp') == option_type ]
strikes = []
for r in subset:
strikes.append(r.get("pkey").get('okey').get('xx'))
strikes_unique = sorted(set(strikes))
with col3:
strike_selected = st.selectbox("Select Strike", strikes_unique)
streamlit run app.py

This implementation enables dynamic data retrieval based on user input: once a ticker is selected, the application automatically queries the relevant option chain, identifies the corresponding contracts, and updates the displayed metrics in real time. By handling this logic transparently in the background, the user experience remains clean and responsive while still offering enough flexibility for meaningful analysis.
Once the ticker is selected and all relevant option contracts are fetched, the user can choose the maturity they are interested in, the kind of option (call or put), and the strike among the available ones.
The application then identifies and displays the corresponding Greeks and other key metrics. This approach ensures that users can quickly focus on the most relevant contracts and view actionable information without being overwhelmed by the full option chain.
The following code snippet shows how this functionality is implemented in practice, forming the entry point for user-driven data exploration within the app. This is the first part of the app.py script.
Live Monitoring
For B2B applications, having access to the latest data is critical. You can’t afford a situation where someone on your team is working with information that’s 10 hours old simply because the page doesn’t refresh automatically.
This is the kind of essential feature that must be implemented even in a proof-of-work or V0 version, as its absence can have real costs. Fortunately, Streamlit’s experimental st.experimental_rerun and auto-refresh capabilities make adding this functionality elementary: just a two-line implementation that does the job.
The Greeks
After establishing a smooth user experience and providing a clear way to select the ticker and option maturity, the next step was to present the option Greeks in a clear and accessible way. Properly displaying these key metrics is essential for quickly understanding an option’s behavior.
To achieve this, I implemented a display_greeks function in the metrics.py file. This function takes the computed Greeks by SpiderRock for the selected option and formats them neatly for the user interface, ensuring that the information is both readable and actionable.
import pandas as pd
def display_greeks(response, t):
greeks = response[t]["message"]
st.subheader("Option Greeks")
col1, col2, col3, col4 = st.columns(4)
# Delta
delta = greeks['de']
col1.metric(label="Δ Delta", value=f"{delta:.4f}")
with col1.expander("ℹ️ About Delta"):
st.markdown(
"""
**Delta** measures how much the option price changes
when the underlying asset price moves by 1 unit.
- Positive → **Call option**
- Negative → **Put option**
- Closer to ±1 → **More responsive** to price changes
"""
)
if delta > 0.7:
col1.markdown("<span style='color:green'>High Delta – very sensitive</span>", unsafe_allow_html=True)
elif delta < 0.3:
col1.markdown("<span style='color:red'>Low Delta – less sensitive</span>", unsafe_allow_html=True)
# Gamma
gamma = greeks['ga']
col2.metric(label="Γ Gamma", value=f"{gamma:.4f}")
with col2.expander("ℹ️ About Gamma"):
st.markdown(
"""
**Gamma** measures the rate of change of Delta
with respect to the underlying price.
- High Gamma → Delta changes **rapidly**
- Important for **hedging stability**
"""
)
# Theta
theta = greeks['th']
col3.metric(label="Θ Theta", value=f"{theta:.4f}")
with col3.expander("ℹ️ About Theta"):
st.markdown(
"""
**Theta** measures **time decay** - how much value
an option loses each day as expiration approaches.
- Typically **negative** for long options
- Accelerates as maturity nears
"""
)
# Vega
vega = greeks['ve']
col4.metric(label="V Vega", value=f"{vega:.4f}")
with col4.expander("ℹ️ About Vega"):
st.markdown(
"""
**Vega** measures sensitivity to **volatility changes**.
- High Vega → price moves strongly with volatility shifts
- Crucial for **volatility trading**
"""
)

Risk Scenarios
The next step was to understand how the option behaves under different market moves. Option value changes are not linear regarding the underlying. Here, I needed to emphasize practical risk scenarios.
To achieve this, I implemented a monitor_risk function in the metrics.py file. This function takes scenario-based option valuations (computed by SpiderRock) and presents the expected option value and P&L impact for predefined price shocks, such as +15%, −15%, or −50% moves in the underlying.
Each scenario is formatted clearly for the user interface, highlighting both the absolute option value and the relative change from the current price. By organizing the results into an easy-to-scan layout, the function ensures that users can quickly assess downside risk, upside potential, and tail-risk exposure, making the information immediately actionable for decision-making.
def monitor_risk(response, t):
st.subheader("📊 Risk Monitoring: P&L under Stress Scenarios")
scenarios = {
"Underlying +50%": response[t]['message'].get("up50"),
"Underlying -50%": response[t]['message'].get("dn50"),
"Underlying +15%": response[t]['message'].get("up15"),
"Underlying -15%": response[t]['message'].get("dn15"),
"Underlying +6%": response[t]['message'].get("up06"),
"Underlying -8%": response[t]['message'].get("dn08")
}
scenarios = {k: v for k, v in scenarios.items() if v is not None}
if not scenarios:
st.info("No stress data available for this option.")
return
df = pd.DataFrame(list(scenarios.items()), columns=["Scenario", "Δ Option Value"])
df["Δ Option Value"] = df["Δ Option Value"].astype(float)
df["Move"] = df["Scenario"].apply(lambda s: float(re.search(r"[-+]?\d+\.?\d*", s).group()))
df = df.sort_values("Move").reset_index(drop=True)
fig = px.bar(
df,
x="Δ Option Value",
y="Scenario",
orientation="h",
color="Δ Option Value",
color_continuous_scale="RdYlGn",
title="Option Value Change by Scenario",
text=df["Δ Option Value"].map(lambda v: f"{v:.2f}")
)
fig.update_layout(
xaxis_title="P&L Change",
yaxis_title="",
coloraxis_showscale=False,
template="plotly_white",
height=400,
margin=dict(l=60, r=100, t=60, b=40),
xaxis=dict(
automargin=True,
range=[
min(df["Δ Option Value"]) * 1.2,
max(df["Δ Option Value"]) * 1.2
]
)
)
fig.update_traces(
textposition="auto",
cliponaxis=False
)
st.plotly_chart(fig, use_container_width=True)

Implied Volatility Sensibility
Once this was in place, I also wanted to get a sense of how the derivatives behaved regarding option maturity and moneyness, specifically looking at implied volatility (IV). Visualizing this relationship helps identify patterns such as volatility skew or term structure dynamics that are difficult to grasp from raw numbers alone.
To achieve this, I used basic plots implemented with the Plotly library, which adds interactivity and responsiveness to the charts. This allows users to hover over points, zoom in on specific maturities, and explore the data dynamically, making the analysis much more intuitive and insightful.
What did I need? The sensibility of ATM implied volatility regarding the maturity, the volatility smile, and the volatility surface. Volatility smile and volatility surface plots require interpolation and smoothing that can be done easily using SciPy. I implemented the following functions in the plots.py file:
import plotly.graph_objects as go
from scipy.interpolate import griddata
import numpy as np
def plot_iv_by_maturity_plotly(data):
st.subheader('ATM Implied Volatility Analysis by Maturity')
x = sorted(data.keys())
y = [data[key] for key in x]
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=x,
y=y,
mode="lines+markers",
name="ATM IV",
marker=dict(size=7),
line=dict(width=2),
hovertemplate="Maturity: %{x}<br>IV: %{y:.2%}<extra></extra>"
)
)
fig.update_layout(
xaxis=dict(
title="Maturity in years",
showgrid=True,
gridcolor="rgba(200,200,200,0.3)",
zeroline=False
),
yaxis=dict(
title="Implied Volatility",
tickformat=".0%",
showgrid=True,
gridcolor="rgba(200,200,200,0.3)",
zeroline=False
),
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1
),
hovermode="x unified",
margin=dict(l=40, r=40, t=70, b=40),
template="plotly_white"
)
st.plotly_chart(fig, use_container_width=True)
def plot_iv_surface_plotly(x, y, z):
st.subheader('Implied Volatility Surface')
xi = np.linspace(min(x), max(x), 50)
yi = np.linspace(min(y), max(y), 50)
Xi, Yi = np.meshgrid(xi, yi)
Zi = griddata((x, y), z, (Xi, Yi), method='cubic')
fig = go.Figure(data=[
go.Surface(
x=Xi,
y=Yi,
z=Zi,
colorscale="Viridis",
colorbar=dict(title="IV", tickformat=".0%"),
hovertemplate="Maturity: %{x:.2f}y<br>Strike: %{y}<br>IV: %{z:.2%}<extra></extra>"
)
])
fig.update_layout(
scene=dict(
xaxis=dict(title="Maturity (years)", gridcolor="rgba(200,200,200,0.3)"),
yaxis=dict(title="Moneyness", gridcolor="rgba(200,200,200,0.3)"),
zaxis=dict(title="Implied Volatility", tickformat=".0%", gridcolor="rgba(200,200,200,0.3)")
),
margin=dict(l=40, r=40, t=70, b=40),
template="plotly_white",
height=700
)
st.plotly_chart(fig, use_container_width=True)
def plot_volatility_smile(strikes, iv_values, maturity=None):
strikes = np.array(strikes, dtype=float)
iv_values = np.array(iv_values, dtype=float)
mask = ~np.isnan(strikes) & ~np.isnan(iv_values)
strikes, iv_values = strikes[mask], iv_values[mask]
if len(strikes) < 3:
st.warning("Not enough data points for quadratic interpolation.")
return
sort_idx = np.argsort(strikes)
strikes = strikes[sort_idx]
iv_values = iv_values[sort_idx]
coeffs = np.polyfit(strikes, iv_values, deg=6)
poly = np.poly1d(coeffs)
strike_min, strike_max = min(strikes), max(strikes)
range_reduction = 0.1 * (strike_max - strike_min)
strikes_smooth = np.linspace(
strike_min + range_reduction,
strike_max - range_reduction,
200
)
iv_smooth = poly(strikes_smooth)
iv_smooth = np.clip(iv_smooth, np.min(iv_values) * 0.8, np.max(iv_values) * 1.2)
title = (
f"Implied Volatility Smile - Maturity {maturity:.2f} years"
if maturity is not None
else "Implied Volatility Smile"
)
st.subheader(title)
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=strikes,
y=iv_values,
mode="markers",
name="Observed IV",
marker=dict(size=7, color="royalblue"),
hovertemplate="Strike: %{x}<br>IV: %{y:.2%}<extra></extra>"
)
)
fig.add_trace(
go.Scatter(
x=strikes_smooth,
y=iv_smooth,
mode="lines",
name="Interpolated Fit",
line=dict(width=2, color="orange"),
hovertemplate="Strike: %{x}<br>IV: %{y:.2%}<extra></extra>"
)
)
fig.update_layout(
xaxis=dict(
title="Strike / Moneyness",
showgrid=True,
gridcolor="rgba(200,200,200,0.3)",
zeroline=False
),
yaxis=dict(
title="Implied Volatility",
tickformat=".0%",
showgrid=True,
gridcolor="rgba(200,200,200,0.3)",
zeroline=False
),
margin=dict(l=30, r=30, t=50, b=40),
template="plotly_white",
hovermode="x unified",
height=450,
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1
)
)
st.plotly_chart(fig, use_container_width=True)

The volatility surface adds a view of how implied volatility behaves across both moneyness and maturity at the same time. For internal users, this unified visualization makes it easier to quickly spot irregularities and areas where the options implied volatility is abnormally high or low. By presenting the full volatility landscape in a single interactive view, the surface helps streamline exploratory analysis, supports faster validation of assumptions, and reduces cognitive load when assessing option behavior across the grid.

The Full Script
To generate all of these results, the app.py script itself is surprisingly simple. It serves as the entry point of the application and ties together the data fetching, metric calculations, and visualizations. In essence, app.py coordinates the workflow, but the heavy lifting is handled by the other modular scripts (data.py, metrics.py, and plots.py). The structure is straightforward, making it easy to maintain and extend.
import streamlit as st
import utils.data as data
import utils.plots as plots
import utils.metrics as metrics
st.set_page_config(
page_title="Option Monitoring Tool",
layout="wide",
initial_sidebar_state="collapsed"
)
st.title("Option Monitoring Tool")
st.write("Ticker Informations")
ticker = st.text_input("Ticker").strip()
if not ticker:
st.write("Please select a ticker")
st.stop()
else:
st.write('Option Informations')
col1, col2, col3 = st.columns([1, 1, 1])
MAX_LIMIT = 1000
response = data.fetch_live_implied_quote_df(f"ticker:eq:{ticker}", limit=MAX_LIMIT)
possible_maturities = sorted(list(set([response[t]['message']['years'] for t in range(0, MAX_LIMIT)])))
possible_maturities_rounded = [round(t * 365.25, 3) for t in possible_maturities]
mapping = {rounded:original for rounded, original in zip(possible_maturities_rounded, possible_maturities)}
with col1:
maturity = st.selectbox(label='Select Maturity (days)', options=possible_maturities_rounded)
with col2:
option_type = st.selectbox("Call or Put", ['Call', 'Put'],)
subset = [response[i]['message'] for i in range(0, MAX_LIMIT) if response[i]["message"]["years"] == mapping[maturity] ]
subset = [sub for sub in subset if sub.get("pkey").get('okey').get('cp') == option_type ]
strikes = []
for r in subset:
strikes.append(r.get("pkey").get('okey').get('xx'))
strikes_unique = sorted(set(strikes))
# --- Step 5. User selects strike ---
with col3:
strike_selected = st.selectbox("Select Strike", strikes_unique)
for t in range(MAX_LIMIT):
if response[t]['message']['years'] == mapping[maturity]:
if response[t]['message'].get("pkey").get('okey').get('cp') == option_type:
if response[t]['message'].get("pkey").get('okey').get('xx') == strike_selected:
break
metrics.display_greeks(response, t)
metrics.monitor_risk(response, t)
st.write("")
st.write("")
ivol_data = {response[t]['message']['years']:response[t]['message']['atmVol'] for t in range(MAX_LIMIT)}
col1, col2 = st.columns([1, 1])
with col1:
plots.plot_iv_by_maturity_plotly(ivol_data)
subset = [response[i]['message'] for i in range(0, MAX_LIMIT)]
subset = [sub for sub in subset if sub.get("pkey").get('okey').get('cp') == option_type]
coord = {}
for s in subset:
if s['xAxis'] in coord.keys():
coord[float(s['xAxis'])] = (coord[s['xAxis']] + s['sVol']) / 2
else:
coord[float(s['xAxis'])] = s['sVol']
x, y = [key for key in coord.keys()], [coord[key] for key in coord.keys()]
with col2:
plots.plot_volatility_smile(x, y)
subset = [response[i]['message'] for i in range(0, MAX_LIMIT)]
subset = [sub for sub in subset if sub.get("pkey").get('okey').get('cp') == option_type]
x, y, z = [], [], []
for s in subset:
x.append(s['xAxis'])
y.append(s['years'])
z.append(s['sVol'])
_, col, _ = st.columns([0.25, 1, 0.25])
with col:
plots.plot_iv_surface_plotly(x, y, z)
Final Output
Some Final Words
Theory and business are two very different things. When building a new feature or an internal tool, what really matters is getting things done well and fast. As Elon Musk often emphasizes, optimization comes last; first you build, then iterate, again and again.
“The most common mistake of a smart engineer is to optimize a thing that should not exist”
This project is just the first seed, a minimal but working version of a tool that could grow as the needs of the business evolve. Tools like Streamlit and data providers like SpiderRock make this possible: Streamlit lets you turn Python scripts into interactive apps in hours, not months, while SpiderRock provides reliable, ready-to-use market data. Together, they allow you to quickly prototype, test, and monitor key metrics without getting bogged down in infrastructure.
In short, with the right tools, you can focus on solving the problem instead of spending months building the system, and that’s what makes innovation actually happen.