Strategy: Vertical Spreads¶
Bull call spread and bear call spread templates with payoff charts.
Part 09 of 37 in the ServLoci algo/options trading notebook series — full index in notebooks/README.md.
Setup¶
# Get your dedicated static IPv6 + SOCKS5 credentials free:
# https://comm.servloci.in/register (or /auth/google?free=1 for an instant trial)
# Your api_key / api_secret pair shows up in the portal after signup:
# https://comm.servloci.in/user
!pip install -q "requests[socks]"
!curl -sL https://comm.servloci.in/sdk/servloci.py -o servloci.py
import os
from servloci import ServLoci
SERVLOCI_API_KEY = os.environ.get("SERVLOCI_API_KEY", "dhan:1000000001") # broker:client_id
SERVLOCI_API_SECRET = os.environ.get("SERVLOCI_API_SECRET", "") # from the portal — leave blank to run this notebook in demo mode
sl = None
if SERVLOCI_API_SECRET:
sl = ServLoci(api_key=SERVLOCI_API_KEY, api_secret=SERVLOCI_API_SECRET)
print("ServLoci configured:", sl.host, sl.port)
else:
print("SERVLOCI_API_SECRET not set — running in demo mode (no live proxy calls).")
Directional views with a capped price tag¶
A vertical spread — buy one strike, sell another strike of the same type and expiry — is how most traders express "I think it goes up (or down), but I want to know my exact worst case before I enter." Compare that to a naked long call: unlimited upside, but you're also paying full premium and full theta decay for it. Selling the further strike against your long reduces both the cost and the decay, at the price of capping the upside.
- Bull call spread (buy lower strike call, sell higher strike call): a moderately bullish trade. Max loss is the net premium paid; max profit is the strike width minus that premium. You'd choose this over an outright long call when you want to lower cost/theta exposure and you don't need unlimited upside — you have a target level in mind, not an open-ended one.
- Bear call spread (sell lower strike call, buy higher strike call): a credit spread — you collect premium up front, betting the underlying stays below the strike you sold. This is a defined-risk way to be bearish (or neutral-to-bearish) without the undefined risk of a naked short call; the long call you bought caps how much you can lose if you're wrong.
The strike width you choose is a direct dial on the risk/reward and the required margin: a wider spread has a bigger max profit and max loss (and needs more margin); a narrower spread is cheaper and safer but caps the reward tighter. There's no "correct" width — it's a function of your conviction and how much of your capital you're willing to allocate to a single view.
Port of bull_call_spread / bear_call_spread from shared/data/strategyTemplates.js — capped-risk directional strategies.
from scipy.stats import norm
import math
def bs_price(opt_type, spot, strike, t_years, vol, rate=0.065):
if t_years <= 0 or vol <= 0:
return max(spot - strike, 0) if opt_type == "CE" else max(strike - spot, 0)
d1 = (math.log(spot / strike) + (rate + vol * vol / 2) * t_years) / (vol * math.sqrt(t_years))
d2 = d1 - vol * math.sqrt(t_years)
if opt_type == "CE":
return spot * norm.cdf(d1) - strike * math.exp(-rate * t_years) * norm.cdf(d2)
return strike * math.exp(-rate * t_years) * norm.cdf(-d2) - spot * norm.cdf(-d1)
def leg_payoff_at_expiry(leg, underlying_price):
iv = max(underlying_price - leg["strike"], 0) if leg["type"] == "CE" else max(leg["strike"] - underlying_price, 0)
sign = 1 if leg["side"] == "buy" else -1
return sign * (iv - leg["premium"]) * leg["qty"]
def strategy_payoff_at_expiry(legs, underlying_price):
return sum(leg_payoff_at_expiry(leg, underlying_price) for leg in legs)
def net_premium(legs):
return sum((-1 if leg["side"] == "buy" else 1) * leg["premium"] * leg["qty"] for leg in legs)
def payoff_curve(legs, spot, rng=0.15, steps=120):
lo, hi = spot * (1 - rng), spot * (1 + rng)
pts = []
for i in range(steps + 1):
p = lo + (hi - lo) * i / steps
pts.append({"price": p, "pnl": strategy_payoff_at_expiry(legs, p)})
return pts
def breakevens(points):
crossings = []
for a, b in zip(points, points[1:]):
if (a["pnl"] < 0 <= b["pnl"]) or (a["pnl"] > 0 >= b["pnl"]):
t = 0 if a["pnl"] == b["pnl"] else -a["pnl"] / (b["pnl"] - a["pnl"])
crossings.append(a["price"] + t * (b["price"] - a["price"]))
return crossings
def max_profit_loss(points):
pnls = [p["pnl"] for p in points]
return {"maxProfit": max(pnls), "maxLoss": min(pnls)}
def nearest_strike(spot, step):
return round(spot / step) * step
def find_premium(chain, strike, opt_type):
if not chain:
return 0
row = next((r for r in chain if r["strike"] == strike), None)
if not row:
return 0
leg = row.get("ce" if opt_type == "CE" else "pe")
return leg["ltp"] if leg and isinstance(leg.get("ltp"), (int, float)) else 0
def fill_demo_premiums(legs, spot, t_years=7 / 365, vol=0.13):
for leg in legs:
if not leg["premium"]:
leg["premium"] = round(bs_price(leg["type"], spot, leg["strike"], t_years, vol), 2)
return legs
def bull_call_spread(spot, step, chain=None, qty=1):
atm = nearest_strike(spot, step)
higher = atm + 2 * step
return [
{"side": "buy", "type": "CE", "strike": atm, "premium": find_premium(chain, atm, "CE"), "qty": qty},
{"side": "sell", "type": "CE", "strike": higher, "premium": find_premium(chain, higher, "CE"), "qty": qty},
]
def bear_call_spread(spot, step, chain=None, qty=1):
atm = nearest_strike(spot, step)
higher = atm + 2 * step
return [
{"side": "sell", "type": "CE", "strike": atm, "premium": find_premium(chain, atm, "CE"), "qty": qty},
{"side": "buy", "type": "CE", "strike": higher, "premium": find_premium(chain, higher, "CE"), "qty": qty},
]
SPOT, STEP = 24000, 50
import matplotlib.pyplot as plt
def plot_payoff(points, title):
xs = [p["price"] for p in points]
ys = [p["pnl"] for p in points]
plt.axhline(0, color="grey", linewidth=0.8)
plt.plot(xs, ys)
plt.title(title)
plt.xlabel("Underlying spot")
plt.ylabel("P&L (INR)")
plt.show()
for name, builder in [("Bull call spread", bull_call_spread), ("Bear call spread", bear_call_spread)]:
legs = fill_demo_premiums(builder(SPOT, STEP), SPOT)
points = payoff_curve(legs, SPOT)
print(name, "max P/L:", max_profit_loss(points))
plot_payoff(points, name)
« Previous: Strategy: Straddle & Strangle
Next: Strategy: Iron Condor »
Try the concepts above interactively: Options Strategy Builder · Docs · Get your static IP