写在前面

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

回测框架

本文使用的回测框架为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_codetrade_dateopenhighlowclosepre_closechangepct_chgvolamount
000776.SZ1997061110.010.08.28.923.65.32147.78146684.0130758.3485
000776.SZ199706128.589.38.528.988.920.060.6773309.065916.5156
000776.SZ199706138.489.888.39.888.980.910.0265911.060784.1554
000776.SZ1997061610.1810.379.810.019.880.131.32100191.0101676.6104
000776.SZ199706179.89.989.39.310.01-0.71-7.0939510.037564.0698
000776.SZ199706189.159.458.888.999.3-0.31-3.3321961.019946.2714
000776.SZ199706199.019.078.78.798.99-0.2-2.2214389.012743.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生态繁荣发展。

更多推荐