
简介面向股票与期权分析学习者及量化爱好者的一套Python分析资源围绕标的挑选、价格预测与交易建议展开适合具备一定数据分析基础、希望把Excel原型迁移到可复现分析流程的读者。上游文件清单显示文件总数为0文件类型明细暂缺压缩包整体约325.82MB描述提到主文件夹含CSS与作为构建基础的Excel原型并附有从网络摘取后直接使用的Excel片段。已有163人学习下载。内容覆盖分析、数据科学、推荐引擎、决策与预测、博弈论及后续机器学习模块摘要分析可输入代码依据历史波动率估算30天至一年价格并用Wycoff方法给出牛熊算法观点推荐选项卡提供相似标的建议。适合用来理解预测性分析、计算器与建议系统如何串联形成股票与期权筛选的决策辅助思路。1. 选股还是选期权先把两类决策塞进同一张评分表一个人同时看着股票账户和期权链的时候最难受的不是判断涨跌而是判断完之后不知道该用哪个工具去表达。看好一只票可以买正股可以卖 covered call 收权利金也可以买个跨式赌波动三条路的资金占用、最大亏损、盈亏平衡点完全不同。这个股票期权分析项目就是来解决这件事的一套 Python 写的分析器pandas 管行情、scikit-learn 做预测性排序、内置计算器算到期损益最后把股票候选和期权合约候选放进同一张评分表里比较。它不替你下单只把做决策需要看的几列压到一屏。适合已经懂行权价、到期日、隐含波动率这些概念手上有 Python 环境想自己改评分权重的开发者。2. 技术栈与工程骨架pandas scikit-learn 撑起的五层结构2.1 技术选型为什么不上数据库、不上 Web 框架这个项目的体量决定了很多东西不该上。数据是每天几十只标的的日线加上盘中几张期权链快照落地成 parquet 或者 csv 完全够用上数据库反而多了一层要维护的东西。前端也不需要一整套前后端分离交互式看板用 streamlit 这种单文件就能起服务的方案最省事改一行代码刷新页面就能看到效果调试体验比打包构建好太多。真正要花心思的是计算层。股票侧的特征计算靠 pandas 的 rolling、shift 这类窗口函数几十行就能把动量、波动率、量比算出来期权侧的定价和希腊字母必须用 numpy scipy闭式解里全是norm.cdf隐含波动率求解得靠brentq做区间求根。预测那块用 scikit-learn 的TimeSeriesSplit和轻量模型GBDT 或 Ridge就够了标的数量在几十到几百这个量级上深度学习的收益远不如把特征对齐做干净。层依赖职责能否替换数据接入pandas 任意行情接口拉日线、拉期权链、写缓存可替换接口固定定价内核numpy scipyBS 定价、隐含波动率、希腊字母不建议换口径要统一特征与预测pandas scikit-learn特征表、模型训练、打分可替换模型计算器numpy损益曲线、盈亏平衡点可扩展策略模板交互层streamlit / plotly / matplotlib看板、图表、导出可整体换掉选型上有一条明确的边界定价内核只依赖 numpy 和 scipy不碰 pandas。原因是定价函数会被特征层、计算器层、可视化层同时调用如果它接收 DataFrame 返回 DataFrame任何一次列名改动都会引发三处连锁报错。让内核只吃标量、返回标量或者小字典是这类项目里最省后悔药的一个决定。2.2 目录结构与模块边界每个包只干一件事options-lab/ ├── config/ │ ├── settings.yaml # 无风险利率、年化因子、数据路径 │ └── strategy_rules.yaml # 评分权重、流动性阈值、筛选条件 ├── data/ │ ├── raw/ # 原始快照按日期分区 │ └── cache/ # 清洗后的 parquet ├── src/ │ ├── datasource/ │ │ ├── base.py # 抽象基类get_daily / get_chain │ │ └── adapter.py # 具体实现换数据源只改这里 │ ├── pricing/ │ │ ├── black_scholes.py # 定价 隐含波动率 │ │ └── greeks.py # delta / gamma / vega / theta │ ├── features/ │ │ ├── price_features.py # 股票侧窗口特征 │ │ └── chain_features.py # 期权链聚合ATM IV、PCR、偏斜 │ ├── models/ │ │ ├── trainer.py # 时序交叉验证 模型落盘 │ │ └── scorer.py # 多因子加权排序 │ ├── calc/ │ │ └── payoff.py # 损益曲线与盈亏平衡点 │ ├── viz/ │ │ └── charts.py # 波动率微笑、损益图 │ └── app/ │ └── dashboard.py # streamlit 入口 ├── notebooks/ # 探索性分析不进生产路径 ├── tests/ │ ├── test_pricing.py # 用已知解校验 BS │ └── test_payoff.py # 校验盈亏平衡点 └── requirements.txt这个结构里最容易被人改乱的是features和models的边界。我的划分标准很简单凡是只依赖原始行情就能算出来的列全部归 features凡是需要拟合参数、需要训练集才能产出的列全部归 models。这样在做时间序列验证的时候只要保证 features 的输出按日期排序且不含未来信息models 那边的划分就不会出问题。反过来如果把特征工程塞进模型训练流程里做 walk-forward 时很容易不小心用全量数据算了一次标准化然后得到一个漂亮得离谱的离线指标。datasource单独抽一层的价值在于数据源会变。免费接口的字段命名、限流规则、期权链返回结构各不相同甚至有段时间拿到的链是延迟快照。把所有来源差异收敛在adapter.py里上层永远看到同一套 schema换源的时候只需要改一个文件。2.3 三步把环境跑起来# 1) 独立虚拟环境别和系统里的 pandas 版本打架 python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -U pip # 2) 安装依赖scipy 和 scikit-learn 建议锁小版本 pip install pandas numpy scipy scikit-learn matplotlib plotly streamlit pyyaml # 3) 先跑测试再跑看板定价内核不对的话后面全是白干 pytest tests/ -q streamlit run src/app/dashboard.pypytest tests/这一步很多人会跳过结果踩坑踩得莫名其妙。test_pricing.py里一般会放两组断言一组是到期日 T 趋近 0 时看涨期权价格等于内在价值max(S-K, 0)另一组是用一个已知的平值期权参数反解隐含波动率解出来的值要能还原输入价格误差在 1e-4 以内。这两条过了说明定价和求根至少没有符号错误。streamlit run启动后默认在本地起一个服务页面上的标的池、到期日、策略模板都是可选的第一次跑建议先把标的池缩到三五个把整条链路走通再放大。3. 数据层与指标计算从行情快照到波动率与希腊字母3.1 数据适配层把不同来源的字段名压平成一套 schema期权链是最脏的一块数据。同一个行权价可能只有买价没有卖价深度虚值的合约成交量是 0挂单价却是 0.01到期日有的给日期字符串有的给天数。如果不做归一化后面每一个计算模块都要写一遍防御逻辑最后整个项目变成一堆 if。import pandas as pd # 数据源字段 - 项目内部字段只映射存在的列 CHAIN_COLUMNS { expiration: expiry, strike_price: strike, option_type: cp, bid_price: bid, ask_price: ask, last_price: last, vol: volume, open_interest: oi, } def normalize_chain(raw: pd.DataFrame, underlying: str, snapshot_ts) - pd.DataFrame: 把任意来源的期权链压平成统一 schema。 raw: 行情接口返回的原始表 underlying: 标的代码写进每一行方便跨标的一次性聚合 snapshot_ts: 快照时间戳必须是数据产生时间不是抓取完成时间 df raw.rename(columns{k: v for k, v in CHAIN_COLUMNS.items() if k in raw.columns}) df[underlying] underlying df[snapshot_ts] pd.to_datetime(snapshot_ts, utcTrue) df[expiry] pd.to_datetime(df[expiry]) df[cp] df[cp].astype(str).str.lower().map({c: call, p: put, call: call, put: put}) # 用中间价而不是最新价最新价可能是几小时前的成交代表性差 df[mid] (df[bid].fillna(0) df[ask].fillna(0)) / 2 # 买价或卖价为 0 的合约基本没有流动性打个标记打分时统一扣分 df[illiquid] (df[bid].fillna(0) 0) | (df[ask].fillna(0) 0) # 剩余年限在这里一次算好全项目不再重复计算避免口径漂移 df[T] (df[expiry] - df[snapshot_ts].dt.tz_localize(None)).dt.days / 365.0 return df[df[T] 0].reset_index(dropTrue)几个参数值得单独说。snapshot_ts用 UTC 存是为了避免跨时区比较时出现「同一时刻的两张链差了八小时」这种玄学问题T用自然日除以 365 算如果你更习惯交易日除以 252全项目必须统一在settings.yaml里配置一个day_count字段不要在不同模块里各写各的。mid用买卖中间价是行业常见做法但遇到买卖价差极大比如买 0.05 卖 0.60的合约mid 会严重高估真实成交价所以流动性标记是必须的它会在后面的打分环节把这个合约压下去。3.2 定价内核Black-Scholes 与隐含波动率的稳健求解期权分析里几乎所有判断都绕不开一个量隐含波动率。行情接口给的 IV 常常是空的或者是脏的自己反解一遍是常规操作。反解的核心是给定市场价格求使得 BS 理论价格等于市场价格的波动率。import numpy as np from scipy.stats import norm from scipy.optimize import brentq def bs_price(S, K, T, r, sigma, q0.0, cpcall): 欧式期权闭式解。 S: 标的价格 K: 行权价 T: 剩余年限年 r: 无风险利率连续复利 sigma: 年化波动率 q: 连续股息率 if T 0 or sigma 0: return max(S - K, 0) if cp call else max(K - S, 0) d1 (np.log(S / K) (r - q 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T)) d2 d1 - sigma * np.sqrt(T) if cp call: 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 implied_vol(price, S, K, T, r, q0.0, cpcall, lo1e-4, hi5.0): 区间求根反解 IV。返回 nan 表示这个价格在模型下无解。 intrinsic max(S - K, 0) if cp call else max(K - S, 0) # 价格低于内在价值 - 模型无解直接丢弃比硬凑一个数更安全 if not np.isfinite(price) or price intrinsic 1e-8 or T 0: return np.nan f lambda s: bs_price(S, K, T, r, s, q, cp) - price if f(lo) * f(hi) 0: # 两端同号说明根不在区间内 return np.nan return brentq(f, lo, hi, maxiter200, xtol1e-6)brentq相比牛顿法最大的好处是不需要好的初值。期权链里深度实值和深度虚值的合约 Vega 极小牛顿法从 0.2 出发经常一步跳到负数或者直接发散然后你会看到 IV 出现-0.03这种不可能的值。区间求根加上定价函数开头的内在价值检查能把绝大多数脏数据挡在外面剩下的返回 nan交给上层过滤。hi5.0对应 500% 波动率已经覆盖了绝大多数极端情况如果你的标的池里有事件驱动型的品种可以临时调高但别调成 20区间太宽会让求根精度下降。希腊字母用同一组 d1、d2 就能顺手算出来注意 vega 和 theta 的单位换算两者的口径如果不统一做策略对比的时候会得出完全相反的结论。def greeks(S, K, T, r, sigma, q0.0, cpcall): d1 (np.log(S / K) (r - q 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T)) d2 d1 - sigma * np.sqrt(T) delta np.exp(-q * T) * (norm.cdf(d1) if cp call else norm.cdf(d1) - 1) gamma np.exp(-q * T) * norm.pdf(d1) / (S * sigma * np.sqrt(T)) vega S * np.exp(-q * T) * norm.pdf(d1) * np.sqrt(T) / 100 # 波动率每变 1 个点 base -S * np.exp(-q * T) * norm.pdf(d1) * sigma / (2 * np.sqrt(T)) if cp call: theta (base - r * K * np.exp(-r * T) * norm.cdf(d2) q * S * np.exp(-q * T) * norm.cdf(d1)) / 365 # 每日衰减 else: theta (base r * K * np.exp(-r * T) * norm.cdf(-d2) - q * S * np.exp(-q * T) * norm.cdf(-d1)) / 365 return {delta: delta, gamma: gamma, vega: vega, theta: theta}vega 除以 100、theta 除以 365是为了让这两个数字读起来符合直觉vega 表示隐含波动率变动一个百分点时期权价格变动多少theta 表示持有一天的理论损耗。这两个换算在greeks.py顶部写死成常量不要在调用处临时除否则迟早有一处漏掉。3.3 特征表让股票和期权可以用同一套列比较股票和期权本来是两种东西要放在一张表里排序就得先找出一组可比的维度。这个项目里我用了五个趋势、估值期权侧是 IV 相对历史波动率的溢价、流动性、风险、模型分。每个维度都从原始数据加工出来输出成 0 到 1 之间的分位数这样量纲就被抹平了。特征计算里最容易翻车的是「用了当天数据算当天信号」。价格类特征一律shift(1)也就是用昨天及之前的数据算今天的信号实盘才成立。开发阶段为了曲线好看而省掉这个 shift是这类项目最经典的数据泄漏。def build_price_features(px: pd.DataFrame) - pd.DataFrame: px: 日线至少包含 date / close / volume按日期升序 df px.sort_values(date).copy() ret df[close].pct_change() df[vol_20d] ret.rolling(20).std() * np.sqrt(252) # 年化历史波动率 df[mom_20d] df[close].shift(1) / df[close].shift(21) - 1 df[mom_60d] df[close].shift(1) / df[close].shift(61) - 1 df[vol_ratio] df[volume].shift(1) / df[volume].shift(1).rolling(20).mean() return df[[date, vol_20d, mom_20d, mom_60d, vol_ratio]].dropna()注意mom_20d用的是shift(1)和shift(21)差值正好是 20 个交易日含义是「截至昨天的 20 日动量」。写成close / close.shift(20)也能算出一个数但那个数包含了今天的收盘价在盘中生成信号时是不存在的。这类细节在 notebook 里无所谓一旦接进每日跑批的流程就是实盘和回测对不上的直接原因。期权链侧的特征走另一条路把某个到期日附近的合约按行权价排序取平值附近的 IV 作为iv_atm用虚值看跌和虚值看涨的 IV 差算偏斜用看跌未平仓量除以看涨未平仓量算 PCR。这三个指标合起来大致能描述「市场给这个标的的波动定了多贵的价」。4. 预测、建议与计算器评分排序怎么落到具体合约4.1 预测模块时间序列交叉验证与特征对齐预测模块输出的不是「明天涨还是跌」这种二元判断而是一个概率或者排序分。这个区别很重要二元判断的准确率做到 55% 就已经很难而且一旦错一次使用者对整个系统的信任就没了排序分允许你说「这批标的里前 5 个相对更值得看」容错空间大得多也更符合实际用法。标签定义通常有两种未来 N 日收益率是否超过某个阈值或者未来 N 日收益率在截面上的分位数。后者更稳因为它自动适应了不同市场环境下的整体涨跌。from sklearn.model_selection import TimeSeriesSplit from sklearn.ensemble import GradientBoostingClassifier import numpy as np def make_label(df, horizon5, q0.6): 截面分位标签未来 horizon 日收益排进前 40% 记 1否则 0 fwd df.groupby(date)[close].shift(-horizon) / df[close] - 1 df df.assign(fwd_retfwd) df[label] df.groupby(date)[fwd_ret].transform(lambda s: (s s.quantile(q)).astype(int)) return df.dropna(subset[fwd_ret]) def train(df, feature_cols): 按时间切分绝不 shuffle。最后一段留作样本外验证。 df df.sort_values(date) X, y df[feature_cols].values, df[label].values tscv TimeSeriesSplit(n_splits5, gap5) # gap 至少等于标签窗口切断重叠 scores [] for tr, va in tscv.split(X): model GradientBoostingClassifier( n_estimators200, max_depth3, learning_rate0.05, subsample0.8, random_state42) model.fit(X[tr], y[tr]) scores.append(model.score(X[va], y[va])) model.fit(X, y) # 全量重训用于生产 return model, float(np.mean(scores))gap5这个参数很多人会漏。如果标签是未来 5 日收益训练集最后 5 行的标签其实用到了验证集前几天的价格训练和验证之间就有了重叠离线分数会虚高。设一个等于 horizon 的 gap 就能切断这层泄漏。max_depth3是刻意压低的金融特征的信噪比很低树深了必然过拟合离线 AUC 冲到 0.8 以上反而应该警惕。模型输出的概率直接当作model_score进入下一步不做阈值截断。截断会把排序信息压成 0/1而后面真正需要的是「谁比谁更好一点」这种细粒度差异。4.2 建议模块多因子打分而不是单一信号到了建议层要把模型分和前面算出来的趋势、波动率溢价、流动性、风险合成一个总分。合成本身不难难的是权重和异常值处理。直接加权求和的最大问题是某个因子出现极端值时会把总分带偏比如某天一个标的的 PCR 突然变成 50不加处理的话它一个人就能决定排名。WEIGHTS {trend: 0.30, value: 0.25, liquidity: 0.20, risk: 0.15, model: 0.10} def rank_candidates(df, weightsWEIGHTS): out df.copy() score_cols [trend_score, value_score, liquidity_score, risk_score, model_score] for col in score_cols: # 截面 z-score 后裁剪到 [-3, 3]极端值只保留方向不保留量级 z (out[col] - out[col].mean()) / (out[col].std(ddof0) 1e-9) out[col _z] z.clip(-3, 3) out[total] sum(weights[k] * out[k _score_z] for k in weights) out[rank] out[total].rank(ascendingFalse, methodmin).astype(int) return out.sort_values(total, ascendingFalse)权重放在strategy_rules.yaml里而不是写死在函数默认参数里是为了让调参这件事不需要改代码。我的习惯是先跑一遍等权看各因子的排名相关性如果两个因子高度相关比如趋势和动量就把其中一个的权重降下来否则等于同一个信息被计了两次。这个模块最重要的一条设计约束是它输出的是排序和分项得分不输出「买入」「卖出」这种词。分项得分能让使用者看到「这个标的排第一是因为流动性好但趋势一般」从而自己判断要不要跟。一旦系统给出明确动作使用者就会把它当黑匣子出错的时候完全无法定位。4.3 计算器盈亏平衡点、最大亏损与策略对比计算器是整个项目里使用频率最高的部分因为它回答的是最具体的问题这个组合到期时标的价格到多少我才不亏。核心逻辑是把多条腿的到期损益加起来然后找零点。import numpy as np def payoff_curve(legs, spot_range, multiplier100): legs: [{cp:call,strike:100,premium:3.2,qty:1,side:long}] qty 恒为正方向由 side 决定premium 是每股权利金 pnl np.zeros_like(spot_range, dtypefloat) for leg in legs: intrinsic (np.maximum(spot_range - leg[strike], 0) if leg[cp] call else np.maximum(leg[strike] - spot_range, 0)) sign 1 if leg[side] long else -1 pnl sign * leg[qty] * (intrinsic - leg[premium]) * multiplier return pnl def summarize(legs, spot_now, width0.5, multiplier100): spots np.linspace(spot_now * (1 - width), spot_now * (1 width), 2000) pnl payoff_curve(legs, spots, multiplier) zeros np.where(np.diff(np.sign(pnl)) ! 0)[0] return { breakevens: [round(float(spots[i]), 2) for i in zeros], max_profit: round(float(pnl.max()), 2), max_loss: round(float(pnl.min()), 2), capital: round(float(sum(l[premium] * l[qty] * multiplier for l in legs)), 2), }multiplier100是标准合约乘数如果你的标的池里有乘数不同的品种这个参数必须从合约元数据里读不能写死。width0.5控制扫描区间的宽度默认正负 50%做宽跨式这类需要看很远的策略时可以调到 1.0。zeros用符号变化来找比解析求解更通用因为多腿组合的盈亏函数是分段线性的用解析法要写一堆分段判断。这个计算器真正的价值在于横向对比。同一个标的直接买正股的资金占用是股价乘以股数卖 covered call 能收回一部分权利金但收益被封顶买跨式的最大亏损就是权利金总额。把这几条曲线画在同一张图上选哪个就不用纠结了。手续费和滑点在这个函数里没有计如果要做更贴近实际的对比在pnl上加一个常数项扣减即可。5. 排查与避坑期权分析项目最容易翻车的五个地方5.1 数据口径类的坑现象回测的收益曲线中间突然跳空某一天净值凭空少了 30%。原因通常是没有做复权处理。原始价格遇到分红或者拆股时会出现断崖用不复权的价格算收益率等于凭空制造了一次巨额亏损。解决方式是把两件事分开算收益率、算动量、算波动率全部用复权价算行权价和期权链的对应关系用原始价。很多接口会同时提供这两列如果只给一列那就自己按分红记录补一份复权因子表。这个坑我踩过一次排查了半天以为是数据接口漏了交易日最后发现是除权日。现象不同标的的评分横向比较时排名完全颠倒单独看每个标的又都正常。原因是期权链快照时间不一致。接口返回的数据是按标的逐个拉的第一个标的拿到的是开盘后半小时的快照第十个拿到的是午盘的两者的 IV 水位根本没法比。解决办法是给每一行打上snapshot_ts聚合前先按时间窗口分组只保留同一时段比如误差 15 分钟内的数据凑不齐的标的当天直接不参与排名。宁可少几个候选也不要拿错位的快照做比较。现象同一套策略在两个模块里算出来的年化收益差了一倍。这是年化因子不统一造成的。历史波动率可能用了 252 个交易日隐含波动率反解时 T 用的是自然日除以 365到期损益的年化又用了 365 天。三个口径混在一起数字当然对不上。处理方式是建一个constants.py里面只有TRADING_DAYS 252、CALENDAR_DAYS 365、DAY_COUNT三个常量所有涉及年化的地方一律引用不允许在任何函数里出现裸写的 252 或 365。现象隐含波动率批量计算后出现大量 NaN或者出现 0.0001 这种极小值。前者通常是合约价格低于内在价值模型在数学上无解后者是牛顿法发散后的残留。深度实值的看涨期权其价格几乎等于 S 减去 K 的现值时间价值只剩几分钱用任何数值方法都很难稳定反解。处理方式是在定价函数开头加内在价值检查求根时限制上下界并对结果做一次合理性裁剪——IV 小于 1% 或者大于 500% 的一律置为 NaN交给上层过滤。不要试图用更复杂的数值方法救这些点它们本来就没有信息量。5.2 模型与计算类的坑现象离线验证的准确率做到 0.85实盘跑了两周发现完全没用。这类问题九成是数据泄漏。常见来源有四个划分数据时用了train_test_split的默认随机打乱特征里包含了当天收盘价却没有 shift标签窗口和训练集尾部重叠却没有在TimeSeriesSplit里设 gap标准化或者缺失值填充用全量数据 fit 之后再 transform 训练集。每一个单独出现都足以让离线分数虚高。我的排查顺序是先看划分方式再看特征 shift最后看预处理是否 fit 在训练集上。做这类项目离线指标明显高于常识的时候第一反应应该是去找泄漏而不是庆祝。现象计算器算出的盈亏平衡点和手算差了 0.01但用它排序时结果完全变了。浮点误差在单次计算里可以忽略但在排序场景下会被放大。两个组合的盈亏平衡点都是 100.00 附近一个算出 100.004一个算出 99.996排序后它们的位置就分出了先后而这个先后其实没有意义。解决办法是在排序前对数值做量化比如统一保留两位小数或者给排序加一个容差区间差在 0.01 以内的视为并列。同时检查合约乘数和手续费是否在两条路径里都计了最容易被漏掉的是卖出腿的权利金收入有的实现直接不扣成本导致卖权策略的盈亏被系统性高估。现象看板上的策略对比图卖权组合的最大亏损显示为一个很大的负数但实际保证金占用并没有那么多。到期损益和保证金占用是两个概念前者是价格走到极端时的理论亏损后者是券商要求的资金。很多实现把「最大亏损」直接当成资金占用去算收益率结果算出一个荒谬的数字。正确做法是分开两个字段max_loss表示损益曲线的最小值capital表示实际占用的资金收益率用max_profit / capital和max_loss / capital分别表达。对于裸卖看涨这种理论亏损无上限的组合max_loss在有限扫描区间内取到的值本身就是有偏的界面上必须标注这是区间内的最小值不能当成真实上限。6. 二次开发把评分权重和回测回路交到你自己手里拿到这套代码之后最值得先动手的不是模型而是把权重和筛选条件从代码里彻底抽出来。默认的WEIGHTS字典写死在scorer.py里改一次要重启一次服务调参效率很低。把它挪到config/strategy_rules.yaml结构大致是这样scoring: weights: trend: 0.30 value: 0.25 liquidity: 0.20 risk: 0.15 model: 0.10 filters: min_open_interest: 500 # 未平仓量下限过滤僵尸合约 max_bid_ask_spread_pct: 0.15 # 买卖价差占中间价的比例上限 min_days_to_expiry: 7 max_days_to_expiry: 60 rank: tolerance: 0.01 # 总分差在此范围内视为并列加载侧只需一个yaml.safe_load配合pydantic或者手写一个校验函数检查权重加起来是否等于 1、阈值是否在合理区间。校验这一步别省min_open_interest手滑多打一个零整个标的池就会空掉而界面上只会显示「无候选」不报错排查起来很费时间。第二个改造方向是补一条轻量的 walk-forward 回路。现在的 trainer 只做了一次时序切分来评估这种方式对参数调整的反馈很弱。更实用的做法是按月滚动用前 12 个月训练第 13 个月验证然后窗口整体后移一个月重复若干次把所有验证段的预测拼起来看整体排序效果。def walk_forward(df, feature_cols, train_months12): 按月滚动返回每段验证期的预测用于评估排序稳定性 df df.sort_values(date) months sorted(df[date].dt.to_period(M).unique()) preds [] for i in range(train_months, len(months)): tr df[df[date].dt.to_period(M).isin(months[i - train_months:i])] va df[df[date].dt.to_period(M) months[i]] if len(tr) 500 or va.empty: continue model, _ train(tr, feature_cols) va va.assign(scoremodel.predict_proba(va[feature_cols].values)[:, 1]) preds.append(va[[date, underlying, score, label]]) return pd.concat(preds, ignore_indexTrue)验证的指标不要只看准确率看分组更直观把每期预测按分数分成五组看第一组和第五组的标签均值差多少以及这个差值在不同月份之间是否稳定。如果某几个月方向反过来说明这个模型对市场状态很敏感实盘里需要加一层状态过滤比如在高波动区间直接降低模型权重、提高流动性权重。第三个方向是把计算器从「到期损益」扩展到「含时间价值的逐日损益」。现在的payoff_curve只算到期日卖权策略在到期前的浮盈浮亏完全看不到而这恰恰是实际持仓时最关心的。做法是加一个days_elapsed参数把剩余 T 代回bs_price重新定价再减去开仓成本就能画出持仓第 7 天、第 14 天的损益曲线。这个改动不大但对理解 theta 衰减非常有帮助。我自己的习惯是每加一个新因子先在 walk-forward 上跑一遍看它有没有让分组单调性变好如果没有哪怕逻辑上再合理也不进生产权重。这个规矩定下来之后项目里的因子从十几个砍到五个反而更稳。希望帮到你。本文还有配套的精品资源点击获取