跳转至



strategy »

『译研报03』Z变换改造均线,一个12年前的策略为何仍能跑赢大盘?


传统移动平均线(MA)是技术分析中常用的趋势跟踪指标,通过对股票价格或指数在一定天数内的平均值进行计算,以刻画其变动方向。MA 的计算天数越多,其平滑性越好,但随之而来的时滞(延迟)影响也越严重。这意味着 MA 指标在跟踪趋势时容易出现“跟不紧”甚至“跟不上”的情况,平滑性和延迟性成为其不可避免的矛盾。

低延迟趋势线(LLT)的构造借鉴了信号处理理论中的滤波方法。传统的 EMA 指标被视为一阶低通滤波器,但其滤波效果相对较差,通带和阻带间的过渡带太长。LLT 通过设计二阶滤波器来优化滤波效果,实现了对信号高频部分的有效过滤,同时保留了低频部分的强度。与 MA 和 EMA 均线相比,LLT 大幅降低了延迟,同时兼顾了趋势线的平滑性,从而克服了传统 MA 指标在跟踪趋势时的滞后问题。

本文取材于广发证券-《低延迟趋势线与交易择时》一文。原文仅回测到2013年。我们在此基础上回测到2024年底,发现它在多空组合时,仍有很好的表现(上证夏普1.33)。

策略档案之LLT

LLT Benchmark Strategy
Start Period 2013-01-04 2013-01-04
End Period 2024-12-31 2024-12-31
Cumulative Return 47% 345%
CAGR﹪ 2% 9%
Sharpe 0.27 1.33

传统均线系统

为了对比,我们先给出传统均线的定义及图形。

移动平均(Moving Average)线,其算法为:

\(MA(n) = \frac{1}{n}\sum^{n-1}_{i=0}price(T-i)\)

其中 price 一般选择收盘价,MA(n) 即为 T 日的 n 日均线指标。对于 MA 指标,n 越大,趋势线的平滑性越好。

基于移动均线,我们可以实现一个简单的趋势跟随策略。信号的判断方式是看移动平均线的切线。如果切线斜率向上,则多头持有;如果切线斜率向下,则多头卖出。

下面的代码演示了 5, 10, 30 和 60 日均线。其中 30 日均线被称为生命线。最后的绘图中,显示了 30 日均线上,切线由正转负的情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def get_price(symbol, start_date, end_date):
    pro = pro_api()

    price_df = pro.index_daily(
        ts_code=symbol,
        start_date=start_date.strftime("%Y%m%d"),
        end_date=end_date.strftime("%Y%m%d"),
    )

    price_df = (
        price_df.rename({"trade_date": "date", "ts_code": "asset"}, axis=1)
        .sort_values("date", ascending=True)
        .set_index("date")
    )

    return price_df[["close"]]

start = datetime.date(2012, 10, 26)
end = datetime.date(2013, 4, 9)

price_df = get_price("000001.SH", start, end)

for i in [5, 10, 30, 60]:
    price_df[f"MA%d" % i] = price_df["close"].rolling(i).mean()

# 计算生命线趋势拐点
price_df["slope_30_5"] = price_df["MA30"].rolling(5, min_periods=5).apply(lambda y: np.polyfit(np.arange(5), y, 1)[0])

price_df["slope_30_5"] = price_df["slope_30_5"].fillna(0)
signs = np.sign(price_df["slope_30_5"])
sign_changes = signs * signs.shift(1) == -1

revert_dates = price_df.index[sign_changes]
print("找到的反转日期:", [i for i in revert_dates])

# 在现有图表上添加切线
cols = ["MA5", "MA10", "MA30", "MA60"]
ax = price_df[cols].plot(figsize=(18, 8), title='30 日均线切线分析')

# 切线长度
tangent_length = 15

for dt in revert_dates:
    # 获取该位置的 MA 值和斜率
    ma_value = price_df.loc[dt, 'MA30']
    slope = price_df.loc[dt, 'slope_30_5']
    i = price_df.index.get_loc(dt)

    # 计算切线范围
    start_idx = max(0, i - tangent_length)
    end_idx = min(len(price_df), i + tangent_length + 1)

    # 计算切线坐标
    x_offset = np.arange(start_idx - i, end_idx - i)
    y_tangent = ma_value + slope * x_offset
    tangent_dates = np.arange(start_idx, end_idx)

    # 绘制切线
    color = 'green' if slope > 0 else 'red'
    linestyle = '-.' if slope > 0 else '--'

    ax.plot(tangent_dates, y_tangent, 
           color=color, linestyle=linestyle, linewidth=2, alpha=0.8)

    # 标记切点
    ax.scatter(i, ma_value, color=color, s=100, zorder=5)
    ax.annotate(dt, 
               xy=(i, ma_value),
               xytext=(5, 10), textcoords='offset points',
               fontsize=9, color=color,
               bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8))

plt.show()

