跳转至


课程  因子投资  机器学习  Python  Poetry  ppw  tools  programming  Numpy  Pandas  pandas  算法  hdbscan  聚类  选股  Algo  minimum  numpy  algo  FFT  模式识别  配对交易  GBDT  LightGBM  XGBoost  statistics  CDF  KS-Test  monte-carlo  VaR  回测  过拟合  algorithms  machine learning  strategy  python  sklearn  pdf  概率  数学  面试题  量化交易  策略分类  风险管理  Info  interview  career  xgboost  PCA  wavelet  时序事件归因  SHAP  Figures  Behavioral Economics  graduate  arma  garch  人物  职场  Quantopian  figure  Banz  金融行业  买方  卖方  story  量化传奇  rsi  zigzag  穹顶压力  因子  ESG  因子策略  投资  策略  pe  ORB  Xgboost  Alligator  Indicator  factor  alpha101  alpha  技术指标  wave  quant  algorithm  pearson  spearman  tushare  因子分析  Alphalens  涨停板  herd-behaviour  momentum  因子评估  review  SMC  聪明钱  trade  history  indicators  zscore  波动率  强化学习  顶背离  freshman  resources  others  AI  DeepSeek  network  量子计算  金融交易  IBM  weekly  LLT  backtest  backtrader  研报  papers  UBL  quantlib  jupyter-notebook  scikit-learn  pypinyin  qmt  xtquant  blog  static-site  duckdb  工具  colors  free resources  barra  world quant  Alpha  openbb  数据  risk-management  llm  prompt  CANSLIM  Augment  arsenal  copilot  vscode  code  量化数据存储  hdf5  h5py  cursor  augment  trae  Jupyter  jupysql  pyarrow  parquet  数据源  quantstats  实盘  clickhouse  notebook  redis  remote-agent  AI-tools  Moonshot  回测,研报,tushare 

tools »

原作者失联8个月,我们接手维护后他突然回来了


Quantstats是非常著名的量化策略评估与可视化库。从2024年底起约8个月里,它没有得到积极的维护,出现了在Python 3.12以上,完全无法运行等严重bug。

好消息是,最近一周,原作者 Ran Aroussi 已经恢复了对这个库的维护,并且连发了5个版本(从0.0.64到0.0.68)。


我们在课程中一直推荐大家使用这个库来进行策略的评估与可视化,以避免重复造轮子。这种推荐,也使得我们有义务去维护它。于是,我们在7月初发布了quantstats-reloaded。

为避免再次失联,我们仍将继续维护quantstats-reloaded一段时间,以便我们的学员始终能有可用的quantstats。另外,我们也将对这个库进行一些重要的改进,首先是进行单元测试增强。

原库缺乏系统的单元测试和CI,这可能是导致原作者无法及时修复bug的主要原因。

这也让我们作为使用者,不免有些担心。所以,在重启维护之后,我们先借由AI,加上了完整的单元测试与CI流程。然后,我们刚刚手工补齐了最重要的stats模块的全部单元测试,测试结果与原库完全一致(但不一定正确!),并达到了91%的单元测试覆盖率。

以下是我们的对照测试方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{code-block} python
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

如果把signal看成因子,那么这段代码里,因子与远期回报是按时间对齐的,而不是错位对齐的。即,代码把\(T_0\)日的收益归因为\(T_0\)日的因子。而\(T_0\)日收益的确切含义是,你要在\(T_{-1}\)日买入,在\(T_0\)日卖出,才能计算出来。这当然是错误的。

如果今天股价上涨,那么均线的切线就可能方向向上,从而 signal = 1;如果今天股价下跌,均线切线就有可能方向向下,从而 signal = -1。这两种情况都被不合理的计入了组合收益。

Tip

按照研报,它是这样建仓的:

这句话虽然没有包含实现细节,但本质上也是包含了未来数据的,不过它的副作用会小一点:它只是要求在收盘后,根据收盘价计算出来趋势线切线斜率和信号后,再以收盘价买入。

