4. 行业轮动信号中的交易成本建模与净收益修正

把“毛收益”拆成可核算的净收益

行业动量轮动策略的净值曲线通常先由行业指数收益算出,这条曲线默认“零成本、无摩擦”。但真实调仓要付双边佣金、卖出印花税,还要承受大额换手带来的冲击成本。本节在例3的月度调仓动量组合基础上,建立一套可复算的成本模型,把毛收益逐期扣减为净收益,并用换手率量化侵蚀程度。

成本口径先固定下来,避免方向与符号混乱。设单边佣金率 c、卖出印花税率 s、单边冲击成本率 k(以成交金额为基数)。一次调仓中,若某行业权重从 w_old 变为 w_new,则换手额 turn = |w_new − w_old|。买入方向只付佣金与冲击成本,卖出方向额外付印花税,因此单期组合成本率为:

cost_t = Σ_i [ c·|Δw_i| + k·|Δw_i| + s·max(0, w_old,i − w_new,i) ]
       = (c + k)·Σ_i|Δw_i| + s·Σ_i max(0, w_old,i − w_new,i)

教学示例参数取:单边佣金 c = 0.0003(万三),卖出印花税 s = 0.0005(千分之五,仅卖出方),单边冲击成本 k = 0.0010(千分之一,假设中小资金、行业ETF流动性中等)。这些是示例假设值,实际应按券商费率、标的流动性与下单方式替换。

用一段可运行代码完成毛/净收益对比

下面代码自包含:构造 6 个行业的月度收益矩阵与动量信号,按例3的“过去N日动量横截面排序、取前2名等权、月度调仓”逻辑生成权重,再逐期扣成本,输出毛净值、净净值、累计换手与侵蚀比例。所有随机数据由固定种子生成,结果可复现,属于教学示例而非实测。

import numpy as np
import pandas as pd

rng = np.random.default_rng(20240604)
T, N = 60, 6                      # 60个月,6个行业
industries = [f"IND{i}" for i in range(1, N+1)]

# 示例:各行业月度收益(含少量持续性,便于动量有信号)
base = rng.normal(0.006, 0.05, size=(T, N))
mom_persist = np.zeros((T, N))
for t in range(1, T):
    mom_persist[t] = 0.3 * base[t-1] + 0.7 * base[t]
rets = pd.DataFrame(mom_persist, columns=industries)

# 动量信号:过去3个月累计收益,横截面排序取前2
lookback, top_k = 3, 2
weights = pd.DataFrame(0.0, index=rets.index, columns=industries)
for t in range(lookback, T):
    signal = (1 + rets.iloc[t-lookback:t]).prod() - 1
    picks = signal.nlargest(top_k).index
    weights.loc[rets.index[t], picks] = 1.0 / top_k

# 成本参数(示例假设)
c, s, k = 0.0003, 0.0005, 0.0010

gross_nav, net_nav = [1.0], [1.0]
turnover_list, cost_list = [], []
prev_w = pd.Series(0.0, index=industries)

for t in range(T):
    w = weights.iloc[t]
    dw = (w - prev_w).abs()
    sell = (prev_w - w).clip(lower=0)
    cost = (c + k) * dw.sum() + s * sell.sum()
    port_ret = (w * rets.iloc[t]).sum()

    gross_nav.append(gross_nav[-1] * (1 + port_ret))
    net_nav.append(net_nav[-1] * (1 + port_ret - cost))
    turnover_list.append(dw.sum())
    cost_list.append(cost)
    prev_w = w

result = pd.DataFrame({
    "gross_nav": gross_nav[1:],
    "net_nav": net_nav[1:],
    "turnover": turnover_list,
    "cost": cost_list,
})
print(result.tail())
print("累计换手:", round(sum(turnover_list), 3))
print("毛收益:", round(gross_nav[-1]-1, 4),
      "净收益:", round(net_nav[-1]-1, 4),
      "侵蚀:", round((gross_nav[-1]-net_nav[-1])/ (gross_nav[-1]-1), 4))

运行后重点看三件事:一是 turnover 序列,月度调仓若每期都大幅换仓,累计换手会远高于1;二是 cost 序列,它随换手线性放大,卖出方向还叠加印花税;三是毛净值与净净值末值的差,即成本侵蚀的绝对幅度。把侵蚀额除以毛收益增量,得到侵蚀比例,可直接用于判断该策略在给定费率下是否还值得执行。

冲击成本 k 不是常数。资金规模越大、标的成交越薄,k 越高。建议对 k 做 0.0005 / 0.0010 / 0.0020 三档敏感性测试,观察净收益曲线何时由正转负,这个临界点就是策略的容量边界。

换手率如何侵蚀轮动收益

下图把单期调仓的成本构成与净值分叉关系画在一起:左侧是权重变化如何分别触发佣金、印花税与冲击成本,右侧是毛净值与净净值随累计换手拉开差距的示意。图中数值为示例参数下的结构性关系,用于理解传导路径,不代表某次真实回测结果。

单期调仓的成本构成 权重变化 Δw |w_new − w_old| 佣金 c·|Δw|(双边) 冲击 k·|Δw|(双边) 印花税 s·卖出额 单期成本率 cost_t 随换手线性放大 示例参数(假设值,需按实际替换) 单边佣金 c = 0.0003 卖出印花税 s = 0.0005 单边冲击 k = 0.0010 单期成本 ≈ (c+k)·Σ|Δw| + s·Σ卖出权重 换手越高、卖出越多,成本越重;冲击成本是最大不确定项 毛净值 vs 净净值 累计换手 → 净值 毛净值 净净值 成本侵蚀 判读:侵蚀比例 = (毛收益 − 净收益) / 毛收益。比例过高说明换手过频或费率假设过乐观,应降频、限换手或换流动性更好的标的。 对 k 做 0.0005 / 0.0010 / 0.0020 三档测试,找到净收益由正转负的临界换手,即为策略容量参考。

结果判读与可执行检查

把代码输出整理成一张对照表,是本节最直接的交付物。下表字段可直接填入自己的回测结果,用于横向比较不同费率假设下的策略可行性。

指标含义示例判读
累计换手各期 Σ|Δw| 之和月度全换仓时约 2×调仓次数,明显偏高
累计成本各期 cost_t 之和与换手近似线性,卖出方向更重
毛收益未扣成本净值末值 − 1策略信号本身的收益上限
净收益扣成本后净值末值 − 1真实可得的近似收益
侵蚀比例(毛收益 − 净收益) / 毛收益超过约 30% 需警惕换手过频
临界冲击成本使净收益转负的 k 值低于该值策略才具备容量
常见错误:把印花税也乘到买入方向,或把冲击成本只算一次。正确口径是佣金与冲击成本对买卖双边都计,印花税只对卖出计。另一个易错点是用期末权重算换手,必须用“上期权重 → 本期权重”的逐期差,首期从全现金建仓也要计入。

完成本节后,应能回答:给定费率与换手序列,净收益比毛收益少多少、侵蚀比例多大、冲击成本升到多少时策略失效。这三个答案就是后续例5做ETF可交易性验证、例6做宏观条件收益统计时的成本基准。