写在前面

本模板致力于让读者可以了解回测框架,并制定自己的交易策略,总体上会分为两部分,一部分是基础策略模板,后续会持续更新。另外也会实现深度学习模型可以使用的交易回测模板。

回测框架

本文使用的回测框架为Backtrader,以下会实现一个最简单的策略,也就是买入并持有(buy and hold)。通过直接导入下面模板直接运行,即可得到回测结果,这个模板目的是为了帮助初学者认识backtrader这个框架,想要了解更多,请参考官方文档,或中文版本文档

数据获取

我们的数据使用tushare的数据,数据获取过程如下,我们将其保存为getData.py

import tushare as ts
import pandas as pd

# 初始化 Tushare
ts.set_token('YOUR_TOKEN')
pro = ts.pro_api()

# 定义起止日期
start_date = '19900101'
end_date = '20251106'

# 定义要获取的股票代码
ts_code = '000776.SZ'

# 获取数据
df = pro.daily(ts_code=ts_code, start_date=start_date, end_date=end_date)

# 排序并保存
df = df.sort_values("trade_date")
df.to_csv('res.csv', index=False)

我们给出下载后,前若干行的数据

ts_code trade_date open high low close pre_close change pct_chg vol amount
000776.SZ 19970611 10.0 10.0 8.2 8.92 3.6 5.32 147.78 146684.0 130758.3485
000776.SZ 19970612 8.58 9.3 8.52 8.98 8.92 0.06 0.67 73309.0 65916.5156
000776.SZ 19970613 8.48 9.88 8.3 9.88 8.98 0.9 10.02 65911.0 60784.1554
000776.SZ 19970616 10.18 10.37 9.8 10.01 9.88 0.13 1.32 100191.0 101676.6104
000776.SZ 19970617 9.8 9.98 9.3 9.3 10.01 -0.71 -7.09 39510.0 37564.0698
000776.SZ 19970618 9.15 9.45 8.88 8.99 9.3 -0.31 -3.33 21961.0 19946.2714
000776.SZ 19970619 9.01 9.07 8.7 8.79 8.99 -0.2 -2.22 14389.0 12743.8352

数据类加载

获取完数据后,我们需要自定义个自己的数据加载类,此处可以参考我们的模板,需要注意的是,默认的backtrader的数据加载方式,不能自定义我们数据的格式,此处我进行了封装,至于要我们数据的每一列进行对应,先对应框架中原本就需要的列,然后解析以下我们额外的列/特征。

import datetime
import backtrader as bt

class TushareCSVData(bt.feeds.GenericCSVData):
		# 我们额外的列需要定义
    lines = (
        'pre_close',   # 昨日收盘价
        'chg',         # 涨跌额
        'pct_chg',     # 涨跌幅(%)
        'amount',      # 成交额
    )
		
		# 列对应csv中的是哪一列,在此处进行指定
    params = (
        ('nullvalue', float('nan')),  # 若csv中为空值,指定其为nan
        ('dtformat', '%Y%m%d'),       # 日期格式化
        ('datetime', 1),              # trade_date
        ('time', -1),                 # 此处照写即可
        ('open', 2),                  # 开盘价
        ('high', 3),                  # 最高价
        ('low', 4),                   # 最低价
        ('close', 5),                 # 收盘价
        ('volume', 9),                # 成交量
        ('openinterest', -1),         # 这个参数描述我们有多少比交易没有结束,此处照写即可

        # 扩展字段
        ('pre_close', 6),             # 昨日收盘价       
        ('chg', 7),                   # 涨跌额
        ('pct_chg', 8),               # 涨跌幅(%)
        ('amount', 10),               # 成交额
    )
		
		# 将这些列进行解析对应
    def _loadline(self, linetokens):
        # 解析日期
        dtstr = linetokens[self.p.datetime]
        try:
            dt = datetime.datetime.strptime(dtstr, self.p.dtformat)
        except Exception as e:
            # 若解析失败,跳过这一行
            return False
        self.lines.datetime[0] = bt.date2num(dt)

        # 先调用父类来解析 open/high/low/close/volume 等
        super()._loadline(linetokens)
				
				# 注意:GenericCSVData._loadline 会解析所有在 params 中声明的列
        # 但因为我们自定义了扩展字段,仍需在本方法中手动赋值
        # 解析扩展字段
        # pre_close
        try:
            self.lines.pre_close[0] = float(linetokens[self.p.pre_close])
        except:
            self.lines.pre_close[0] = self.p.nullvalue

        # chg(change)
        try:
            self.lines.chg[0] = float(linetokens[self.p.chg])
        except:
            self.lines.chg[0] = self.p.nullvalue

        # pct_chg
        try:
            self.lines.pct_chg[0] = float(linetokens[self.p.pct_chg])
        except:
            self.lines.pct_chg[0] = self.p.nullvalue

        # amount
        try:
            self.lines.amount[0] = float(linetokens[self.p.amount])
        except:
            self.lines.amount[0] = self.p.nullvalue

        return True

