ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

A股多维度量化择时:因子选型、系统构建与回测避坑

A股多维度量化择时:因子选型、系统构建与回测避坑

简介:这份金融工程专题报告围绕A股多维度量化择时系统的构建与回测展开,适合量化投研人员、金融工程学习者以及关注资产配置的投资者阅读。报告以经济运行、流动性、动量趋势、事件驱动四个维度分别生成指标打分,并将前三维度作为主交易系统,事件驱动作为高胜率、非连续性的额外加分项,通过加权合成得到综合择时信号;随后采用分仓方式在2012年1月至2019年6月区间回测,年化收益达12.8%,高于同期沪深300的6.8%,滚动一年平均最大回撤由-22.2%收窄至-9.6%,Calmar比率为1.33。包内为1个PDF文件,约1.06MB,全文15页,既有单指标择时测试与净值曲线展示,也有维度加权合成、收益贡献分析及模型设定偏差的风险提示。已有102人学习下载,适合作为理解量化择时体系设计、因子打分合成和回测评估逻辑的参考。

1. A股多维度量化择时:为什么“多维度”才是系统而非玄学

一份15页的券商金融工程专题报告,能在标题里同时放下“A股”、“多维度”、“量化择时”、“构建与回测”这四个关键词,本身就说明了一件事:真正的难点从来不在某一个因子或某一段代码,而在于把多个互不相关的信号拧成一套能自洽、能回测、能上线验证的体系。作为长期做量化策略落地的人,我见过太多单因子回测很漂亮、一进组合就翻车的案例。原因很简单——单一维度总有失效的时候,而多维度系统提供的是冗余与互补,让策略在风格切换时不会瞬间失灵。

这篇博文顺着这类报告的标题框架,把系统从因子逻辑讲到代码骨架、回测框架和避坑清单。适合正在做A股择时研究、或者准备把单因子研究升级为多因子系统的量化工程师和研究员。你会看到我在因子选型上的取舍,也包含我自己踩过的坑。

2. 择时因子的选型逻辑:趋势、量价、市场宽度与资金流的取舍

2.1 四个维度各自回答什么问题:趋势、量价、宽度、资金的分工

先说结论:所谓“多维度”并不是把十个指标堆在一起然后求加权平均。我见过不少实现把 MACD、RSI、KDJ、布林带全塞进一个模型里,最后发现它们高度共线,等于换了个马甲的单因子。真正的多维,是让不同因子回答不同层面的问题。我通常把A股择时因子拆成四组。

第一组是趋势组。均线、MACD、唐奇安通道这类指标,回答的是“市场目前处于什么状态”。趋势组的特点是慢,滞后是它的天性,但胜在稳定。你不应该指望趋势组抓到拐点,它的职责是让你在上升趋势里别轻易下车,在下降趋势里别急着抄底。做一个多维度择时系统,趋势组通常是打底的维度,其他全部是辅助。

第二组是量价组。成交量、换手率、量比、成交额变化率,回答的是“这个价格有没有资金愿意承接”。量价组的特点是敏感,它通常比趋势组更早出现异动。价格创新高但量能连续萎缩,是典型的量价背离警示;反过来,缩量回调到关键均线附近企稳,又是相对高胜率的介入点。量价组适合做趋势组的确认信号,而不是独立出信号——因为量能本身的方向性很弱。

第三组是市场宽度。涨跌家数比、新高新低家数差、均线上方个股占比、连板高度,回答的是“指数涨跌背后是不是全体股票都参与”。这个维度是做指数择时时我很看重的一层,因为A股指数经常被权重股控制,指数不动不代表市场没机会,指数大涨也不代表人人赚钱。宽度因子能帮你过滤掉这种假信号。

第四组是资金面。融资余额变化、外资流向净买入、ETF份额变化、行业成交占比,回答的是“增量资金到底进没进来”。资金面数据的公布频率通常不高,但它往往是趋势和量价的底层原因。需要特别提醒的是,资金面数据的统计口径经常调整,回测时如果不对齐发布口径,前视偏差非常容易混进来,这一点在第5章会专门展开。