尽管这也有一点点未来数据的含义,但在实践中是允许的,因为理论上你可以赶在尾盘集合竞价时计算信号并买入:这样你计算信号时使用的价格,与最终的收盘价相差并不大,很可能就是一个滑点的价格。在一些回测工具中,也是允许这样做的。比如,backtrader 就允许以当天收盘价买入,只要你声明允许 COC(Cheat on Close)。

修正算法

现在,我们来修正上述代码的错误,我们先考虑能否连带研报中的Cheat on Close这个小小的瑕疵也规避了?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def backtest(df, calc_signal, args, 
             price: str = "open", 
             long_weight: float = 0.5, 
             short_weight: float = 0.5):
    df = df.copy()
    df["signal"] = calc_signal(df, *args)
    df["signal"] = df["signal"].fillna(0)
    df["signal_shifted"] = df["signal"].shift(1)
    df["benchmark"] = df[price].pct_change()

    df['long_return'] = np.where(df['signal_shifted'] == 1, df['benchmark'], 0)
    df['short_return'] = np.where(df['signal_shifted'] == -1, -df['benchmark'], 0)
    df["long_short_return"] = df['long_return'] * long_weight + df['short_return'] * short_weight

    return df

与上一版相比,重要的区别有两点:

  1. 我们将信号后移了一行(而不是向前!)。这样在计算仓位时,如果前一天信号为 1,我们就用这个 1 来乘以当天的涨跌幅。
  2. 我们允许指定计算收益的价格数据列,默认为 open

如果你熟悉 Alphalens 的话,就会知道它在计算收益时,是以信号发出之后,第二天的开盘价买入,第三天的开盘价卖出来计算 period = 1D 时的收益的。现在,看起来我们正是这样做的!

下图显示了最初几次交易的完整过程(使用合成数据,涨跌固定为 5%,-5%交替):

在 1 月 2 日,策略发出做多信号,于是,我们在 1 月 3 日开盘时多方买入,价格是 104.73,当日涨幅 5%,把 shift 之后的信号 1 作为仓位,最终多空组合收益计为 2.5%(按多、空各可用 50%仓位)。1 月 3 日的信号也为 1,所以持仓中;但当天跌 5%,所以组合收益为-2.5%;4 号的信号为 0,策略需要在 5 日开盘卖出;5 号当天由于不持仓,所以组合的收益为 0。

当使用 open 作为计算收益的价格列之后,我们仍然会得到与之前几乎一样的累积收益图。这次结果可信吗?

看上去都是对的,很完美。除了这一点:在基于 dataframe 的向量化回测框架中,我们不能使用按次日开盘价买入的策略。以1月2日的买入信号为例,由于1月2日的买入信号(1)被移位到了1月3日,算成了多方仓位,所以这会导致把1月3日开盘价对1月2日开盘价的涨幅算成多方收益。而1月2日开盘时,我们还没有实现多方买入呢。

考虑到如果\(T_0\)日以上涨收盘,就更容易发出做多信号,并且次日开盘价也很有可能高于前一日开盘价,所以,这样计算出来的策略收益,会在第一天有较大的作弊成份,这是导致在使用开盘价计算收益时,回测结果很好看的主要原因。

但我们又不能在信号发出后的第三天以开盘价买入(信号不等人!),所以,我们必须使用 Cheat on Close 的策略,也就是价格列必须指定为"close"。

在指定价格列为 close 时,如果记信号发生日为\(T_0\),信号为 1,由于移位的原因,我们在计算\(T_1\)日的组合收益将是 1 * \(T_1\)日收益,\(T_1\)日的收益是 \(T_1\)日的收盘价与\(T_0\)日的收盘价计算得来的。尽管这是一种 Cheat on Close,但并不会引起太大的误差。

我们使用 DataFrame来进行回测的主要原因是简单快捷。从一个想法到最终实盘,需要经过无数道工序,几个月的时间(以日频计,算上仿真),所以,没有必要一开始就使用更重的框架。如果一个想法在简单测试中就不工作,我们大概率应该放弃它 -- 因为从统计上讲,多数想法本身就是无效的。

backtrader: 慢一点,但是更可靠