如果你想要自定义自己的数据,可以在这个模板下进行简单修改,添加自己想要的字段。此处应该也有更好的处理方案,同学们可以自行挖掘。

第一个策略

完成了数据加载类的设计,我们就进入了我们的第一个策略设计,首先导入相关的包。

from __future__ import (absolute_import, division, print_function,
                        unicode_literals)
import datetime
import os
import sys

# 导入backtrader
import backtrader as bt

# 导入我们的数据处理类
from MyCSVData import TushareCSVData

最简单的策略,买入并持有(buy and hold)模板如下:

# 第一个策略类
class MyStrategy(bt.Strategy):
		
		# 初始化
    def __init__(self):
        pass
		# next() 函数定义了每个时间步要做的事,例如我们的数据是日线数据,我们就需要在每天做点什么,例如买入或者卖出股票。
    def next(self):
        # 开盘第一天买入
        if len(self.data) == 1:
		        self.buy()
		        self.buy()
		        self.buy()
		        self.buy()

买入函数,我们使用self.buy()调用,通常为买一手。

运行策略的模板,即如何使用backtrader进行回测,最简单的案例

		        
if __name__ == '__main__':

    #cerebro是这个框架的核心,我们直接实例化
    cerebro = bt.Cerebro()
		
		# 我们需要读取数据,所以我们定义一下目录,然后导入数据		
    modpath = os.path.dirname(os.path.abspath(sys.argv[0]))
    datapath = os.path.join(modpath, './res.csv')

    # 数据加载
    data = TushareCSVData(
        dataname=datapath, # 文件路径
        format='%Y%m%d', # 日期格式
        fromdate=datetime.datetime(1997, 6, 11), # 起始日期,这里是可以对原始读到的数据进行切割的
        todate=datetime.datetime(2025, 11, 1),  # 终止日期,此处同理也可以切割
        headers=True, 
        reverse=False)

    cerebro.adddata(data) # 向 cerebro 导入数据
    cerebro.broker.setcash(100000.0) # 设置初始现金

    cerebro.addstrategy(MyStrategy) # 加载策略
    cerebro.broker.setcommission(0.0003)  # 设置手续费
    cerebro.signal_accumulate(False)  # False 禁用
		
		# 这里在打印初始资金和最总资金
    print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())
    cerebro.run()
    print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
		
		# 画出交易回测图
    cerebro.plot()

这里的运行结果如下图所示。

请添加图片描述

控制台结果为

C:\Users\hell\anaconda3\python.exe E:\postgraduate\dream_code_v2\backtrader\backtrader\my_backtrader\test1.py 
Starting Portfolio Value: 100000.00
Final Portfolio Value: 100055.95

可以看到,我们持有”广发证券“约28年,怒赚55.95元。

现在我们已经对backtrader的使用,有了一点认知。接下来,我们开始写一些复杂一点的策略。

我们已经知道怎么买了,那么下一个要考虑的问题就是怎卖,一般我们在策略的def next()函数中,调用self.sell()就可以卖出了。

基础策略

考虑一个策略,就是要定义什么时候买,什么时候卖的问题。判断依据不能只依靠数据中给定的open、high、low、close等简单的指标,我们需要考虑其他的交易信号,来指导我们的交易。其中,双均线策略就是一个经典的策略。

双均线策略

双均线策略,即给定两个不同的周期,分别计算一条能代表股价波动趋势的趋势线,其中小周期的线会被称为”快线“,长周期的线会被称为”慢线“。当快线上穿慢线时(金叉),代表股票将进入上涨阶段,考虑买入;反之,当股票慢线下穿快线时(死叉),代表股票将进入下跌阶段,考虑卖出。我们来实现一下。

首先介绍以下快线慢线的计算方法,不管我们的快线还是慢线,都指的是我们的移动平均线(MA, Moving Average)。

移动平均线(MA)用于平滑价格波动,计算公式如下:

M A n = C 1 + C 2 + C 3 + ⋯ + C n n MA_n = \frac{C_1 + C_2 + C_3 + \cdots + C_n}{n} MAn=nC1+C2+C3++Cn

其中:

  • ( M A n MA_n MAn ): n n n 日移动平均值
  • ( C i C_i Ci ):第 i i i 天的收盘价(Close)
  • ( n n n ):计算周期(例如 5、10、30 等)

示例(MA5)

假设最近 5 日的收盘价为:

日期 收盘价
第1天 10.0
第2天 10.2
第3天 10.4
第4天 10.3
第5天 10.1

则:

M A 5 = 10.0 + 10.2 + 10.4 + 10.3 + 10.1 5 = 10.2 MA_5 = \frac{10.0 + 10.2 + 10.4 + 10.3 + 10.1}{5} = 10.2 MA5=510.0+10.2+10.4+10.3+10.1=10.2

了解了MA的计算方法,我们就可以计算了。首先我们的快线慢线周期分别为10日和20日,当然你也可以设置为5日和10日,自己进行尝试。然后当计算结果计算出来后,我们判断其是金叉还是死叉,然后根据信号进行交易即可。

我们使用模板,为其添加东西。首先,我们要初始化快慢线周期,然后我们在策略初始化的时候传入数据,计算出我们的快慢线结果。为了能够让我们在next函数中拿到我们的信号,我们必须要把我们的快线和慢线的计算结果放到我们的self中。然后在策略执行的时候进行每一步判断,直到时间步结束。

class MAStrategy(bt.Strategy):
    params = dict(
        fast=10,     # 快速均线
        slow=20     # 慢速均线
    )

    def __init__(self):
        self.ma_fast = bt.indicators.SMA(self.data.close, period=self.p.fast)
        self.ma_slow = bt.indicators.SMA(self.data.close, period=self.p.slow)

        # 判断是否发生金叉/死叉的内置指标,感兴趣可以查看源码,肥肠简单!
        self.crossover = bt.indicators.CrossOver(self.ma_fast, self.ma_slow)

    def next(self):
        # 如果没有持仓
        print(self.data.close[0])
        if not self.position:
            if self.crossover > 0:      # 金叉 → 买入
                self.buy()

        # 如果已经持仓
        else:
            if self.crossover < 0:      # 死叉 → 卖出
                self.sell()

我们使用模板来运行策略;

from __future__ import (absolute_import, division, print_function,
                        unicode_literals)

import datetime
import os
import sys

import backtrader as bt
from my_backtrader.MyCSVData import TushareCSVData

class MAStrategy(bt.Strategy):
    params = dict(
        fast=10,     # 快速均线
        slow=20     # 慢速均线
    )

    def __init__(self):
        self.ma_fast = bt.indicators.SMA(self.data.close, period=self.p.fast)
        self.ma_slow = bt.indicators.SMA(self.data.close, period=self.p.slow)

        # 判断是否发生金叉/死叉的内置指标
        self.crossover = bt.indicators.CrossOver(self.ma_fast, self.ma_slow)

    def next(self):
        # 如果没有持仓
        print(self.data.close[0])
        if not self.position:
            if self.crossover > 0:      # 金叉 → 买入
                self.buy()

        # 如果已经持仓
        else:
            if self.crossover < 0:      # 死叉 → 卖出
                self.sell()

if __name__ == '__main__':
    cerebro = bt.Cerebro()

    modpath = os.path.dirname(os.path.abspath(sys.argv[0]))
    datapath = os.path.join(modpath, './res.csv')

    # --- 数据加载 ---
    data = TushareCSVData(
        dataname=datapath,
        format='%Y%m%d',
        fromdate=datetime.datetime(1997, 6, 11),
        todate=datetime.datetime(2025, 11, 1),
        headers=True)

    cerebro.adddata(data)
    cerebro.broker.setcash(100000.0)

    cerebro.addstrategy(J_Min_Strategy)
    cerebro.broker.setcommission(0.0003)
    cerebro.signal_accumulate(False)  # 或 False 禁用

    print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())
    cerebro.run()
    print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())

    cerebro.plot()

我们运行代码,得到的结果如下:

请添加图片描述

控制台输出为:

C:\Users\hell\anaconda3\python.exe E:\postgraduate\dream_code_v2\backtrader\backtrader\my_backtrader\test.py 
Starting Portfolio Value: 100000.00
Final Portfolio Value: 100060.56