一份组织得好的券商专题报告,通常会用一张表格把这四组的因子定义、更新频率、适用周期列清楚。我自己做系统时也会先建这样一张表:

维度代表指标回答的问题更新频率滞后特性
趋势均线偏离、MACD、唐奇安通道市场处于什么状态日频滞后明显
量价量比、换手率、成交额变化率价格有没有资金承接日频/盘中较敏感
市场宽度涨跌家数比、均线上方占比、新高新低差上涨是否具备普遍性日频同步
资金面融资余额、外资净流入、ETF份额增量资金是否入场日频/周频发布滞后

这张表决定了后面代码里数据对齐怎么做,也决定了你在描述策略逻辑时,能不能说清楚“为什么这几个因子放在一起能互补”。

2.2 因子计算的细节口径:窗口参数、标准化与阈值设定

因子选型定了之后,真正决定回测结果好坏的往往是计算口径。同一个“20日均线”,用收盘价算还是用复权价算,前20日还是前21日交易日,结果差异不大;但“量比”是取当日每分钟成交量均值与过去5日每分钟均值之比,还是单纯用当日总成交量除以过去5日平均成交量,差异就大了。我的原则是:能用日频数据就不用分钟级,能少调参就少调参。

举一个我常用的做法。拿“均线上方占比”为例,它的定义是:全市场在某个时间切片里,收盘价位于自己60日均线上方的股票数量,占当时全市场有效股票数的比例。计算上需要注意三点:剔除上市不满61个交易日的股票,剔除停牌股,剔除当日涨跌停但无法交易的股票。否则你会把“根本没机会买入”的票也算进宽度里,导致宽度虚高。

趋势组和量价组的因子计算也有细节。比如均线类因子,我会用加权移动平均而不是简单移动平均,单纯是为了减少跳空缺口带来的毛刺。量价类因子则一定要先对量能做对数化,因为成交额在A股市场的分布是长尾的,直接拿原始成交额算均值会被极端值带偏,2015年和2024年那几波天量行情就是典型例子。

标准化这一步也容易被忽视。多维度因子体系里,各因子的量纲差得很远:涨跌家数比是0到1区间的比率,外资净流入是几十亿级别的金额,MACD是价格单位。如果不做标准化,后面合成信号时权重就会被量纲大的因子绑架。我常用的是滚动Z-Score标准化,即用过去N日(通常取250或500日)的均值和标准差把当前值标准化。这里的关键是,Z-Score的均值和标准差只能用当时的“已实现”数据来计算,用未来数据等于作弊。滚动窗口取250日的隐含逻辑是“用最近一年的市场状态作为基准”,超过一年,市场生态可能已经切换了。

阈值是最后一个容易翻车的点。很多人喜欢把阈值设成固定值,比如宽度因子超过0.8就满仓,低于0.2就空仓。经验上A股各因子的分布会随市场生态变化而漂移,固定阈值早晚会失灵。我更习惯把阈值做成动态的分位数:对每个因子,取过去一段时间分布上的某个分位数作为当前阈值。这套做法本质上是让系统自适应地感知“当下算高还是算低”,而不是拿着十年前的有效区间去套今天的市场。

3. 系统搭建与代码骨架:从数据对齐到信号合成的可复现路径

3.1 数据层先定边界:行情、资金与情绪数据的对齐问题

聊完因子逻辑,进入搭建阶段。我一般把系统拆成数据层、因子层、信号层、执行层四层。数据层是第一步,也是回测最容易出前视偏差的地方。构造择时系统最少需要三类数据:指数与个股行情,资金面数据,以及计算市场宽度所需的成分股列表和全市场行情快照。

这里最大的坑是数据对齐。日线行情大家都用交易日自然对齐,但资金面数据未必是日频的,融资余额是滞后披露的,外资流向数据也有过口径调整。对齐的口径必须和“实盘时你真正拿到数据的时间”一致,而不是和“自然日期”一致。比如某天的融资余额数据实际上当晚或次日才公布,回测就不应该用当天的融资数据去解释当天的行情。

