Skip to content

9.6 Python 量化实践:六个小项目

This page hasn’t been translated into English yet, so you’re reading the Chinese original. Chapters are being translated one by one.

边学边练读懂 → 自己算例子 → 答「想一想」→看项目卡问 AI 导师本章引导

一句话:用几十行 Python,把前八章的公式变成可以反复运行、可以自动检查的工具:定价、求 IV、模拟盈亏分布、提取事件波动、模拟 Gamma scalping、做回测。

为什么要学:券商的期权计算器是「黑箱」,你看不到它用了什么 IV、什么模型。自己写一遍,你会真正弄懂每个公式;再配上单元测试,错误会被自动抓出来。这些代码还能一直用下去:压力测试、事件研究、复盘都离不开它们。本专题的产出,可以直接放进你自己的 options 代码仓库,慢慢积累成自用的定价与风险工具库。

生活类比:自己按食谱做菜。外卖(现成的计算器)方便,但你不知道里面放了什么。自己做,每一步都清楚;每做一步就尝一口(单元测试),能在上桌前发现太咸。

  • 类比的局限:菜好不好吃,尝一口就知道;代码算出的数字「看起来合理」却可能是错的。而且模型只是对市场的简化,代码写得再正确,结果也只和它的假设一样可靠。

细讲:

  • 环境:Python 3.10 以上,加上 numpy、scipy、matplotlib、pytest 四个库。安装命令:pip install numpy scipy matplotlib pytest。
  • 建一个文件夹(例如 options/),每个项目一个文件;测试放在同名的 test_ 文件里。
  • 单元测试(unit test):一小段自动运行的检查代码,断言「在已知输入下,结果必须满足某个条件」。改了代码就重跑一遍,出错会立刻报警。
  • 单位最容易出错:IV 要写成小数(15% 写 0.15,不是 15);时间要换成年(30 天写 30/365);利率 4% 写 0.04。算出来的价格是每股,乘以 100 才是每张。
  • 每个文件只做一件事,函数的输入、输出和单位写在注释里。半年后回来看,你会感谢现在的自己。
  • 下面的代码都已实际运行过,输出就贴在代码后面,数字与课程的示例价格一致。

这是其余五个项目的地基。用 numpy 写,同一个函数既能算一个数,也能一次算一整组数组。

# bs.py:Black-Scholes 定价与希腊值(欧式,连续股息率 q)
import numpy as np
from scipy.stats import norm
def d1_d2(S, K, T, r, q, sigma):
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
return d1, d1 - sigma * np.sqrt(T)
def bs_price(S, K, T, r, q, sigma, cp):
d1, d2 = d1_d2(S, K, T, r, q, sigma)
if cp == "c":
return S * np.exp(-q * T) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
return K * np.exp(-r * T) * norm.cdf(-d2) - S * np.exp(-q * T) * norm.cdf(-d1)
def bs_delta(S, K, T, r, q, sigma, cp):
d1, _ = d1_d2(S, K, T, r, q, sigma)
return np.exp(-q * T) * (norm.cdf(d1) if cp == "c" else norm.cdf(d1) - 1)
def bs_gamma(S, K, T, r, q, sigma):
d1, _ = d1_d2(S, K, T, r, q, sigma)
return np.exp(-q * T) * norm.pdf(d1) / (S * sigma * np.sqrt(T))
def bs_vega(S, K, T, r, q, sigma): # 每 1 个 IV 点
d1, _ = d1_d2(S, K, T, r, q, sigma)
return S * np.exp(-q * T) * norm.pdf(d1) * np.sqrt(T) / 100
if __name__ == "__main__":
spy = (770, 770, 30 / 365, 0.04, 0.012, 0.15) # SPY 770,30 天,IV 15%
print(f"{bs_price(*spy, 'c'):.2f} {bs_price(*spy, 'p'):.2f}")
print(f"{bs_delta(*spy, 'c'):.3f} {bs_gamma(*spy):.4f} {bs_vega(*spy):.3f}")