对事件型策略的回测,backtrader尽管会慢一点,但是在结果上会更可靠一些,表现在:

  1. backtrader默认会在信号发出之后的下一个bar以开盘价买入。
  2. backtrader会计算手续费、仓位限制和成交量限制。
  3. 基于DataFrame的向量式回测因为很简单,所以没有一个标准的库来实现。我们自己的手工实现比较容易引入错误。

我们就用backtrader来验证一下之前的回测结果。

计算LLT和切线斜率的代码在前一期提供了,这里不再重复。

这是计算llt与切线斜率,生成信号的代码。与前一篇相比,我们增加了thresh参数,将会在调优中使用。

 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
def calculate_llt(prices, alpha=0.05):
    # 转换为numpy数组以避免pandas索引问题
    if hasattr(prices, 'values'):
        prices = prices.values

    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

    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

# 信号计算函数
def llt_slope_signal(df, d: int=39, slope_window=5, thresh=(0, 0)):
    df = df.copy()
    alpha = 2 / (d + 1)
    df["llt"] = calculate_llt(df["close"], alpha)
    df['slope'] = (df["llt"].rolling(slope_window)
                    .apply(lambda x: np.polyfit(np.arange(slope_window), x, 1)[0]))

    signals = pd.Series(0, index=df.index)
    signals[df['slope'] > thresh[1]] = 1
    signals[df['slope'] < thresh[0]] = -1
    signals.ffill(inplace = True)

    return signals

Tip

这里对 llt_slope_signal 的参数 d 进行一点说明。它来自于EMA指标公式。当 d 取 9, 19, 39等数值时,对应的 alpha 分别为 0.2, 0.1, 0.05等。

这是回测策略类:

 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
import backtrader as bt
class LLTStrategy(bt.Strategy):
    params = (
        ('d', 39),
        ('slope_window', 5), 
        ('position_ratio', 1),
        ('thresh', (0, 0))
    )

    def __init__(self):
        self.order_dict = {}
        self.signals = llt_slope_signal(
            self.data._dataname,
            d=self.p.d,
            slope_window=self.p.slope_window,
            thresh=self.p.thresh
        )

        self._last_direction = 0

        print(f"策略初始化完成,信号数量: {len(self.signals)}")
        print(f"信号前20个值: {self.signals.head(20)}")

    def next(self):
        current_date = pd.Timestamp(self.data.datetime.date())

        current_signal = self.signals.loc[current_date]
        position = self.getposition(self.data).size

        if current_signal != 0:
            print(f"日期: {current_date}, 信号: {current_signal}, 当前持仓: {position}")

        if current_signal == 1 and self._last_direction <= 0:
            order = self.order_target_percent(target=0.95)
            if order:
                print(f"做多信号: {current_date.date()}")
        elif current_signal == -1 and self._last_direction >= 0:
            order = self.order_target_percent(target=-0.95)
            if order:
                print(f"做空信号: {current_date.date()}")
        elif current_signal == 0 and position != 0:
            order = self.order_target_percent(target=0.0)
            if order:
                print(f"平仓信号: {current_date.date()}")
        else:
            pass

我们省略回测调用代码,以节省篇幅。如果你需要这些代码,可以购买匡醍会员。如果你不太理解我们在讨论些啥,你应该报名匡醍的《量化24课》和《因子挖掘与机器学习策略》。

 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