我常用做法是建一个“可用日期”的数据字典:对每一类数据,单独记录其数据发布日,而不是简单地把日期索引对齐。这样在因子计算时,使用的是每个交易日实际可用的数据切片。这会给代码增加一点复杂度,但能避免最致命的前视偏差问题。后面第5章我会用一条真实踩坑记录说明这个细节能造成多大的差距。

3.2 因子计算与信号合成的核心代码:一个最小可用的 Python 实现

下面给一个最小可用的代码骨架,模拟一个三维择时系统的核心路径。这里用假数据接口替代具体的数据库,你需要按自己的数据源替换。

import pandas as pd import numpy as np class MultiFactorTimer: def __init__(self, price_df, breadth_df, fund_df, lookback=250): """ price_df: 指数日线行情,列含 close, volume, amount,索引为交易日期 breadth_df: 市场宽度数据,列含 up_count, total_count fund_df: 资金数据,列含 foreign_net, margin_change lookback: 滚动标准化的窗口长度,默认250个交易日 """ self.price_df = price_df self.breadth_df = breadth_df self.fund_df = fund_df self.lookback = lookback def _align_on_publish_date(self): """ 核心对齐逻辑:统一到“数据真正可用的日期”上。 资金数据普遍滞后发布,这里用 shift(1) 模拟滞后一个交易日。 """ fund_aligned = self.fund_df.shift(1) # 滞后一个交易日才能拿到 aligned = self.price_df.join(self.breadth_df, how='left').join(fund_aligned, how='left') return aligned.dropna() def _trend_factor(self, df, window=20): """趋势因子:收盘价相对均线的偏离度""" ma = df['close'].rolling(window).mean() return df['close'] / ma - 1.0 def _volume_factor(self, df, short_window=5, long_window=20): """量价因子:短均量相对长均量的比率,做对数化""" volume_short = df['amount'].rolling(short_window).mean() volume_long = df['amount'].rolling(long_window).mean() return np.log(volume_short / volume_long)

这段代码展示了三个核心点。第一,_align_on_publish_date里用shift(1)模拟资金数据滞后一个交易日才可用,这是回测结果和实盘结果一致性的关键之一。第二,趋势因子用的是“收盘价/均线-1”的相对偏离,而不是直接用均线值,这样不同指数、不同价位区间之间可以横向比较。第三,量价因子用短期均量比长期均量再取对数,比单纯放“今日成交额/昨日成交额”稳定得多,不会因为单日异常放量产生毛刺。参数上,short_window=5是一周的交易日均值,long_window=20是一个月,这是日频择时里比较常见的配置。

然后是因子合成部分。

def compute_factors(self): """计算所有因子并做滚动标准化""" df = self._align_on_publish_date() df['trend'] = self._trend_factor(df) df['volume'] = self._volume_factor(df) df['breadth'] = df['up_count'] / df['total_count'] df['fund'] = df['foreign_net'].rolling(20).mean() # 滚动Z-Score标准化:只用当前时刻之前的数据计算均值/标准差 factor_list = ['trend', 'volume', 'breadth', 'fund'] for f in factor_list: mean = df[f].rolling(self.lookback).mean() std = df[f].rolling(self.lookback).std() df[f + '_z'] = (df[f] - mean) / std return df def generate_signal(self, weight_dict=None): """将标准化因子加权合成多空信号,输出每日仓位""" if weight_dict is None: weight_dict = {'trend': 0.3, 'volume': 0.2, 'breadth': 0.3, 'fund': 0.2} df = self.compute_factors() df['signal'] = sum(df[f + '_z'] * w for f, w in weight_dict.items()) df['position'] = np.where(df['signal'] > 0, 1, 0) # 0/1 仓位 return df[['close', 'signal', 'position']]

资金面因子在合成前先做了一次20日均值平滑,因为外资和融资数据的单日噪音很大,直接进合成信号会导致仓位频繁抖动。合成后的signal是连续值,但position是二元化的0或1。为什么不用连续仓位?因为A股的个人账户和很多产品账户实际执行时,连续仓位会导致频繁小比例调仓,手续费和冲击成本会吃掉收益。0/1二元仓位看起来粗糙,但执行起来干净,回测也更贴近真实。如果你做的是资金容量更大的组合,可以考虑分级仓位,比如0.3/0.6/1.0三档。