运行 python bs.py 的输出:

14.08 12.32
0.529 0.0120 0.877

与第3章的 SPY 770C 一致($14.10、Δ 0.53、Γ 0.012、Vega 0.88)。

两个必备的测试写在 test_bs.py 里:

# test_bs.py:在终端运行 pytest
from math import exp
from bs import bs_price, bs_delta, bs_gamma, bs_vega
CASES = [(770, 770, 30/365, 0.04, 0.012, 0.15), # SPY 平值
(225, 250, 30/365, 0.04, 0.0, 0.40), # NVDA 虚值
(340, 310, 90/365, 0.04, 0.003, 0.25)] # AAPL 实值
def test_put_call_parity():
for S, K, T, r, q, s in CASES:
lhs = bs_price(S, K, T, r, q, s, "c") - bs_price(S, K, T, r, q, s, "p")
rhs = S * exp(-q * T) - K * exp(-r * T)
assert abs(lhs - rhs) < 1e-8
def test_greeks_match_finite_difference():
for S, K, T, r, q, s in CASES:
h = 0.01
up, mid, dn = (bs_price(x, K, T, r, q, s, "c") for x in (S + h, S, S - h))
assert abs((up - dn) / (2 * h) - bs_delta(S, K, T, r, q, s, "c")) < 1e-5
assert abs((up - 2 * mid + dn) / h**2 - bs_gamma(S, K, T, r, q, s)) < 1e-4
v_up = bs_price(S, K, T, r, q, s + 1e-4, "c")
v_dn = bs_price(S, K, T, r, q, s - 1e-4, "c")
assert abs((v_up - v_dn) / 2e-4 / 100 - bs_vega(S, K, T, r, q, s)) < 1e-5
  • 第一个测试检查平价关系(第2章 2.7):C − P 必须等于 S·e^(−qT) − K·e^(−rT),误差小于 1e-8。它能抓住公式里的符号错误、漏掉的股息项。
  • 第二个测试用有限差分(finite difference)检查希腊值:把股价挪一点点、重算价格,「价格变化 ÷ 挪动量」应该等于公式给出的 Delta;Gamma 和 Vega 同理。
  • 运行 pytest 两个测试都通过。实测平价误差在 1e-13 量级,希腊值误差在 1e-8 量级或更小,远低于容差。训练题 6 要你再补三个测试,并想清楚每个测试在防什么错。

项目 2:iv.py——用 Brent 法求隐含波动率

Section titled “项目 2:iv.py——用 Brent 法求隐含波动率”

求 IV 就是解方程:找一个 σ,让模型价格等于市场价格。

  • 布伦特法(Brent’s method):先给一个一定包含答案的区间(这里是 0.01% 到 500%),再不断缩小区间。只要区间两端的误差一正一负,它就一定收敛。
  • 为什么不用更快的牛顿法?牛顿法每一步要除以 Vega,深度虚值期权的 Vega 接近 0,会发散。
  • 求解前先检查价格是否在无套利区间(no-arbitrage bounds)内,也就是期权价格必须落在的上下限之间。区间外的价格不存在对应的 IV,通常说明报价过期或数据有误。
