9.6 Python 量化实践:六个小项目
边学边练读懂 → 自己算例子 → 答「想一想」→看项目卡问 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 才是每张。
- 每个文件只做一件事,函数的输入、输出和单位写在注释里。半年后回来看,你会感谢现在的自己。
- 下面的代码都已实际运行过,输出就贴在代码后面,数字与课程的示例价格一致。
项目 1:bs.py——定价与希腊值
Section titled “项目 1:bs.py——定价与希腊值”这是其余五个项目的地基。用 numpy 写,同一个函数既能算一个数,也能一次算一整组数组。
# bs.py:Black-Scholes 定价与希腊值(欧式,连续股息率 q)import numpy as npfrom 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.320.529 0.0120 0.877与第3章的 SPY 770C 一致($14.10、Δ 0.53、Γ 0.012、Vega 0.88)。
两个必备的测试写在 test_bs.py 里:
# test_bs.py:在终端运行 pytestfrom math import expfrom 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 npfrom scipy.optimize import brentqfrom 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:
- σ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 npfrom 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.00for 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,800mid:每组收 2.40,10 组共 $2,400- 同一笔交易,只因成交假设不同就差 $600(第6章训练题 9)。一年开 12 次、每次进出各一次,差距会非常大。
- 第8章 8.7 的回测陷阱都要防:前视偏差(用了当时还不知道的数据)、幸存者偏差、提前指派、数据缺口、过度拟合。
- 历史期权链数据通常要付费购买;免费数据多半只有指数、VIX 等的收盘值。
例子(示例价格,非实时):把六个工具串起来,回答一个问题:「这次 NVDA 财报,卖一个铁鹰的尾部风险有多大?」
- iv.py:从财报前一天的期权链快照,反解出 4 天与 32 天到期的平值 IV:91% 与 49.4%。
- event_vol.py:两期法得到 m ≈ 8.55%、基准 IV ≈ 40%,预期绝对波动约 6.9%。
- mc_pnl.py:把 terminal_prices 改成「普通日子的波动 + 一次事件跳动」,事件跳动按 1σ = m 的正态分布抽样;再把行权价换成你设计的铁鹰。
- 输出盈亏分布、95% VaR 与 CVaR,再按第8章的规则定仓位:单笔最大亏损不超过净值的 1–2%。
- 交叉检查:bs.py 的测试全部通过;两期法的 m 应和 9.3 手算的 8.6% 一致。任何一步对不上,先查代码,再查数据。
常见误区:
- 「代码跑出了数字,就说明是对的。」——没有测试的代码只是「看起来对」。平价测试和差分测试能抓住大部分公式错误。
- 「模拟次数越多,结果就越接近真相。」——次数只减少随机误差。假设错了(没有偏斜、没有跳空),跑一亿次也是错的。
- 「求 IV 用牛顿法最快,所以最好。」——深度虚值时 Vega 接近 0,牛顿法会发散;Brent 法慢一点,但稳。
- 「回测按中间价成交就好。」——多腿策略按中间价记账会大幅高估收益,至少要同时报告 natural 成交的结果。
- 「拟合出来的微笑曲线可以随便外推。」——二次拟合在数据范围外,可能弯向完全错误的方向。
想一想:
- 为什么求 IV 之前,要先检查价格是否在无套利区间内?(答:区间外的价格,没有任何 σ 能对上,求解器会报错或给出错误值;这通常说明报价过期或数据有误。)
- 平价测试的误差是 1e-3,可以接受吗?(答:不可以。正确的实现,误差应在 1e-10 以下;1e-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 | 买在卖价、卖在买价的成交假设 |
项目卡
问 AI 导师
卡住了,或者想换个角度再学一遍?把下面这段提示词交给 Claude 或其他 AI 助手,它会按本课程的教学规则一对一带你学这一节。提示词可以先改再复制。
AI 也会算错:它给的数字请自己复算,拿不准时以讲义和答案页为准。