ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

期权领先指标实战:PCR、隐含波动率与偏度的Python实现

期权领先指标实战:PCR、隐含波动率与偏度的Python实现 做期权交易这几年我最大的一个感受是真正能提前判断指数方向的市场往往不是现货而是期权。现货K线是“已经发生的事”期权成交、持仓、隐含波动率却是市场参与者用真金白银投出来的“未来预期”。尤其是指数期权和ETF期权参与者结构里机构占比高、资金量大相关指标往往比技术指标更早发出转向信号。这篇文章就把我常看的几个指数期权指标——持仓量PCR、成交量PCR、隐含波动率百分位、虚值波动率偏度——以及对应的Python实现、可视化、初步回测框架完整写一遍。适合已经会用Python做数据分析但想从期权市场角度理解趋势判断逻辑的同学也适合想把“感觉”变成“信号”的量化爱好者。1. 为什么期权指标能领先市场趋势1.1 期权市场交易的是“未来分布”现货或者指数期货本质上是线性交易上涨赚钱、下跌亏钱方向对了就行。期权不一样它同时涉及方向、时间、波动率三样东西。一个交易者买入看涨期权不只是看涨还要判断“涨多少、多久能涨、会不会出现剧烈波动”。所以期权价格里天然包含了市场对未来概率分布的预期这份预期会通过成交量、持仓量、隐含波动率等形式表露出来。指数期权和ETF期权还有一个好处底层标的是大盘指数不容易被单一资金操纵流动性也足够。做市商、保险型资金、趋势资金都会在这里对冲自己的现货头寸。当有一部分资金提前知道或者提前预判到市场要变最便捷的应对方式就是在期权市场做保护或者进攻这种动作会留下数据痕迹。这也是为什么期权指标往往领先K线几周甚至一两个月。我刚开始接触期权指标时也踩过坑直接拿成交量PCR做反转信号结果在单边下跌行情里反复被套。后来才明白期权指标不是拿一个数就直接买卖而是要放到不同的市场环境下解读。理解“为什么指标有效”比“指标怎么算”重要得多。1.2 五个我常跟踪的关键指标用的最多、也最不容易出错的是下面这几个第一成交量PCRPut-Call Ratio认沽认购比。分母是当日所有认购期权成交量之和分子是所有认沽期权成交量之和。数值越高说明当天买认沽保护的人越多短期情绪偏空数值越低说明交易者偏向认购进攻。第二持仓量PCR。同样是认沽总持仓量除以认购总持仓量。持仓量比成交量稳定很多因为持仓是过夜的意愿比日内情绪更接近“真实立场”。持仓PCR长期有一些规律低位意味着市场整体偏多头高位意味着有大量认沽持仓在保护下跌。第三隐含波动率IV。它是从期权价格里反解出来的波动率预期相当于市场给未来一段时间的“风险定价”。IV处于历史低位时市场往往很舒适、自满反而容易见顶IV处于历史高位时往往恐慌充分释放距离底部可能不远。第四波动率偏度Skew。常用做法是计算相同剩余期限下虚值认沽期权IV减去虚值认购期权IV。认购期权定价更贵还是认沽期权定价更贵能看出资金在防范哪一侧的风险。偏度快速拉高说明有人集中买入认沽做保护趋势可能走弱。第五期限结构。近月IV减去远月IV或者近月对远月的比值。如果近月IV明显高于远月IV说明短期风险事件压力大市场处于紧张状态反过来近月低于远月说明短期平静、远期担忧更多。指标不用多关键是组合使用。1.3 不同趋势阶段下指标的状态组合我习惯把趋势分为四个阶段底部区域、上涨初期、上涨中后段、下跌确认。每个阶段里指标组合的“图像”不太一样。在底部区域常见状态是IV处于历史高百分位持仓PCR处于较高水平但成交量PCR可能冲高回落偏度快速上升后开始走平。这时候市场情绪虽然悲观但期权市场的认沽持仓已经积累得很厚继续追空的赔率很差。在上涨初期常见状态是持仓PCR从高位回落成交量PCR走低IV开始温和抬升偏度仍然偏高但没有继续恶化。这个阶段往往是现货反弹、期权波动率抬升同时出现的环境。在上涨中后段指数不断新高但指标会背离持仓PCR下滑到低位IV反而被压到低百分位偏度走低说明大家都在买认购、卖认沽没人愿意买保护了。在下跌确认阶段成交量PCR会突然放大IV快速拉升偏度急剧走阔。此时即使现货还没大跌期权市场的“恐惧”已经先到了。这里先建立一个直觉单一指标可能反复但指标之间的背离和共振能够把市场状态描述得更清楚。后面用Python一步步把这些指标算出来。2. 数据准备与Python环境2.1 环境搭建与依赖库我用的是Python 3.8以上的版本主要依赖这几个库pandas处理数据numpy做数值计算scipy负责标准正态分布和部分优化matplotlib画图。如果需要交互式看盘还可以装plotly。pip install pandas numpy scipy matplotlib如果你是Anaconda环境里Chrom有了大部分依赖如果是在linux服务器上跑回测可以再加一个jupyter lab方便调试。我自己的习惯是先装一个干净的虚拟环境避免不同项目依赖冲突。这里有一个容易坑人的地方matplotlib默认的中文字体很容易变成方框所以要在绘图前显式设置中文字体和负号。我在下面的代码里也会专门处理。2.2 数据从哪里来计算期权指标需要两类数据标的指数日线行情 和 期权每日行情T型报价或按合约维度的日频快照。国内能拿到完整数据的方式主要是交易所公布的每日汇总数据、第三方数据服务商、以及akshare/tushare这类开源接口。以akshare为例可以获取期权每日行情但因为接口字段和可用性经常变动我更建议先把它下载成CSV再看字段这样代码相对稳定。我自己在实际项目中更常用一个固定格式的CSV字段至少包括trade_date交易日期、contract_code合约代码、option_type认沽/认购、exercise_price行权价、close_price结算价/收盘价、volume成交量、open_interest持仓量、underlying_close标的价格、remain_days剩余交易日。用CSV的好处是代码可复现你不需要依赖实时网络接口专心研究指标本身。2.3 数据清洗与合约筛选期权合约很多千万不能直接把所有合约塞进指标计算。不同到期日、不同行权价的合约含义差异很大。一个比较稳妥的顺序是先按到期月份分组优先选择近月合约再根据标的价格筛选出行权价在0.85到1.15倍之间的合约然后区分认购和认沽分别聚合。下面的代码实现从原始DataFrame到“近月合约日频聚合”的基本步骤import pandas as pd def load_option_data(file_path): df pd.read_csv(file_path, parse_dates[trade_date]) df[remain_days] df[remain_days].astype(int) df[option_type] df[option_type].str.upper() return df def filter_near_month_contracts(df, month_rank1): # 按交易日计算剩余交易日最短的月份作为近月次月为第二短 df[year_month] df[trade_date].dt.to_period(M) contract_month df[contract_code].str.extract(r(\d{6}))[0] # 假设合约代码中包含到期年月取到月份进行排序 df[expire_ym] pd.to_datetime(contract_month, format%y%m%d, errorscoerce).dt.to_period(M) # 按每个交易日计算每个月距离当前月份的差值 df[month_diff] df[expire_ym] - df[trade_date].dt.to_period(M) df df[df[month_diff] 0] # 每个交易日选出remaining month差分最小的月份作为近月 df[rank] df.groupby(trade_date)[month_diff].rank(methoddense) return df[df[rank] month_rank].copy() def filter_atm_strikes(df, strike_range0.15): # 只保留行权价贴近标的的合约 df df[(df[exercise_price] df[underlying_close] * (1 - strike_range)) (df[exercise_price] df[underlying_close] * (1 strike_range))] return df在筛选时要注意不同交易所的合约代码规则可能不同如果是ETF期权合约代码里会带到期日和合约类型。还有一点期权在邻近到期日时会出现Gamma异常IV失真很严重因此我有时会把剩余交易日小于3天的合约也剔除掉。3. 核心指标计算与Python实现3.1 成交量PCR与持仓量PCRPCR的公式很简单分别把每天的认购成交量、认沽成交量、认购持仓量、认沽持仓量聚合起来然后计算比值。def calculate_pcr(df): pivot df.groupby([trade_date, option_type]).agg( volume_sum(volume, sum), oi_sum(open_interest, sum) ).reset_index() call pivot[pivot[option_type] C][[trade_date, volume_sum, oi_sum]].rename( columns{volume_sum: call_volume, oi_sum: call_oi}) put pivot[pivot[option_type] P][[trade_date, volume_sum, oi_sum]].rename( columns{volume_sum: put_volume, oi_sum: put_oi}) pcr pd.merge(call, put, ontrade_date, howinner) pcr[volume_pcr] pcr[put_volume] / pcr[call_volume] pcr[oi_pcr] pcr[put_oi] / pcr[call_oi] return pcr.sort_values(trade_date).reset_index(dropTrue)计算出来的PCR原始序列会很毛糙我会再做两件事一是取5日和20日移动平均减少噪音二是把PCR转换成历史百分位这样才好判断“当前这个数值到底是高还是低”。转换方式很简单对过去250个交易日滚动计算当前值在历史窗口中的排名百分比。def pcr_percentile(pcr, window250): pcr[oi_pcr_ma5] pcr[oi_pcr].rolling(5).mean() pcr[oi_pcr_ma20] pcr[oi_pcr].rolling(20).mean() pcr[oi_pcr_percentile] pcr[oi_pcr].rolling(window).apply( lambda x: (x[-1] x).sum() / len(x), rawTrue ) pcr[volume_pcr_percentile] pcr[volume_pcr].rolling(window).apply( lambda x: (x[-1] x).sum() / len(x), rawTrue ) return pcr.dropna()这里有一个经验持仓量PCR的历史百分位比绝对值好用。同一个数值0.9在牛市环境里属于偏低在熊市环境里可能已经很高了。只看绝对值很容易误判。3.2 隐含波动率计算隐含波动率无法直接得到需要从期权价格反推。最常用的是Black-Scholes-Merton模型。对指数期权可以用连续分红率简化为指数红利忽略不计慢慢调整即可。核心思路是给定行权价、标的价格、剩余期限、无风险利率和初始波动率算出一个理论价格再用二分法或牛顿法不断调整波动率直到理论价格和实际价格差距足够小。二分法实现比较直观适合新手理解。完整代码如下import numpy as np from scipy.stats import norm def bs_price(S, K, T, r, sigma, option_typeC): if T 0 or sigma 0: return np.nan d1 (np.log(S / K) (r 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T)) d2 d1 - sigma * np.sqrt(T) if option_type.upper() C: price S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2) else: price K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1) return price def implied_volatility(price, S, K, T, r, option_typeC, max_iter200, tol1e-6): lower, upper 1e-4, 5.0 if T 0: return np.nan for _ in range(max_iter): mid 0.5 * (lower upper) est bs_price(S, K, T, r, mid, option_type) if not np.isfinite(est): break if est price: lower mid else: upper mid if abs(est - price) tol: return mid return 0.5 * (lower upper)实际使用中要注意利率、剩余期限和连续复利问题。无风险利率我一般用一年期国债收益率近似也可以固定为2%左右剩余的期限最好精确到交易日/年天数。接下来把每个合约的隐含波动率算出来然后根据不同行权价加权汇总成“平值附近的IV”def calculate_iv_series(df, r0.02): df df.copy() df[T] df[remain_days] / 365.0 df[iv] df.apply( lambda row: implied_volatility( pricerow[close_price], Srow[underlying_close], Krow[exercise_price], Trow[T], rr, option_typerow[option_type] ), axis1 ) return df.dropna(subset[iv])计算量可能比较大比如一份数据有几十万行逐行apply很慢。更好的做法是向量化批量计算或者在分钟级数据上用衍生品库。不过作为指标研究和日频信号逐行也够用跑一天的数据只要几十秒。3.3 波动率偏度与期限结构偏度衡量的是虚值认沽和虚值认购之间的IV差。你不要只看平值真正的风险定价在虚值期权里。通常我会选出虚值程度3%~8%的认沽和认购合约分别计算IV平均值然后做差。def calculate_skew(df): df df.copy() df[moneyness] df[exercise_price] / df[underlying_close] - 1.0 # 选取虚值认购moneyness 0和虚值认沽moneyness 0 otm_call df[(df[option_type] C) (df[moneyness] 0.03) (df[moneyness] 0.12)] otm_put df[(df[option_type] P) (df[moneyness] -0.03) (df[moneyness] -0.12)] call_iv otm_call.groupby(trade_date)[iv].mean().rename(call_iv_otm) put_iv otm_put.groupby(trade_date)[iv].mean().rename(put_iv_otm) skew pd.merge(call_iv, put_iv, left_indexTrue, right_indexTrue, howinner) skew[skew] skew[put_iv_otm] - skew[call_iv_otm] return skew.dropna()期限结构我一般用近月和次月平值IV做差。如果近月IV比次月高说明短期风险溢价高资金在支付短期保护费。def calculate_tenor_structure(df, r0.02): # 对每个月到期合约分别计算平值IV再按交易日透视 df df.copy() df[T] df[remain_days] / 365.0 # 近似取平值附近合约 atm df[abs(df[exercise_price] / df[underlying_close] - 1.0) 0.02] atm_iv atm.groupby([trade_date, expire_ym]).apply( lambda x: np.mean(x[iv]), include_groupsFalse ).rename(iv) term atm_iv.unstack(levelexpire_ym) # 这里假设term列已经按到期月份排序取前两列 if term.shape[1] 2: term[near_iv] term.iloc[:, 0] term[next_iv] term.iloc[:, 1] term[term_structure] term[near_iv] - term[next_iv] return term这个计算依赖前面的IV结果所以建议把IV计算结果先存成DataFrame再跑偏度和期限结构避免每一步都重复计算。3.4 综合评分信号设计即便每个指标单独看都有逻辑直接拿来跟踪也很容易互相冲突。我的做法是先把各指标统一到0到100分的量纲再按一定权重合成一个综合情绪分。def generate_signal(metric_df, pcr_weight0.35, iv_weight0.25, skew_weight0.25, tenor_weight0.15): df metric_df.copy() # 持仓PCR百分位数值越高认沽保护越厚偏多但高位反转时要注意 pcr_score df[oi_pcr_percentile] * 100 # IV百分位越高越接近恐慌顶点适合观察反转可能 iv_score df[iv_percentile] * 100 # Skew越高说明认沽偏贵防御情绪越重偏中性偏空 skew_score df[skew_percentile] * 100 # 期限结构近月IV高于远月短期紧张中性偏空 tenor_score df[term_structure_percentile] * 100 df[total_score] ( pcr_score * pcr_weight (100 - iv_score) * iv_weight (100 - skew_score) * skew_weight (100 - tenor_score) * tenor_weight ) df[signal] 震荡 df.loc[df[total_score] 65, signal] 偏多 df.loc[df[total_score] 35, signal] 偏空 return df这个权重不是拍脑袋定死的。我自己的经验是在不同市场环境下权重应该有差异。震荡市里持仓PCR权重太高容易被反复打脸反而是IV百分位更有效单边趋势里Skew和期限结构更敏感。所以后面要做回测用数据验证而不是靠感觉调参。4. 实操过程从指标到可视化面板4.1 因子矩阵组装这里开始把前面所有函数串起来。我推荐以交易日为主键把PCR、IV、Skew、期限结构全部merge到一个宽表里后续做分析、可视化、回测都方便。def build_indicator_panel(df): # df是清洗后的原始期权数据 pcr_df calculate_pcr(df) pcr_df pcr_percentile(pcr_df) iv_df calculate_iv_series(df) # 汇总平值IV均值 atm_iv iv_df[abs(iv_df[exercise_price] / iv_df[underlying_close] - 1.0) 0.03] iv_series atm_iv.groupby(trade_date)[iv].mean().reset_index() skew_df calculate_skew(iv_df) term_df calculate_tenor_structure(iv_df) panel pcr_df[[trade_date, volume_pcr, oi_pcr, oi_pcr_percentile, volume_pcr_percentile]] panel panel.merge(iv_series, ontrade_date, howleft) # 对IV计算滚动百分位 panel[iv_percentile] panel[iv].rolling(250).apply( lambda x: (x[-1] x).sum() / len(x), rawTrue) panel panel.merge(skew_df.reset_index(), ontrade_date, howleft) panel[skew_percentile] panel[skew].rolling(250).apply( lambda x: (x[-1] x).sum() / len(x), rawTrue) panel panel.merge(term_df.reset_index(), ontrade_date, howleft) panel[term_structure_percentile] panel[term_structure].rolling(250).apply( lambda x: (x[-1] x).sum() / len(x), rawTrue) return panel.dropna().copy()merge的时候特别注意skew_df的index如果设置成trade_datereset_index之后可能出来两列命名要对齐。实际操作里我会先把每个子表的日期列统一改名为trade_date然后再merge省去很多麻烦。4.2 可视化K线加指标子图有了因子面板画图就顺理成章。我用matplotlib画四个子图第一行是标的指数收盘价第二行是成交量PCR和持仓量PCR第三行是隐含波动率第四行是Skew。如果出现综合信号就在价格图上用箭头标记。import matplotlib.pyplot as plt import pandas as pd plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False def plot_panel(price_df, panel_df): # price_df 必须有 trade_date 和 close fig, axes plt.subplots(4, 1, figsize(12, 14), sharexTrue) axes[0].plot(price_df[trade_date], price_df[close], label指数收盘价, colorblack) axes[0].set_ylabel(价格) axes[1].plot(panel_df[trade_date], panel_df[volume_pcr], label成交量PCR, alpha0.5) axes[1].plot(panel_df[trade_date], panel_df[oi_pcr], label持仓量PCR, alpha0.8) axes[1].legend() axes[2].plot(panel_df[trade_date], panel_df[iv], label平值IV, colorblue) axes[2].fill_between(panel_df[trade_date], 0, panel_df[iv], alpha0.2) axes[3].plot(panel_df[trade_date], panel_df[skew], labelSkew, colorred) axes[3].axhline(0, colorgray, linestyle--) for ax in axes: ax.grid(True, alpha0.3) ax.legend(locupper right) plt.tight_layout() plt.show()用这个面板你能直观看到指标和行情之间的背离。比如指数创新高但持仓PCR却压到20%以下这就是一个需要警惕的背离。4.3 一个最小参考回测框架验证指标信号到底有没有用不能凭眼睛看。我写了一个最简单的日频回测每天根据收盘后的信号决定次日开盘是否持有现货多头、空头或空仓。这里我把信号简化成偏多持有头寸、偏空反向头寸、震荡空仓。def backtest_signal(panel_df, price_df, forward_ret1): # 对齐日期 merge_df panel_df[[trade_date, total_score, signal]].merge( price_df[[trade_date, close, open]], ontrade_date, howinner) merge_df[next_open] merge_df[open].shift(-1) merge_df[next_close] merge_df[close].shift(-1) merge_df[next_ret] merge_df[next_close] / merge_df[next_open] - 1 merge_df[pos] 0 merge_df.loc[merge_df[signal] 偏多, pos] 1 merge_df.loc[merge_df[signal] 偏空, pos] -1 merge_df[strategy_ret] merge_df[pos] * merge_df[next_ret] merge_df.dropna(inplaceTrue) total_ret (1 merge_df[strategy_ret]).prod() - 1 benchmark_ret (1 merge_df[next_ret]).prod() - 1 trade_days len(merge_df) annual_factor 252 / trade_days if trade_days 0 else 0 annual_ret (1 total_ret) ** annual_factor - 1 win_rate (merge_df[strategy_ret] 0).mean() return { 累计收益: total_ret, 年化收益: annual_ret, 基准累计: benchmark_ret, 胜率: win_rate, 样本天数: trade_days, }这段代码非常粗糙没有扣手续费和滑点也假设次日开盘价能成交。但用来观察指标方向是否有边际预测力已经足够。把信号改成“偏多”和“偏空”后我对比过不同时段持仓PCR和Skew的组合信号在震荡市确实容易失效但在趋势启动初期有不错的领先性。5. 常见问题与避坑实录5.1 合约换月导致指标跳变这个问题很隐蔽。期权近月到期后数据源如果不换月会把临近到期的深度虚值合约也算进来导致PCR、IV突然出现假信号。我的做法是固定“剩余交易日大于等于7天且小于等于40天”的合约池并且每次计算都用相同的日历窗口。这样即使换月指标序列的跳变也会小很多。另外不同月份IV的绝对值有差异不能直接把近月和次月IV混在一起算历史百分位要先统一到期结构。否则可能出现“明明近月IV升高但因为换月变成次月百分位突然降低”的错觉。5.2 极端行情下IV失真做市商在期权报价上并不总是和理论模型一致。当市场出现极端行情时部分合约流动性枯竭盘口买卖价差拉大用收盘价反推IV很容易出异常值。曾经有一次大跌后某些虚值认沽合约的买价接近0.50元一算IV高达80%明显脱离实际。遇到这种情况我会先按成交量过滤成交量太小的合约不纳入IV均值计算同时用“中位数”替代“平均值”因为中位数对极端报价不敏感。还有一个技巧是看IV的日内分时如果某一天IV从低到高拉升超过10个百分点要检查是否伴随现货跳空还是单纯报价失真。5.3 不要把PCR倒背如流就开仓PCR是最容易理解的指标但也最容易误导人。持仓PCR高逻辑上说明多方认沽保护多看上去偏多但如果这个高位是突然集中建仓形成的反而可能意味着大资金在系统性对冲随后现货会跌。我通常增加一条约束只有当持仓PCR高位且成交量PCR回落时才看作“保护后情绪修复”如果持仓PCR和成交量PCR同时在高位说明多空分歧依然很大继续观察。这也是为什么一定要看指标组合而不是单点判断。5.4 回测过拟合和参数陷阱把窗口长度比如历史百分位用120天、250天权重调整一下回测结果可能差别很大。如果你反复调试参数直到历史胜率最高那大概率是过拟合。我的做法是先把逻辑写成固定版本只用一种标准配置跑全样本然后按年度拆开看收益稳定性。如果某一年收益特别好另外几年就一般这个指标可能只在特定市场环境下有效。接着再做样本外测试用前面5年的数据定参数后面1年验证。交易这件事不怕策略简单怕的是自己骗自己。5.5 Python实现里几个实用细节一是日期索引问题在merge DataFrame时必须保证trade_date是共同键且类型一致否则会莫名出现空行。二是滚动百分位计算时如果窗口长度不足会返回NaN一定要在后续数据处理中dropna不要带着NaN去算回测否则会把空值当成0仓位。三是无风险利率对IV计算结果影响没那么大但对深度虚值合约影响很大建议保持统一。还有一个细节是scipy的norm.cdf在大数值输入时可能返回1导致d1计算异常需要在函数里加边界保护。这些看似小的问题在真实数据上都可能让整个指标面板断掉。我个人目前的使用方式不是用这套指标直接做自动交易而是每天把它当作“市场温度计”来辅助方向判断。当多个指标同时指向一个方向时再去看现货技术形态胜率会高一些。大家如果只是做研究可以把回测结果当成相对强弱参考而不是收益承诺。期权指标能告诉我们的是概率不是确定性把不确定性管理好本身就是一门重要的功课。
返回列表