3.3 把窗口、阈值、权重配置化:避免参数淹死在代码里

我见过很多策略代码,参数直接写在函数调用里,最后调参时改一个窗口要翻遍整个文件。搭建这类系统时,我会第一时间把所有因子的窗口、阈值、权重抽到一个配置数据结构里,最好是字典或专门的配置模块。

CONFIG = { 'trend': {'window': 20, 'weight': 0.3}, 'volume': {'short_window': 5, 'long_window': 20, 'weight': 0.2}, 'breadth': {'quantile': 0.8, 'weight': 0.3}, 'fund': {'smooth_window': 20, 'weight': 0.2}, 'signal': {'zscore_window': 250, 'position_threshold': 0.0} }

参数配置化之后,参数扫描会变得非常方便。你只需要写一个双层循环遍历不同权重组合,就能批量回测。但注意,参数扫描本身是个陷阱源——扫得越多,过拟合的可能性越大。后面第4章我会专门讲怎么用热力图识别过拟合。

4. 回测框架与指标评估:如何分辨“拟合得好”和“真的有用”

4.1 向量化回测与事件驱动回测:先选对框架再谈其他

择时类策略有一个特点:标的少、调仓频率低。这种情况下,我会优先选向量化回测而不是事件驱动回测。向量化回测的核心是把整段历史净值用数组运算算出来,速度快、代码短,适合策略逻辑还在演进时的快速迭代。事件驱动回测更贴近实盘,逐根K线模拟撮合和资金流,但代码量大、运行慢,一般是策略成熟后做最终校验时用。

向量化回测在择时场景下的表达式很简洁。有了每天的仓位序列position和标的的每日收益率ret,策略每日收益就是position.shift(1) * ret。注意这里一定要shift(1)——它确保当天用收盘信号产生的仓位,要到第二天才生效,规避了用当日收盘价买卖导致的前视偏差。很多人回测曲线虚高,查到最后就是少了这一个位移。

我的习惯是先用向量化回测快速试错,等策略逻辑定型后,再用事件驱动框架做一次全量校验,重点看滑点、停牌、涨跌停无法成交等执行细节的影响。两套框架合起来,才是完整的回测体系。

4.2 指标组合怎么看:年化、回撤、胜率之外还有三个必看数

很多人看回测只盯着年化收益率和最大回撤,这远远不够。年化收益可以被最后一段行情抬得很高,最大回撤则取决于样本是否覆盖了极端行情。我至少会同时看五个数:年化收益率、最大回撤、夏普比率、卡玛比率和月度正收益占比。

指标计算方式在择时系统里回答什么
年化收益率总收益换算到年度这个系统长期能不能赚钱
最大回撤峰值到谷值的最大跌幅极端行情下你扛不扛得住
夏普比率年化超额收益 / 年化波动率每承担一单位波动换来多少收益
卡玛比率年化收益 / 最大回撤收益和回撤之间的性价比
月度正收益占比正收益月份数 / 总月份数收益是否集中在少数几个月

一个反直觉的经验:月度正收益占比比胜率重要得多。胜率只管“这笔交易赚没赚”,月度正收益占比管的是“这个策略的收益是不是靠天吃饭”。如果年化20%但正收益月份只有55%,说明总收益集中在某几个大月,其余月份在反复磨损,这种策略的下场通常是被磨损期熬死。

4.3 参数扫描与过拟合识别:热力图和“退化的角落”

参数扫描是过拟合的高发区。我推荐一个简单实用的排查方法:把两个最关键的参数(比如趋势窗口和量价确认窗口)放进二维网格里扫描,画一张热力图,横纵轴是参数值,颜色是年化或夏普。如果最优点孤零零地悬在中间,四周都是深色差区,那多半是过拟合。反过来,如果最优点的周围是一个连续的“高原”,相邻参数组合表现都不差,这个策略才值得信任。

这个方法背后的逻辑是:真实有效的策略对参数应该是平滑依赖的。市场规律不会精确地只存在于“窗口20”上——相邻的18、21、23如果表现迥异,说明你拟合的是噪音而不是规律。热力图之外,我还建议配合一个“退化的角落”观察:参数很大或很小的一端,策略表现通常会变差。如果极值区域的差是从最优点平滑地恶化,说明参数依赖健康;如果出现突变跳崖,就要怀疑数据里有特殊结构在作祟。