def run_backtest(data, d=39, 
                 slope_window=5, 
                 thresh=(0,0),
                 initial_cash=1_000_0000, 
                 commission=1e-4):
    cerebro = bt.Cerebro()

    cerebro.broker.setcash(initial_cash)
    cerebro.broker.setcommission(commission=commission)

    cerebro.addstrategy(LLTStrategy, d=d, slope_window=slope_window, thresh=thresh)

    bt_data = bt.feeds.PandasData(dataname=data)
    cerebro.adddata(bt_data)

    # 添加绩效分析器
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe')
    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')

    # 运行回测
    print(f"初始资金: {cerebro.broker.getvalue():.2f}")
    results = cerebro.run()
    final_value = cerebro.broker.getvalue()
    print(f"最终资金: {final_value:.2f}")

    # 输出绩效指标
    strat = results[0]
    returns = strat.analyzers.returns.get_analysis()
    sharpe = strat.analyzers.sharpe.get_analysis()
    drawdown = strat.analyzers.drawdown.get_analysis()

    print(f"夏普比率: {sharpe.get('sharperatio', 0):.2f}")
    print(f"最大回撤: {drawdown.get('max', {}).get('drawdown', 0):.2f}%")
    print(f"年化收益率: {returns.get('rnorm', 0):.2%}")

    return returns.get('rnorm', 0), sharpe.get('sharperatio', 0), drawdown.get('max',{}).get('drawdown',0)


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")
    )

    price_df.index = pd.to_datetime(price_df.index)
    return price_df

start = datetime.date(2005, 9, 6)
end = datetime.date(2013, 6, 28)
prices= get_price("000001.SH", start, end)

run_backtest(prices, commission = 1e-3)

我们得到的结果是,年化收益11.8%,夏普0.4。会比基准略好一点。但是,如果我们对2013年之后的区间进行回测,会发现我们其实只是捡回来一条咬人的毒蛇:

1
2
3
4
5
start = datetime.date(2013, 1, 1)
end = datetime.date(2024, 12, 31)
prices= get_price("000001.SH", start, end)

run_backtest(prices, commission=1e-3)

这次的年化是-6.4%,最大回撤79%。

backtrader参数优化

我们应该对此失望吗?

不!我们从来就不应该指望一个简单的策略,甚至只有不到 100 行代码,就能成为一台印钞机。繁复深奥并不必然成功,但在资本市场上,简单甚至简陋肯定是不行的。任何赚钱的生意,都必须有壁垒。

所以,我们的优化之路刚刚开始。远远不到应该失望的时候!

首先,千分之一的手续费太高了。当我们在指数上回测时,一定要记住指数本身就没有太大的盈利空间,任何跑冒滴漏都不能允许!

现在,多数券商的交易手续费,特别是量化交易的,已经低到万分之 0.854 了。所以,我们完全没必要设置千分之一这么高的手续费。

Info

当我们把佣金调到万分之一时(仍高于市场),年化收益率就提升到了-3.7%。已经好多了。我们会在后面的测试中,使用这个设置。不过,这并不是策略优化。真正的优化,马上开始!

在仔细分析之后,我们发现信号翻转过于频繁。当切线斜率从-0.01 变化为 0.005 时,我们就要从空单马上变为多单?这显然不合理,我们应该过滤掉这种虚假的信号。切线斜率的计算本身,也受到 alpha 的影响。我们之前的回测中,使用的是 0.05,它会是最优的吗?

我们决定使用 backtrader 自带的参数优化方案来帮我们调优。但是,调优可能出现过拟合,所以,我们最后还要分享如何判断调优的结果没有过拟合。

我们先来定义优化函数。

 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
from IPython.display import clear_output
def optimize(data, d, thresh):
    cerebro = bt.Cerebro()

    cerebro.broker.setcash(1_000_0000)
    # 万分之一的佣金。现在多数券商给到了万分之 0.854
    cerebro.broker.setcommission(commission=0.0001)

    bt_data = bt.feeds.PandasData(dataname=data)
    cerebro.adddata(bt_data)

    # 添加绩效分析
    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')

    cerebro.optstrategy(LLTStrategy, d = d, thresh = thresh)
    strats = cerebro.run(maxcpus = 1, optreturn = True)

    clear_output()

    params_and_returns = []
    for s in strats:
        returns = s[0].analyzers.returns.get_analysis()
        d, thresh = s[0].params.d, s[0].params.thresh[0]

        rnorm, pnl = f"{returns['rnorm']:.2%}", f"{returns['rtot']:.2%}"
        params_and_returns.append((d, thresh, rnorm, pnl))

    return pd.DataFrame(params_and_returns, columns=["d", "thresh", "rnorm", "pnl"])

start = datetime.date(2008, 1, 1)
end = datetime.date(2012, 12, 31)
prices= get_price("000001.SH", start, end)