可以看到,我们通过密密麻麻的交易,怒赚 60.56 60.56 60.56 元,比buy and hold 还要多整整 60.56 − 55.95 = 4.61 60.56-55.95=4.61 60.5655.95=4.61(元)。

KDJ策略

这个策略来源于网络。顾名思义,交易信号来自于KDJ指标,当KDJ指标中J处于极小值的时候考虑买入。相应的,当J处于极大值的时候考虑卖出(事实上,出入场都可以灵活变换,本案例就是用了不同的离场方案)。为了得到我们的KDJ指标,首先要知道是怎么计算的。

KDJ 是在随机指标(Stochastic Indicator, KD)基础上发展而来的技术指标,常用于判断股票的超买超卖状态。


🧩 KDJ 计算步骤

  1. 计算 RSV(未成熟随机值)

R S V = C L O S E − L L V ( L O W , N ) H H V ( H I G H , N ) − L L V ( L O W , N ) × 100 RSV = \frac{CLOSE - LLV(LOW, N)}{HHV(HIGH, N) - LLV(LOW, N)} \times 100 RSV=HHV(HIGH,N)LLV(LOW,N)CLOSELLV(LOW,N)×100

其中:

  • C L O S E CLOSE CLOSE :当日收盘价
  • L O W LOW LOW :当日最低价
  • ( H I G H HIGH HIGH :当日最高价
  • L L V ( L O W , N ) LLV(LOW, N) LLV(LOW,N) :最近 N 日内最低价的最小值
  • H H V ( H I G H , N ) HHV(HIGH, N) HHV(HIGH,N) :最近 N 日内最高价的最大值
  • 通常 N = 9 N = 9 N=9

  1. 计算 K 值(平滑 RSV)

通达信算法使用 SMA 平滑移动平均

K = S M A ( R S V , 3 , 1 ) K = SMA(RSV, 3, 1) K=SMA(RSV,3,1)

即:

  • 3:平滑周期
  • 1:权重参数(表示新值的权重较高)

  1. 计算 D 值

同样使用 SMA:

D = S M A ( K , 3 , 1 ) D = SMA(K, 3, 1) D=SMA(K,3,1)


  1. 计算 J 值

J = 3 × K − 2 × D J = 3 \times K - 2 \times D J=3×K2×D

我们接下来,来实现这个策略,KDJ在计算的时候需要考虑初始化问题,在同花顺和通达信中,会将KDJ的初始值设置为RSV,这样就可以进行滚动计算。我们首先实现以下SMA和KDJ这俩个函数。

class SMA(bt.Indicator):
    
    lines = ('sma',)
    params = (('period', 3), ('m', 1))

    def __init__(self):
        self.addminperiod(1)

    def next(self):
        n = self.p.period
        m = self.p.m
        x = self.data[0]

        if len(self) == 1:
            # 第一天 SMA = X
            self.lines.sma[0] = x
        else:
            prev = self.lines.sma[-1]
            self.lines.sma[0] = (m * x + (n - m) * prev) / n

SMA相对比与MA,做了平滑处理。

class KDJ(bt.Indicator):
    lines = ('k', 'd', 'j')
    params = (('period', 9),)

    def __init__(self):
        self.addminperiod(1)

        # 自己维护 9 日高低
        self.high_buf = []
        self.low_buf = []

    def next(self):
        period = self.p.period

        # push 当前 high/low
        self.high_buf.append(self.data.high[0])
        self.low_buf.append(self.data.low[0])

        # 保证窗口维持 period 大小
        if len(self.high_buf) > period:
            self.high_buf.pop(0)
            self.low_buf.pop(0)

        # 通达信:不足 period 时,用现有窗口计算
        hh = max(self.high_buf)
        ll = min(self.low_buf)

        # 计算 RSV
        if hh == ll:
            rsv = 50.0   # 防止除零,通达信中该情况 RSV=50
        else:
            rsv = (self.data.close[0] - ll) / (hh - ll) * 100

        #  KDJ 
        if len(self) == 1:
            # 第一天
            k = d = j = rsv
        else:
            # SMA 平滑
            prev_k = self.lines.k[-1]
            prev_d = self.lines.d[-1]

            # 通达信 SMA(K,3,1)
            k = (1 * rsv + 2 * prev_k) / 3
            d = (1 * k + 2 * prev_d) / 3
            j = 3 * k - 2 * d

        # 输出结果
        self.lines.k[0] = k
        self.lines.d[0] = d
        self.lines.j[0] = j

然后我们实现一下这个策略。与之前不同的是,我们设置了仓位,且为全仓。之前的双均线策略使用的是买一手,且我们设定盈利7%时离场。

class J_Min_Strategy(bt.Strategy):
    params = dict(
        buy_j=-5,       # 买入条件:J < 0
        sell_gain=0.07,   # 卖出条件:涨幅达到7%
        invert_ratio=0.99, # 仓位(全仓)
    )

    def __init__(self):
        self.kdj = TDX_KDJ(self.data)   # 你自己定义的通达信版KDJ指标
        self.buy_price = None         # 记录买入价

    def log(self, txt, dt=None): # 设定了一个日志函数
        dt = dt or self.data.datetime.date(0)
        print(f"{dt} | {txt}")

    def next(self):
        close = float(self.data.close[0])
        j = float(self.kdj.j[0])

        if not self.position:
            if j < self.p.buy_j:
                self.buy_price = close
                cash = self.broker.get_cash()
                # 计算买入手数(按可用现金和 invest_ratio)使用仓位
                size = int((cash / close) * float(self.p.invert_ratio))
                if size <= 0:
                    self.log(f"买入信号但资金不足或 invest_ratio 太小:cash={cash:.2f}, price={close:.4f}, size={size}")
                    return
                self.log(f"下买单:J={j:.2f}, 价格={close:.4f}, 计划数量={size}")
                self.buy(size=size)  # 下单并记录未决订单

        else:
            if self.buy_price is None:
                self.buy_price = self.position.price if hasattr(self.position, 'price') else close

            gain = (close - self.buy_price) / self.buy_price
            if gain >= self.p.sell_gain:
                size = self.position.size
                self.log(f"下卖单:当前涨幅={gain * 100:.2f}%, 价格={close:.4f}, 卖出数量={size}")
                self.sell(size=size)

我们运行以下这个策略来看看结果。

请添加图片描述

控制台输出

C:\Users\hell\anaconda3\python.exe E:\postgraduate\dream_code_v2\backtrader\backtrader\my_backtrader\test.py 
Starting Portfolio Value: 100000.00
1997-06-27 | 下买单:J=-5.16, 价格=8.1500, 计划数量=12147
1997-11-03 | 下卖单:当前涨幅=7.98%, 价格=8.8000, 卖出数量=12147
1999-02-01 | 下买单:J=-5.37, 价格=17.1200, 计划数量=6221
1999-07-02 | 下卖单:当前涨幅=10.92%, 价格=18.9900, 卖出数量=6221
1999-09-17 | 下买单:J=-11.99, 价格=16.5100, 计划数量=7230
2010-02-12 | 下卖单:当前涨幅=203.15%, 价格=50.0500, 卖出数量=7230
2010-03-09 | 下买单:J=-9.47, 价格=51.8500, 计划数量=7033
2010-03-11 | 下卖单:当前涨幅=7.21%, 价格=55.5900, 卖出数量=7033
2010-07-01 | 下买单:J=-11.47, 价格=29.9900, 计划数量=12844
2010-07-23 | 下卖单:当前涨幅=7.47%, 价格=32.2300, 卖出数量=12844
2010-08-11 | 下买单:J=-6.18, 价格=31.3200, 计划数量=13091
2010-10-08 | 下卖单:当前涨幅=8.37%, 价格=33.9400, 卖出数量=13091
2010-11-15 | 下买单:J=-9.07, 价格=50.9900, 计划数量=8897
2010-12-13 | 下卖单:当前涨幅=7.26%, 价格=54.6900, 卖出数量=8897
2011-01-14 | 下买单:J=-8.98, 价格=47.0300, 计划数量=10315
Final Portfolio Value: 239430.64

看起来结果还是不错的,但是我们发现我们最后一笔单子锁定在了2011年,我们长达14年没有进行操作。读者可以思考一下离场是否有其他的方式呢?对于本策略中的离场方式,是否还有优化的空间呢?

我们现在知道了,想要写策略,就得给我们的策略函数传递一些数据信息,或者直接给定交易信号,然后在next函数中,判断数据信息进行交易,或者直接根据信号进行交易。策略制定的过程就是选出/算出好的数据信息的过程。由此,如果我们想要通过深度学习来指定策略,那就是通过深度学习的预测输出制定交易策略。

深度学习策略

未完待续…

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