从聚宽到本地:我用 Backtrader 搭建了一个 A股量化回测框架

从聚宽到本地:我用 Backtrader 搭了一个 A股量化回测框架(失败了)

> 结论先行:这个方案行不通。Backtrader 的 _runonce 机制在股票数量超过约 400 只时会导致内存溢出,属于架构层面的瓶颈。但踩过的坑有价值,记录下来备忘,也供同样在探索的朋友参考。


1. 为什么想从聚宽搬回本地

之前一直在聚宽(JoinQuant)上跑策略,选择它主要是生态成熟、因子丰富、数据不用操心。但用久了几个痛点越来越明显:

  • 数据不自由:导出走人,之前的因子和策略跟平台强绑定
  • 策略归属:代码在别人服务器上,心里总有点不踏实
  • 运行成本:免费版功能有限,专业版按月付费
  • 隐私:不想把正在测试的策略暴露给平台

于是开始探索「本地化」方案,目标是:

> 保持和聚宽近似的体验(多股票、多因子、申万行业分类),但跑在自己机器上,数据自己管。


2. 技术选型

组件选择理由回测引擎BacktraderPython 原生、文档清晰、社区活跃数据格式Parquet列式存储、谓词下推、pyarrow 支持好数据加载AKShare + 自建 loader免费数据源,支持 A股日线行业分类申万行业分类480+ 股票,覆盖主流市场


3. 框架结构


quant-backtest/
├── config.py                         # 全局配置
├── run_backtest.py                   # 回测引擎封装
├── run_strategies.py                 # 回测配置与运行入口(推荐)
│
├── data/                             # 数据模块
│   ├── parquet_loader.py             # Parquet 数据加载器
│   ├── csv_to_parquet.py            # CSV → Parquet 转换
│   └── stocks.parquet               # 股票数据 (数据源)
│
├── engine/                           # 回测引擎
│   ├── backtest_engine.py           # 引擎核心 (Cerebro 封装)
│   └── performance.py               # 绩效分析 (17+ 指标)
│
├── strategies/                       # 策略模块
│   ├── macd_strategy.py             # MACD 金叉/死叉策略 (单股)
│   ├── rsi_strategy.py               # RSI 超买超卖策略 (单股)
│   ├── mACD_kdj_atr_sw_strategy.py  # MACD+KDJ+ATR 行业中性策略
│   └── limit_up_smallcap_strategy.py # 涨停板小市值策略
│
└── utils/                           # 工具模块
    ├── jqdata_adapter.py            # 聚宽 API 适配层
    ├── sw_industry.py               # 申万行业分类
    └── stock_filter.py              # 股票筛选工具

4. 核心设计思想

4.1 Parquet 谓词下推

Backtrader 原生的 CSV loader 会把数据全部加载到内存。改进方案:


import pyarrow.parquet as pq

# 只读取需要的股票和时间范围
pf = pq.ParquetFile('data/stocks.parquet')
table = pf.read_rows(
    filters=[('code', 'in', stocks), ('date', '>=', start_date)]
)

数据量小时差异不明显,但 A股 4000+ 股票、全量历史数据时,Parquet 可以节省 50%+ 加载时间。

4.2 股票池 = 全市场

这里有一个设计上的取舍:

  • 方案 A:外部预定义「好股票」列表 → 策略拿到的是精选池
  • 方案 B:外部传入全市场,策略内部筛选 → 更灵活,但性能压力大

我选了方案 B,理由是:

> 策略的筛选逻辑本身就是策略的一部分,如果外部先筛选,实际上是把策略逻辑分散到了配置文件里。

但这个选择后来成了性能问题的一部分原因。

4.3 聚宽 API 适配

原本想把在聚宽上写的策略直接搬过来。通过适配层做了接口转换:


# 聚宽 API
def initialize(context):
    context.stock_list = get_index_stocks('000300.XSHG')  # 沪深300

def handle_bar(context, bar_dict):
    for stock in context.stock_list:
        ...

# 适配层 → Backtrader
class JQDataAdapter:
    get_index_stocks = lambda self, index: [...]
    get_price = lambda self, stock, count, freq: [...]

好处是迁移简单,坏处是有些聚宽特有的函数(如 get_fundamentals)没有好替代。


5. 策略实现

5.1 MACD + KDJ + ATR 行业中性策略

