note: monitor debit spread
Options traders often start with a simple question. Can a small amount of capital be turned into consistent weekly profits by selecting appropriate option spreads? We can look at two Python scripts designed to explore these ideas. One monitors an existing bull call spread position, and another scans available strikes to suggest spreads based on risk and reward.
The first script, nvda_spread_monitor.py, is meant to simulate checking a position exactly as if you were using a brokerage app. It records the entry prices for the long and short strikes, fetches current option prices from an API, and computes the current spread value along with mark-to-market profit and loss, breakeven, maximum profit, and maximum loss. Importantly, it does not assume a new entry; it simply compares current market prices to the prices paid when the position was opened. If the P/L per share is positive, it reflects gains relative to the original entry, not a hypothetical new trade.
The second script, call_spread_scanner.py, evaluates the current option chain and ranks potential spreads by metrics such as debit, maximum profit, width, implied volatility, probability of the short strike finishing in the money, and an overall score. This script is forward-looking, offering guidance on what spread might be optimal if a position were opened at the present moment. Comparing the actual trade to the suggested spreads can help visualize the efficiency of the entry relative to current market conditions.
Using these scripts to monitor positions or select spreads does not generate alpha on its own. Alpha is defined as persistent, repeatable outperformance that is not explained by market risk or chance. The market already prices in expected earnings outcomes, volatility expectations, order flow, and historical patterns. A one-off prediction, such as anticipating an earnings beat because the underlying dipped the day before the report, may make money but does not constitute alpha. Alpha requires measurable predictive power across many events, outperforming probabilities implied by volatility and option pricing.
Many traders imagine that a weekly spread with positive expected value could translate into a high annual return. But, in efficient options markets, the expected value of a call spread held to expiration is usually negative or very close to zero. The spread price already incorporates the market’s risk-neutral distribution and any volatility risk premium. Transaction costs accumulate weekly, and the law of large numbers ensures that if expected value is negative, repeated trades will converge to losses. If expected value is zero, the outcome will fluctuate randomly around breakeven. Achieving annualized returns above 10 percent without an edge is highly unlikely, which explains why most professional funds aim for modest, risk-adjusted returns rather than exploiting simple weekly strategies.
Despite not generating alpha on their own, these scripts are useful for learning how option positions behave, exploring different structures, and building intuition grounded in numbers. Monitoring shows how P/L evolves over time, while the scanner highlights how risk and reward vary with strike selection and volatility. They also lay the groundwork for a more sophisticated research process involving historical backtesting, event-driven modeling, and volatility forecasting. When paired with statistical validation and rigorous testing, these tools can become part of a framework capable of detecting alpha.
The next step for someone interested in turning a weekly call spread into a repeatable, statistically justified strategy is to gather historical data, backtest spreads across many events, and evaluate metrics like expected value, probability of profit, and exposure to volatility. Only with systematic analysis can intuition about price movements be converted into evidence-based strategies that have the potential to outperform the market over time.
#!/usr/bin/env python3
"""
Minimal NVDA bull-call spread monitor.
Purpose:
EXACTLY mimic what you'd see if you bought this spread on Robinhood.
No Heston, no FFT, no Black Scholes models, no plots.
Just:
- fetch NVDA underlying
- fetch option-chain mids
- compute mark-to-market
- compute P/L
- print in Robinhood-like layout
"""
import math
import time
import datetime as dt
import numpy as np
import pandas as pd
import pytz
import csv
import os
import yfinance as yf
# ---------------- User configuration ----------------
TICKER = "NVDA"
# Your exact spread (bought 11/18 ~7:45pm PT)
K_LONG = 197.5
K_SHORT = 207.5
ENTRY_LONG = 1.71
ENTRY_SHORT = 0.59
ENTRY_NET = ENTRY_LONG - ENTRY_SHORT # 1.12
CONTRACTS = 1
SHARES_PER_CONTRACT = 100
LOG_CSV = "nvda_spread_log.csv"
RUN_LOOP = False
LOOP_SECONDS = 30
S0_FALLBACK = 185.00 # used only if data fetch fails
# --------- Helpers ----------
def mid_from_row(row):
bid = row.get("bid", float("nan"))
ask = row.get("ask", float("nan"))
last = row.get("lastPrice", float("nan"))
if not math.isnan(bid) and not math.isnan(ask) and ask > 0:
return float((bid + ask) / 2.0)
if not math.isnan(last) and last > 0:
return float(last)
return None
def fetch_snapshot():
now_utc = dt.datetime.now(pytz.UTC)
t = yf.Ticker(TICKER)
# ------------- underlying -------------
try:
fi = t.fast_info
S = fi.get("last_price", None)
if S is None:
hist = t.history(period="1d", interval="1m")
S = float(hist["Close"].iloc[-1])
except Exception:
S = S0_FALLBACK
# ------------- next expiry -------------
expiry = None
try:
opts = t.options
today = now_utc.date()
for dstr in opts:
ddate = dt.datetime.strptime(dstr, "%Y-%m-%d").date()
if ddate >= today and ddate.weekday() == 4:
expiry = dstr
break
if expiry is None:
expiry = opts[0]
except Exception:
expiry = None
# ------------- mids -------------
observed_long_mid = None
observed_short_mid = None
if expiry is not None:
try:
chain = t.option_chain(expiry).calls
row_long = chain.iloc[(np.abs(chain["strike"] - K_LONG)).argmin()].to_dict()
row_short = chain.iloc[(np.abs(chain["strike"] - K_SHORT)).argmin()].to_dict()
observed_long_mid = mid_from_row(row_long)
observed_short_mid = mid_from_row(row_short)
except Exception:
pass
if observed_long_mid is not None and observed_short_mid is not None:
mark_net = observed_long_mid - observed_short_mid
else:
mark_net = None
# ------------- P/L -------------
if mark_net is None:
pnl_per_share = None
pnl_total = None
else:
pnl_per_share = mark_net - ENTRY_NET
pnl_total = pnl_per_share * SHARES_PER_CONTRACT * CONTRACTS
# ------------- time to expiry -------------
if expiry is not None:
exp_date = dt.datetime.strptime(expiry, "%Y-%m-%d")
ny = pytz.timezone("America/New_York")
exp_dt_ny = ny.localize(dt.datetime.combine(exp_date.date(), dt.time(16,0)))
exp_dt_utc = exp_dt_ny.astimezone(pytz.UTC)
tte = max((exp_dt_utc - now_utc).total_seconds(), 0) / (365*24*3600)
else:
tte = 0.0
snap = {
"timestamp": now_utc.isoformat(),
"S": S,
"expiry": expiry,
"tte": tte,
"long_mid": observed_long_mid,
"short_mid": observed_short_mid,
"mark_net": mark_net,
"pnl_per_share": pnl_per_share,
"pnl_total": pnl_total,
"entry_net": ENTRY_NET,
"breakeven": K_LONG + ENTRY_NET,
"max_profit": (K_SHORT - K_LONG - ENTRY_NET) * 100,
"max_loss": ENTRY_NET * 100,
}
return snap
def print_snapshot(s):
print("===== NVDA Bull Call Spread =====")
print("Time (UTC):", s["timestamp"])
print(f"NVDA price: ${s['S']:.2f}")
print(f"Expiry: {s['expiry']} (TTE: {s['tte']*365:.2f} days)")
print(f"\nLong {K_LONG}C mid = {s['long_mid']}")
print(f"Short {K_SHORT}C mid = {s['short_mid']}")
print(f"\nSpread mark: {s['mark_net']}")
print(f"Entry net: {s['entry_net']:.2f}")
print("\n--> P/L per share: ", s['pnl_per_share'])
print("--> P/L per contract:", s['pnl_total'])
print("\nMax profit: $", s["max_profit"])
print("Max loss: $", s["max_loss"])
print("Breakeven: ", s["breakeven"])
print("=================================\n")
def append_log(s):
head = ["timestamp","S","expiry","tte","long_mid","short_mid",
"mark_net","entry_net","pnl_per_share","pnl_total",
"max_profit","max_loss","breakeven"]
exists = os.path.exists(LOG_CSV)
with open(LOG_CSV,"a",newline="") as f:
w = csv.writer(f)
if not exists:
w.writerow(head)
w.writerow([s[k] for k in head])
# --------- Main ----------
def main(loop=False):
snap = fetch_snapshot()
print_snapshot(snap)
append_log(snap)
if loop:
print("Monitoring... Ctrl+C to stop.\n")
try:
while True:
time.sleep(LOOP_SECONDS)
snap = fetch_snapshot()
print_snapshot(snap)
append_log(snap)
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main(loop=RUN_LOOP)
===== NVDA Bull Call Spread ===== Time (UTC): 2025-11-20T14:54:21.381647+00:00 NVDA price: $195.50 Expiry: 2025-11-21 (TTE: 1.25 days) Long 197.5C mid = 2.72 Short 207.5C mid = 0.98 Spread mark: 1.7400000000000002 Entry net: 1.12 --> P/L per share: 0.6200000000000001 --> P/L per contract: 62.000000000000014 Max profit: $ 887.9999999999999 Max loss: $ 112.00000000000001 Breakeven: 198.62 =================================
#!/usr/bin/env python3
"""
strike_scanner.py
Scan option chain to suggest bull-call spreads.
Usage:
- edit CONFIG below (ticker, expiry, scan widths, risk prefs)
- run: python strike_scanner.py
Outputs:
- CSV "scan_results.csv"
- printed ranked suggestions
Notes:
- Uses yfinance for option chain (public data, may be delayed).
- Uses implied vol from chain when present, else historical vol fallback.
- Probability calculations assume log-normal returns (Black-Scholes world).
"""
import math, datetime as dt
import numpy as np
import pandas as pd
from scipy.stats import norm
import yfinance as yf
# ---------------- CONFIG ----------------
TICKER = "NVDA"
# expiry: set to None to auto-select nearest Friday >= today, or set to "YYYY-MM-DD"
EXPIRY = None
# scan widths (distance between K_short and K_long)
SPREAD_WIDTHS = [5.0, 10.0] # dollars; e.g., 5 and 10 point wide spreads
# candidate long strikes offset below short (we'll search K_short then set K_long = K_short - width)
MIN_SHORT_DELTA = 0.15 # option price increase by $0.15 if stock goes up by $1
MAX_SHORT_DELTA = 0.35
MAX_DEBIT_PER_CONTRACT = 200 # maximum you're willing to pay (dollars)
MIN_PROB_PROFIT = 0.15 # minimum probability of profit (by your preference)
CONTRACTS = 1
R = 0.02 # risk-free annual rate assumption
Q = 0.0 # dividend yield
HIST_VOL_LOOKBACK_DAYS = 60
OUTPUT_CSV = "scan_results.csv"
# ---------------- Utilities ----------------
def bs_call_price(S, K, r, q, sigma, t):
if t <= 0 or sigma <= 0:
return max(S - K, 0.0)
d1 = (math.log(S / K) + (r - q + 0.5 * sigma ** 2) * t) / (sigma * math.sqrt(t))
d2 = d1 - sigma * math.sqrt(t)
return S * math.exp(-q * t) * norm.cdf(d1) - K * math.exp(-r * t) * norm.cdf(d2)
def bs_delta_call(S, K, r, q, sigma, t):
if t <= 0 or sigma <= 0:
return 1.0 if S > K else 0.0
d1 = (math.log(S / K) + (r - q + 0.5 * sigma ** 2) * t) / (sigma * math.sqrt(t))
return math.exp(-q * t) * norm.cdf(d1)
def prob_ITM(S, K, r, q, sigma, t):
# P(S_T > K) under risk-neutral lognormal model
# compute z = (ln(K/S) - (r - q - 0.5*sigma^2)T) / (sigma sqrt(T))
if t <= 0 or sigma <= 0:
return 1.0 if S > K else 0.0
num = math.log(K / S) - (r - q - 0.5 * sigma ** 2) * t
den = sigma * math.sqrt(t)
z = num / den
return 1.0 - norm.cdf(z)
# choose expiry logic
def choose_expiry(ticker_obj, prefer_friday=True):
opts = ticker_obj.options
today = dt.datetime.now().date()
if EXPIRY:
return EXPIRY
if prefer_friday:
for dstr in opts:
ddate = dt.datetime.strptime(dstr, "%Y-%m-%d").date()
if ddate >= today and ddate.weekday() == 4:
return dstr
# fallback earliest >= today
for dstr in opts:
ddate = dt.datetime.strptime(dstr, "%Y-%m-%d").date()
if ddate >= today:
return dstr
return opts[0] if opts else None
# compute implied vol if available on row (yfinance gives 'impliedVol' sometimes)
def row_implied_vol(row):
iv = row.get("impliedVol", None)
if iv is None or (isinstance(iv, float) and np.isnan(iv)):
return None
return float(iv)
# fallback historical vol (annualized) using close returns
def historical_vol(ticker_obj, days=HIST_VOL_LOOKBACK_DAYS):
try:
hist = ticker_obj.history(period=f"{days}d")["Close"].dropna()
if len(hist) < 2:
return None
rets = np.log(hist / hist.shift(1)).dropna()
sigma = rets.std(ddof=1) * np.sqrt(252) # annualized
return float(sigma)
except Exception:
return None
# ---------------- Scanner ----------------
def scan_spreads(ticker=TICKER):
t = yf.Ticker(ticker)
# underlying
try:
S = t.fast_info.get("last_price", None)
except Exception:
S = None
if S is None:
S = float(t.history(period="1d", interval="1m")["Close"].iloc[-1])
expiry = choose_expiry(t)
if expiry is None:
raise RuntimeError("No expiries available for ticker.")
chain = t.option_chain(expiry)
calls = chain.calls.copy()
# ensure strikes sorted
calls = calls.sort_values("strike").reset_index(drop=True)
# compute time to expiry in years
exp_dt = dt.datetime.strptime(expiry, "%Y-%m-%d")
# assume expiry at 16:00 ET
ny = dt.timezone(dt.timedelta(0)) if False else None # dummy: we will compute simple days
now = dt.datetime.utcnow()
time_to_exp = max((dt.datetime(exp_dt.year, exp_dt.month, exp_dt.day, 16) - now).total_seconds(), 0) / (365.0 * 24 * 3600)
if time_to_exp <= 0:
time_to_exp = 2.0 / 365.0
# get historical vol fallback
hist_vol = historical_vol(t) or 0.5
# prepare rows for each strike: mid, iv (use row iv or fallback = hist_vol)
rows = []
for _, r in calls.iterrows():
strike = float(r["strike"])
bid = r.get("bid", np.nan)
ask = r.get("ask", np.nan)
last = r.get("lastPrice", np.nan)
mid = None
if not np.isnan(bid) and not np.isnan(ask) and (ask > 0):
mid = 0.5 * (bid + ask)
elif not np.isnan(last) and last > 0:
mid = float(last)
else:
# fallback compute theoretical mid by BS with hist_vol
mid = bs_call_price(S, strike, R, Q, hist_vol, time_to_exp)
iv = row_implied_vol(r) or hist_vol
delta = bs_delta_call(S, strike, R, Q, iv, time_to_exp)
prob = prob_ITM(S, strike, R, Q, iv, time_to_exp)
rows.append({"strike": strike, "mid": mid, "iv": iv, "delta": delta, "prob": prob})
df = pd.DataFrame(rows).drop_duplicates(subset="strike").sort_values("strike").reset_index(drop=True)
# scanning candidate shorts and widths
results = []
for width in SPREAD_WIDTHS:
for _, short_row in df.iterrows():
K_short = short_row["strike"]
# pick K_long = K_short - width
K_long = round(K_short - width, 2)
if K_long <= 0:
continue
# find nearest available long row in df
long_candidates = df[df["strike"] <= K_long + 1e-6]
if len(long_candidates) == 0:
continue
long_row = long_candidates.iloc[(np.abs(long_candidates["strike"] - K_long)).argmin()]
# premiums (per share)
prem_long = float(long_row["mid"])
prem_short = float(short_row["mid"])
debit = prem_long - prem_short
if debit <= 0:
# skip credit spreads (we want debit bull-call)
continue
# per contract values
debit_contract = debit * 100
max_profit = (K_short - K_long - debit) * 100
max_loss = debit_contract
breakeven = K_long + debit
# probability of profit (approx): P(S_T > breakeven) under lognormal approx
prob_profit = prob_ITM(S, breakeven, R, Q, short_row["iv"], time_to_exp)
# short delta (proxy)
short_delta = short_row["delta"]
# only include reasonable short deltas
if short_delta < MIN_SHORT_DELTA or short_delta > MAX_SHORT_DELTA:
continue
# filter by debit, prob
if debit_contract > MAX_DEBIT_PER_CONTRACT:
continue
if prob_profit < MIN_PROB_PROFIT:
continue
# score: expected payoff approx = P(S_T>K_short)*max_profit - (1-P)*max_loss ? simpler: (prob_profit * max_profit) / max_loss
# more conservative score: ratio of expected profit (using prob of surpassing short) to max_loss
p_short_itm = prob_ITM(S, K_short, R, Q, short_row["iv"], time_to_exp)
expected_payoff = p_short_itm * max_profit - (1 - p_short_itm) * max_loss
# normalized score
score = expected_payoff / max_loss if max_loss > 0 else -999
results.append({
"K_long": K_long,
"K_short": K_short,
"width": width,
"prem_long": prem_long,
"prem_short": prem_short,
"debit_per_share": debit,
"debit_per_contract": debit_contract,
"max_profit": max_profit,
"max_loss": max_loss,
"breakeven": breakeven,
"prob_profit": prob_profit,
"prob_short_itm": p_short_itm,
"short_delta": short_delta,
"short_iv": short_row["iv"],
"score": score
})
res_df = pd.DataFrame(results)
if res_df.empty:
print("No candidate spreads passed the filters. Try expanding delta range, increasing max debit, or lowering min probability.")
return res_df, df, S, expiry, time_to_exp
res_df = res_df.sort_values(["score", "prob_profit"], ascending=[False, False]).reset_index(drop=True)
# Save CSV
res_df.to_csv(OUTPUT_CSV, index=False)
return res_df, df, S, expiry, time_to_exp
# ---------------- Example run ----------------
if __name__ == "__main__":
res_df, chain_df, S, expiry, T = scan_spreads(TICKER)
print(f"Underlying S0 = {S:.2f}, expiry = {expiry}, T ≈ {T*365:.2f} days")
if res_df is not None and not res_df.empty:
print("Top spread suggestions (sorted by score):")
print(res_df.head(10).to_string(index=False))
else:
print("No spreads found with current filters.")
/tmp/ipython-input-843127176.py:126: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). now = dt.datetime.utcnow()
Underlying S0 = 194.38, expiry = 2025-11-21, T ≈ 1.05 days Top spread suggestions (sorted by score): K_long K_short width prem_long prem_short debit_per_share debit_per_contract max_profit max_loss breakeven prob_profit prob_short_itm short_delta short_iv score 192.5 197.5 5.0 4.25 2.72 1.53 153.0 347.0 153.0 194.03 0.532766 0.215717 0.221737 0.380661 -0.295041
- ← Previous
note: betting against black markets - Next →
note: debit spread alpha