Python股票期权分析:多因子评分与盈亏平衡计算

简介:面向股票与期权分析学习者及量化爱好者的一套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.py

pytest 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, utc=True) 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(drop=True)

几个参数值得单独说。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, q=0.0, cp="call"): """欧式期权闭式解。 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, q=0.0, cp="call", lo=1e-4, hi=5.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, maxiter=200, xtol=1e-6)

brentq相比牛顿法最大的好处是不需要好的初值。期权链里深度实值和深度虚值的合约 Vega 极小,牛顿法从 0.2 出发经常一步跳到负数或者直接发散,然后你会看到 IV 出现-0.03这种不可能的值。区间求根加上定价函数开头的内在价值检查,能把绝大多数脏数据挡在外面,剩下的返回 nan,交给上层过滤。hi=5.0对应 500% 波动率,已经覆盖了绝大多数极端情况;如果你的标的池里有事件驱动型的品种,可以临时调高,但别调成 20,区间太宽会让求根精度下降。

希腊字母用同一组 d1、d2 就能顺手算出来,注意 vega 和 theta 的单位换算,两者的口径如果不统一,做策略对比的时候会得出完全相反的结论。

def greeks(S, K, T, r, sigma, q=0.0, cp="call"): 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, horizon=5, q=0.6): """截面分位标签:未来 horizon 日收益排进前 40% 记 1,否则 0""" fwd = df.groupby("date")["close"].shift(-horizon) / df["close"] - 1 df = df.assign(fwd_ret=fwd) 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_splits=5, gap=5) # gap 至少等于标签窗口,切断重叠 scores = [] for tr, va in tscv.split(X): model = GradientBoostingClassifier( n_estimators=200, max_depth=3, learning_rate=0.05, subsample=0.8, random_state=42) model.fit(X[tr], y[tr]) scores.append(model.score(X[va], y[va])) model.fit(X, y) # 全量重训用于生产 return model, float(np.mean(scores))

gap=5这个参数很多人会漏。如果标签是未来 5 日收益,训练集最后 5 行的标签其实用到了验证集前几天的价格,训练和验证之间就有了重叠,离线分数会虚高。设一个等于 horizon 的 gap 就能切断这层泄漏。max_depth=3是刻意压低的,金融特征的信噪比很低,树深了必然过拟合,离线 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, weights=WEIGHTS): 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(ddof=0) + 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(ascending=False, method="min").astype(int) return out.sort_values("total", ascending=False)

权重放在strategy_rules.yaml里而不是写死在函数默认参数里,是为了让调参这件事不需要改代码。我的习惯是先跑一遍等权,看各因子的排名相关性,如果两个因子高度相关(比如趋势和动量),就把其中一个的权重降下来,否则等于同一个信息被计了两次。

这个模块最重要的一条设计约束是:它输出的是排序和分项得分,不输出「买入」「卖出」这种词。分项得分能让使用者看到「这个标的排第一是因为流动性好但趋势一般」,从而自己判断要不要跟。一旦系统给出明确动作,使用者就会把它当黑匣子,出错的时候完全无法定位。

4.3 计算器:盈亏平衡点、最大亏损与策略对比

计算器是整个项目里使用频率最高的部分,因为它回答的是最具体的问题:这个组合到期时,标的价格到多少我才不亏。核心逻辑是把多条腿的到期损益加起来,然后找零点。

import numpy as np def payoff_curve(legs, spot_range, multiplier=100): """legs: [{"cp":"call","strike":100,"premium":3.2,"qty":1,"side":"long"}] qty 恒为正,方向由 side 决定;premium 是每股权利金 """ pnl = np.zeros_like(spot_range, dtype=float) 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, width=0.5, multiplier=100): 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), }

multiplier=100是标准合约乘数,如果你的标的池里有乘数不同的品种,这个参数必须从合约元数据里读,不能写死。width=0.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_months=12): """按月滚动,返回每段验证期的预测,用于评估排序稳定性""" 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(score=model.predict_proba(va[feature_cols].values)[:, 1]) preds.append(va[["date", "underlying", "score", "label"]]) return pd.concat(preds, ignore_index=True)

验证的指标不要只看准确率,看分组更直观:把每期预测按分数分成五组,看第一组和第五组的标签均值差多少,以及这个差值在不同月份之间是否稳定。如果某几个月方向反过来,说明这个模型对市场状态很敏感,实盘里需要加一层状态过滤,比如在高波动区间直接降低模型权重、提高流动性权重。

第三个方向是把计算器从「到期损益」扩展到「含时间价值的逐日损益」。现在的payoff_curve只算到期日,卖权策略在到期前的浮盈浮亏完全看不到,而这恰恰是实际持仓时最关心的。做法是加一个days_elapsed参数,把剩余 T 代回bs_price重新定价,再减去开仓成本,就能画出持仓第 7 天、第 14 天的损益曲线。这个改动不大,但对理解 theta 衰减非常有帮助。

我自己的习惯是每加一个新因子,先在 walk-forward 上跑一遍,看它有没有让分组单调性变好,如果没有,哪怕逻辑上再合理也不进生产权重。这个规矩定下来之后,项目里的因子从十几个砍到五个,反而更稳。希望帮到你。

本文还有配套的精品资源,点击获取