# iv.py:用 Brent 法由期权价格反求隐含波动率
import numpy as np
from scipy.optimize import brentq
from bs import bs_price
def implied_vol(price, S, K, T, r, q, cp, lo=1e-4, hi=5.0):
# 先检查价格是否在无套利区间内;区间外不存在对应的 IV
fwd, pv_k = S * np.exp(-q * T), K * np.exp(-r * T)
lower = max(fwd - pv_k, 0) if cp == "c" else max(pv_k - fwd, 0)
upper = fwd if cp == "c" else pv_k
if not lower < price < upper:
raise ValueError(f"价格 {price} 不在 ({lower:.2f}, {upper:.2f}) 之内")
return brentq(lambda s: bs_price(S, K, T, r, q, s, cp) - price, lo, hi, xtol=1e-10)
if __name__ == "__main__":
print(f"NVDA 300C:{implied_vol(0.35, 225, 300, 30/365, 0.04, 0.0, 'c'):.4f}")
quotes = {725: 2.99, 740: 4.86, 745: 5.72, 750: 6.58, 770: 12.32} # SPY 30 天 put 中间价
ks = np.array(list(quotes))
ivs = np.array([implied_vol(p, 770, k, 30/365, 0.04, 0.012, "p") for k, p in quotes.items()])
print(dict(zip(ks.tolist(), ivs.round(4).tolist())))
a, b, c = np.polyfit(np.log(ks / 770), ivs, 2) # 微笑拟合:IV ≈ a·x² + b·x + c
print(f"拟合:a={a:.3f} b={b:.3f} c={c:.4f};K=735 处 IV≈{a*np.log(735/770)**2 + b*np.log(735/770) + c:.4f}")

输出:

NVDA 300C:0.5050
{725: 0.2, 740: 0.1849, 745: 0.18, 750: 0.173, 770: 0.15}
拟合:a=-2.093 b=-0.959 c=0.1499;K=735 处 IV≈0.1901
  • NVDA 300C 报 $0.35,IV 约 50.5%,与第2章训练题 6 的「约 50%」一致。
  • SPY 各 put 的中间价反解回 17.3%、18.0%、18.5%、20%,正是课程的示例偏斜。
  • 最后一行用二次函数拟合微笑,插值出没有报价的 735 行权价的 IV,约 19.0%。
  • 注意:拟合只在数据范围内可信。这条拟合曲线外推到约 610 以下的行权价,会开始往下弯——现实的偏斜不会这样。

项目 3:mc_pnl.py——蒙特卡洛盈亏与 VaR、CVaR

Section titled “项目 3:mc_pnl.py——蒙特卡洛盈亏与 VaR、CVaR”

蒙特卡洛模拟(Monte Carlo simulation):用随机数生成成千上万种可能的到期价格,逐个算盈亏,再看盈亏的分布。

  • 风险价值(Value at Risk,VaR):「最差 5% 的情景」的分界线。95% VaR 为 $2,767,意思是只有 5% 的情景亏得比 $2,767 多。
  • 条件风险价值(Conditional VaR,CVaR,也叫 expected shortfall):最差 5% 情景的平均亏损。它看的是「坏起来有多坏」,比 VaR 更能反映尾部。
  • 随机种子(random seed):固定随机数的起点,让每次运行结果都一样,别人才能重现你的数字。
