跳过正文
  1. Posts/

我从零搭了一个股票回测框架,踩了哪些坑

·4 分钟
大伟
作者
大伟
10年+ DevOps/SRE,热爱技术,探索自由
目录

做量化交易两年多了,我一直用聚宽、米筐这些平台回测。功能是挺全的,但总有些东西想自己控制——数据格式、策略逻辑、报告样式。更重要的是,我发现自己对量化交易的本质理解得还不够深,与其继续当一个”调参侠”,不如自己从零搭一个框架,把每个环节都想清楚。

这就是 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,欢迎交流。踩过的坑写出来,希望对同样想从零做量化的人有点参考价值。

量化这条路,知其然更要知其所以然。共勉。

相关文章

失业第 100 天

·1 分钟
从去年 12 月 31 日到现在,刚好 100 天了。这个数字有点巧,像是老天在提醒我:该给自己一个节点。这三个月,说长不长,说短不短。刚开始还有点新鲜感——久违地睡到自然醒,不用挤早高峰,不用回复那些"收到"和"好的"。但慢慢地,焦虑还是会爬上来。