恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
A股多维度量化择时系统构建与回测避坑指南
首页
资讯中心
/
A股多维度量化择时系统构建与回测避坑指南
A股多维度量化择时系统构建与回测避坑指南
发布时间:2026/10/18 20:27:41
简介这份金融工程专题报告来自华宝证券聚焦A股多维度量化择时系统的构建与回测定位给量化投研人员、金融工程学习者及关注资产配置的投资者。报告沿用前期债券与黄金择时框架从经济运行、流动性、动量趋势、事件驱动四个维度分别生成指标打分单指标先按择时收益与胜率加权合成再由经济运行、流动性、动量趋势构成主交易系统事件驱动因高胜率、非连续性作为额外加分项并测试出等权合成方式收益稳定性更好。回测区间为2012年1月至2019年6月采用分仓配置后年化收益约12.8%较同期沪深300指数的6.8%高出6个百分点滚动一年平均最大回撤由-22.2%收窄至-9.6%Calmar比率达到1.33事件驱动对收益贡献明显且未显著扩大回撤。资源以单个PDF文件呈现大小约1.06MB包含15页完整研报、指标图表及相关研究报告索引。文末还提示了模型设定偏差风险便于延伸阅读。已有102人学习适合希望借鉴成熟量化择时框架并落地A股策略验证的读者。1. A股多维度量化择时系统一张15页报告背后真正要回答的问题一份金融工程专题报告标题把“A股”“多维度”“量化择时”“系统构建与回测”串成一串乍看像是又一套神秘指标卡。拆开看这个标题真正要解决的是三个连续问题选哪些维度、怎么合成信号、用什么证据证明它有效。多数做择时的人死在第二步——每个维度单独看都有道理合成时权重拍脑袋回测曲线漂亮一上模拟盘就原形毕露。这篇笔记按我实际落地这类系统的顺序讲维度怎么选、数据怎么对齐、信号怎么合、回测框架怎么写、以及回测结果里哪些是真相哪些是幻觉。适合正在从拍脑袋择时转向系统性构建的从业者也适合想验证自己想法能不能跑通的新手。2. 拆解多维度信号源量价、市场广度与情绪的指标选型2.1 先说清“多维度”是哪几个维度每个维度解决一个不同的问题量化择时最容易犯的错误是把同类指标堆在一起比如动量因子、均线乖离率、MACD三个指标本质都在描述价格趋势相关性极高放进同一个模型等于只选了一个维度。我构建A股择时系统时一般会从三个互相独立的维度取信号量价维度、市场广度维度、情绪维度。量价维度解决的是“指数自身处于什么状态”常用指标是20日收益率和量比。20日收益率代表中期动量一个月的涨跌能过滤掉日线噪音又不至于像60日动量那样滞后量比反映的是近期成交是否在放大价格配合放量才有持续性缩量上涨在A股往往不牢固。市场广度维度解决的是“指数涨跌是不是普涨普跌”用全市场当日上涨家数占比或者创60日新高的家数占比来衡量。指数可以被权重股抬起来但如果没有宽度支撑行情持续性差这也是很多人只在指数上看技术指标却频繁吃面的原因。情绪维度解决的是“当前参与者有多热”用全市场换手率或涨停跌停家数比来观察换手率在低位极值附近往往是底部区域在历史高位附近则要防备均值回归。口径上要注意不同数据源的停牌处理和上市新股纳入规则不一样。有的接口把上市首日股票算进广度统计有的剔除这两者对上涨家数占比的影响在震荡市能差出好几个百分点。我先固定口径只统计上市满60个交易日且当日非停牌、非ST的股票这样情绪维度才有稳定的参照系。2.2 数据对齐与时间戳陷阱T日收盘算出的信号最早只能在T1日开盘用数据对齐是这套系统里第一个容易翻车的地方。上游数据接口返回的交易日历偶尔会多出或漏掉一天比如某些接口在长假前最后一天和假期后第一天之间插入一个“非交易日”的空行指数成分股的交易日期也可能不一致直接join会让信号错位一整天。我的做法是先维护一个独立的交易日历表以此为基准做outer join再把缺失信号用前值填充。两个日期的语义必须分清楚signal_date表示这个信号在哪一天收盘后计算出来trade_date表示这个信号在哪一天真正执行。T日收盘价全部落地后再算信号T1日开盘价成交这是日频择时系统的时间底线。凡是外部文章说“当日收盘价信号当日成交”的在实盘里都做不到因为收盘前不知道收盘价是多少。import pandas as pd # 假设交易日历表 calendar字段 trade_date # 行情数据 mkttrade_date, open, close, high, low, volume # 信号数据 sigtrade_date, momentum, breadth, turnover # 第一步以交易日历为基准对齐 aligned calendar.merge(mkt, ontrade_date, howleft) aligned aligned.merge(sig, ontrade_date, howleft) # 第二步缺失的信号用最近一个有值的前日填充代表“沿用上一交易日信号” # 注意 ffill 只能填中间缺失如果最前面空着仍会是 NaN aligned[signal_ffill] aligned[momentum].ffill() # 第三步shift(1) 把T日信号挪到T1日作为交易日可用的执行信号 aligned[exec_signal] aligned[signal_ffill].shift(1) # 最后丢掉有NaN的行避免前几天的信号参与回测 aligned aligned.dropna(subset[exec_signal])这里shift这一步就是物理隔离无论你用什么库算信号最终到回测引擎手里的只有exec_signal这一列。反过来说如果哪一回测代码里出现“signal当日直接乘当日收益”的写法不用看别的结果必然虚高这是前视偏差里最常见的一种。参数上需要留意的是ffill策略连续停牌超过5天时是沿用旧信号还是强制空仓取决于你的系统容忍度——沿用旧信号会减少交易频率但停牌复牌后往往有补涨补跌沿用可能吃到也可能吃跌这个不是对错问题是必须明确写进系统里的规则不能模棱两可。2.3 清洗与复权处理前复权还是后复权直接影响动量信号的方向A股有除权除息如果不复权价格会产生人为跳空20日动量指标会误把这些缺口当作真实涨跌。常见的做法是前复权把历史价格按复权因子统一缩放到当前日期使历史价格与最新价可比。缺点是最新一次除权后整个历史序列都会变化回测结果在不同时间点重跑会有细微差异但实盘跟踪时前复权最直观大多数数据接口默认给的也是前复权价。我选择前复权并保证因子列存在否则只能用简易还原法代替。# 复权处理adj_factor 是数据源返回的复权因子列 def adjust_price(df: pd.DataFrame) - pd.DataFrame: # 按日期升序排序滚动窗口计算都依赖顺序 df df.sort_values(trade_date).reset_index(dropTrue) # 前复权历史价乘以因子缩放 df[close_adj] df[close] * df[adj_factor] / df[adj_factor].iloc[-1] df[open_adj] df[open] * df[adj_factor] / df[adj_factor].iloc[-1] # 复权因子出现缺失时用最近的值填充避免断崖 if df[adj_factor].isnull().any(): df[adj_factor] df[adj_factor].ffill() return df注意复权因子缺失通常不是简单填充就能解决——新股上市首日没有因子、停牌期间数据源返回空值这些都要在来源侧补齐。另一个容易忽略的点是成交量不需要复权但成交额在除权后会自然变化如果你用换手率或者量比这类成交类指标最好统一用成交额而不是成交量。A股有低价股拆股后成交量激增的情况只看成交量会误判为资金涌入看成交额则平滑很多。这一层做扎实后后续所有信号计算都建立在同一套清洗后的数据上不会再出现“这个指标用的复权价、那个指标用的原始价”这种隐性错配。3. 从单指标到综合打分权重设定与滚动窗口的取舍3.1 连续指标先转信号强度z-score比阈值发信号更抗噪声拿到三个维度的原始值之后常见做法是直接设阈值比如“20日收益率大于5%就做多小于负5%就空仓”。这种做法太脆行情好的时候指标一直在阈值上方行情差的时候一直在下方信号几乎没有区分度参数一调整结果就大变。我更习惯先把每个维度标准化为z-score。对动量、广度、换手率分别做滚动标准化用过去120个交易日的均值与标准差把当前值映射成一个分数。这样信号天然是连续的能表达“稍微偏多”和“强烈偏多”的差异。滚动窗口的选择也有讲究120个交易日覆盖大约半年的市场状态足够包含一轮涨跌又不会长到让参数对多年前的行情反应迟钝。有一个边界情况必须处理市场横盘时滚动标准差会接近0z-score会爆炸放大噪声。我在代码里强制把标准差为0的位置填成NaN再统一补0等于告诉系统“这段没有有效波动信号中性”。import numpy as np def zscore(series: pd.Series, window: int 120) - pd.Series: # 滚动均值与滚动标准差 mean series.rolling(window, min_periods60).mean() std series.rolling(window, min_periods60).std() # 标准差为0的时段用NaN替代统一补0返回中性分数 std_safe std.replace(0, np.nan) z (series - mean) / std_safe return z.fillna(0)这段逻辑里把min_periods设成60意思是即使前60天数据不足也尽量不产生缺失。如果你把min_periods删掉前120天会全是NaN策略开局有半年的空白期回测结果和实盘对不上。这个参数容易被忽略但它直接决定策略从第几天开始产生交易信号。3.2 综合打分等权不是最差选择固定权重也别指望一步到位三个维度的z-score算出来后需要合成一个综合分。最常见的方案是等权相加它的好处是少了一个可调参数避免了在样本内拟合一个“历史最优权重”。等权的隐含假设是三个维度同等重要虽然不完美但至少不会因为某个权重被刻意调高而放大过拟合。如果你想让权重更精细一点我建议不要拍脑袋配而是用一种更粗糙但稳定的方式先分别看每个维度单独回测的表现把波动率更高、胜率更低的维度权重压一压。比如量价维度单独跑胜率55%情绪维度单独跑只有48%那权重可以给0.4对0.2而不是直接给0.5对0.5。注意这里的权重是“按单独表现分配”不是“在样本内网格搜出来的最优解”两者性质完全不同。# 三个维度的zscore统一滚动标准化后合成 score ( 0.4 * zscore(momentum, 120) 0.3 * zscore(breadth, 120) 0.3 * zscore(turnover, 120) ) # 综合分本身再做一次平滑降低单日噪声造成的频繁调仓 score_smooth score.rolling(5).mean() # 生成仓位大于0.5分位做多否则空仓避免阈值刚好卡在0附近来回翻转 # 这里0.5不是固定数值而是滚动60日的分位数阈值 threshold score_smooth.rolling(60).quantile(0.5) position (score_smooth threshold).astype(int)最后一步用了滚动分位数而不是固定0.5这是我的习惯。固定阈值在市场趋势单边时会长时间满仓或空仓滚动分位数则强制系统在“相对强弱”之间切换天然适应不同市场环境。代价是信号会更敏感所以我在前面加了一层5日平滑。这个5日平滑窗口和60日分位数窗口是这套系统里最值得调的两个参数我后面会专门讲怎么验证它们是否过拟合。3.3 参数敏感性一改就翻车的是这几个参数不是模型结构系统里的参数大致分两类一类是数据口径类比如回看窗口120天、平滑期5天这类参数在一定范围内移动对结果影响不大另一类是阈值类比如仓位阈值、止损线这类参数对结果极其敏感经常会发现阈值从0.5改成0.55年化收益率掉一半。理解敏感性的分布很重要。当你跑完回测先做一次最简单的敏感性测试把每个参数上下浮动20%看绩效指标的变化幅度。如果某个参数浮动一点就让年化收益大幅波动说明系统已经在那个位置过拟合如果整体变化平缓说明结果相对稳健。我固定做一张敏感性热力表行是参数取值列是年化收益、最大回撤、换手率三个指标只改一个参数其他不动。这样能很快找出哪些参数该冻结哪些参数可以留作优化空间。还有个容易混淆的认知回看窗口越长信号越平滑但参数不是越长越好。120日回看和250日回看在震荡市里结果差异很大因为250日会把去年的大涨大跌一直包含在均值里导致信号长期偏离中性。滚动窗口本质上是一个遗忘机制A股市场状态切换很快半年到一年的记忆长度是比较平衡的选择。4. 回测框架落地事件循环、成本模型与绩效核对4.1 回测引擎的最小框架逐日循环虽然慢但看得清每一笔交易很多量化框架提供优雅的向量化回测接口几行代码就能算出净值曲线但代价是中间交易过程变成了黑匣子什么时候买入、为什么买入、买在什么价全部看不清楚。对于择时这类交易频率不高的系统我宁愿用逐日循环写一个几百行的事件引擎每一步都打点记录出了问题能直接定位到是哪一天的哪一笔交易。日频择时的交易频率通常一年几十次逐日循环完全可以承受。核心循环逻辑是每天先看是否有信号切换再从空仓到满仓或从满仓到空仓按T1规则执行。这里要注意A股普通账户不能裸做空所以择时系统的空头仓位我统一处理为“空仓”而不是反向开空。某些回测框架允许做空实盘却做不了结果虚高一大截。import pandas as pd def run_backtest(daily: pd.DataFrame, initial_cash1_000_000, fee0.0003, slippage0.001): cash initial_cash shares 0 eq_curve [] for i in range(len(daily)): row daily.iloc[i] price row[open_adj] # 日频系统统一按开盘价成交 signal row[exec_signal] if signal 1 and shares 0: # 买入现金扣除佣金成交价考虑滑点 buy_price price * (1 slippage) shares int(cash // (buy_price * (1 fee))) cash - shares * buy_price * (1 fee) elif signal 0 and shares 0: # 卖出同样计入滑点与佣金 sell_price price * (1 - slippage) cash shares * sell_price * (1 - fee) shares 0 eq_curve.append(cash shares * price) return pd.Series(eq_curve, indexdaily[trade_date])这个引擎里有两个关键选择。一是成交价用开盘价而不是收盘价因为信号在T日收盘后产生最早T1能成交开盘价是诚实可达的二是滑点计入方向买入时滑点让成交价更高卖出时滑点让成交价更低这样净值曲线天然是保守的。如果你用收盘价回测再用开盘价实盘验证差异可能来自价格口径不只是滑点设置。成本设置上佣金按万三计已经算中低标准滑点千分之一对流动性好的宽基指数ETF偏保守对个股现货偏激进。我想强调一点A股印花税只在卖出时收这一点千万不要简化成一个统一费率方向不对会让做空策略的费率高估、做多策略的费率低估。4.2 成本模型别只算佣金印花税、冲击成本、涨停无法买入择时系统的交易频率不高费用绝对值看起来不大但如果信号频繁在阈值附近抖动隐形成本会吃掉大部分收益。成本模型至少要包含四部分佣金、印花税、滑点、冲击成本。佣金和印花税是确定性成本按固定费率计算即可滑点是可估计的不确定性成本流动性好的品种千分之一到千分之二流动性差的需要自己根据盘口估算冲击成本在资金规模超过一定量级时才显著个人和小资金可以先忽略但一定要在代码里预留一个参数否则策略换手率一高日后资金变大时结果会断崖式下滑。还有一个在A股特别容易翻车的点涨停板无法买入。回测里价格达到涨停价就默认成交但实盘里涨停板是要排队的小资金可能根本排不上。处理办法是在回测里判断如果开盘价已经涨停或者跌停当天不执行买入或卖出操作顺延到下一个交易日。加了这个规则之后策略的收益通常会下降一截但这才接近真实可交易结果。# 在循环内增加涨跌停过滤A股主板涨跌停幅度10% limit_up (daily[open_adj] daily[close_adj].shift(1) * 1.095) limit_down (daily[open_adj] daily[close_adj].shift(1) * 0.905) if signal 1 and shares 0 and not limit_up.iloc[i]: # 正常买入 pass elif signal 0 and shares 0 and not limit_down.iloc[i]: # 正常卖出 pass这里的阈值用1.095而不是1.10是因为涨停价按昨收的1.10计算但四舍五入到分后开盘价等于昨收乘以1.100或1.099都可能封板回测里统一按1.095判断更贴近实际。这个细节不写进回测里你的成交记录里会出现大量涨停价成交净值和实盘对不齐。4.3 绩效指标要手动核对年化、最大回撤、Calmar缺一不可回测跑完第一件要做的事不是看净值曲线而是把绩效指标拆开核对。常用的几项指标各有各的陷阱年化收益率会被最后一段行情的时间长度影响最大回撤与起算点有关夏普比率对收益率分布的正态性假设在A股经常不成立。我的核对习惯是先看累计收益率的绝对水平是否合理再看最大回撤发生在什么时候、当时仓位是多少最后看Calmar比率年化收益除以最大回撤绝对值是否大于1。A股择时系统能做到Calmar大于1已经是不错的水平吹嘘多高多高往往来自前视偏差或零成本假设对这类结果保持警惕。# 由净值曲线计算核心绩效指标 def performance(eq: pd.Series): ret eq.pct_change().dropna() total_return eq.iloc[-1] / eq.iloc[0] - 1 n len(ret) # 252对应一年交易日数不同年份可微调 annual_return (1 total_return) ** (252 / n) - 1 annual_vol ret.std() * (252 ** 0.5) sharpe annual_return / annual_vol if annual_vol ! 0 else 0 drawdown eq / eq.cummax() - 1 max_drawdown drawdown.min() calmar annual_return / abs(max_drawdown) if max_drawdown ! 0 else 0 return { total_return: total_return, annual_return: annual_return, sharpe: sharpe, max_drawdown: max_drawdown, calmar: calmar }注意这里的年化是用252个自然交易日做的简化严格来说还要考虑复利与无风险利率但作为内部一致性核对已经够用。有一个很容易被忽略的点年化收益率和最大回撤应该与基准指数同时计算否则你只能讨论“策略赚了多少钱”没法讨论“策略相对于买入持有到底值不值”。基准选宽基指数还是全收益指数是个大话题核心原则是口径一致你回测用全收益就都用全收益你回测用价格指数基准就都用价格指数混用会让超额收益的结论失真。5. 择时回测避坑前视偏差、幸存者偏差与参数过拟合的5条血泪排查5.1 用了当日收盘信号与当日成交价净值曲线全是纸面富贵现象回测年化收益率高得离谱超过50%净值曲线几乎无回撤逐笔检查发现大量买入发生在价格上涨当天。原因典型的T日信号当日成交前视偏差。信号是用当日收盘价算出来的而当日收盘价本身包含了当天已经发生的涨跌等于用未来信息交易。这在用“当日上涨X%就买入”这类动量规则时特别常见。解决把信号整体shift(1)强制交易发生在信号的下一日。买点从收盘价改为下一日开盘价。如果你发现策略在shift(1)之后收益大幅下降甚至归零恭喜你之前的结果就是幻觉。5.2 股票池里全是“现在还活着”的股票用过去的指数成分回测未来现象回测中用当前流通市值排名前100的股票作为成分池历史回测收益很好但某一年的收益集中来自几只在当时市值很小的股票而现在它们已经是大盘股。原因幸存者偏差。你用今天的成分股名单去回测过去相当于在回测里剔除了过去存在但今天已经退市或者衰落的股票这些股票大概率贡献了负收益剔除它们自然让结果变好。解决回测每一段时期都用当时的成分股名单或者至少用一只指数历史点位作为基准来对照。手动重建指数成分很耗时常见做法是用“全部上市满一定时间的股票”作为统一股票池固定口径避免成分变动带来的偏差。5.3 网格搜索在样本内做到极致样本外全失效现象参数网格搜完样本内年化45%最大回撤8%你兴奋地推进模拟盘一个月后策略开始持续跑输。原因过拟合。参数组合是在同一段历史上反复试出来的已经记住这段历史的噪声特征。样本内表现越好往往过拟合越严重尤其当参数空间很大、回测时间只有两三年的时候。解决把历史数据切成样本内和样本外样本内只用来做粗选样本外只验证一次。我推荐一个简单实用的方法只用策略默认参数跑一遍样本外如果结果没有显著变差说明参数有泛化能力如果变差了回到“参数先默认、不做精细搜索”的状态。宁可参数粗糙一些不要拿噪声当信号。5.4 涨停买不进、跌停卖不出成交假设过于理想现象回测曲线显示出大量在极端行情里的“神操作”某日持仓股涨停第二日继续大幅上涨回测竟然在涨停价满仓买入吃到了全部涨幅。原因模型假设开盘价必然可成交没有考虑涨跌停封板时流动性为零。A股涨停板上买单排队跌停板上卖单排队个人的单子未必成交尤其在缩量封死的时候。解决在回测中加入涨跌停不可成交规则判断开盘价是否触及涨跌停触及则跳过这笔交易顺延到下一个交易日。如果策略长期依赖涨停价买入后继续上涨的行情加上这条规则后收益会明显缩水这才是真实可交易的水平。5.5 基准选错口径价格指数与全收益指数混用超额收益失真现象策略回测超额收益10%细看基准用的价格指数年化6%而全收益指数同一年年化9%超额收益其实只有3%。原因A股分红较多价格指数不考虑分红再投资低估了买入持有收益。宽基指数尤其明显。策略如果持仓中包含高分红标的用价格指数作基准会高估超额收益。解决统一使用全收益指数口径或者至少保证策略与基准在同一种口径下比较。内部对比策略优劣无所谓对外的超额收益结论必须以全收益为基准这直接关系到策略值不值得继续投入。6. 进阶用法样本外滚动验证与参数冻结的最小可行方案6.1 滚动前推验证每隔一段时间重新拟合但只验证未来一个月静态的样本外验证仍然不够因为市场会变化一段时期表现好的参数可能第二年失效。滚动前推是目前我在择时系统里最常用也最稳定的验证方式每6个月重新用当时的窗口拟合一回参数但只对未来一个月做预测并记录结果随后窗口前移不断重复。这个过程模拟了实盘里“定期更新模型”的操作。参数冻结同样是关键习惯每次滚动窗口开始时冻结一批参数滚动期间绝不根据已产生的净值好坏去调整。调整一次之前的验证结果全部作废。# 滚动前推伪代码示意 for start_idx in range(0, len(dates) - 180, 30): train_end start_idx 120 # 用过去120天拟合 test_end train_end 30 # 验证未来30天 params fit_params(train_window) # 参数一旦拟合完成test期不允许再改动 pred predict(test_window, params) record_result(pred)每分钟改一次θ每次验证完都“重新来过”这等于没有验证。所以滚动前推项目启动时就要把规则写入代码参数只能在训练段拟合验证段只负责产生预测读取结果的代码与拟合代码物理隔离。6.2 从回测到模拟盘上线前先跑两周逐日核对回测验证做完下一步是模拟盘。我自己的习惯是模拟盘至少跑两周以上每日收盘后把模拟盘的持仓和回测引擎在相同日期的持仓做对比逐行核对。差异来源通常是两类数据源行情异步更新导致的信号延迟以及成本模型设置与实际账户费率不一致。前者要在数据抓取层修复后者只需要调整参数。模拟盘核对顺序建议先看信号再看仓位最后看净值。信号错了后面一切都是错的仓位对但净值对不上大概率是成本问题。不要一上来就对比净值那样定位问题会绕远路。当时我刚搭建这套体系时回测结果漂亮得让我怀疑人生最大回撤只有3%后来才发现是涨停买入规则没写所有极端行情下的买入都理想成交。加上涨跌停过滤后最大回撤翻了一倍那才接近真实。从那以后我养成一个习惯每次回测报告的净值曲线图旁边都附上一张同一时期的交易明细表逐笔数一遍交易次数和成交价格用常识去质疑每一个数字。想让你相信一个策略优秀之前先让它经得起这种笨办法的检验。希望帮到你。本文还有配套的精品资源点击获取