简介:一套围绕股票与期权筛选与决策的网页分析工具及其 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,别混object |
| numpy | 定价与希腊值向量化 | np.log/np.exp批量算比循环快一到两个数量级 |
| scipy | norm.cdf、brentq求隐含波动率 | brentq比手写牛顿法稳,因为 vega 在深度虚值时趋近 0 |
| scikit-learn | 分类模型与时间序列切分 | 树模型对小样本更友好,别一上来就上神经网络 |
| matplotlib / plotly | 盈亏图、隐含波动率微笑 | 静态图用 matplotlib,交互看盘用 plotly |
| pyyaml | 参数外置 | 利率、乘数绝对不能硬编码 |
数据可视化这块我建议单独留一层,不要和计算逻辑混在一起。原因很实际:图形是给人看的,需求变化快;计算是给机器用的,必须稳定。两者耦合在一起,改一个配色都可能动到定价函数的调用链。
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(S=100, K=100, T=1, r=0.03, sigma=0.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一律是剩余自然日除以 365,r和q都是年化小数(3% 写成 0.03),sigma是年化隐含波动率,kind用"call"/"put"。函数内部不读配置、不读文件,全部靠参数传入。
from math import log, sqrt, exp from scipy.stats import norm def bs_price(S, K, T, r, sigma, q=0.0, kind="call"): """ 欧式期权理论价。 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, q=0.0, kind="call", lo=1e-4, hi=5.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, maxiter=200, xtol=1e-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% 的波动率范围,覆盖绝大多数品种。xtol=1e-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, dtype=float) 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[i+1] - spot_range[i]) / (pnl[i+1] - 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(drop=True)三个特征各有分工: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_estimators=120, max_depth=2, learning_rate=0.05, subsample=0.8, random_state=42 ) model.fit(X_train, y_train) proba = model.predict_proba(X_test)[:, 1] return model, roc_auc_score(y_test, proba), probamax_depth=2是刻意的。金融特征信噪比低,树深了必然过拟合,训练集 AUC 能到 0.9,样本外直接掉到 0.5 附近。subsample=0.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(lower=0.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", ascending=False).reset_index(drop=True)三个权重的含义要说清楚。w_dir是方向权重,来自模型概率,最不稳定,所以默认只给 0.4;w_ev是性价比权重,target_delta=0.35表示偏好轻度虚值合约,因为平值太贵、深虚又容易归零;w_liq是流动性权重,min_volume=50是一条硬阈值,低于这个数直接判 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混用,或者乘数位置放错。比如卖出看涨写成了sign=1, 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.py+FEATURES | 样本外 AUC 增量 |
| 换模型 | model.py | 时间序列交叉验证 |
| 加策略模板 | strategy.py | 固定输入的盈亏回归测试 |
| 换输出格式 | report.py | 人工核对一期报告 |
有一个细节值得单独说:report.py里生成的盈亏图,横轴范围别写死成S*0.7到S*1.3。遇到大幅波动的时候,拐点可能落在范围外,图上看不出最大亏损。我一般会根据所有腿的行权价范围动态算一个区间,再往两边各留 20% 的余量。
从那以后我每次加新特征或者调权重,都强制走一遍历史回放,哪怕只改了一个数字。因为这套东西的输出是给人做决策用的,一次没验证的改动,代价可能远超过省下的那二十分钟。希望帮到你。
本文还有配套的精品资源,点击获取