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.

Fig. — Capstone C2: the 9 structures in the trial log (delta of the short put and of the wing), ranked by the Sharpe of their 143 monthly cycles, against the maximum expected by luck with 9 trials
Level 7 lessons
  1. 7.1 · Software engineering for trading

    1. 7.1.1 Project structure, configuration and quality: from notebook to repository
    2. 7.1.2 Performance: measure, profile and compile only what is needed
    3. 7.1.3 Patterns: events, message queues and hexagonal architecture
  2. 7.2 · Infrastructure and deployment

    1. 7.2.1 Docker and docker-compose: reproducible images with uv
    2. 7.2.2 VPS, cloud and latency; CI/CD with GitHub Actions
    3. 7.2.3 Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats
    4. 7.2.4 Security and continuity: 2FA, IP allowlists, backups and failure procedures
  3. 7.3 · MLOps for trading models

    1. 7.3.1 Tracking experiments, models and data: MLflow, DVC and a run reproduced end to end
    2. 7.3.2 Scheduled retraining, automatic validation and champion/challenger
    3. 7.3.3 Live model monitoring and automatic shutdown
    4. 7.3.4 Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production
  4. 7.4 · Regulation, ethics and taxation

    1. 7.4.1 MiFID II: clients, suitability and algorithmic trading
    2. 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
    3. 7.4.3 PRIIPs, the KID and US ETFs
    4. 7.4.4 MiCA after the transitional period: checking a provider in the registers
    5. 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
    6. 7.4.6 Trading taxes in Spain
    7. 7.4.7 Ethics: personal data, artificial intelligence and selling signals
  5. 7.5 · Quant career

    1. 7.5.1 Quant roles: six jobs around the same system
    2. 7.5.2 Where to work: what each firm does and what the registers say
    3. 7.5.3 Public portfolio and interviews: show judgment, not returns
    4. 7.5.4 Certifications, training and prop firms: what each one certifies and what a challenge measures
  6. 7.6 · Final capstone

    1. 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
    2. 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
    3. 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
    4. 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
    5. 7.6.5 Capstone C4: a research platform with LLMs and alternative data
    6. 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.

OPRA chainQuality controlSVI surfaceStrikes by deltaSize by stressGreeks limitsXSP combo orderReconciliationKill switch
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
PieceWhat it doesLibraryWhere it was learned
Quality controldiscards crossed and frozen quotes, zero bids and parity violationspandas3.6.4
Implied volatilityfrom the mid prices, checked against vollib and QuantLibnumpy, vollib, QuantLib3.4.4
SurfaceSVI per expiration, without butterfly arbitragescipy3.4.5
Greeksvectorized BSM in USD per 1% and per pointnumpy3.4.3, 4.2.6
Sizingworst loss on a grid of drops and in stress scenariosnumpy3.5.4, 3.6.1
Executioncombo order to the paper brokerib_async4.4.2, 4.5.2
Reconciliationpositions, executions and cash against the statementpandas, SQLite4.5.5
Monitordaily limits, kill switch, alertsPrometheus or structured logs7.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/):

FileWhat it contributes to the project
opciones3_cadena.parquetdaily chain of the three nearest monthly expirations: bid, ask, volume and open interest
opciones3_indice.csvORX close, dividend yield and rate curve from 1 month to 2 years
opciones3_verdad.parquetthe true surface (SVI per day and expiration) and the forward: the known answer
opciones3_superficie.parquetthe full Wednesday surface, up to 2 years, for the hedge with another expiration
opciones3_incidencias.csvplanted defects (frozen, crossed, parity violated, a day without a chain) to test the quality control
vol3_indices.csv, vol3_futuros.parquetthe 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.

IN [01]python
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}")
OUT [01]Data, pricing engine and cross-check
put 2750: mid 11.15, own σ 0.143291
difference from vollib 3.6e-16, from QuantLib 1.5e-12

Our 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:

IN [02]python
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)
OUT [02]Surface: SVI per expiration and check against the truth
        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.300

In 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%.

IN [03]python
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")
OUT [03]One cycle: selection by delta, prices and size
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 x

On 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.

IN [04]python
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)
OUT [04]The cycle sweep and the trial log
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.00

Nine 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.

IN [05]python
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}")
OUT [05]Walk-forward of the choice and Deflated Sharpe
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.853

The 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.

IN [06]python
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)
OUT [06]The book day by day: Greeks, limits and kill switch
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.4

The 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.

IN [07]python
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"])
OUT [07]Metrics and report
                      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'}
Fig. — Capstone C2: the 20Δ/5Δ book day by day (month-end capital and drawdown from peak), with the 20% kill switch and without it, 2014-2025

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 chosenRiskControl in the project
Short delta and wingchoosing the best of many in the same sampletrial log, DSR with its N
Choice over timethat today's best is not tomorrow'sanchored walk-forward of the choice
Limits and kill switchtuning thresholds until the history looks prettyparameters fixed in week 3, logged if they change
Datadeciding with information that was not availablevolatility 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:

RECONCILINGTRADINGBLOCKEDREDUCINGCLOSINGPAUSED
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:

IN [08]python
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:

WeekVerifiable deliverable
1Repository from the template; data README with sources, licenses and fingerprints; 7.6.1 auditor green
2Loading and quality control of the chain; tests against opciones3_incidencias.csv; implied volatility checked against vollib and QuantLib
3Surface with its error against opciones3_verdad.parquet; selection, grid sizing, costs; limits and kill switch fixed in writing
4Trial log, walk-forward of the choice, DSR; stopping criterion applied; informe_c2.json
5Combo executor with ib_async against a simulated broker and then against the paper account; Docker; Greeks monitor
6-9Paper in four weekly sub-books on XSP, with journal and daily reconciliation; report in parallel
10Paper 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

  1. 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 1/T1/\sqrt{T} 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.

IN [09]python
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)
OUT [09]The days outside the gamma limit
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
  1. 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.

IN [10]python
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}")
OUT [10]What the bid-ask spread costs
spread paid: 0.27% of capital a year
Sharpe at bid/ask 0.63, at the mid 0.66
  1. 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

  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. ↩

  2. Cboe DataShop (https://datashop.cboe.com/): “Option EOD Summary” and “Option Quotes” product pages. Accessed October 24, 2026. ↩

  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. ↩

  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. ↩

Each answer is folded under its question

Self-assessment

  1. 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.

  2. 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.

  3. 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.

  4. Why does the skeleton compute deltas with the implied volatility of the mid prices and not with the chain's iv_modelo column?

    • ABecause iv_modelo is the truth of the data generator: no market publishes it, and using it to decide is information the system would not have
    • BBecause iv_modelo has errors
    • CBecause mid prices are cheaper
    • DBecause QuantLib does not accept iv_modelo
    Show answer

    Answer A. Because iv_modelo is the truth of the data generator: no market publishes it, and using it to decide is information the system would not have

    The 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.

  5. 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.

Resources

Data