Backtest: Weekly Short Straddle¶
A simplified, cost-free backtest of a weekly ATM short straddle.
Part 14 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).")
Backtesting is easy to get wrong in ways that flatter the strategy¶
A backtest answers "what would have happened" — it only becomes evidence of a real edge if the simulation avoids a short list of well-known ways to cheat, mostly by accident:
- Look-ahead bias. Using information that wouldn't have been available at the time of the trade — e.g. sizing today's position off a volatility figure computed with tomorrow's close. This backtest uses a 20-day rolling realized vol computed only from prior closes, which avoids the obvious version of this bug, but always audit every input a strategy touches for this.
- Survivorship bias. Testing only on instruments that still exist today (and dropping ones that were delisted or went to zero) makes strategies look better than they were, because the losers are missing from the sample. Less relevant for a single continuous index like NIFTY, very relevant the moment you backtest a stock-picking strategy over a universe.
- Ignoring costs. Real trades pay the bid-ask spread, brokerage, exchange transaction charges, and in India, STT (securities transaction tax) — which on options is charged on the sell side and is not trivial for high-frequency weekly strategies. A strategy that's profitable before costs can easily be a loser after them.
- Overfitting to one historical window. Tuning strike selection, entry day, or holding period until this specific 2-year window looks good is curve-fitting, not strategy design. The fix is out-of-sample or walk-forward testing — train assumptions on one period, validate on a period the tuning never saw — which this notebook does not do.
Illustrative only — no slippage, costs, or margin, and no out-of-sample split. Sells a weekly ATM straddle every Monday, priced with 20-day realized vol via Black-Scholes (notebook 06), held to Friday's close. Treat the P&L number below as a sanity check on the mechanics, not as a claim that this strategy makes money.
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)
import pandas as pd
import math
nifty = pd.read_csv("nifty_2y.csv", index_col=0, parse_dates=True)
nifty["ret"] = nifty["Close"].pct_change()
nifty["realized_vol"] = nifty["ret"].rolling(20).std() * math.sqrt(252)
trades = []
mondays = nifty[nifty.index.weekday == 0].dropna(subset=["realized_vol"])
for entry_date, row in mondays.iterrows():
exit_idx = nifty.index.searchsorted(entry_date) + 4 # ~Friday, 5 trading days later
if exit_idx >= len(nifty):
continue
spot_in, spot_out = row["Close"], nifty["Close"].iloc[exit_idx]
vol, t_years = row["realized_vol"], 5 / 252
strike = round(spot_in / 50) * 50
call_prem = bs_price("CE", spot_in, strike, t_years, vol)
put_prem = bs_price("PE", spot_in, strike, t_years, vol)
payoff = -(max(spot_out - strike, 0) - call_prem) - (max(strike - spot_out, 0) - put_prem)
trades.append({"entry": entry_date, "spot_in": spot_in, "spot_out": spot_out, "pnl_per_lot": payoff})
results = pd.DataFrame(trades)
print(results.tail())
print("Total simulated P&L (1 lot, no costs):", round(results["pnl_per_lot"].sum(), 2))
« Previous: Historical Data for Backtesting
Next: Position Sizing & Risk Management »
Try the concepts above interactively: Options Strategy Builder · Docs · Get your static IP