Python期权分析:pandas+机器学习选权与B-S定价 简介一套围绕股票与期权筛选与决策的网页分析工具及其 Excel 原型面向具备 Python、Pandas 与数据分析基础、希望把量化思路落到实操的投资者与学习者。网页以自制 Excel 原型为底稿搭建辅以样式文件与整理过的表格片段第一个「摘要分析」选项卡只需输入标的代码即可依据历史波动率给出未来 30 天乃至一年期的价格参考并用威科夫方法输出牛熊倾向的算法判断第二个「推荐」选项卡用于筛选标的并给出建议整体还预留了数据分析、推荐引擎、决策预测、博弈论与机器学习等模块的扩展位置。压缩包约 325.82MB文件总数与类型明细上游暂未提供目录以主文件夹为入口组织。已有 163 人浏览学习适合观摩预测逻辑、指标计算与可视化呈现的完整实现路径。1. 股票期权分析这套脚本到底解决什么决策问题盘前十几分钟打开券商页面同一只标的下几十个行权价、两个方向、四五个到期日一眼扫过去全是数字最后还是挑了个“看着顺眼”的合约——这种选择方式我见过太多次。这份资源要做的事就是把这种拍脑袋的过程拆成三步可以分别验证的动作用 pandas 把历史价格和波动率整理成结构化数据用机器学习模型给出一个方向概率最后丢进计算器里算出盈亏比和希腊值让“挑股票和期权”这件事有据可依。它不是一个能预测涨跌的工具而是一套把预测性分析、建议逻辑和计算器绑在一起的本地 Python 工具集适合已经会用 DataFrame、想把自己的选权流程从手工表格升级成脚本的开发者。2. 项目骨架从数据层到计算器层的四层拆分把这类工具写成一个大main.py是最容易翻车的写法三个月后自己都不认识。合理的拆法是把整条链路切成四层数据获取与清洗、定价与希腊值计算、预测与打分、展示与交互。四层之间只通过 DataFrame 和 dict 传递谁都不去直接读对方的内部变量这样任何一层单独拿出来都能写单元测试。2.1 目录结构与模块职责常见做法是按职责分目录而不是按“文件类型”分。下面这个结构是我在一线项目里反复用过的版本删掉了所有业务专属命名只保留通用骨架stock_option_lab/ ├── config/ │ └── settings.yaml # 无风险利率、乘数、手续费、HV 窗口等参数 ├── data/ │ ├── raw/ # 原始日线、期权链快照只读不改 │ └── cache/ # 拉取后的 parquet 缓存 ├── src/ │ ├── loader.py # 行情/期权链读取统一列名与时区 │ ├── features.py # 特征工程历史波动率、均线偏离、量比 │ ├── pricing.py # BS 定价、隐含波动率反解、希腊值 │ ├── strategy.py # 组合腿的盈亏与保证金估算 │ ├── model.py # 训练、时间序列切分、概率输出 │ ├── recommend.py # 打分与排序产出候选合约 │ └── report.py # 图表与文本报告输出 ├── notebooks/ │ └── explore.ipynb # 只做探索不写业务逻辑 ├── tests/ │ └── test_pricing.py # 定价与希腊值的回归测试 └── main.py # 命令行入口串流程关键点在于pricing.py和strategy.py必须是纯函数模块输入是数字输出是数字不碰网络、不碰文件。所有 IO 都收在loader.py和report.py里。这样做的直接收益是定价算错了可以用固定输入一眼复现而不用担心“是不是今天行情接口返回了脏数据”。2.2 技术选型为什么是 pandas numpy 而不是重框架这块没什么好纠结的。整个项目的计算量级是“几百到几千行期权链 × 几十个交易日”pandas 加 numpy 的向量化足够引入分布式或深度学习框架只会让调试成本翻倍。具体分工是依赖用途备注pandas行情表、期权链、特征拼接统一用float64别混objectnumpy定价与希腊值向量化np.log/np.exp批量算比循环快一到两个数量级scipynorm.cdf、brentq求隐含波动率brentq比手写牛顿法稳因为 vega 在深度虚值时趋近 0scikit-learn分类模型与时间序列切分树模型对小样本更友好别一上来就上神经网络matplotlib / plotly盈亏图、隐含波动率微笑静态图用 matplotlib交互看盘用 plotlypyyaml参数外置利率、乘数绝对不能硬编码数据可视化这块我建议单独留一层不要和计算逻辑混在一起。原因很实际图形是给人看的需求变化快计算是给机器用的必须稳定。两者耦合在一起改一个配色都可能动到定价函数的调用链。2.3 最小可跑环境与第一次自检环境不需要复杂虚拟环境加依赖清单就行。真正重要的是装完之后先跑一次自检确认定价函数在标准输入下能算出教科书上的值而不是等到接上真实数据才发现公式抄错了。python -m venv .venv source .venv/bin/activate pip install pandas numpy scipy scikit-learn matplotlib pyyaml python -c from src.pricing import bs_price; print(bs_price(S100, K100, T1, r0.03, sigma0.2))最后一行是自检命令参数含义是标的价格 100、行权价 100、剩余期限 1 年、无风险利率 3%、波动率 20%。平价期权在无股息假设下的理论价大概在 9.4 到 9.5 之间如果你算出来是 0.94 或者 94那基本可以确定是单位问题——要么乘数多乘了 100要么年化利率被当成了百分比数值直接塞进公式。提示第一次跑通之前别急着接实时数据。先用固定参数把pricing.py的测试跑过再谈数据接入否则你分不清是模型错了还是数据错了。3. 计算器内核B-S 定价、隐含波动率与希腊值期权分析工具最容易被低估的就是计算器部分。很多人以为定价公式是现成的抄一段就完事但真正在实战里出问题的往往不是公式本身而是参数约定T 用什么单位、r 是年化还是当期、乘数是 100 还是 1、股息率放不放进去。这些约定一旦乱了后面所有建议都是错的。3.1 定价函数与输入参数约定先定约定T一律是剩余自然日除以 365r和q都是年化小数3% 写成 0.03sigma是年化隐含波动率kind用call/put。函数内部不读配置、不读文件全部靠参数传入。from math import log, sqrt, exp from scipy.stats import norm def bs_price(S, K, T, r, sigma, q0.0, kindcall): 欧式期权理论价。 S: 标的现价 K: 行权价 T: 剩余年限(日/365) r: 无风险利率(年化小数) sigma: 波动率(年化小数) q: 股息率 if T 0 or sigma 0: # 到期或波动率为 0 时退化为内在价值避免除零 intrinsic max(S - K, 0) if kind call else max(K - S, 0) return intrinsic d1 (log(S / K) (r - q 0.5 * sigma ** 2) * T) / (sigma * sqrt(T)) d2 d1 - sigma * sqrt(T) if kind call: return S * exp(-q * T) * norm.cdf(d1) - K * exp(-r * T) * norm.cdf(d2) return K * exp(-r * T) * norm.cdf(-d2) - S * exp(-q * T) * norm.cdf(-d1)这里的边界处理是有意为之。T 0时直接返回内在价值是因为回测里经常出现“持有到到期日”的场景那时候再用公式算会得到NaN一个NaN混进 DataFrame 就会污染整列求和结果。sigma 0同理深度实值合约的隐含波动率有时会被反解成负数或者极小值直接退化处理比抛异常更实用——实战里你更希望流程跑完、把异常行标记出来而不是中途崩掉。3.2 隐含波动率反解与失败兜底期权链里给的是价格不是波动率。要用 B-S 做分析就得从价格反解波动率。这里不要用牛顿法手写迭代vega 在深度虚值时接近 0迭代会发散。用brentq做区间二分稳定得多。from scipy.optimize import brentq import pandas as pd def implied_vol(price, S, K, T, r, q0.0, kindcall, lo1e-4, hi5.0): 从市场价格反解隐含波动率无解时返回 NaN 而不是抛异常 def gap(sigma): return bs_price(S, K, T, r, sigma, q, kind) - price # 价格必须落在 [内在价值, 标的价格] 之间否则区间内无根 intrinsic max(S - K, 0) if kind call else max(K - S, 0) if price intrinsic or price S * 1.5: return float(nan) try: return brentq(gap, lo, hi, maxiter200, xtol1e-6) except ValueError: return float(nan) def enrich_chain(chain: pd.DataFrame, S: float, r: float) - pd.DataFrame: 给整张期权链补上隐含波动率列 chain chain.copy() chain[iv] [ implied_vol(row[mid], S, row[strike], row[T], r, 0.0, row[kind]) for row in chain.to_dict(records) ] return chaingap函数减的是市场价格brentq会在[1e-4, 5.0]这个区间里找根即 0.01% 到 500% 的波动率范围覆盖绝大多数品种。xtol1e-6是收敛容差比默认值严一点代价是多几次迭代但能避免因为精度问题导致同一合约两次计算出来的 IV 不一致。enrich_chain里的价格过滤逻辑很关键。如果某个深度虚值合约的mid价格低于内在价值在没有套利机会的市场里理论上不该发生但盘口挂单确实存在这种价差反解会失败。这时候返回NaN而不是抛异常让调用方可以用dropna或者单独标记处理比让整个流程中断要好。注意成交量极低的合约mid价格本身就没有参考意义反解出来的 IV 是“算出来的数”而不是“市场共识”。后面第五章会专门讲这个坑。3.3 组合腿的盈亏计算器单个合约的定价只是原料真正要看的是组合。多腿策略价差、跨式、备兑的盈亏计算核心是把每条腿的收益加总乘数、方向、权利金三个要素一个都不能错。import numpy as np def strategy_payoff(legs, spot_range): legs: [{kind: call, strike: 100, premium: 3.2, qty: 1, sign: 1}, ...] sign: 1 表示买入(付权利金)-1 表示卖出(收权利金) spot_range: 到期时标的价格的取值数组 total np.zeros_like(spot_range, dtypefloat) for leg in legs: mult 100 * leg.get(qty, 1) # 标准期权乘数 strike, premium leg[strike], leg[premium] if leg[kind] call: intrinsic np.maximum(spot_range - strike, 0) else: intrinsic np.maximum(strike - spot_range, 0) # 买入付出权利金卖出收取权利金符号相反 total leg[sign] * mult * (intrinsic - premium) return total def breakeven_points(legs, spot_range): 找盈亏平衡点曲线穿过 0 的位置做线性插值 pnl strategy_payoff(legs, spot_range) idx np.where(np.diff(np.sign(pnl)) ! 0)[0] return [round(float(spot_range[i] - pnl[i] * (spot_range[i1] - spot_range[i]) / (pnl[i1] - pnl[i])), 2) for i in idx]spot_range一般用np.linspace(S * 0.7, S * 1.3, 400)生成400 个采样点足够让盈亏曲线的拐点清晰再多就是浪费算力。breakeven_points用了线性插值因为采样点是离散的真正的零点大概率落在两个采样点之间直接取整会差几毛钱——在期权这种杠杆品种上几毛钱的误差足以让结论反转。组合计算器还有一个容易忽略的输出最大亏损和最大盈利。对于有限风险策略比如牛市价差最大亏损是有界的对于裸卖空看涨理论最大亏损无界。这个判断不该靠人眼看图最好在strategy.py里写一个risk_profile()函数根据腿的构成自动判定风险类型输出给前端做展示标签。4. 预测与建议从特征表到推荐打分计算器解决的是“给定假设盈亏是多少”预测模块解决的是“假设本身靠不靠谱”。这两块必须分开因为它们的验证方式完全不同计算器用固定输入做回归测试模型用时间序列做样本外验证。混在一起写最后你连是模型错了还是公式错了都分不清。4.1 特征表怎么拼出来特征这块的原则是能用价格和成交量算出来的就别引入外部数据。历史波动率、均线偏离、量比这三类特征在小样本上表现稳定而且可解释性强出问题好排查。import numpy as np import pandas as pd def build_features(df: pd.DataFrame, window: int 20, horizon: int 5) - pd.DataFrame: df 至少包含 close / volume 两列按日期升序 window: 波动率与均线的回看窗口 horizon: 预测未来多少天的方向 out df.copy() out[ret_1] out[close].pct_change() # 年化历史波动率252 是交易日数 out[hv] out[ret_1].rolling(window).std() * np.sqrt(252) # 收盘价相对均线的偏离度衡量短期强弱 out[ma_gap] out[close] / out[close].rolling(window).mean() - 1 # 量比当日成交量 / 近 window 日均量 out[vol_ratio] out[volume] / out[volume].rolling(window).mean() # 标签未来 horizon 日收益率是否为正 out[label] (out[close].shift(-horizon) / out[close] - 1 0).astype(int) # 最后 horizon 行没有标签必须丢掉否则训练时会把 NaN 当 0 return out.dropna().reset_index(dropTrue)三个特征各有分工hv描述波动水平直接影响你该选虚值还是平值ma_gap描述趋势位置用来判断方向概率的偏置vol_ratio描述资金关注度低量比的信号可靠性差。label用的是shift(-horizon)也就是未来数据这是训练标签的正确用法但也是最容易在特征列里误用的地方——第五章会专门讲这个坑。dropna()那一步不能省。滚动窗口的前window行是空的shift(-horizon)的后horizon行也是空的。如果只fillna(0)不删除模型会学到“特征全 0 时标签是 0”这种根本不存在的规律。4.2 模型训练与时间序列切分模型选型上我一般先用梯度提升树做基线特征只有个位数的时候逻辑回归也够用。重点不在模型复杂度而在切分方式时间序列绝对不能随机打乱。from sklearn.ensemble import GradientBoostingClassifier from sklearn.metrics import roc_auc_score FEATURES [hv, ma_gap, vol_ratio] def train_and_eval(feat: pd.DataFrame, split_ratio: float 0.8): X, y feat[FEATURES].values, feat[label].values # 按时间顺序切分前 80% 训练后 20% 验证 split int(len(X) * split_ratio) X_train, X_test X[:split], X[split:] y_train, y_test y[:split], y[split:] model GradientBoostingClassifier( n_estimators120, max_depth2, learning_rate0.05, subsample0.8, random_state42 ) model.fit(X_train, y_train) proba model.predict_proba(X_test)[:, 1] return model, roc_auc_score(y_test, proba), probamax_depth2是刻意的。金融特征信噪比低树深了必然过拟合训练集 AUC 能到 0.9样本外直接掉到 0.5 附近。subsample0.8是行采样进一步压方差。roc_auc_score只看排序能力不看绝对概率准不准这符合我们的用途——后面打分只用到概率的相对大小。如果样本外 AUC 长期在 0.48 到 0.52 之间徘徊别急着换模型。先检查特征是不是有前视偏差再检查标签的构造方式。很多时候不是模型不行是标签本身就没定义清楚。4.3 推荐打分把概率折算成可排序的分数模型输出的是“上涨概率”但选合约不能只看方向。方向对了合约选贵了照样亏。所以最终打分要把三个维度加权方向概率、盈亏比、流动性。def score_candidates(chain: pd.DataFrame, prob_up: float, cfg: dict) - pd.DataFrame: chain 需包含: iv, delta, mid, volume, strike, T cfg: {w_dir: 0.4, w_ev: 0.4, w_liq: 0.2, target_delta: 0.35} df chain.copy() # 方向分看涨期权用 prob_up看跌期权用 1 - prob_up df[dir_score] np.where(df[kind] call, prob_up, 1 - prob_up) # 盈亏比分用 delta 近似到期收益 / 权利金越接近目标 delta 分越高 df[ev_ratio] df[delta].abs() / df[mid].clip(lower0.01) df[ev_score] 1 - (df[delta].abs() - cfg[target_delta]).abs().clip(0, 1) # 流动性分成交量取对数后归一化冷门合约直接判 0 df[liq_score] np.where( df[volume] cfg.get(min_volume, 50), 0, np.log1p(df[volume]) / np.log1p(df[volume].max()) ) df[score] ( cfg[w_dir] * df[dir_score] cfg[w_ev] * df[ev_score] cfg[w_liq] * df[liq_score] ) return df.sort_values(score, ascendingFalse).reset_index(dropTrue)三个权重的含义要说清楚。w_dir是方向权重来自模型概率最不稳定所以默认只给 0.4w_ev是性价比权重target_delta0.35表示偏好轻度虚值合约因为平值太贵、深虚又容易归零w_liq是流动性权重min_volume50是一条硬阈值低于这个数直接判 0 分不管模型多看好都不推荐——买不进去或者卖不出来的合约理论收益再高也没意义。ev_ratio那一列其实只是给人看的参考值真正进公式的是ev_score。这种“算出来但不参与加权”的列在实战里很有用排查问题时能直观看到某个合约为什么分高。注意这套打分只做候选排序不构成任何投资建议。它输出的是“在给定假设下相对更合理的选项”假设本身错了排序再漂亮也没用。5. 避坑与排查期权数据里最容易翻车的五个地方这一章是我踩过的坑里最费时间的五个。共同点是代码能跑通、结果看着正常但结论是错的。这类问题最难查因为没有报错。5.1 现象反解出一堆 300% 以上的隐含波动率原因低价合约的买卖价差极大mid价格可能是 0.05 对 0.35取中间值 0.20 完全没有市场意义。用这个价格反解波动率自然离谱。解决在enrich_chain之前加一道过滤价差超过mid的 30% 或者成交量低于阈值直接标记为无效iv置NaN并在展示层灰掉。同时在 report 里输出“有效合约占比”这个比例低于 60% 就说明当天期权链质量差不该采信排序结果。5.2 现象模型训练集 AUC 0.95样本外 0.49原因特征里混进了未来数据。常见的是用整段数据的均值和标准差做标准化或者用rolling之后又做了一次shift方向搞反。更隐蔽的是标签泄漏——把未来的收益率也算进了某个“波动率”特征里。解决所有滚动计算必须在shift之前完成标准化用训练集的均值方差去 transform 测试集绝不 fit 全量。写完特征函数后做一次“时间点断言”随机抽一行手工确认它用到的所有数据都早于该行日期。这个检查我一般写成测试用例跑一次几秒钟能省掉几天排查。5.3 现象回测收益率漂亮得不像话实盘完全对不上原因用了未复权价格算收益率。除权除息那天价格跳空pct_change()会算出一个巨大的假跌幅模型学到了这个假信号回测自然好看。解决统一用后复权价格做特征和收益率计算展示层再换算回实际价格。数据加载时就固定这个约定写在loader.py的函数注释里别让调用方自己决定。5.4 现象长周期合约的理论价总是偏低原因无风险利率被硬编码成 0.02或者是用了一个很久没更新的常数。短周期影响不大剩余期限一年以上时利率差 1% 能让理论价差出好几个百分点。解决把利率放进settings.yaml并且带上生效日期字段。每次跑之前打印一行“当前使用利率 X%生效日期 Y”让使用者一眼看到参数是不是过期了。这个习惯很土但确实管用。5.5 现象组合盈亏图方向完全反了原因sign和qty混用或者乘数位置放错。比如卖出看涨写成了sign1, qty-1两处都表达方向结果互相抵消。解决约定qty永远是正数只表示数量方向只由sign表达。在strategy_payoff入口加一行断言assert all(leg[qty] 0 for leg in legs)违反约定立刻报错比事后看图发现方向反了要省事得多。6. 二次开发把它改成你自己的选权面板这套骨架真正的价值不在现成功能而在它留出的改造口子。最常改的三个地方打分权重、特征集、输出形态。打分权重可以直接丢进settings.yaml不用改代码。我一般会先跑一段历史数据把w_dir从 0 到 0.8 做个网格看样本外收益率的稳定性而不是看单次收益高点。单次高点大概率是运气稳定性才是可复现的。# config/settings.yaml risk_free_rate: 0.031 rate_effective_date: 2024-06-01 multiplier: 100 scoring: w_dir: 0.35 w_ev: 0.45 w_liq: 0.20 target_delta: 0.35 min_volume: 50 features: window: 20 horizon: 5加新特征的做法是在build_features里加一列同时把列名追加到model.py的FEATURES列表然后在回测里对比加了这列和没加这列的样本外 AUC。如果提升不到 0.01我一般不加——特征越多过拟合的风险和维护成本越高收益不成正比就不值得。验证方法上除了单元测试我会额外做一次“历史回放”取过去 60 个交易日每天用当时可见的数据跑一遍完整的选权流程把推荐结果和实际走势存成表最后统计命中率和平均盈亏比。这个回放脚本不追求快一天跑几秒都行关键是它模拟了真实使用场景——你盘前能拿到的数据就是那天之前的数据。改造方向改动位置验证方式换打分权重settings.yaml历史回放的稳定性曲线加特征features.pyFEATURES样本外 AUC 增量换模型model.py时间序列交叉验证加策略模板strategy.py固定输入的盈亏回归测试换输出格式report.py人工核对一期报告有一个细节值得单独说report.py里生成的盈亏图横轴范围别写死成S*0.7到S*1.3。遇到大幅波动的时候拐点可能落在范围外图上看不出最大亏损。我一般会根据所有腿的行权价范围动态算一个区间再往两边各留 20% 的余量。从那以后我每次加新特征或者调权重都强制走一遍历史回放哪怕只改了一个数字。因为这套东西的输出是给人做决策用的一次没验证的改动代价可能远超过省下的那二十分钟。希望帮到你。本文还有配套的精品资源点击获取