---
title: "7.6.3 Capstone C2: options book and Greeks"
description: "Systematic premium selling with tail wings on the ORX chain: SVI surface, worst-case sizing, daily Greeks, kill switch and DSR."
url: "https://pythonparatrading.com/en/python-trading-course/level-7/capstone-options-book-volatility-greeks/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-7/capstone-options-book-volatility-greeks.png"
---

# 7.6.3 · 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.

- Course: [Level 7: Production, MLOps, regulation, career and capstone](https://pythonparatrading.com/en/python-trading-course/level-7/)
- Module: 7.6 · Final capstone
- Duration: 70 min
- Prerequisites: [7.6.1 · Capstone: common requirements, the panel's rubric and preparing the defense](https://pythonparatrading.com/en/python-trading-course/level-7/capstone-requirements-rubric-defense/), [3.4.4 · Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib](https://pythonparatrading.com/en/python-trading-course/level-3/implied-volatility-newton-brent-quantlib/), [3.4.5 · Smile, skew and term structure; volatility surface with SVI](https://pythonparatrading.com/en/python-trading-course/level-3/volatility-smile-skew-svi-surface/), [3.6.1 · Systematic premium selling: put-write, strangles and their tail risk](https://pythonparatrading.com/en/python-trading-course/level-3/systematic-premium-selling-tail-risk/), [3.6.4 · Options backtesting: data, fills and event adjustments](https://pythonparatrading.com/en/python-trading-course/level-3/options-backtesting/), [4.2.6 · Derivatives risk: portfolio Greeks, margin and liquidations](https://pythonparatrading.com/en/python-trading-course/level-4/derivatives-risk-greeks-margin-and-liquidations/), [2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/en/python-trading-course/level-2/deflated-sharpe-ratio-and-pbo/)

## By the end of this lesson

- Scope the brief of a systematic options book: what is sold, what hedge is bought, with what limits and what is left out of the project.
- Build the backbone of the system with the course data: implied volatility from the quotes, SVI surface, selection by delta, sizing by the worst scenario, daily Greeks and kill switch.
- Validate the choice of structure with a trial log, a walk-forward of the choice and the Deflated Sharpe, and write the stopping criterion beforehand.
- Plan four weeks of paper trading with XSP at IBKR that see several entries and settlements, with daily reconciliation and Greeks monitoring.
- Apply the common rubric of 7.6.1 to C2 and recognize the mistakes that fail this project.

<aside class="callout" data-kind="riesgo">

Selling options exposes you to large and fast losses; a bought wing limits them, but it does not prevent losing the
maximum loss of a cycle several times in a row. This guide describes a training project with synthetic data and a
paper account. It is not an investment or product recommendation.

</aside>

## 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
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 SPX[^1].

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 NBBO[^2]. 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 subscriptions[^3].
- **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.

```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}")
```
```output
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:

```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)
```
```output
        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%.

```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")
```
```output
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.

```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)
```
```output
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.

```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}")
```
```output
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.

```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)
```
```output
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.

```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"])
```
```output
                      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.* Top, capital as a multiple of the initial amount: with the kill switch it ends at 2.47 times and without it at 2.35. The highlighted dot is the only kill switch close, on 22 October 2018, when the drawdown from peak exceeds 20%. Bottom, drawdown from peak: with the kill switch (blue line) it reaches −23.4%; without it (grey line), −27.1%.

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 500[^4]. 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
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:

```python skip
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

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/\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.

```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)
```
```output
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
```

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

```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}")
```
```output
spread paid: 0.27% of capital a year
Sharpe at bid/ask 0.63, at the mid 0.66
```

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

[^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.

## 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?

   - A. Because next-day scenarios cannot be computed with Black-Scholes
   - 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
   - C. Because the grid is more conservative with the naked put, which is the one that matters
   - D. Because the margin of the spread is greater than its maximum loss

   *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?

   - A. That the strategy is validated, because the PSR(0) is 0.931
   - B. That the sweep must be repeated with more structures until it passes 0.95
   - 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
   - D. That the DSR does not apply to options

   *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?

   - A. Raise the gamma limit to −1%
   - B. Hedge the delta every hour
   - C. Sell the spread further out of the money
   - 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

   *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?

   - 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
   - B. Because `iv_modelo` has errors
   - C. Because mid prices are cheaper
   - D. Because QuantLib does not accept `iv_modelo`

   *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?

   - A. Extend the paper period to a year
   - 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
   - C. Simulate the missing entries with the backtest
   - D. Trade 0-day options to get more trades

   *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

### Research

- [Uses of Volatility and Current Volatility - VIX1D](https://pythonparatrading.com/en/uses-of-volatility/) · Research-0003 · 5 min

### Documentation

- [Cboe: XSP (Mini-SPX) options specifications](https://www.cboe.com/tradable_products/sp_500/mini_spx_options/specifications/) · cboe.com
- [Cboe DataShop: historical options data (Option EOD Summary, Option Quotes)](https://datashop.cboe.com/) · datashop.cboe.com
- [Databento: OPRA, real-time and historical US options data](https://databento.com/datasets/OPRA.PILLAR) · databento.com
- [ib_async (successor to ib_insync): repository and documentation](https://github.com/ib-api-reloaded/ib_async) · github.com

### Books

- [Euan Sinclair, Positional Option Trading (Wiley, 2020)](https://www.wiley.com/en-us/Positional+Option+Trading%3A+An+Advanced+Guide-p-9781119583516) · wiley.com

### Data

- [Level 3 data: daily ORX options chain (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/opciones3_cadena.parquet) · Download · opciones3_cadena.parquet

Previous lesson: [7.6.2 · Capstone C1: a multi-asset futures system, from forecast to paper account](https://pythonparatrading.com/en/python-trading-course/level-7/capstone-multi-asset-futures-nautilustrader/)

Next lesson: [7.6.4 · Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation](https://pythonparatrading.com/en/python-trading-course/level-7/capstone-ml-portfolio-hrp-spanish-taxes/)