result = optimize(prices, 
                  (9, 19, 39, 49, 59), 
                  (
                    (-0.01, 0.01), 
                    (-0.02, 0.02), 
                    (-0.04, 0.04), 
                    (-0.08, 0.08), 
                    (-0.12, 0.12)
                  ))
result

通过backtrader来进行参数优化,关键是这样两行代码:

1
2
3
{code-block} python
    cerebro.optstrategy(LLTStrategy, d = d, thresh = thresh)
    strats = cerebro.run(maxcpus = 1, optreturn = True)

在 notebook 中运行时,我们必须设置maxcpus = 1。如果maccpus > 1,将会启动多进程优化。此时涉及到notebook中代码的持久化(因为要把代码传送到新的进程中),就会出错。

我们在《量化24课》中,详细介绍了如何使用backtrader,也包括了策略优化。

我们让参数 d 在 (9, 19, 39, 49, 59, 69),之间取值,这样对应的alpha会是(0.2, 0.1, 0.05, 0.03),而thresh则在(-0.01, 0.01)到(-0.12, 0.12)之间,以倍增的方式来取值。

参数优化

为了视觉上美观,这里只筛选了部分结果。

从参数优化结果来看,d = 59, thresh = -0.01 是最好的一组,达到了年化26.6%的超高收益率。总体来看,随着 d 值增加,收益变好;thresh的影响并不大。另外,还有一个对性能有影响的参数,即计算切线斜率时,使用多少个 bar? 这里只使用了固定的 5。

刚刚的参数优化是基于2008年到2012年的数据得到的。它在2005年到2013年间的情况怎么样?结论是:年化收益率达到了25.6%。

如果我们把这个参数用于2013年到2014年的投资,那么,将会得到13.9%的年化和0.97的夏普率。这个成绩应该说相当不错。

正确看待过拟合

当我们使用backtrader来进行参数优化时,一定要注意会比较容易出现过拟合。

在上一节,我们已经把基于[2008,2012]得到的最优参数,运用于过去[2005, 2012]和未来[2013,2014],来对比观察结果。这是检验过拟合是否存在的一种方法。如果参数没有过拟合,那么它就应该在它没有见过的数据上,也能讲出好的故事出来。

这里还介绍另外一个思路。关于thresh参数的。基于 thresh 来决定 signal,显然要比简单地基于 0 来判断 signal 合理,这一点无庸置疑。但优化的thresh参数会不会导致过拟合?这时候我们可以观察下切线斜率的分布:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def llt_slope(df, d: int=59, slope_window=5):
    df = df.copy()
    alpha = 2 / (d + 1)
    df["llt"] = calculate_llt(df["close"], alpha)
    df['slope'] = (df["llt"].rolling(slope_window)
                    .apply(lambda x: np.polyfit(np.arange(slope_window), x, 1)[0]))

    return df['slope']

start = datetime.date(2005, 1, 1)
end = datetime.date(2013, 12, 31)
prices= get_price("000001.SH", start, end)

slopes = llt_slope(prices, d = 39)
slopes.plot(kind='hist')
s1 = (slopes < -0.02).sum()/len(slopes)
s2 = (slopes < 0.02).sum()/len(slopes)

s2 - s1
切线斜率分布

可以看出,通过设置 thresh 为 [-0.02, 0.02],我们只排除了 0.3%左右的数据。这说明:我们并没有通过 hacking 的手段,排除大多数情形,只留下让最终收益好看的少量数据。因此,至少在thresh参数上,很有可能这里并没有发生过拟合。

如果我们通过[2008, 2012]年的数据优化出来的参数 d = 59, thresh = [-0.01, 0.01] 用在2013年到2024年不变的话,那么年化收益率会衰减到2.79%。这说明,市场风格其实是一直在变化的。这个策略也只是一个\(\beta\)因子。

不过,我们可以每隔几年就更新一次参数,再在接下来的一小段时间内,使用这个策略。

下面的代码演示了如何基于过去5年的数据搜索出优化参数,然后用在随后两年的投资中:

```python