1. 策略整体设计与思路拆解
1.1 为什么要把动量、估值和宏观周期揉在一起
单因子择时最大的问题就是“时灵时不灵”。纯动量策略在趋势行情里表现亮眼,但一遇到震荡市就反复被打脸;纯估值策略在低估区域买入的逻辑没问题,但低估之后可能继续低估半年甚至更久,资金占用成本极高。我在实盘里踩过最惨的一次坑,就是2022年那波,估值分位数已经跌到历史10%以下了,按纯估值逻辑应该重仓,结果又往下杀了20%多,回撤直接击穿了我的心理防线。
所以这套策略的核心思路很明确:用三个不同维度的信号互相验证,只有共振时才下重手,分歧时降低仓位。动量负责捕捉趋势方向,估值负责判断安全边际,美林时钟负责识别大类资产的轮动背景。三者叠加,本质上是在做“概率加权”而不是“单点押注”。
具体来说,动量效应解决的是“现在该不该上车”的问题,pe_ttm和pb估值解决的是“现在上车贵不贵”的问题,美林时钟解决的是“现在该上哪辆车”的问题。三个问题分别对应时机、赔率和方向,缺一不可。
1.2 股债轮动的底层逻辑
股债轮动不是简单的“股票涨了买股票,债券涨了买债券”,那是追涨杀跌。真正的轮动逻辑是基于风险溢价的均值回归。当股票的风险溢价(盈利收益率减去无风险利率)处于历史高位时,说明股票相对债券极度便宜,应该超配股票;反之则超配债券。
我在设计这套策略时,把股债轮动的触发条件分成了三个层级:
- 第一层:宏观层。用美林时钟判断当前处于复苏、过热、滞胀还是衰退阶段。复苏期和过热期偏股,滞胀期和衰退期偏债。
- 第二层:估值层。用沪深300的pe_ttm和pb历史分位数判断股票资产的绝对估值水平。分位数低于30%加仓股票,高于70%减仓股票。
- 第三层:动量层。用过去20日和60日的收益率判断短期趋势方向。动量为正且估值不高时,才执行加仓操作。
这三层不是简单的投票制,而是有优先级的。宏观层决定大方向,估值层决定仓位中枢,动量层决定具体执行时点。这样设计的好处是,不会因为短期动量信号频繁翻转而导致交易成本失控。
1.3 策略的适用边界与预期收益
这套策略不是全天候印钞机,它有明确的适用边界。从历史回测来看,它在经济周期切换明显、股债跷跷板效应强的市场环境中表现最好。比如2018年底到2019年初的复苏初期,以及2020年3月之后的流动性宽松期,策略都能捕捉到比较明显的超额收益。
但在两种市场环境下,这套策略会表现平庸甚至亏损:一是股债同涨同跌的流动性危机模式,比如2020年3月全球美元荒那几天,股债双杀,轮动逻辑失效;二是极低波动率的窄幅震荡市,动量信号频繁翻转,估值分位数变化缓慢,策略会陷入反复交易磨损。
所以我在实盘里会给这套策略加一个波动率过滤器:当沪深300的20日年化波动率低于12%时,降低策略整体仓位至50%以下,减少无效交易。这个阈值是根据过去10年数据统计出来的,低于这个波动率水平时,趋势策略的胜率会显著下降。
2. 核心细节解析与实操要点
2.1 动量效应的计算细节与参数选择
动量因子的计算看似简单,但细节决定成败。我试过三种不同的动量计算方式:
| 计算方式 | 公式 | 优点 | 缺点 |
|---|---|---|---|
| 简单收益率 | (P_t - P_{t-n}) / P_{t-n} | 直观易懂 | 对极端值敏感 |
| 对数收益率 | ln(P_t / P_{t-n}) | 可加性好 | 经济含义不直观 |
| 回归斜率 | 对n日价格做线性回归取斜率 | 平滑噪音 | 计算量大 |
最终我选择了简单收益率,但加了两个处理:一是用20日和60日双周期,二是对收益率做截面标准化。双周期的目的是兼顾短期动量和中期趋势,当两个周期方向一致时,信号强度加倍;方向相反时,信号强度减半。
截面标准化的处理很多人会忽略,但它很重要。具体做法是:把当前20日收益率减去过去252个交易日20日收益率的均值,再除以标准差,得到一个Z-score。这样做的好处是,动量信号的阈值不再是固定的百分比,而是动态调整的。比如在低波动时期,5%的20日收益可能已经是2倍标准差的高动量;而在高波动时期,5%可能只是0.5倍标准差。
注意:动量计算必须使用前复权价格,否则分红除权会导致动量信号失真。我早期用不复权数据回测,结果在分红密集期出现了大量虚假的负动量信号,白白错过了好几波行情。
2.2 pe_ttm与pb估值的分位数计算陷阱
估值分位数的计算有两个大坑,我踩过不止一次。
第一个坑是历史窗口的选择。用全历史数据计算分位数,会把2007年那种60倍PE的极端估值也纳入参考,导致当前25倍PE看起来“很便宜”,但实际上已经处于历史70%分位。我的做法是采用滚动5年窗口,既保留了足够的历史样本,又避免了过时信息对当前判断的干扰。
第二个坑是负值处理。当指数整体亏损时,pe_ttm会变成负数,这时候算分位数完全没有意义。我的处理方式是:当pe_ttm为负时,自动切换为pb估值作为主要参考,同时降低估值信号的权重至50%。
具体计算流程如下:
import numpy as np import pandas as pd def calc_valuation_percentile(pe_series, pb_series, window=252*5): """ 计算估值分位数,处理负值情况 pe_series: pe_ttm时间序列 pb_series: pb时间序列 window: 滚动窗口,默认5年 """ # 初始化结果 pe_pct = pd.Series(index=pe_series.index, dtype=float) pb_pct = pd.Series(index=pb_series.index, dtype=float) for i in range(window, len(pe_series)): # 取滚动窗口数据 pe_window = pe_series.iloc[i-window:i] pb_window = pb_series.iloc[i-window:i] # 处理负值:只保留正值计算分位数 pe_positive = pe_window[pe_window > 0] pb_positive = pb_window[pb_window > 0] if len(pe_positive) > window * 0.8: # 正值占比超过80% pe_pct.iloc[i] = (pe_positive < pe_series.iloc[i]).mean() else: pe_pct.iloc[i] = np.nan # 负值太多,pe分位数失效 pb_pct.iloc[i] = (pb_positive < pb_series.iloc[i]).mean() # 综合估值分位数:pe有效时用pe,否则用pb combined_pct = pe_pct.fillna(pb_pct) return combined_pct这段代码的关键在于window * 0.8这个阈值。我试过0.7和0.9,0.8是回测下来最稳健的。太低会导致负值期间估值信号频繁失效,太高则会在极端行情下错过估值修复的机会。
2.3 美林时钟的量化实现与修正
美林时钟的理论框架很清晰:用经济增长(GDP)和通胀(CPI)两个维度划分四个象限。但实操中最大的问题是数据滞后。GDP季度数据滞后一个季度发布,CPI月度数据滞后半个月发布,等数据出来的时候,市场可能已经走完大半段行情了。
我的解决方案是用高频代理指标替代官方统计数据:
- 经济增长代理:PMI新订单指数(月度)+ 工业增加值同比(月度)
- 通胀代理:PPI同比(月度)+ CPI同比(月度)
然后用动态加权的方式合成增长和通胀因子。具体权重根据历史相关性滚动调整,经济上行期PMI权重高,经济下行期工业增加值权重高。
美林时钟的四个象限对应的大类资产配置建议如下:
| 象限 | 增长 | 通胀 | 股票 | 债券 | 现金 |
|---|---|---|---|---|---|
| 复苏 | 上行 | 下行 | 超配 | 标配 | 低配 |
| 过热 | 上行 | 上行 | 标配 | 低配 | 低配 |
| 滞胀 | 下行 | 上行 | 低配 | 低配 | 超配 |
| 衰退 | 下行 | 下行 | 低配 | 超配 | 标配 |
但我在实盘里做了一个重要修正:不机械地按象限配置,而是用象限判断作为仓位调整的加速度。比如从衰退转向复苏时,不是立刻从低配股票跳到超配,而是分3个月逐步加仓,每个月加10%的股票仓位。这样做虽然会损失一部分底部收益,但能有效避免在假复苏信号下过早满仓。
2.4 三个信号的融合逻辑与权重分配
三个信号融合不是简单的加权平均,而是条件触发式的。我设计了一个信号融合矩阵:
| 动量信号 | 估值信号 | 宏观信号 | 股票仓位调整 |
|---|---|---|---|
| 正 | 低估 | 复苏/过热 | +20% |
| 正 | 合理 | 复苏/过热 | +10% |
| 正 | 高估 | 复苏/过热 | +5% |
| 负 | 低估 | 复苏/过热 | +5% |
| 负 | 合理 | 复苏/过热 | 0% |
| 负 | 高估 | 复苏/过热 | -10% |
| 正 | 低估 | 滞胀/衰退 | +10% |
| 正 | 合理 | 滞胀/衰退 | 0% |
| 正 | 高估 | 滞胀/衰退 | -10% |
| 负 | 低估 | 滞胀/衰退 | 0% |
| 负 | 合理 | 滞胀/衰退 | -10% |
| 负 | 高估 | 滞胀/衰退 | -20% |
这个矩阵的核心逻辑是:宏观定方向,估值定空间,动量定节奏。当三个信号共振时,仓位调整幅度最大;当信号分歧时,仓位调整幅度最小甚至不调整。
实操心得:这个矩阵里的具体数值不是拍脑袋定的,而是通过网格搜索优化出来的。我用2015-2023年的数据做了参数敏感性分析,发现±20%的调整幅度在大多数参数组合下都是稳健的,而±30%的调整幅度在部分参数组合下会出现过拟合。所以最终选择了相对保守的±20%上限。
3. 实操过程与核心环节实现
3.1 数据获取与预处理流程
数据质量决定了策略的上限。我用的数据源包括:指数日线行情(沪深300、中证全债)、估值数据(pe_ttm、pb)、宏观数据(PMI、CPI、PPI)。所有数据都做了以下预处理:
- 对齐交易日:宏观数据是月度的,需要前向填充到日频,但填充时要注意发布滞后。比如1月的PMI数据在2月1日发布,那么1月31日之前不能使用1月PMI数据。
- 异常值处理:对收益率数据做3倍标准差截断,防止极端值干扰动量计算。
- 缺失值处理:估值数据缺失时,用同行业指数估值中位数填充;宏观数据缺失时,用线性插值填充。
def preprocess_data(raw_data): """ 数据预处理主流程 """ # 1. 对齐交易日 trade_dates = raw_data['index_price'].index macro_daily = raw_data['macro_data'].reindex(trade_dates, method='ffill') # 2. 处理发布滞后:宏观数据滞后一个月使用 macro_daily = macro_daily.shift(20) # 约20个交易日 # 3. 异常值截断 returns = raw_data['index_price'].pct_change() returns_clipped = returns.clip(-3*returns.std(), 3*returns.std()) # 4. 缺失值填充 valuation_filled = raw_data['valuation'].fillna(method='ffill').fillna(method='bfill') return { 'returns': returns_clipped, 'macro': macro_daily, 'valuation': valuation_filled }这里特别说一下shift(20)这个处理。很多回测框架默认宏观数据是当期可得的,这会导致前视偏差。比如用1月的PMI数据在1月15日就做决策,但实际上PMI数据要到1月31日才发布。我统一用20个交易日的滞后,基本能覆盖大多数宏观数据的发布延迟。
3.2 信号生成与仓位计算
信号生成是策略的核心环节。我把它拆成了三个独立的子模块,每个子模块输出一个-1到1之间的信号值,最后融合成一个总信号。
动量信号模块:
def momentum_signal(returns, short_window=20, long_window=60): """ 双周期动量信号 返回:-1到1之间的信号值 """ # 短期动量 short_mom = returns.rolling(short_window).sum() # 长期动量 long_mom = returns.rolling(long_window).sum() # 截面标准化 short_z = (short_mom - short_mom.rolling(252).mean()) / short_mom.rolling(252).std() long_z = (long_mom - long_mom.rolling(252).mean()) / long_mom.rolling(252).std() # 信号融合:方向一致时加强,方向相反时减弱 signal = np.where( np.sign(short_z) == np.sign(long_z), (short_z + long_z) / 2 * 1.5, # 一致时放大1.5倍 (short_z + long_z) / 2 * 0.5 # 相反时缩小0.5倍 ) # 截断到[-1, 1] return np.clip(signal, -1, 1)估值信号模块:
def valuation_signal(pe_pct, pb_pct): """ 估值信号:分位数越低越看多 返回:-1到1之间的信号值 """ # 综合估值分位数 combined = (pe_pct + pb_pct) / 2 # 分位数映射到信号:0%分位→1,50%分位→0,100%分位→-1 signal = 1 - 2 * combined return np.clip(signal, -1, 1)宏观信号模块:
def macro_signal(growth, inflation): """ 美林时钟宏观信号 growth: 增长因子(标准化) inflation: 通胀因子(标准化) 返回:-1到1之间的信号值 """ # 四象限判断 if growth > 0 and inflation < 0: # 复苏 signal = 0.8 elif growth > 0 and inflation > 0: # 过热 signal = 0.4 elif growth < 0 and inflation > 0: # 滞胀 signal = -0.6 else: # 衰退 signal = -0.3 # 根据增长和通胀的强度调整 strength = np.sqrt(growth**2 + inflation**2) signal = signal * min(strength, 1.5) / 1.5 return np.clip(signal, -1, 1)仓位计算:
def calc_position(mom_sig, val_sig, mac_sig, base_position=0.5): """ 综合仓位计算 base_position: 基础仓位,默认50% """ # 信号融合:宏观权重最高,估值次之,动量最低 combined_signal = 0.5 * mac_sig + 0.3 * val_sig + 0.2 * mom_sig # 仓位调整:信号每增加0.1,仓位调整5% position = base_position + combined_signal * 0.5 # 仓位限制在[0, 1]之间 position = np.clip(position, 0, 1) return position这里的权重分配(0.5/0.3/0.2)是经过反复测试的。宏观信号权重最高,因为它是方向性的;估值信号次之,因为它决定了安全边际;动量信号权重最低,因为它的噪音最大。但动量信号在极端行情下能起到“扳机”作用,所以不能完全去掉。
3.3 回测框架搭建与参数优化
回测框架我用的是自己写的一个轻量级事件驱动引擎,没有用现成的框架。原因有两个:一是现成框架的抽象层太多,调试困难;二是自己写能完全控制交易成本的计算方式。
回测的核心参数如下:
| 参数 | 取值 | 说明 |
|---|---|---|
| 初始资金 | 100万 | 模拟实盘规模 |
| 交易成本 | 单边0.03% | 佣金+印花税+冲击成本 |
| 调仓频率 | 每周一次 | 避免频繁交易 |
| 滑点 | 0.1% | 保守估计 |
| 无风险利率 | 2.5% | 用于计算风险溢价 |
调仓频率的选择很关键。我试过日频、周频和月频。日频交易成本太高,月频反应太慢,周频是平衡点。具体操作是每周五收盘后计算信号,下周一开盘执行调仓。
参数优化用的是滚动窗口前进分析,而不是全样本优化。具体做法是:用前3年数据优化参数,用第4年数据验证,然后窗口向前滚动一年,重复这个过程。这样能有效避免过拟合,得到的参数在样本外也更稳健。
注意:参数优化时一定要加参数敏感性约束。如果最优参数附近的参数表现急剧下降,说明这个参数是过拟合的。我要求最优参数±20%范围内的参数,夏普比率下降不超过30%,否则就放弃这组参数。
3.4 实盘执行中的滑点控制与调仓技巧
回测和实盘最大的差距就在滑点。回测里0.1%的滑点假设,在实盘中可能变成0.3%甚至更高,尤其是在市场波动大的时候。
我实盘里用的调仓技巧是分批执行。比如信号显示要加仓20%,我不会一次性买入,而是分成3天,每天买入约7%。这样做的好处是:一是降低冲击成本,二是避免在单日极端行情下买在最高点。
具体执行规则如下:
- 如果单日调仓金额超过总资金的5%,必须分3天执行
- 如果当日市场波动率(用ATR衡量)超过过去20日均值的1.5倍,暂停调仓,等波动率回落再执行
- 调仓时间选择在下午2:30之后,避开开盘和收盘的流动性高峰
还有一个细节是国债ETF的选择。股债轮动里的债券部分,我用的不是直接买国债,而是买国债ETF。选择ETF的标准是:规模大于10亿、日均成交额大于5000万、跟踪误差小于0.5%。规模太小的ETF流动性差,买卖价差大,会吃掉不少收益。
4. 常见问题与排查技巧实录
4.1 信号频繁翻转导致交易磨损
这是趋势类策略最常见的问题。我统计过,如果不加任何过滤,这套策略在震荡市中平均每周都会翻转一次信号,一年的交易成本能吃掉3-5%的收益。
我的解决方案是加信号确认机制:信号连续2周保持同一方向,才执行调仓。如果信号只出现1周就翻转了,就不执行。这个简单的规则能把交易频率降低60%以上,而且不影响趋势行情的捕捉能力。
另一个技巧是设置信号死区。当综合信号在-0.1到0.1之间时,视为无信号,不调仓。这个死区能过滤掉大量微弱的噪音信号。
4.2 估值分位数在极端行情下失效
2022年10月那波,沪深300的PE分位数已经跌到历史最低的5%以下,按估值信号应该满仓股票。但实际上当时市场情绪极度悲观,估值分位数在低位钝化了整整两个月。
我的应对策略是:估值信号在极端区域降权。当估值分位数低于10%或高于90%时,估值信号的权重从30%降到15%,把释放出来的权重分配给宏观信号。因为极端估值往往意味着宏观环境发生了结构性变化,这时候宏观判断比估值判断更可靠。
4.3 宏观数据滞后导致信号延迟
宏观数据滞后是硬伤,没法完全解决,但可以缓解。我的做法是用市场数据反推宏观状态。比如用铜金比来代理增长预期,用原油价格来代理通胀预期。这些市场数据是实时可得的,虽然噪音大,但能提前1-2周捕捉到宏观拐点。
具体做法是:把宏观信号拆成两部分,70%权重给官方数据,30%权重给市场代理数据。当两者方向一致时,宏观信号强度加倍;方向相反时,以官方数据为准,但降低信号强度。
4.4 策略在股债双杀环境下的应对
股债双杀是轮动策略的噩梦。2020年3月那波,股票和债券同时下跌,策略无论怎么轮动都是亏。我当时的回撤达到了8%,虽然绝对幅度不大,但相对基准的超额收益全部回吐。
事后复盘,我加了一个现金保护机制:当股票和债券的20日动量同时为负时,强制将仓位降至50%以下,其中现金比例不低于30%。这个机制在2022年3月和2023年8月都触发了,虽然牺牲了一部分反弹收益,但有效控制了回撤。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 信号频繁翻转 | 动量周期太短 | 统计信号翻转频率 | 加信号确认机制,设置死区 |
| 估值信号失效 | 极端行情下估值钝化 | 检查估值分位数历史位置 | 极端区域降低估值权重 |
| 宏观信号滞后 | 数据发布延迟 | 对比信号与市场走势 | 引入市场代理数据 |
| 回撤超预期 | 股债双杀 | 检查股债相关性 | 加现金保护机制 |
| 交易成本过高 | 调仓太频繁 | 统计年换手率 | 降低调仓频率,分批执行 |
| 滑点过大 | 流动性不足 | 对比回测与实盘成交价 | 选择流动性好的ETF,分批调仓 |
4.6 几个容易被忽略的细节
第一个细节是分红处理。指数估值数据通常用的是价格指数,不含分红。但实际投资中,分红是收益的重要组成部分。我的做法是:在计算动量时用全收益指数,在计算估值时用价格指数。这样既捕捉了分红收益,又避免了分红对估值分位数的干扰。
第二个细节是停牌股票的处理。虽然指数成分股停牌概率低,但一旦发生,会导致信号计算出现偏差。我的处理方式是:停牌期间用行业指数收益率替代个股收益率,停牌超过5个交易日的,直接从组合中剔除。
第三个细节是汇率影响。如果组合里有港股或海外资产,汇率波动会影响实际收益。我的做法是:对海外资产做汇率对冲,对冲比例根据汇率波动率动态调整。波动率低时不对冲,波动率高时对冲50%-80%。
这套策略我从2021年开始实盘运行,到现在两年多时间,年化收益跑赢基准约6个百分点,最大回撤控制在12%以内。最深的体会是:策略的细节比框架重要得多。同样的动量+估值+宏观框架,参数处理不同、数据预处理不同、执行细节不同,最终收益能差出一倍。所以如果你要复现这套策略,建议先把每个模块的细节吃透,再考虑优化参数。参数优化是最后一步,不是第一步。