Lesson 7.6.3 · 70 min
Capstone C2: systematic options book, volatility surface and real-time Greeks
A book that sells premium every month and buys its own tail hedge, with the volatility surface fitted to the quotes, the size set by the worst scenario and the Greeks monitored every day. This guide is the brief of project C2, its architecture, a skeleton that works end to end with the course data and the way to validate it, run it on paper and defend it.
- Module
- 7.6 · Final capstone
- Duration
- 70 min
- Prerequisites
- 7.6.1 · Capstone: common requirements, the panel's rubric and preparing the defense, 3.4.4 · Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib, 3.4.5 · Smile, skew and term structure; volatility surface with SVI, 3.6.1 · Systematic premium selling: put-write, strangles and their tail risk, 3.6.4 · Options backtesting: data, fills and event adjustments, 4.2.6 · Derivatives risk: portfolio Greeks, margin and liquidations, 2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- Author
- Jesús Cuesta
- Updated
Level 7 lessons
7.1 · Software engineering for trading
7.2 · Infrastructure and deployment
7.3 · MLOps for trading models
7.4 · Regulation, ethics and taxation
- 7.4.1 MiFID II: clients, suitability and algorithmic trading
- 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
- 7.4.3 PRIIPs, the KID and US ETFs
- 7.4.4 MiCA after the transitional period: checking a provider in the registers
- 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
- 7.4.6 Trading taxes in Spain
- 7.4.7 Ethics: personal data, artificial intelligence and selling signals
7.5 · Quant career
7.6 · Final capstone
- 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
- 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
- 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
- 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
- 7.6.5 Capstone C4: a research platform with LLMs and alternative data
- 7.6.6 Capstone C5: crypto market making or arbitrage with counterparty control
The brief
A small asset manager wants a systematic options book on the index that collects the volatility risk premium with the tail risk bounded by construction. The brief, as you would receive it:
- What is built. A system that every month sells an index put chosen by delta and buys in the same expiration a further put (the wing) as a tail hedge; it fits the volatility surface to the quotes, sets the number of contracts by the worst possible loss of the cycle, values the book and its Greeks every day, applies limits and a kill switch, sends the orders as a combo to a paper account and reconciles every day against the broker statement.
- For whom. A risk committee that is not going to read the code: it wants a report with the maximum loss per cycle, the Greeks in USD, the days outside limits, the kill switch closures and the statistical evidence, with the number of trials that were run.
- What is out. Short calls (lesson 3.6.1 measured that the call leg loses in a bull sample and is a different bet), continuous delta hedging, single-stock options with early exercise, 0-day options and any real money. Nor is it a volatility desk: the book always buys and sells the same structure, with parameters fixed before looking at the result.
Deliverables, in addition to the common ones of 7.6.1: the package with the pricing engine, the surface, the
selection, the sizing, the daily book and the executor; informe_c2.json regenerated with one command; the complete
trial log; the journal of the four weeks of paper with the daily reconciliation; and the technical report.
The project applies what has already been taught; the guide does not repeat it and refers to it by code. Implied
volatility (3.4.4), SVI surface (3.4.5), Greeks (3.4.3), systematic premium selling and its tail (3.6.1), options
backtesting and chain cleaning (3.6.4), position management and pin risk (3.5.5), derivatives risk in a book (4.2.6),
Interactive Brokers with ib_async (4.4.2), live system (4.5.x) and monitoring and shutdown (7.2.3, 7.3.3).
Architecture
The system is a cycle: the chain comes in, passes a quality control, becomes a surface, the surface decides the strikes and the size, the limits approve or trim, the executor sends a combo order and the reconciliation closes the day. The monitor watches the book between cycles and can close everything.
Mermaid source
flowchart TB D[OPRA chain] --> Q[Quality control] Q --> V[SVI surface] V --> S[Strikes by delta] S --> T[Size by stress] T --> R[Greeks limits] R --> E[XSP combo order] E --> C[Reconciliation] C --> M[Kill switch] M --> R
| Piece | What it does | Library | Where it was learned |
|---|---|---|---|
| Quality control | discards crossed and frozen quotes, zero bids and parity violations | pandas | 3.6.4 |
| Implied volatility | from the mid prices, checked against vollib and QuantLib | numpy, vollib, QuantLib | 3.4.4 |
| Surface | SVI per expiration, without butterfly arbitrage | scipy | 3.4.5 |
| Greeks | vectorized BSM in USD per 1% and per point | numpy | 3.4.3, 4.2.6 |
| Sizing | worst loss on a grid of drops and in stress scenarios | numpy | 3.5.4, 3.6.1 |
| Execution | combo order to the paper broker | ib_async | 4.4.2, 4.5.2 |
| Reconciliation | positions, executions and cash against the statement | pandas, SQLite | 4.5.5 |
| Monitor | daily limits, kill switch, alerts | Prometheus or structured logs | 7.2.3, 7.3.3 |
Two design decisions the committee will ask about. The first: the wing has the same expiration, so the maximum loss of the cycle is known at opening (the width minus the credit) and does not depend on how volatility moves. A tail hedge with another expiration (three-month puts, futures or calls on the VIX) protects better in a long crisis, but its result depends on the term structure; it is an extension, not the core. The second: the volatility with which the strikes are chosen and the Greeks computed comes from the quotes, not from a model or a vendor, and the model (SVI) only smooths it.
Data
The project starts with the synthetic level 3 data, which imitates S&P 500 options on a fictitious index, ORX, from
2014 to 2025 (README of public/curso/datos/nivel-3/):
| File | What it contributes to the project |
|---|---|
opciones3_cadena.parquet | daily chain of the three nearest monthly expirations: bid, ask, volume and open interest |
opciones3_indice.csv | ORX close, dividend yield and rate curve from 1 month to 2 years |
opciones3_verdad.parquet | the true surface (SVI per day and expiration) and the forward: the known answer |
opciones3_superficie.parquet | the full Wednesday surface, up to 2 years, for the hedge with another expiration |
opciones3_incidencias.csv | planted defects (frozen, crossed, parity violated, a day without a chain) to test the quality control |
vol3_indices.csv, vol3_futuros.parquet | the VORX and its futures, for a volatility hedge |
ORX options are European, cash-settled at the close of expiration day and have a multiplier of 100. The skeleton uses a multiplier of 10 so that the size resembles that of XSP: according to its Cboe specifications, XSP options are European, cash-settled options on an index worth one tenth of the S&P 500, with a multiplier of 100 (10 USD per S&P 500 point), standard expirations on the third Friday and weeklies from Monday to Friday, and the same margin for uncovered short options as SPX1Note 1 · Cboe, “XSP Options Product Specifications” (https://www.cboe.com/tradable_products/sp_500/mini_spx_options/specifications/): underlying “based on 1/10th the value of the Standard & Poor's 500 Index”, multiplier 100, European exercise, cash settlement with one tenth of the official S&P 500 close and margin for uncovered short options of 100% of the premium plus 15% of the contract value minus the out-of-the-money amount. Accessed October 24, 2026..
For the real project, three documented sources, without downloading anything in the cells:
- Cboe DataShop sells US options history, including “Option EOD Summary”, a snapshot at the end of the day and another at 15:45 ET with quotes, prices and volume and, optionally, implied volatilities and Greeks, and “Option Quotes”, per-minute summaries with the NBBO2Note 2 · Cboe DataShop (https://datashop.cboe.com/): “Option EOD Summary” and “Option Quotes” product pages. Accessed October 24, 2026.. It is paid per dataset; the license of each one is on its product page.
- Databento distributes OPRA (all listed US options) with the same API for history and real time. Its page says it passes through the monthly real-time license fees with no upcharge and that new users get 125 USD of free credit for history and subscriptions3Note 3 · Databento, OPRA dataset page (https://databento.com/datasets/OPRA.PILLAR): “We pass through these license fees with no upcharge” and “New users get $125 in free credits”. Accessed October 24, 2026..
- Interactive Brokers provides the chain and its model's Greeks through the API in the paper account, with the market data subscriptions the account has; which subscription is needed is checked in the account before week 5 (lesson 4.4.2).
Real data brings what the synthetic data only imitates: sessions with an incomplete chain, strikes that appear in the middle of a cycle, the settlement of standard SPX with opening prices versus that of XSP with the close, and closing quotes that are not tradable. The project's data README documents, for each source, license, cost, fields, snapshot time and fingerprint of the versioned file (7.6.1).
Runnable skeleton
The skeleton is the minimal backbone that really works with the course data: seven cells that go from the chain to the report. The first sets up the pricing engine (the one from lessons 3.5.4 and 4.2.6, vectorized) and an implied volatility by bisection for whole chains, and checks it against vollib and QuantLib on one put: it is the first test of the repository.
import numpy as np
import pandas as pd
from scipy.special import ndtr
AÑO, MULT, CAPITAL = 365.0, 10, 1_000_000 # MULT 10, like XSP
ind = pd.read_csv("opciones3_indice.csv", parse_dates=["fecha"])
ind = ind.set_index("fecha")
S = ind["cierre"]
q = ind["rentabilidad_dividendo"] / 100
CURVA = ind[["tipo_1m", "tipo_3m", "tipo_6m", "tipo_1a",
"tipo_2a"]] / 100
PLAZOS = [30, 91, 182, 365, 730]
cad = pd.read_parquet("opciones3_cadena.parquet",
columns=["fecha", "vencimiento", "tipo",
"strike", "bid", "ask"])
cad["medio"] = (cad["bid"] + cad["ask"]) / 2
dias = dict(tuple(cad.groupby("fecha")))
vencs = sorted(cad["vencimiento"].unique())
def tipo(f, d):
"""Continuous rate of day f's curve at d days (as a fraction)."""
return float(np.interp(d, PLAZOS, CURVA.loc[f].to_numpy()))
def bsm(S, K, T, v, r, q, w=-1.0):
"""Vectorized BSM price and Greeks (w = -1 put, +1 call).
Vega per volatility point; theta per calendar day."""
S, K, T, v, r, q = np.broadcast_arrays(S, K, T, v, r, q)
rt = v * np.sqrt(T)
d1 = (np.log(S / K) + (r - q + v**2 / 2) * T) / rt
d2 = d1 - rt
dq, dr = np.exp(-q * T), np.exp(-r * T)
n1 = np.exp(-d1**2 / 2) / np.sqrt(2 * np.pi)
return {"precio": w * (S * dq * ndtr(w * d1)
- K * dr * ndtr(w * d2)),
"delta": w * dq * ndtr(w * d1),
"gamma": dq * n1 / (S * rt),
"vega": S * dq * n1 * np.sqrt(T) / 100,
"theta": (-S * dq * n1 * v / (2 * np.sqrt(T))
+ w * q * S * dq * ndtr(w * d1)
- w * r * K * dr * ndtr(w * d2)) / AÑO}
def iv(precio, S, K, T, r, q, w=-1.0):
"""Implied volatility by vectorized bisection (3.4.4)."""
lo = np.full(np.shape(precio), 1e-3)
hi = np.full(np.shape(precio), 4.0)
for _ in range(60):
m = (lo + hi) / 2
alto = bsm(S, K, T, m, r, q, w)["precio"] > precio
hi, lo = np.where(alto, m, hi), np.where(alto, lo, m)
return (lo + hi) / 2
# cross-check: a put on 2019-12-20 with vollib and QuantLib
import QuantLib as ql
from vollib.black_scholes_merton.implied_volatility import (
implied_volatility)
f0 = pd.Timestamp("2019-12-20")
g0 = dias[f0].set_index(["vencimiento", "tipo", "strike"])
v0 = pd.Timestamp("2020-01-17")
px, K0 = g0.at[(v0, "P", 2750.0), "medio"], 2750.0
T0 = (v0 - f0).days / AÑO
r0 = tipo(f0, (v0 - f0).days)
mio = float(iv(px, S[f0], K0, T0, r0, q[f0]))
vl = implied_volatility(px, S[f0], K0, T0, r0, q[f0], "p")
F0 = S[f0] * np.exp((r0 - q[f0]) * T0)
sd = ql.blackFormulaImpliedStdDev(ql.Option.Put, K0, F0, px,
np.exp(-r0 * T0), 0.0, 0.3,
1e-12, 200)
print(f"put {K0:.0f}: mid {px}, own σ {mio:.6f}")
print(f"difference from vollib {abs(mio - vl):.1e}, "
f"from QuantLib {abs(mio - sd / np.sqrt(T0)):.1e}")put 2750: mid 11.15, own σ 0.143291
difference from vollib 3.6e-16, from QuantLib 1.5e-12Our own volatility differs from vollib's by 3.6·10⁻¹⁶ and from QuantLib's by 1.5·10⁻¹² (the tolerance passed to it). The multiplier of 10 and the capital of one million USD are parameters; the rest of the guide takes them as fixed.
The surface is fitted every opening day with SVI (lesson 3.4.5) on the implied volatility of the mids of the out-of-the-money options with a bid. Since the data includes the true surface, you can measure how wrong it is:
from scipy.optimize import least_squares
def svi(k, a, b, rho, m, sig):
return a + b * (rho * (k - m) + np.sqrt((k - m)**2 + sig**2))
LIM = ([-1, 1e-6, -0.999, -1, 1e-4], [1, 5, 0.999, 1, 2])
_cache = {}
def superficie(f, v):
"""SVI of expiration v on day f, fitted to the implied
volatility of the mids of the OTM options with bid > 0 (3.4.5)."""
if (f, v) not in _cache:
g = dias[f]
g = g[(g["vencimiento"] == v) & (g["bid"] > 0)]
d = (v - f).days
T, r = d / AÑO, tipo(f, d)
F = S[f] * np.exp((r - q[f]) * T)
otm = g[np.where(g["tipo"] == "P", g["strike"] < F,
g["strike"] >= F)]
w = np.where(otm["tipo"] == "P", -1.0, 1.0)
K = otm["strike"].to_numpy()
vol = iv(otm["medio"].to_numpy(), S[f], K, T, r, q[f], w)
k = np.log(K / F)
p = least_squares(
lambda p: np.sqrt(np.maximum(svi(k, *p), 1e-10) / T) - vol,
[0.5 * (vol**2 * T).min(), 0.1, -0.5, 0.0, 0.1],
bounds=LIM).x
_cache[(f, v)] = {"T": T, "r": r, "F": F, "p": p, "K": K,
"iv": vol}
return _cache[(f, v)]
def vol_svi(s, K):
return np.sqrt(svi(np.log(np.asarray(K) / s["F"]), *s["p"])
/ s["T"])
ver = pd.read_parquet("opciones3_verdad.parquet")
ver = ver.set_index(["fecha", "vencimiento"])
COLS = ["svi_a", "svi_b", "svi_rho", "svi_m", "svi_sigma"]
filas = []
for f, v in [(f0, v0), (f0, pd.Timestamp("2020-02-21")),
(pd.Timestamp("2020-03-20"), pd.Timestamp("2020-04-17"))]:
s, x = superficie(f, v), ver.loc[(f, v)]
k_v = np.log(s["K"] / x["forward"])
verdad = np.sqrt(svi(k_v, *x[COLS].to_numpy(float)) / s["T"])
filas.append({"fecha": f.date(), "días": (v - f).days,
"opciones": len(s["K"]),
"ATM_%": 100 * float(vol_svi(s, s["F"])),
"ruido_max": 100 * np.abs(s["iv"] - verdad).max(),
"error_svi_max": 100 * np.abs(
vol_svi(s, s["K"]) - verdad).max()})
pd.DataFrame(filas).round(3) fecha días opciones ATM_% ruido_max error_svi_max
0 2019-12-20 28 22 10.402 0.247 0.053
1 2019-12-20 63 26 11.158 0.270 0.064
2 2020-03-20 28 64 62.904 0.660 0.300In calm conditions (December 2019, ATM volatility of 10.4%), the quotes deviate from the truth by up to 0.25 points and the fitted SVI by only 0.05: the model filters out the noise of the bid-ask spread. In the March 2020 crisis, with ATM volatility at 62.9% and 64 options with a bid, the fitting error rises to 0.30 points, less than half the noise (0.66). It is the proof that the piece works; with real data there is no truth and the proof is the day-to-day stability of the fit and its error against the quotes.
The cycle opens on the day of each expiration with the next one that has at least 20 days. The short put is the one with the delta closest to −0.20 with the SVI volatility; the wing, the one with delta −0.05. It sells at the bid and buys at the ask, with an assumed commission of 1 USD per contract and side (the real broker's goes in the configuration file). The size is the smaller of two: the one that keeps the worst loss of the cycle at 15% of capital and the one that keeps margin at 50%.
COMISION = 1.0 # USD per contract and side (assumption)
PERDIDA_MAX = 0.15 # of capital in the worst scenario
MARGEN_MAX = 0.50 # of capital committed to margin
ESTRES = ((0.10, 0.35), (0.20, 0.60), (0.30, 0.80)) # 3.6.1
def margen_put(prima, s, K):
"""Cboe margin of an uncovered short index put."""
return MULT * max(prima + 0.15 * s - max(s - K, 0),
prima + 0.10 * K)
def abrir(f, corta, ala, capital):
"""Put with delta -corta sold at the bid and, if there is a wing,
put with delta -ala bought at the ask, from the first expiration
with >= 20 days. Size: the smaller of max loss and margin."""
v = next(x for x in vencs if (x - f).days >= 20)
s = superficie(f, v)
p = dias[f]
p = p[(p["vencimiento"] == v) & (p["tipo"] == "P")]
K = p["strike"].to_numpy()
dl = bsm(S[f], K, s["T"], vol_svi(s, K), s["r"], q[f])["delta"]
patas = [(p.iloc[int(np.argmin(np.abs(dl + corta)))], -1)]
if ala:
patas.append((p.iloc[int(np.argmin(np.abs(dl + ala)))], 1))
credito = sum(x["bid"] if n < 0 else -x["ask"] for x, n in patas)
# worst loss per contract: drops at expiration and next-day
# stress scenarios with the smile scaled up
peor = estres = 0.0
for mov in np.arange(0.05, 0.41, 0.05):
pago = sum(n * max(x["strike"] - S[f] * (1 - mov), 0)
for x, n in patas)
peor = max(peor, -(credito + pago))
atm = float(vol_svi(s, s["F"]))
for mov, va in ESTRES:
valor = sum(n * float(bsm(
S[f] * (1 - mov), x["strike"], s["T"] - 1 / AÑO,
float(vol_svi(s, x["strike"])) * va / atm, s["r"],
q[f])["precio"]) for x, n in patas)
estres = max(estres, -(credito + valor))
peor = max(peor, estres)
perdida = MULT * peor + 2 * COMISION * len(patas)
if ala:
m = MULT * (patas[0][0]["strike"] - patas[1][0]["strike"])
else:
m = margen_put(patas[0][0]["bid"], S[f], patas[0][0]["strike"])
n = int(min(PERDIDA_MAX * capital / perdida,
MARGEN_MAX * capital / m))
return {"venc": v, "n": n, "credito": credito,
"perdida": perdida, "solo_estres": MULT * estres,
"margen": m,
"patas": [(float(x["strike"]), k) for x, k in patas]}
for ala in (None, 0.05):
c = abrir(f0, 0.20, ala, CAPITAL)
nom = c["n"] * MULT * c["patas"][0][0] / CAPITAL
print("20Δ/5Δ:" if ala else "20Δ naked:", c["patas"],
f"credit {c['credito']:.2f}")
print(f" max loss {c['perdida']:,.0f} USD/contract "
f"(stress only: {c['solo_estres']:,.0f}); margin "
f"{c['margen']:,.0f}; n = {c['n']}; notional {nom:.2f} x")20Δ naked: [(2750.0, -1)] credit 11.00
max loss 10,290 USD/contract (stress only: 7,938); margin 3,383; n = 14; notional 0.39 x
20Δ/5Δ: [(2750.0, -1), (2625.0, 1)] credit 7.70
max loss 1,177 USD/contract (stress only: 447); margin 1,250; n = 127; notional 3.49 xOn December 20, 2019, the 2750 put on its own collects 11 points; its worst loss, on the grid down to −40%, is 10,290 USD per contract, so the limit allows 14 contracts: a notional of 0.39 times the capital. With the 2625 wing, the credit drops to 7.70 points, but the maximum loss is the width minus the credit, 1,177 USD per contract: 127 contracts and a notional of 3.49 times the capital. This is how a wing changes a book: less credit per contract, a known maximum loss and much more notional for the same tail risk.
The “stress only” figure shows the trap that motivated the grid. The scenarios of lesson 3.6.1, revalued the next day, give a loss of 447 USD per contract for the spread: the day after a 20% drop it still has time value left. At expiration, a drop of 8% (the index below the wing) already costs the full width. Sizing with the next-day scenarios would have allowed about 2.6 times more contracts and a cycle loss of almost 40% of capital. In a spread, the worst point of the profile is not in the extreme scenario.
With the opening written, the sweep applies the same logic to each cycle from 2014 to 2025 for each candidate structure and saves the result in the trial log. Each cycle is held to expiration; the capital at each opening includes the result of the previous ones and the interest on cash.
def aperturas():
"""Opening day of each cycle: the first of the sample and,
after that, each expiration (or the next session)."""
f, out = S.index[0], []
while True:
v = next((x for x in vencs if (x - f).days >= 20), None)
if v is None or v > S.index[-1]:
return out
out.append(f)
f = v if v in dias else S.index[S.index > v][0]
APERTURAS = aperturas()
def liquidacion(v, patas):
"""Value of the legs at the expiration close (in points)."""
return sum(k * max(K - S[v], 0) for K, k in patas)
def ciclos(corta, ala):
"""Each cycle to expiration: return in excess of cash on the
capital at opening (compounded)."""
cap, filas = CAPITAL, []
for f in APERTURAS:
c = abrir(f, corta, ala, cap)
pyg = c["n"] * (MULT * (c["credito"]
+ liquidacion(c["venc"], c["patas"]))
- COMISION * len(c["patas"]))
caja = cap * (np.exp(tipo(f, 30) * (c["venc"] - f).days
/ AÑO) - 1)
filas.append({"venc": c["venc"], "r_ex": pyg / cap,
"n": c["n"], "cap": cap, "apertura": f})
cap += pyg + caja
return pd.DataFrame(filas).set_index("venc")
def nombre(corta, ala):
return f"{corta * 100:.0f}Δ" + (f"/{ala * 100:.0f}Δ" if ala
else " no wing")
REJILLA = [(c, a) for c in (0.30, 0.20, 0.10)
for a in (None, 0.05, 0.02)]
R = pd.DataFrame({nombre(c, a): ciclos(c, a)["r_ex"]
for c, a in REJILLA})
registro = pd.DataFrame({
"sharpe": R.mean() / R.std() * np.sqrt(12),
"media_%": 100 * R.mean(), "peor_%": 100 * R.min(),
"asim": R.skew()})
registro.index.name = "prueba"
registro.to_csv("registro_pruebas.csv")
print(f"{len(R)} cycles from {R.index[0].date()} to "
f"{R.index[-1].date()}; {len(registro)} trials logged")
registro.round(2)143 cycles from 2014-02-21 to 2025-12-19; 9 trials logged
sharpe media_% peor_% asim
prueba
30Δ no wing 0.59 0.19 -10.05 -6.12
30Δ/5Δ 0.62 0.55 -14.88 -3.85
30Δ/2Δ 0.58 0.40 -14.86 -4.55
20Δ no wing 0.56 0.16 -9.65 -7.92
20Δ/5Δ 0.63 0.49 -14.95 -4.98
20Δ/2Δ 0.56 0.34 -14.89 -5.80
10Δ no wing 0.36 0.09 -9.34 -9.82
10Δ/5Δ 0.36 0.25 -14.94 -5.87
10Δ/2Δ 0.34 0.18 -14.92 -7.00Nine structures, 143 cycles each. The header figure ranks them by Sharpe. Three readings: the wings raise the mean return per cycle (0.49% versus 0.16% in 20Δ) because they allow more notional, and with it the worst cycle loss rises to the design limit of 15%; the puts without a wing have the most negative skewness (down to −9.8), and the 0.10 delta ones, the worst Sharpe: they collect little for what they risk. The Sharpe differences among the top six are small (0.56 to 0.63) for 143 cycles: it is exactly the case in which choosing the best is overfitting.
The validation uses the whole log: a walk-forward of the choice (each January the structure with the best Sharpe up to December is chosen and traded that year) and the Deflated Sharpe of the best with the 9 trials.
from scipy.stats import norm
def psr(sr, umbral, t, asim, curt):
"""Probabilistic Sharpe Ratio (2.5.4), with per-period Sharpe."""
den = np.sqrt(1 - asim * sr + (curt - 1) / 4 * sr**2)
return norm.cdf((sr - umbral) * np.sqrt(t - 1) / den)
def max_esperado(n):
"""Expected maximum of n Sharpes with mean 0 and variance 1."""
gm = np.euler_gamma
return ((1 - gm) * norm.ppf(1 - 1 / n)
+ gm * norm.ppf(1 - 1 / (n * np.e)))
# anchored walk-forward: each January, the best up to December
anyo = R.index.year
oos, elegida = [], {}
for y in range(2017, 2026):
pasado = R[anyo < y]
elegida[y] = (pasado.mean() / pasado.std()).idxmax()
oos.append(R.loc[anyo == y, elegida[y]])
oos = pd.concat(oos)
print("chosen:", "; ".join(f"{y}: {e}" for y, e in elegida.items()
if y in (2017, 2019, 2021, 2022)), "…")
sr_m = lambda x: x.mean() / x.std() * np.sqrt(12)
print(f"walk-forward 2017-2025: Sharpe {sr_m(oos):.2f}; "
f"20Δ/5Δ fixed over the same period: "
f"{sr_m(R.loc[anyo >= 2017, '20Δ/5Δ']):.2f}")
# Deflated Sharpe of the best of the 9 over the whole sample
srs = R.mean() / R.std()
mejor = srs.idxmax()
x = R[mejor]
sr0 = srs.std() * max_esperado(len(srs))
print(f"best: {mejor}, Sharpe {srs[mejor] * np.sqrt(12):.2f}; "
f"threshold for 9 trials {sr0 * np.sqrt(12):.2f}")
mom = (len(x), x.skew(), x.kurt() + 3)
print(f"PSR(0) {psr(srs[mejor], 0, *mom):.3f}; "
f"DSR {psr(srs[mejor], sr0, *mom):.3f}")chosen: 2017: 10Δ/5Δ; 2019: 20Δ no wing; 2021: 30Δ/5Δ; 2022: 20Δ/5Δ …
walk-forward 2017-2025: Sharpe 0.36; 20Δ/5Δ fixed over the same period: 0.46
best: 20Δ/5Δ, Sharpe 0.63; threshold for 9 trials 0.18
PSR(0) 0.931; DSR 0.853The walk-forward changes structure three times in nine years and obtains a Sharpe of 0.36 in 2017-2025, versus the 0.46 of sticking with 20Δ/5Δ over the whole period. The best of the sample, 20Δ/5Δ, has a PSR(0) of 0.931 and a DSR of 0.853: the threshold demanded by the number of trials (0.18 of annual Sharpe with this dispersion) and the skewness of the cycles eat up a good part of the evidence. The validation section explains what the project does with these figures.
The daily book carries the structure of the brief, 20Δ/5Δ, fixed before the sweep. It values each leg at the mid every day (if the quote is missing, the last one), accrues cash at the 1-month rate and applies the kill switch: if capital falls more than 20% from its peak, it closes everything paying the bid-ask spread and does not open again until 21 sessions have passed (meanwhile, capital is the cash with its interest). The Greeks are computed afterwards, in vectorized form, with the implied volatility of each mid.
mid = cad[cad["tipo"] == "P"].set_index(
["fecha", "vencimiento", "strike"])["medio"]
fac = np.exp(CURVA["tipo_1m"] / 252).cumprod() # cash at the 1-month rate
LIM = {"vega": -0.004, "gamma": -0.003} # × capital, per point and 1%
def libro(corta=0.20, ala=0.05, dd_max=0.20, pausa=21):
"""Opens each cycle, values at the mid every day and closes
everything (at the ask/bid) if the drawdown from the peak exceeds
dd_max; after a closure, does not open until `pausa` sessions
have passed."""
caja, t_caja, pico, hasta = CAPITAL, APERTURAS[0], CAPITAL, None
eq, patas, cierres = {}, [], []
for f in APERTURAS:
if hasta is not None and f < hasta:
continue
caja *= fac[f] / fac[t_caja] # interest up to today
c = abrir(f, corta, ala, caja)
caja += c["n"] * (MULT * c["credito"]
- COMISION * len(c["patas"]))
sesiones = S.index[(S.index > f) & (S.index <= c["venc"])]
base = caja / fac[f]
for d in sesiones:
valor = 0.0
for K, k in c["patas"]:
px = mid.get((d, c["venc"], K), np.nan)
if d == c["venc"]:
px = max(K - S[d], 0)
u = k * c["n"] * MULT
patas.append((d, K, u, px, (c["venc"] - d).days))
valor += u * px
e = base * fac[d] + valor
if not np.isfinite(e): # no quote: yesterday's
e = eq[S.index[S.index < d][-1]]
eq[d] = e
pico = max(pico, e)
if e < (1 - dd_max) * pico and d < c["venc"]:
g = dias[d].set_index(["vencimiento", "tipo", "strike"])
coste = sum(-k * c["n"] * MULT * g.at[
(c["venc"], "P", K), "ask" if k < 0 else "bid"]
for K, k in c["patas"])
caja = base * fac[d] + coste - COMISION * c["n"] * len(
c["patas"])
eq[d], pico, t_caja = caja, caja, d
cierres.append(d)
hasta = S.index[S.index.get_loc(d) + pausa]
break
else:
caja = base * fac[c["venc"]] + c["n"] * MULT * liquidacion(
c["venc"], c["patas"])
t_caja = c["venc"]
eq = pd.Series(eq)
eq = eq.reindex(S.index[S.index >= eq.index[0]])
k = fac.reindex(eq.index)
eq = eq.fillna((eq / k).ffill() * k) # paused: cash only
return eq, pd.DataFrame(
patas, columns=["fecha", "K", "u", "px", "dias"]), cierres
def griegas(p):
"""Book Greeks in USD per day (delta and gamma per 1%)."""
p = p[(p["dias"] > 0) & (p["px"] > 0)].copy()
f = p["fecha"]
T = p["dias"].to_numpy() / AÑO
r = np.array([tipo(a, b) for a, b in zip(f, p["dias"])])
s, qq = S[f].to_numpy(), q[f].to_numpy()
vol = iv(p["px"].to_numpy(), s, p["K"].to_numpy(), T, r, qq)
b = bsm(s, p["K"].to_numpy(), T, vol, r, qq)
u = p["u"].to_numpy()
p["delta"] = u * b["delta"] * s * 0.01
p["gamma"] = u * 0.5 * b["gamma"] * (0.01 * s)**2
p["vega"], p["theta"] = u * b["vega"], u * b["theta"]
return p.groupby("fecha")[["delta", "gamma", "vega", "theta"]].sum()
eq, patas, cierres = libro()
G = griegas(patas)
fuera = {k: int((G[k] < v * eq.reindex(G.index)).sum())
for k, v in LIM.items()}
print(f"{len(eq)} sessions; drawdown closures: "
f"{[str(d.date()) for d in cierres]}")
print("days outside limit:", fuera)
(G.quantile([0.01, 0.5, 0.99]) / 1000).round(1)3017 sessions; drawdown closures: ['2018-10-22']
days outside limit: {'vega': 0, 'gamma': 16}
delta gamma vega theta
0.01 0.0 -3.0 -3.4 -0.8
0.50 2.8 -0.5 -0.8 0.5
0.99 22.4 -0.0 0.0 3.4The kill switch fires once, on October 22, 2018. On no day does the book exceed the vega limit (−0.4% of capital per point); on 16 days it exceeds the gamma limit (−0.3% per 1%), and exercise 1 explains which ones. The table gives the Greeks in thousands of USD: the median is a book with positive theta (500 USD a day), vega of −800 per point and gamma of −500 per 1%; the 99th percentile of delta, 22,400 USD per 1%, is the days after a drop, with the short put in the money.
The last cell computes the metrics with monthly returns (like the sweep) with and without the kill switch, and writes
the artifact: informe_c2.json, with the configuration, the data fingerprint, the metrics, the validation and the
risk summary. It is the file that the repository's single command must regenerate identically.
import hashlib
import json
def metricas(eq):
"""With monthly returns, like the cycle sweep."""
mes = eq.resample("ME").last().pct_change().dropna()
ex = mes - fac.resample("ME").last().pct_change().reindex(
mes.index)
anos = (eq.index[-1] - eq.index[0]).days / 365.25
return {"cagr": (eq.iloc[-1] / eq.iloc[0]) ** (1 / anos) - 1,
"vol": mes.std() * np.sqrt(12),
"sharpe": ex.mean() / ex.std() * np.sqrt(12),
"max_caida": (eq / eq.cummax() - 1).min(),
"peor_mes": mes.min(), "asim_mensual": mes.skew()}
sin_kill, _, _ = libro(dd_max=1.0)
tabla = pd.DataFrame({"with kill switch": metricas(eq),
"without kill switch": metricas(sin_kill)}).T
print(tabla.round(3).to_string())
def sha(ruta):
return hashlib.sha256(open(ruta, "rb").read()).hexdigest()[:12]
m = metricas(eq)
informe = {
"proyecto": "C2 options book", "config": "20Δ/5Δ",
"datos": {a: sha(a) for a in ("opciones3_cadena.parquet",
"opciones3_indice.csv")},
"metricas": {k: round(float(v), 4) for k, v in m.items()},
"validacion": {"n_pruebas": len(registro),
"dsr": round(float(psr(srs[mejor], sr0, *mom)), 3),
"sharpe_wf": round(float(sr_m(oos)), 3)},
"riesgo": {"cierres_kill_switch": len(cierres),
"dias_fuera_de_limite": fuera,
"perdida_max_ciclo": PERDIDA_MAX}}
with open("informe_c2.json", "w") as fh:
json.dump(informe, fh, ensure_ascii=False, indent=1)
print(json.dumps(informe["validacion"]), informe["datos"]) cagr vol sharpe max_caida peor_mes asim_mensual
with kill switch 0.078 0.087 0.706 -0.234 -0.141 -4.342
without kill switch 0.074 0.090 0.636 -0.271 -0.144 -4.358
{"n_pruebas": 9, "dsr": 0.853, "sharpe_wf": 0.359} {'opciones3_cadena.parquet': 'b13d44a9b279', 'opciones3_indice.csv': 'e71e0fdee66b'}With the kill switch, the book returns 7.8% a year with a volatility of 8.7%, a monthly Sharpe of 0.71, a maximum drawdown of −23.4% and a worst month of −14.1%; without it, 7.4%, 0.64 and −27.1%. The figure tracks both curves. A single activation does not prove that the kill switch adds value: with another date it could have closed at the low and missed the recovery. What it does prove is that it works, that its cost (the bid-ask spread when closing) is counted and that its two parameters (20% and 21 sessions) were fixed beforehand; if they are tuned by looking at the result, they go into the log as trials.
Validation
The committee's question is not whether the book makes money in the sample, but how much of its edge survives the way it was chosen. In C2 there are three sources of selection, and each one has its control:
| What is chosen | Risk | Control in the project |
|---|---|---|
| Short delta and wing | choosing the best of many in the same sample | trial log, DSR with its N |
| Choice over time | that today's best is not tomorrow's | anchored walk-forward of the choice |
| Limits and kill switch | tuning thresholds until the history looks pretty | parameters fixed in week 3, logged if they change |
| Data | deciding with information that was not available | volatility from the quotes, never iv_modelo; same-day surface |
The log keeps all the trials, including the discarded ones, with their series of returns per cycle (lessons 2.2.4 and 7.6.1). The skeleton logs 9; a real project will log more (widths, expirations, early-close rules, hedges with other expirations), and the DSR is computed with all of them. Hiding trials or choosing the period after the fact fails on its own.
The stopping criterion is written in week 4, before computing anything, and the one for this skeleton is:
- If the Sharpe of the walk-forward of the choice is ≤ 0 or the worst loss of a cycle exceeds the design limit, the book does not pass: the paper trading is done to evaluate the system, but the report presents it as not tradable.
- If the DSR of the best structure is < 0.95, the report says that there is no statistical evidence of an edge after the trials run, and the book is not proposed for real money with this evidence.
- With the course data: walk-forward Sharpe 0.36 > 0, maximum cycle loss of 14.95% < 15% and DSR 0.853 < 0.95. The system moves to paper; the report does not present it as tradable with money.
Two caveats specific to this project. The first: 143 monthly cycles are few for a strategy with a skewness of −5; the PSR and the DSR already take it into account, but the period contains a single large crisis (2020), and the result depends on how the book gets through it. The second: the data is synthetic, with the volatility risk premium planted by the generator. It validates the code and the method, not the strategy. Exercise 2 measures how much the bid-ask spread costs, and the extension to CPCV or to the probability of backtest overfitting (PBO, lesson 2.5.4) on the 9 series of the log is done with the same code as exercise 1 of C3 (7.6.4).
Risks and paper trading
The risks that really bring down a premium-selling book are not the ones its Sharpe measures:
- Tail and chaining. The wing bounds the loss of one cycle, not that of three in a row. With 15% per cycle, three bad cycles in a row are −39%. The design limit is chosen with the streak in mind, not the cycle.
- Gap and liquidity. A one-day drop leaves no time to adjust; in a crisis the bid-ask spread widens and closing costs much more than in the backtest (lesson 3.6.4 measured the spread by distance to the money).
- Pin risk. Near expiration, with the index next to the short strike, gamma shoots up (exercise 1).
- Model and data. A frozen or crossed quote moves the SVI and the selection delta; the quality control discards before fitting, and a fit with a large error against the quotes blocks the opening.
- Settlement. Standard SPX settles with a value calculated from the opening prices of its components on expiration day (and stops trading the previous session); XSP, with the official close of the S&P 5004Note 4 · Cboe, “SPX Options Product Specifications” (https://www.cboe.com/tradable_products/sp_500/spx_options/specifications/): “The exercise-settlement value, SET, is calculated using the opening sales price in the primary market of each component security on the expiration date”. Accessed October 24, 2026.. The backtest and the paper must use the same rule as the contract being traded.
- Operational. A partially filled combo order leaves a short put without its wing; the executor only sends combos and the reconciliation looks for loose legs every day.
The limits go in the validated configuration (7.1.1) and the monitor checks them every day: maximum cycle loss (15% of capital), committed margin (50%), vega (−0.4% per point), gamma (−0.3% per 1%) and delta, with a calendar rule for pin risk. The kill switch has explicit states, as in 4.5.5 and 7.3.3:
Mermaid source
stateDiagram-v2 [*] --> RECONCILING RECONCILING --> TRADING RECONCILING --> BLOCKED TRADING --> REDUCING REDUCING --> TRADING TRADING --> CLOSING CLOSING --> PAUSED PAUSED --> RECONCILING BLOCKED --> RECONCILING
REDUCING trims contracts when a Greek goes outside its limit; CLOSING closes everything (20% drawdown, stale data,
impossible reconciliation) and moves to PAUSED, which is only left with a person acknowledging the event; BLOCKED
is the startup with something unexplained in the reconciliation.
Paper trading plan. Four weeks of paper with a monthly cycle would see one entry. To exercise the full cycle several times, the paper book is staggered into four weekly sub-books on XSP, each with a quarter of the capital and expirations 4 to 5 weeks out: four openings, at least one settlement and the same sizing logic per sub-book. The decision code does not change during the four weeks (7.6.1). The connection is the one from lesson 4.4.2, with IB Gateway in paper mode; this cell is the real executor and is not run here because it needs the account:
from ib_async import IB, ComboLeg, Contract, Index, LimitOrder, Option
ib = IB()
ib.connect("127.0.0.1", 4002, clientId=23) # IB Gateway, paper
xsp = Index("XSP", "CBOE", "USD")
ib.qualifyContracts(xsp)
venc = "20261127" # the sub-book's one
corta, ala = (Option("XSP", venc, k, "P", "SMART", tradingClass="XSP")
for k in (655.0, 640.0))
ib.qualifyContracts(corta, ala)
for t in ib.reqTickers(corta, ala): # broker's Greeks
g = t.modelGreeks
print(t.contract.strike, t.bid, t.ask, g.impliedVol, g.delta)
# buy spread (buys the short strike, sells the wing) and the combo
# is SOLD: the positive limit price is the credit collected
combo = Contract(symbol="XSP", secType="BAG", currency="USD",
exchange="SMART", comboLegs=[
ComboLeg(conId=corta.conId, ratio=1,
action="BUY", exchange="SMART"),
ComboLeg(conId=ala.conId, ratio=1,
action="SELL", exchange="SMART")])
orden = LimitOrder("SELL", 3, 1.85, orderRef="C2-S2-20261023",
tif="DAY")
print(ib.whatIfOrder(combo, orden).initMarginChange) # margin
trade = ib.placeOrder(combo, orden)Three details the real executor must solve and test against a simulated broker (7.1.1): the idempotency key in
orderRef (sub-book and date, lesson 4.5.5); what to do if the combo is not filled at the limit (repeat lowering the
credit down to a written minimum, never switch to two separate orders); and the comparison of the IBKR Greeks
(modelGreeks) with your own, which is another reconciliation: a large difference in delta says that one of the two
surfaces is wrong.
Each paper day leaves in the journal (7.6.1): the saved chain with its fingerprint, the SVI and its error, your own Greeks and the broker's, the orders and executions, the reconciliation of positions and cash against the statement (4.5.5), the slippage against the mid at the time of the order (4.5.3) and the alerts. The monitor from 7.2.3 exposes the Greeks and the kill switch state; the one from 7.3.3 compares the P&L explained by the Greeks with the actual one and alerts when the residual grows (a surface that has stopped fitting).
Milestones and rubric
The schedule is the one from 7.6.1 (ten weeks, 40 to 80 hours), applied to C2:
| Week | Verifiable deliverable |
|---|---|
| 1 | Repository from the template; data README with sources, licenses and fingerprints; 7.6.1 auditor green |
| 2 | Loading and quality control of the chain; tests against opciones3_incidencias.csv; implied volatility checked against vollib and QuantLib |
| 3 | Surface with its error against opciones3_verdad.parquet; selection, grid sizing, costs; limits and kill switch fixed in writing |
| 4 | Trial log, walk-forward of the choice, DSR; stopping criterion applied; informe_c2.json |
| 5 | Combo executor with ib_async against a simulated broker and then against the paper account; Docker; Greeks monitor |
| 6-9 | Paper in four weekly sub-books on XSP, with journal and daily reconciliation; report in parallel |
| 10 | Paper report (slippage, own Greeks versus the broker's, incidents); rehearsal and defense |
The common rubric of 7.6.1, with the specific evidence it asks for in C2:
| Criterion (weight) | Evidence in C2 |
|---|---|
| Reproducibility (15%) | make reproduce regenerates informe_c2.json with the report's fingerprint; uv.lock, Docker image, data with fingerprints |
| Data (15%) | origin, license and cost of each source; snapshot time; quality control with its counters; volatility from the quotes, not from a model |
| Validation (20%) | complete log; walk-forward of the choice; DSR with the log's N (or CPCV/PBO); stopping criterion written beforehand |
| Risk and execution (15%) | bid-ask spread and commissions; sizing by the worst cycle loss; Greeks limits in USD; kill switch tested; combo orders only |
| Paper trading (15%) | 28 days and at least 20 sessions; four openings and one settlement at minimum; daily reconciliation with no unexplained differences |
| Code (10%) | tests of the engine (parity, check against vollib and QuantLib), the surface, the sizing and the executor; ruff and pre-commit |
| Report and defense (10%) | decisions with figures from the report; what 143 synthetic cycles do not prove; answers to the typical questions |
Mistakes that fail on their own in this project, in addition to the common ones: deciding with the generator's
volatility (iv_modelo) or with a surface fitted with later quotes; selling at the mid without saying so (a backtest
without the bid-ask spread is a backtest without costs); sizing by the broker's margin; and short legs without their
wing in the paper account that are not detected in that day's reconciliation.
Exercises
- The daily book goes outside the gamma limit on 16 days. List those days with the days remaining to expiration and the distance from the index to the short strike. What management rule would avoid them?
Solution
The 16 days fall 4 calendar days or less before expiration (median 2), with the index at a median distance of 0.4% from the short strike: it is the pin risk of lesson 3.5.5. The gamma of an at-the-money put grows like and the limit stops being met even though the size was correct at opening. The rule is a calendar one: close or roll the cycle a few sessions before expiration when the index is near the short strike, with its cost (one more bid-ask spread) measured in the sweep as a new trial in the log.
lim = LIM["gamma"] * eq.reindex(G.index)
malos = G.index[G["gamma"] < lim]
corto = patas[patas["u"] < 0].set_index("fecha")
filas = []
for d in malos:
x = corto.loc[d]
filas.append({"fecha": d.date(), "dias": int(x["dias"]),
"dist_%": 100 * (S[d] / x["K"] - 1),
"gamma_%": 100 * G.at[d, "gamma"] / eq[d]})
m = pd.DataFrame(filas)
print(f"{len(m)} days; days to expiration: median "
f"{m['dias'].median():.0f}, max {m['dias'].max()}; "
f"median |distance| {m['dist_%'].abs().median():.1f} %")
m.sort_values("gamma_%").head(4).round(2)16 days; days to expiration: median 2, max 4; median |distance| 0.4 %
fecha dias dist_% gamma_%
9 2015-05-14 1 -0.26 -0.93
8 2015-05-13 2 0.11 -0.63
5 2014-07-17 1 0.48 -0.61
13 2019-05-15 2 -0.11 -0.46- The sweep sells at the bid and buys at the ask. Compute how much of the capital the bid-ask spread takes each year in 20Δ/5Δ and what Sharpe the book would have if both legs were executed at the mid.
Solution
The opening bid-ask spread costs 0.27% of capital a year and lowers the Sharpe from 0.66 (at the mid) to 0.63 (at the bid and the ask). It is little because both legs are liquid in the data and the cycle is held to expiration, without paying the closing spread. In a book with early closes or rolls, and with the real spread of a crisis, the cost multiplies; that is why paper execution is always compared with the mid at the time of the order.
base = ciclos(0.20, 0.05)
extra = []
for venc, fila in base.iterrows():
f = fila["apertura"]
c = abrir(f, 0.20, 0.05, fila["cap"])
g = dias[f].set_index(["vencimiento", "tipo", "strike"])
medio = sum(-k * g.at[(c["venc"], "P", K), "medio"]
for K, k in c["patas"])
extra.append(fila["n"] * MULT * (medio - c["credito"])
/ fila["cap"])
al_medio = base["r_ex"] + np.array(extra)
print(f"spread paid: {12 * np.mean(extra):.2%} of capital a year")
print(f"Sharpe at bid/ask {sr_m(base['r_ex']):.2f}, "
f"at the mid {sr_m(al_medio):.2f}")spread paid: 0.27% of capital a year
Sharpe at bid/ask 0.63, at the mid 0.66- A committee member asks why you present 20Δ/5Δ if the walk-forward of the choice gives a Sharpe of 0.36 and the DSR is 0.853. What do you answer?
Solution
That 20Δ/5Δ was not chosen for being the best: it is the structure of the brief, fixed before the sweep, and its place in the table is a coincidence that the DSR discounts. That the log has 9 trials and all of them are in the report. That with a DSR of 0.853 there is not enough statistical evidence of an edge, and that the walk-forward also shows that choosing the structure with the past does not improve on sticking with a fixed one. That is why the stopping criterion was applied exactly as written: the system moved to paper, where it is evaluated that it works, is monitored and is reconciled, and the report does not present it as tradable with money. What would be needed to change that conclusion is more sample outside the one used for design, not more trials on the same one.
Footnotes
-
Cboe, “XSP Options Product Specifications” (https://www.cboe.com/tradable_products/sp_500/mini_spx_options/specifications/): underlying “based on 1/10th the value of the Standard & Poor's 500 Index”, multiplier 100, European exercise, cash settlement with one tenth of the official S&P 500 close and margin for uncovered short options of 100% of the premium plus 15% of the contract value minus the out-of-the-money amount. Accessed October 24, 2026. ↩
-
Cboe DataShop (https://datashop.cboe.com/): “Option EOD Summary” and “Option Quotes” product pages. Accessed October 24, 2026. ↩
-
Databento, OPRA dataset page (https://databento.com/datasets/OPRA.PILLAR): “We pass through these license fees with no upcharge” and “New users get $125 in free credits”. Accessed October 24, 2026. ↩
-
Cboe, “SPX Options Product Specifications” (https://www.cboe.com/tradable_products/sp_500/spx_options/specifications/): “The exercise-settlement value, SET, is calculated using the opening sales price in the primary market of each component security on the expiration date”. Accessed October 24, 2026. ↩
Each answer is folded under its question
Self-assessment
The skeleton sizes the 20Δ/5Δ put spread with the worst loss of a grid of drops at expiration, not only with next-day stress scenarios. Why?
- ABecause next-day scenarios cannot be computed with Black-Scholes
- BBecause the worst loss of a put spread (the width minus the credit, 1,177 USD per contract on 2019-12-20) arrives with any drop that takes the index below the wing at expiration; next-day scenarios only see 447 USD and would allow trading about 2.6 times more contracts
- CBecause the grid is more conservative with the naked put, which is the one that matters
- DBecause the margin of the spread is greater than its maximum loss
Show answer
Answer B. Because the worst loss of a put spread (the width minus the credit, 1,177 USD per contract on 2019-12-20) arrives with any drop that takes the index below the wing at expiration; next-day scenarios only see 447 USD and would allow trading about 2.6 times more contracts
The day after a −20% move the spread still has time value left and the loss looks small; at expiration, a drop of 8% (the index below the wing) already costs the full width. Size is set with the worst point of the loss profile, which in a spread is not in the extreme scenario.
The best of the 9 logged structures, 20Δ/5Δ, has a Sharpe of 0.63 and a DSR of 0.853. The walk-forward of the choice gives 0.36. What does the report say?
- AThat the strategy is validated, because the PSR(0) is 0.931
- BThat the sweep must be repeated with more structures until it passes 0.95
- CThat there is not enough evidence of an edge after 9 trials and that choosing each year with the past earns less than the fixed one; the paper trading is done (the system is evaluated), but the book is not presented as tradable with money
- DThat the DSR does not apply to options
Show answer
Answer C. That there is not enough evidence of an edge after 9 trials and that choosing each year with the past earns less than the fixed one; the paper trading is done (the system is evaluated), but the book is not presented as tradable with money
The DSR discounts the number of trials and the skewness of the cycles; 0.853 is below the 0.95 set by the stopping criterion. Adding trials to pass the threshold is exactly what the DSR penalizes.
The daily book is outside the gamma limit (−0.3% of capital per 1%) on 16 days. In exercise 1 they all fall 4 days or less before expiration, with the index 0.4% from the short strike. Which rule fixes it?
- ARaise the gamma limit to −1%
- BHedge the delta every hour
- CSell the spread further out of the money
- DClose or roll the cycle a few sessions before expiration when the index is near the short strike: it is the pin risk of lesson 3.5.5
Show answer
Answer D. Close or roll the cycle a few sessions before expiration when the index is near the short strike: it is the pin risk of lesson 3.5.5
The gamma of an at-the-money option grows without bound as expiration approaches. The days outside the limit are not a sizing failure but a calendar one: the rule is written in the configuration and tested before paper.
Why does the skeleton compute deltas with the implied volatility of the mid prices and not with the chain's
iv_modelocolumn?- ABecause
iv_modelois the truth of the data generator: no market publishes it, and using it to decide is information the system would not have - BBecause
iv_modelohas errors - CBecause mid prices are cheaper
- DBecause QuantLib does not accept
iv_modelo
Show answer
Answer A. Because
iv_modelois the truth of the data generator: no market publishes it, and using it to decide is information the system would not haveThe ground-truth columns of the synthetic data are for checking the code (the fitted SVI against the truth), not for trading. Deciding with them is a demonstrable look-ahead, one of the mistakes that fail.
- ABecause
Four weeks of paper with a monthly cycle would see a single entry. What does the guide propose?
- AExtend the paper period to a year
- BStagger the book into four weekly sub-books on XSP, with expirations 4 to 5 weeks out: four entries, at least one settlement and the same sizing logic per sub-book
- CSimulate the missing entries with the backtest
- DTrade 0-day options to get more trades
Show answer
Answer B. Stagger the book into four weekly sub-books on XSP, with expirations 4 to 5 weeks out: four entries, at least one settlement and the same sizing logic per sub-book
Paper has to exercise the full cycle (selection, combo order, execution, valuation, settlement and reconciliation) several times. XSP has weekly expirations and a notional one tenth of the S&P 500, suitable for a small account.
Key points
- C2 is a premium-selling book with a tail hedge: a put sold by delta and a wing bought in the same expiration, with the size set by the worst possible loss of the cycle and not by the margin.
- The surface is fitted to the quotes (SVI on the implied volatility of the mids) and checked against the truth of the data; deciding with the generator's volatility would be look-ahead.
- The worst point of a put spread is at expiration, in any drop below the wing: with only next-day scenarios, the size comes out about 2.6 times larger.
- With 9 logged structures, the best has a Sharpe of 0.63 and a DSR of 0.853, and the walk-forward of the choice gives 0.36: there is not enough evidence to trade with money, and the report says so.
- The book's Greeks are measured every day in USD; gamma breaches concentrate in the last days before expiration, near the strike: pin risk is managed with a calendar rule.
- Paper with XSP staggered into weekly sub-books exercises several entries and settlements in four weeks, with daily reconciliation against the statement.