做量化交易两年多了,我一直用聚宽、米筐这些平台回测。功能是挺全的,但总有些东西想自己控制——数据格式、策略逻辑、报告样式。更重要的是,我发现自己对量化交易的本质理解得还不够深,与其继续当一个”调参侠”,不如自己从零搭一个框架,把每个环节都想清楚。
这就是 stock-backtest-engine 的由来。
为什么不直接用聚宽? #
聚宽的问题是:你不知道底层发生了什么。
- 滑点怎么算的?
- 涨停无法买入时怎么处理?
- 个股归因用什么方法(FIFO?平均成本?)?
- 归因分析在多次买卖时是否正确?
这些问题平台不会告诉你细节,但自己做框架就必须把每个决策都想清楚。想清楚的前提是真的理解——这正是我欠缺的。
整体架构 #
项目结构如下:
stock-backtest-engine/
├── config_local.py # 用户参数覆盖层(不改代码)
├── run_backtest.py # MACD+KDJ 策略入口
├── run_jack010_backtest.py # Jack010 策略入口
├── run_macdma_backtest.py # MACD均线趋势策略入口
├── export_report_data.py # 报告数据导出
│
├── backtest_engine/
│ ├── config/settings.py # 默认配置
│ ├── data/loader.py # Parquet 数据加载
│ ├── indicators/technical.py # MACD/KDJ/ATR 计算
│ ├── strategies/
│ │ ├── macd_kdj_strategy.py
│ │ ├── jack010_strategy.py # 涨停基因 + 启动点
│ │ └── macd_ma_strategy.py # MACD均线趋势
│ ├── utils/
│ │ ├── logger.py # 交易记录/净值日志
│ │ └── statistics.py # 绩效分析
│ └── engine.py # 回测引擎核心
│
└── results/ # 自动生成的报告三套策略支持,但老实说,先跑通一个最重要。
坑一:性能 — 选股从 488 秒到 0.2 秒 #
最早的 Jack010 选股逻辑是逐股循环查询:
# 原始写法(慢)
for stock_code in stock_list:
data = get_stock_data(stock_code) # 每次查一次
limit_days = calculate_limit_up_days(data) # 逐股算
start_point = find_start_point(data)3000 只股票,每次调用 get_stock_data() 都要读数据、算指标,单次 0.1s,3000次 = 300s,加上其他计算,总耗时 488 秒。
向量化改造 #
改用矩阵一次性加载 + 向量化计算:
# 改造后:一次性加载所有数据到矩阵
loader = DataLoader(parquet_path)
matrices = loader.prebuild_matrices() # close/open/high/low/volume/change
# 涨停基因:向量化列求和(NumPy原生,无需循环)
limit_up_count = np.nansum(
matrices['change'] > LIMITUP_THRESHOLD,
axis=0
)
# 启动点:矩阵切片直接读,无需逐股
recent_data = matrices['close'][-lookback_days:, :]结果:从 488 秒降到 0.2 秒,提升超过 1000 倍。
这个优化让我理解了一个道理:量化框架的核心竞争力之一就是数据处理速度,速度慢了你根本没法快速迭代策略。
坑二:归因分析 — FIFO 配对法 #
这是个容易被忽视但很重要的问题。
问题场景: 同一只股票分三次买入、两次卖出,卖出时的盈亏怎么算?
- 简单做法:卖出价 × 数量 − 最近买入价 × 数量(错)
- 正确做法:FIFO(先进先出)配对法
# FIFO 配对法核心逻辑
def calculate_attribution(trades_df):
pending_buys = []
attribution = []
for _, trade in trades_df.iterrows():
if trade['action'] == 'buy':
pending_buys.append(trade)
elif trade['action'] == 'sell':
# 找最早一笔买入配对
buy = pending_buys.pop(0)
profit = (trade['price'] - buy['price']) * trade['shares']
attribution.append({
'code': trade['code'],
'profit': profit,
'cost': buy['cost'] # 含手续费的真实成本
})第一次做归因时我没用 FIFO,报告里显示某只股票盈利 20%,但实际因为中间补过仓、平过仓,真实盈利只有 8%。数字对不上,策略判断就会出错。
坑三:配置层 — 参数怎么管 #
一开始所有参数都硬编码在 settings.py 里。想改个止损线?打开文件,找到数字,改掉。
问题是:
- 每次改完要重启
- 参数分布在多个文件里
- Git 提交时容易泄露自己的参数
后来做了 config_local.py 用户覆盖层:
# config_local.py(不进 Git)
# 取消注释想改的参数,自动覆盖 defaults
# 回测配置
START_DATE = "2023-01-01"
SLIPPAGE = 0.002
# Jack010 策略
STOCK_NUM = 6
STOPLOSS_LIMIT = 0.91
# MACD均线策略
MA_SHORT = 5
MA_MID = 10
MA_LONG = 20运行时每次重新读取,改完不用重启,通用参数会自动覆盖多套策略。这个改动虽小,但让迭代速度快了不少。
坑四:可视化报告 — 动态 vs 硬编码 #
一开始报告里的卖出原因是硬编码列表:
// 硬编码(错误做法)
const SELL_REASONS = ['死叉', '止损', '止盈', '换仓'];策略一换,报告里就显示”未知原因”,用户体验很差。
改成从回测结果动态读取:
// 动态渲染(正确做法)
const sellReasons = attributionData.sell_reasons || [];
// 渲染饼图,标签完全由数据决定这样任何策略输出的任何卖出原因,报告都能正确展示。
现在回过头看——有没有跑偏? #
坦白说,有一点。
我原本只是想搭一个回测框架,但做着做着加了三套策略、归因分析、参数配置层、可视化报告……工程侧的东西越来越多,策略侧的理解却没有跟上。
三套策略的参数加起来几十个,但我不确定每个参数调到极致是否真的有效。参数越多,过拟合的风险越大。
所以下一步我打算:
- 学点真正的量化知识,不只是调参
- 只留一个策略,跑通完整的验证闭环
- 补上样本外测试和敏感性分析
结语 #
搭框架的过程是一个很好的学习方式——你必须为每一个决策找到理由,而不仅仅是复制别人的代码。
代码开源在 GitHub,欢迎交流。踩过的坑写出来,希望对同样想从零做量化的人有点参考价值。
量化这条路,知其然更要知其所以然。共勉。