# mc_pnl.py:蒙特卡洛模拟到期盈亏,比较空头宽跨式与铁鹰(每组,美元)
import numpy as np
def terminal_prices(S0, T, sigma, n, r=0.04, q=0.012, jump_prob=0.0, jump=-0.10, seed=42):
rng = np.random.default_rng(seed)
z = rng.standard_normal(n)
ST = S0 * np.exp((r - q - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * z)
hit = rng.random(n) < jump_prob # 哪些路径在期间内发生一次跳空
return np.where(hit, ST * (1 + jump), ST)
def short_strangle(ST): # 卖 740P(4.85)+ 卖 800C(2.90)
return 100 * (7.75 - np.maximum(740 - ST, 0) - np.maximum(ST - 800, 0))
def iron_condor(ST): # 再买 725P(3.00)+ 815C(0.95)当翼
wings = np.maximum(725 - ST, 0) + np.maximum(ST - 815, 0)
return short_strangle(ST) + 100 * (wings - 3.95)
def var_cvar(pnl, level=0.95):
cut = np.quantile(pnl, 1 - level) # 最差 5% 的分界线
return -cut, -pnl[pnl <= cut].mean() # 用正数表示亏损
if __name__ == "__main__":
ST = terminal_prices(770, 30/365, 0.15, n=200_000) # 先不加跳空
for name, f in [("宽跨式", short_strangle), ("铁鹰", iron_condor)]:
pnl = f(ST)
var, cvar = var_cvar(pnl)
print(f"{name}:均值 {pnl.mean():+.0f} 95% VaR {var:.0f} 95% CVaR {cvar:.0f} 最差 {pnl.min():.0f}")

输出(20 万条路径,σ 15%,不加跳空):

宽跨式:均值 +106 95% VaR 2767 95% CVaR 4080 最差 -14857
铁鹰:均值 -14 95% VaR 1120 95% CVaR 1120 最差 -1120

怎么读:

  • 铁鹰的 VaR 和 CVaR 都等于最大亏损 $1,120:约 18% 的情景落在翼外,最差的 5% 全部被翼「封顶」。
  • 宽跨式的 CVaR 是 $4,080,最差情景亏近 $15,000:没有翼,左尾没有底。
  • 宽跨式的均值为正(+$106),是因为模拟用的是 15% 的平坦波动率,而卖出的 740P 是按 18.5% 的偏斜 IV 定价的。这不是真的优势,而是模型没有偏斜、没有肥尾。
  • 函数里留了 jump_prob(期间内发生一次跳空的概率)和 jump(跳空幅度)两个参数。把 jump_prob 改成 0.02 或 0.05 再跑,比较两个结构的 CVaR 怎么变,这就是训练题 7。提示:先想清楚铁鹰的最大亏损被什么锁住了。

项目 4:event_vol.py——从两个到期日提取事件波动

Section titled “项目 4:event_vol.py——从两个到期日提取事件波动”
# event_vol.py:从期限结构提取一次性事件波动(全部用年化 IV、以年计的 T)
from math import sqrt
def event_move_front(T_front, iv_front, iv_normal):
"""只用前月:需要另外给出不含事件的基准 IV。"""
return sqrt(T_front * (iv_front**2 - iv_normal**2))
def event_move_two(T1, iv1, T2, iv2):
"""两个都包含事件的到期日:同时解出基准 IV 与事件波动 m。"""
var_normal = (iv2**2 * T2 - iv1**2 * T1) / (T2 - T1) # 普通日子的年化方差
m = sqrt((iv1**2 - var_normal) * T1)
return m, sqrt(var_normal)
if __name__ == "__main__":
m = event_move_front(4/365, 0.91, 0.40)
print(f"前月法:m = {m:.4f},预期绝对波动 ≈ {0.8 * m:.4f}")
m2, base = event_move_two(4/365, 0.91, 32/365, 0.494)
print(f"两期法:m = {m2:.4f},基准 IV = {base:.4f}")

输出:

前月法:m = 0.0856,预期绝对波动 ≈ 0.0685
两期法:m = 0.0855,基准 IV = 0.4007
  • 前月法就是 9.3 的公式,需要你自己给出基准 IV。
  • 两期法用两个都含事件的到期日联立:σ1²T1 = σn²T1 + m²,σ2²T2 = σn²T2 + m²。两式相减消去 m²,就解出基准方差;再代回去求 m:
σn2=σ22T2−σ12T1T2−T1,m=(σ12−σn2)T1\sigma_{n}^{2} = \frac{\sigma_{2}^{2}T_{2} - \sigma_{1}^{2}T_{1}}{T_{2} - T_{1}}, \qquad m = \sqrt{\left(\sigma_{1}^{2} - \sigma_{n}^{2}\right)T_{1}}
  • σ1、T1:较近到期日的 IV 与剩余年数;σ2、T2:较远到期日的 IV 与剩余年数;σn:基准 IV;m:事件波动。
  • 两种方法给出几乎相同的结果,说明 9.3 的例子前后自洽。

项目 5:scalp_sim.py——Gamma scalping 的结果分布

Section titled “项目 5:scalp_sim.py——Gamma scalping 的结果分布”

第6章 6.6 讲过:买入跨式并按 Delta 对冲,赚的是「实现波动 − 隐含波动」。这个模拟看它在不同实现波动下的结果分布。

# scalp_sim.py:买入平值跨式、每天收盘做一次 Delta 对冲,看结果分布(每股)
import numpy as np
from bs import bs_price, bs_delta
def scalp_pnl(realized, n=20_000, S0=340.0, K=340.0, days=30, iv=0.25, r=0.04, q=0.003, seed=7):
rng, dt = np.random.default_rng(seed), 1 / 365
value = lambda S, T: bs_price(S, K, T, r, q, iv, "c") + bs_price(S, K, T, r, q, iv, "p")
delta = lambda S, T: bs_delta(S, K, T, r, q, iv, "c") + bs_delta(S, K, T, r, q, iv, "p")
S, pnl = np.full(n, S0), np.zeros(n)
for d in range(days):
T = (days - d) * dt
z = rng.standard_normal(n)
S_new = S * np.exp((r - q - 0.5 * realized**2) * dt + realized * np.sqrt(dt) * z)
V_new = np.abs(S_new - K) if d == days - 1 else value(S_new, T - dt)
# 当天盈亏 = 跨式价值的变化 − 对冲股票的变化 − 占用资金的利息
pnl += (V_new - value(S, T)) - delta(S, T) * (S_new - S) - r * dt * (value(S, T) - delta(S, T) * S)
S = S_new
return pnl
if __name__ == "__main__":
for rv in (0.30, 0.25, 0.20):
p = scalp_pnl(rv)
print(f"实现波动 {rv:.0%}:均值 {p.mean():+.2f} 标准差 {p.std():.2f} 亏钱的比例 {np.mean(p < 0):.0%}")

输出(每股,2 万条路径,每天对冲一次):

实现波动 30%:均值 +3.88 标准差 3.98 亏钱的比例 13%
实现波动 25%:均值 +0.00 标准差 3.03 亏钱的比例 50%
实现波动 20%:均值 -3.88 标准差 2.74 亏钱的比例 94%
  • 均值 ≈ Vega × (实现 − 隐含) = 0.78 × 5 ≈ $3.9,与第6章训练题 5 一致。
  • 即使实现波动(30%)高于隐含(25%),仍有约 13% 的路径亏钱:结果高度依赖路径,一次交易说明不了什么。

项目 6:backtest/——成交价假设决定回测结果

Section titled “项目 6:backtest/——成交价假设决定回测结果”
  • 回测(backtest):用历史数据检验一套规则会有什么结果。
  • 做一个简单的规则策略,例如每月固定时间开一组铁鹰,赚到 50% 或满 21 天就离场。然后按两种成交假设分别记账:
    • natural 成交(natural fill):买在卖价、卖在买价,最差但最现实的即时成交。
    • mid 成交:按中间价成交,最理想,现实中常常做不到。
# backtest/fills.py:成交价假设决定回测结果
def fill_price(bid, ask, side, mode="natural"):
"""side:'buy' 或 'sell'。natural:买在卖价、卖在买价;mid:按中间价。"""
if mode == "mid":
return (bid + ask) / 2
return ask if side == "buy" else bid
# 例:卖出 10 组铁鹰,组合报价 bid 1.80 / ask 3.00
for mode in ("natural", "mid"):
credit = fill_price(1.80, 3.00, "sell", mode)
print(f"{mode}:每组收 {credit:.2f},10 组共 ${credit * 100 * 10:,.0f}")

输出:

natural:每组收 1.80,10 组共 $1,800
mid:每组收 2.40,10 组共 $2,400
  • 同一笔交易,只因成交假设不同就差 $600(第6章训练题 9)。一年开 12 次、每次进出各一次,差距会非常大。
  • 第8章 8.7 的回测陷阱都要防:前视偏差(用了当时还不知道的数据)、幸存者偏差、提前指派、数据缺口、过度拟合。
  • 历史期权链数据通常要付费购买;免费数据多半只有指数、VIX 等的收盘值。

例子(示例价格,非实时):把六个工具串起来,回答一个问题:「这次 NVDA 财报,卖一个铁鹰的尾部风险有多大?」

  1. iv.py:从财报前一天的期权链快照,反解出 4 天与 32 天到期的平值 IV:91% 与 49.4%。
  2. event_vol.py:两期法得到 m ≈ 8.55%、基准 IV ≈ 40%,预期绝对波动约 6.9%。
  3. mc_pnl.py:把 terminal_prices 改成「普通日子的波动 + 一次事件跳动」,事件跳动按 1σ = m 的正态分布抽样;再把行权价换成你设计的铁鹰。
  4. 输出盈亏分布、95% VaR 与 CVaR,再按第8章的规则定仓位:单笔最大亏损不超过净值的 1–2%。
  5. 交叉检查:bs.py 的测试全部通过;两期法的 m 应和 9.3 手算的 8.6% 一致。任何一步对不上,先查代码,再查数据。

常见误区:

  • 「代码跑出了数字,就说明是对的。」——没有测试的代码只是「看起来对」。平价测试和差分测试能抓住大部分公式错误。
  • 「模拟次数越多,结果就越接近真相。」——次数只减少随机误差。假设错了(没有偏斜、没有跳空),跑一亿次也是错的。
  • 「求 IV 用牛顿法最快,所以最好。」——深度虚值时 Vega 接近 0,牛顿法会发散;Brent 法慢一点,但稳。
  • 「回测按中间价成交就好。」——多腿策略按中间价记账会大幅高估收益,至少要同时报告 natural 成交的结果。
  • 「拟合出来的微笑曲线可以随便外推。」——二次拟合在数据范围外,可能弯向完全错误的方向。

想一想:

  1. 为什么求 IV 之前,要先检查价格是否在无套利区间内?(答:区间外的价格,没有任何 σ 能对上,求解器会报错或给出错误值;这通常说明报价过期或数据有误。)
  2. 平价测试的误差是 1e-3,可以接受吗?(答:不可以。正确的实现,误差应在 1e-10 以下;1e-3 说明公式、股息或贴现项写错了。)
  3. 项目 5 里,实现波动等于隐含(25%)时均值为 0,但标准差仍有 $3.03。这说明什么?(答:即使定价公平,单次对冲交易的结果也会大幅偏离期望;要看很多次的平均,才能判断有没有优势。)

关键术语:

中文 English 白话解释
单元测试 unit test 自动检查代码在已知输入下是否给出正确结果
有限差分 finite difference 把输入挪一点点、重算,用变化量近似导数
布伦特法 Brent’s method 在一定包含答案的区间里稳妥求根的算法
无套利区间 no-arbitrage bounds 期权价格必须落在的上下限
蒙特卡洛模拟 Monte Carlo simulation 用大量随机情景估计结果的分布
风险价值 Value at Risk(VaR) 最差 5%(或 1%)情景的亏损分界线
条件风险价值 CVaR / expected shortfall 最差那部分情景的平均亏损
随机种子 random seed 固定随机数的起点,让结果可以重现
回测 backtest 用历史数据检验一套规则会有什么结果
natural 成交 natural fill 买在卖价、卖在买价的成交假设

项目卡

时长
2 周 · 32 小时
先修回顾
0.6–0.8;2.7;2.8;8.5;8.7
项目产出
六个小项目的代码,每个附单元测试。

问 AI 导师

卡住了,或者想换个角度再学一遍?把下面这段提示词交给 Claude 或其他 AI 助手,它会按本课程的教学规则一对一带你学这一节。提示词可以先改再复制。

AI 也会算错:它给的数字请自己复算,拿不准时以讲义和答案页为准。