从聚宽到本地:我用 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