这是从聚宽迁移过来的策略,核心逻辑:

Step 1 – MACD 方向过滤:只保留 MACD > 0(金叉趋势中)的股票

Step 2 – KDJ 择时:剔除 J 值 > 80(超买)的股票

Step 3 – 行业中性抽取:按申万行业分类,每个行业最多选 1 只,保证组合行业分散

Step 4 – ATR 止损:持仓期间用 ATR 的 N 倍作为动态止损线


class MACDKDJATRMultiStockStrategy:
    def __init__(self, fast=12, slow=26, signal=9,
                 kdj_n=9, kdj_m1=3, kdj_m2=3,
                 atr_n=14, atr_multiplier=2.0,
                 stock_num=6):
        ...

    def rank_stocks(self, candidates):
        # 申万行业中性:每行业最多1只
        sw_map = get_sw_industry_map()
        ranked = sorted(candidates, key=lambda s: self._macd_rank(s))
        selected = []
        industry_used = set()
        for stock in ranked:
            ind = sw_map.get(stock)
            if ind not in industry_used and len(selected) < self.stock_num:
                selected.append(stock)
                industry_used.add(ind)
        return selected

5.2 涨停板小市值策略

追涨停板,次日出局。简单直接:


class LimitUpSmallCapStrategy:
    def next(self):
        # 今日涨停的票
        limit_up = [s for s in self.stocks if self._is_limit_up(s)]

        # 过滤ST、科创板(涨跌停限制不同)
        candidates = [s for s in limit_up
                      if not is_st(s) and not is_kcb(s)]

        # 等权买入,次日卖出
        target_value = self.broker.getvalue() / len(candidates)
        for stock in candidates:
            self.order_target_value(stock, target_value)

        # 持仓超过1天 → 卖出
        for pos in self.positions:
            if self._hold_days(pos) >= 1:
                self.close(pos)

6. 踩过的坑

坑1:Backtrader _runonce 内存溢出 ⚠️

问题:当股票数量超过约 400 只时,回测直接 OOM(Out Of Memory)。

原因:Backtrader 的 _runonce 机制会在每次 next() 调用前一次性处理所有数据。数据量大时:


总内存占用 ≈ 股票数 × 数据长度 × 每行数据大小

对于 4000 只股票 × 5 年日线数据,轻松超过 20GB。

症状


TypeError: _runonce() got an unexpected keyword argument 'kdj_period'
# (这是表象,实际上是内存溢出导致参数解析出错)

临时解法:限制股票数量、缩短回测周期、使用 exactbars=1 减少内存。

根本解法:换一个引擎。


坑2:参数命名不一致

聚宽的 KDJ 参数名是 kdj_period,Backtrader 策略里用的是 kdj_n。迁移时两边对不上,导致回测静默失败。

教训:参数命名规范 + 类型提示 + 文档,缺一不可。


坑3:AKShare 数据质量

AKShare 的免费数据偶尔有缺失值和错位,需要做:


df = df.dropna()  # 删除空值
df = df[df['volume'] > 0]  # 删除停牌日
df = df.sort_values('date')  # 确保时间顺序

7. 为什么这个方案失败了

核心瓶颈:Backtrader 不适合大规模股票池回测。

指标期望实际支持股票数全市场 4000+~400 只(内存上限)日线数据量5 年 × 全市场内存溢出组合构建行业中性自动选股需要手动过滤

Backtrader 的设计目标是单策略单标的或小规模组合,不适合「从全市场筛选」这种场景。


8. 后续计划

接下来打算试几个其他框架:

  • Backtrader + 分批处理:把全市场分成小批次,分别回测,再合并结果(绕开内存问题)
  • Zipline:Python 原生回测框架,Quantopian 开源版本,支持大数据量
  • RiceQuant:继续云端,但保持策略可迁移
  • 自建引擎:用 NumPy/Pandas 向量化计算,完全控制数据流

9. 结语

虽然这个方案失败了,但过程本身有价值:

  • 对 Backtrader 有了深入理解
  • 搭建了完整的数据 pipeline(AKShare → Parquet → Backtrader)
  • 申万行业分类、聚宽适配层等模块可以复用

框架是工具,选对工具的前提是知道工具的边界在哪里。


代码仓库:https://github.com/your-repo/quant-backtest

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注