为了让你直观感受热力图排查的操作,我给出一个简化的扫描框架:

import numpy as np import pandas as pd def parameter_sweep(timer_class, param_grid_a, param_grid_b, data): """ 对两个参数做网格扫描,返回二维指标矩阵。 param_grid_a, param_grid_b: 候选参数列表 data: 对齐后的行情数据 """ results = np.zeros((len(param_grid_a), len(param_grid_b))) for i, a in enumerate(param_grid_a): for j, b in enumerate(param_grid_b): # 用传入的参数实例化策略并跑回测 result = timer_class(data, window_a=a, window_b=b).run_backtest() results[i, j] = result['annual_return'] return results

这个扫描框架本身没有玄学,关键是后续怎么读这张矩阵。我会把矩阵转成DataFrame后用seaborn.heatmap画出来,然后重点看最高点周围5×5网格内的颜色变化:如果高亮区域是连片且渐变过渡的,说明参数平滑;如果只有一个孤立亮点,直接判定为过拟合,不值得继续投入。

5. 回测避坑实录:数据、执行与逻辑上的四个典型陷阱

5.1 前视偏差:它怎么混进因子计算

现象:回测曲线极其漂亮,年化超过40%,最大回撤不到8%,但一模一样的策略上到模拟盘就完全失灵,收益曲线接近直线甚至反向。

原因:最常见的就是前视偏差。有一个很典型的案例,某开发者做A股择时,用数据接口直接拉取当日外资流向数据,然后用当天的净流入信号触发次日开盘的调仓决定,回测显示策略在2019年和2024年尤其赚钱。后来逐笔排查发现,接口里“当日”的数据实际上是次日凌晨才完成复核的,他在实盘中根本拿不到回测里用的那个数据切片。这就等于站在未来看过去,回测曲线自然漂亮。

解决:所有信号因子必须只用“当时可得”的数据计算。资金面数据普遍有滞后发布问题,统一用发布日对齐而不是自然日对齐。实操上我习惯在数据层就把滞后一天的数据作为当前可用数据,这样上游因子和信号层根本不可能犯错。代码层面就是第3章里那个shift(1)要做扎实,而且要对每一类数据单独验证,不能想当然。

5.2 停牌、涨跌停与复权:三个数据问题同时出现时

现象:回测里某几天出现了极端的单日收益,比如上涨9.8%或下跌9.9%,而同期指数只波动了0.3%。进一步检查发现,这些极端收益都来自个股层面,但你的择时系统做的是指数级信号,理论上不该被单只个股影响。

原因:停牌复牌和涨跌停数据没有清洗。长期停牌的股票复牌当天经常连续补涨或补跌,量化上被记为单日巨额涨跌。涨跌停的股票当天根本买不进或卖不出,但回测系统如果忽略这个约束,它就会在涨停板上“买入”、在跌停板上“卖出”,产生现实中不可能存在的成交。复权问题则更隐蔽——前复权价格在历史数据上是变化的,每次复权重算都会改变历史价格,导致你的因子值跟着变。

解决:在数据预处理阶段做三件事。第一,剔除停牌日的收益数据,复牌日的收益不参与因子计算。第二,给成交价格加限制,如果当日涨停且收盘封板,那么当日不可买入;如果当日跌停,当日不可卖出。第三,择时因子统一使用后复权价格计算趋势和量价指标,避免前复权带来的历史数据漂移。做完这三步再去跑回测,你会发现年化收益率掉下来一截,但那才是真实可执行的结果。

5.3 换手成本设不准,回测曲线直接腰斩

现象:某策略在回测里年化20%,叠加双边万三的佣金后还有18%,但实盘跑下来只有8%左右,差距大得让人怀疑是不是代码写错了。

原因:成本模型漏掉了印花税和冲击成本。A股卖出有印花税,费率随政策有过调整;此外,策略每次调仓的规模一上来,冲击成本就不可忽略。很多人只在代码里写了个固定佣金比例,完全没建模印花税和冲击成本。特别是做小盘股或流动性一般的标的时,冲击成本可能远超佣金。