在该图中,绿色线为30日线。红点处,30日均线斜率由正转负,即为卖出信号。我们看到,通过 30 日均线的趋势跟随,可以捕捉到大的波段行情。但是,当趋势线提示我们该下车时,已经离行情高点下降不少。但如果使用 5 日均线这种短一点的均线,又会导致频繁发出信号,增加交易成本的情况。

这个结果表明,传统均线存在窗口小时,均线不平滑,趋势线切线上下抖动现象严重;窗口大时,均线平滑性好,但滞后性较强的问题。

Tip

寻找切线反转点(即切线斜率由正变负,或者由负变正)时,比较有技巧。这个技巧在很多场合下都会遇到:

1
2
3
4
5
    price_df["slope_30_5"] = price_df["slope_30_5"].fillna(0)
    signs = np.sign(price_df["slope_30_5"])
    sign_changes = signs * signs.shift(1) == -1

    revert_dates = price_df.index[sign_changes]

如何解决传统均线在延迟和平滑上,存在鱼与熊掌不可兼得的矛盾?

LLT 均线

研报在 LLT 均线的原理及推导上介绍得比较深入细致。但是,要理解 LLT,需要有 Z 变换等基础,我们简单解释如下:

\[\frac{LLT(z)}{price(z)} = \frac{(\alpha-\alpha^2/4) + (\alpha^2/2)z^{-1} - (\alpha-3\alpha^2/4)z^{-2}}{1-2(1-\alpha)z^{-1} + (1-\alpha)^2z^{-2}}\]

这是一个所谓的Z域上的公式,我们需要按相应的规则,变换为时域公式。它的变换规则是:

  • 上式是一个二阶 IIR 滤波器的传递函数。要得到时域递推公式,需要将分子分母都乘以分母的表达式,使分母变为 1(即左边只剩 LLT(z)),右边是分子的多项式与 price(z) 的卷积: \(\(LLT(z) \cdot [1-2(1-\alpha)z^{-1} + (1-\alpha)^2z^{-2}] = price(z) \cdot [(\alpha-\alpha^2/4) + (\alpha^2/2)z^{-1} - (\alpha-3\alpha^2/4)z^{-2}]\)\)
  • 展开后,按 z 变换的性质,将 z{-1}、z分别对应到时域的 t-1、t-2 期: \(\(LLT_t - 2(1-\alpha)LLT_{t-1} + (1-\alpha)^2LLT_{t-2} = (\alpha-\alpha^2/4)price_t + (\alpha^2/2)price_{t-1} - (\alpha-3\alpha^2/4)price_{t-2}\)\)
  • 移项得到递推公式: \(\(LLT_t = (\alpha-\alpha^2/4)price_t + (\alpha^2/2)price_{t-1} - (\alpha-3\alpha^2/4)price_{t-2} + 2(1-\alpha)LLT_{t-1} - (1-\alpha)^2LLT_{t-2}\)\)

变换后,我们最终得到的公式为:

\[ y_t = (\alpha-\alpha^2/4)x_t + (\alpha^2/2)x_{t-1} - (\alpha-3\alpha^2/4)x_{t-2} + 2(1-\alpha)y_{t-1} - (1-\alpha)^2y_{t-2} \]

可以看到,最终公式是一个递归函数,这里的\(y_t\)即为我们要求的LLT,它由前两期的LLT与最近三期的价格、以及一个\(\alpha\)参数共同决定。

它的实现代码是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

def calculate_llt(prices, alpha=0.05):
    """
    计算LLT (Linearly Weighted Least Squares Triangular) 均线

    参数:
    prices (array-like): 价格序列
    alpha (float): 平滑系数,范围(0,1),值越小均线越平滑,滞后性越大

    返回:
    array: LLT均线序列
    """
    n = len(prices)
    llt = np.zeros(n)

    # 初始化前两个值
    if n >= 1:
        llt[0] = prices[0]
    if n >= 2:
        llt[1] = prices[1]

    # 计算系数
    a1 = alpha - (alpha**2) / 4
    a2 = (alpha**2) / 2
    a3 = alpha - 3 * (alpha**2) / 4
    a4 = 2 * (1 - alpha)
    a5 = - (1 - alpha)**2

    # 递归计算LLT
    for t in range(2, n):
        llt[t] = a1 * prices[t] + a2 * prices[t-1] - a3 * prices[t-2] + a4 * llt[t-1] + a5 * llt[t-2]

    return llt

下面,我们对比一下EMA, 5日均线以及LLT均线:

1
2
3
4
5
6
7
8
price_df = get_price("000001.SH", start, end)
price_df['EMA'] = price_df['close'].ewm(alpha=0.05, adjust=False).mean()


price_df['MA30'] = price_df['close'].rolling(30).mean()
price_df['LLT'] = calculate_llt(price_df['close'], 0.05)

price_df.plot(figsize=(18,8),title='各类趋势线比较')

从这个个例上看,LLT确实跟得更紧(接近MA5),同时做到了比较平滑(比EMA更平滑,接近MA30)。

在计算LLT时,有一个\(\alpha\)参数,它的取值对平滑程度和延迟性都有影响:

1
2
3
4
5
6
for a in [0.03,0.04,0.05]:
    price_df[f'LLT(%s)'%a] = calculate_llt(price_df['close'],a)


show_cols = ["LLT(0.03)", "LLT(0.04)", "LLT(0.05)"]
price_df[show_cols].plot(figsize=(18,9),title='不同α参数的LLT趋势线')

回测与对比

我们先来看看传统均线的回测情况。我们将定义一个交易函数,它接受一个dataframe,因子列名和计算slope的窗口。后面我们在对LLT进行回测时,我们将使用同一个函数,这样可以确保对比的公正性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import quantstats as qs
mport quantstats as qs
def trading_strategy(df, factor_col: str, slope_window=5, long_weight=0.5, short_weight=0.5):
    """
    计算基于均线斜率的多空组合策略收益

    参数:
    price_df: 包含收盘价的DataFrame
    factor_col: 因子列
    slope_window: 计算斜率的窗口大小
    long_weight: 多头仓位权重 (0-1)
    short_weight: 空头仓位权重 (0-1)

    返回:
    DataFrame: 包含策略收益的DataFrame
    """
    df = df.copy()
    df['slope'] = (df[factor_col].rolling(slope_window)
                    .apply(lambda x: np.polyfit(np.arange(slope_window), x, 1)[0]))

    df['signal'] = 0
    df.loc[df['slope'] > 0, 'signal'] = 1
    df.loc[df['slope'] < 0, 'signal'] = -1

    # 计算每日收益率
    df['benchmark'] = df['close'].pct_change()

    # 计算多空组合收益
    df['long_return'] = np.where(df['signal'] == 1, df['benchmark'], 0)
    df['short_return'] = np.where(df['signal'] == -1, -df['benchmark'], 0)

    # 组合收益 = 多头收益 * 多头权重 + 空头收益 * 空头权重
    df['strategy'] = df['long_return'] * long_weight + df['short_return'] * short_weight

    return df


def backtest_ma(start, end, win:int=30):
    price_df = get_price("000001.SH", start, end)
    factor_col = f"ma{win}"
    price_df[factor_col] = price_df["close"].rolling(win).mean()

    strategy_df = trading_strategy(price_df, factor_col)
    strategy_df.index = pd.to_datetime(strategy_df.index)

    qs.plots.returns(
                returns=strategy_df["strategy"],
                benchmark=strategy_df["benchmark"]
            )

    metrics = qs.reports.metrics(
            returns = strategy_df["strategy"],
            benchmark = strategy_df["benchmark"],
            display=False
        )

    print(metrics[:10])

start = datetime.date(2005, 9, 6)
end = datetime.date(2013, 6, 28)
backtest_ma(start, end)

我们在30日均线上,回测得到的结果,与研报基本一致,都在300%左右。

Tip

理论上,知道回测时间,我们应该能做到像素级的复现。但是,这里有一个重要的参数,即求斜率时的窗口大小,研报没有披露。我们在复现时,使用的窗口为5。

所以,在对LLT进行回测时,我们只需要先计算出LLT,再将LLT作为因子传入 trading_strategy 函数即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def backtest_llt(start, end, win:int=30, d=20):
    price_df = get_price("000001.SH", start, end)

    alpha = 2 / (d + 1)
    price_df["llt"] = calculate_llt(price_df["close"], alpha)

    strategy_df = trading_strategy(price_df, "llt")
    strategy_df.index = pd.to_datetime(strategy_df.index)

    qs.plots.returns(
                returns=strategy_df["strategy"],
                benchmark=strategy_df["benchmark"]
            )

    metrics = qs.reports.metrics(
            returns = strategy_df["strategy"],
            benchmark = strategy_df["benchmark"],
            display=False
        )

    print(metrics[:10])

start = datetime.date(2005, 1, 1)
end = datetime.date(2012, 10, 1)
backtest_llt(start, end)

运行结果表明,改用LLT均线之后,在同样的时间段,累积收益高出100%,夏普也由1.35提升到1.61,超额非常明显。

以上是研报复现的情况。研报发表时间较早,因此只回测到2013年,在此之后,情况如何?这个策略是否仍然有效?

如果把回测时间改到2013年之后,2024年底之前,保持alpha仍为0.05, 我们发现收益率对benchmark,仍有明显的超额。考虑到2015年之后,A股就一直没有大的行情,作为趋势跟随策略,收益不可能象之前那么好,也是理所当然。

不过,考虑增加alpha值(对应ema线窗口缩短)为0.1,以捕捉更短的波段,增加交易机会之后,则仍可以得到非常漂亮的超额和夏普:

1
2
3
start = datetime.date(2013, 1, 1)
end = datetime.date(2024, 12, 31)
backtest_llt(start, end, d = 10)

如果进一步增加alpha值到0.17(对应d=5)左右,则从2014年之后,上证指数多空组合收益达到了345%,夏普达到了1.33!

本策略全部代码以notebook格式发布在Quantide Research平台。加入平台后,可自行运行和验证此策略。