解决:我常用的成本模型是三段式:固定佣金、印花税、冲击成本。冲击成本用调仓金额的固定比例来近似,保守的做法是单边设千分之一。把成本参数塞进回测框架后,再对比“含成本”和“不含成本”的年化差距。如果差距超过30%,说明策略的交易频率已经高到不切实际,需要对信号做低通滤波或提高触发阈值。这个差距本身,就是策略对交易成本的敏感度,必须写进评估报告里。

5.4 策略退化:样本内外漂移的三种典型表现

现象:同一个参数组合,2016到2020年回测年化25%;2021到2024年间同样参数回测只有6%,而且换手率变得异常高,信号频繁翻转。

原因:市场微观结构变了。A股的波动特征、涨跌停规则、投资者结构都在变,一个十年前有效的因子,在量化资金大量涌入后可能已经失效。典型表现有三类:因子收益衰减、换手率异常放大、相关性结构变化。衰减意味着因子的超额收益在逐年下降;换手率异常说明因子在当下市场里给出的信号互相矛盾;相关性结构变化则是说原本互不相关的几个维度,现在开始同涨同跌,多维度体系的“冗余”优势被削弱了。

解决:没有一劳永逸的办法,只能从机制上增加系统自适应能力。我的做法有三层。第一,给每个因子加半衰期权重,越近的数据权重越高,让因子对市场变化更敏感。第二,定期重算一次因子的滚动分位数阈值,比如每季度一次,把阈值锚定到最新的市场分布上。第三,设置一个“策略熔断”机制,当连续一段时间策略跑输基准时,自动降仓位或切换到保守模式。熔断机制不能保证赚钱,但能防止策略在退化期把前期的利润全部回吐。

6. 从回测到模拟盘:三个动作验证择时系统是否真的能用

6.1 滚动重估:用逐月重算看因子时效性

回测报告可以是一锤子买卖,但实盘运行是持续过程。我常用的验证方式是滚动窗口重估:以3年为一个训练窗口,滚动往前推进,每推进一个月就重新计算一次模型权重和参数,然后把新参数用于下一个月。这样你能得到一条“样本外滚动收益曲线”。这条曲线比一次性全样本回测可信得多,因为它模拟了真实系统会遇到的参数更新节奏,也直接暴露了因子在哪个时间段开始失效。如果滚动曲线在最近两年持续走平,说明策略大概率已经不适应现在的市场,再调参也很难救回来。

6.2 滑点敏感度测试:把乐观假设全打上问号

实盘和回测最普遍的鸿沟是成交价格。回测里用收盘价成交,实盘里你很难拿到那个收盘价。我会做一个滑点敏感度测试:把成交价格改成收盘价加固定的滑点比例,分别测试0、0.1%、0.3%、0.5%四档。如果0.5%滑点下系统年化收益转负,那这个系统在实盘里的安全边际就很薄了。这个测试成本很低,但能帮你消灭大量不切实际的策略。记住一个习惯:只有当0.3%滑点下策略仍然能跑出正收益时,我才会把它推进到模拟盘阶段。

6.3 留下一张“黑匣子清单”:哪些参数永远不该动

最后一个习惯,也是我交过几年学费换来的。我会在系统里维护一张“黑匣子清单”,记录哪些参数已经过滚动验证、哪些参数在实盘运行中绝对禁止手动调整。原因很简单——实盘亏损时你一定会想“是不是换个参数就回来了”,但大多数情况下,短期亏损只是随机波动,频繁调参是最快拖垮一个量化系统的方式。我一般只允许每季度根据滚动重估结果统一调整一次参数,其余时间全部冻结,连自己也只有这个权限。

这套系统的核心价值也在这里:多维度不是为了把回测做得更漂亮,而是为了让策略在单一维度失效时还有别的维度托底。把数据对齐、因子口径、回测成本和参数边界都管理好,剩下的交给市场验证。希望我把这些年的搭建经验和踩坑记录梳理出来的内容,能让你在这个方向上少走一点弯路。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进