StrategyForge 量化策略锻造工坊
把「交易想法」变成能跑、能被证伪的策略代码。
核心立场:回测的目的是证伪,不是证明。 报告会主动去找策略不行的证据,
找不到才算暂时可用。
触发场景
- "帮我写一个 XX 策略" / "把交易想法写成代码"
- "回测一下这个交易策略"
- "优化我的量化策略参数"
- "策略绩效评估"
环境
- Python:
C:/Users/Lenovo/.workbuddy/binaries/python/envs/default/Scripts/python.exe - 依赖:
akshare pandas numpy scipy matplotlib openpyxl backtrader - 指标全部用 pandas/numpy 自研,不依赖 TA-Lib(后者要编译 C 库,Windows 极易装不上)
- Backtrader 1.9.78 已实测可在 Python 3.13 + pandas 3.0 下正常运行
快速开始
PY="C:/Users/Lenovo/.workbuddy/binaries/python/envs/default/Scripts/python.exe"
S="C:/Users/Lenovo/.workbuddy/skills/strategyforge/scripts/strategyforge.py"
# 标准:双均线 + 参数优化 + 全部稳健性检验
"$PY" "$S" --strategy sma_cross --symbol 600519 --start 2016-01-01 --end 2026-08-30 \
--optimize --mc-sims 2000 --outdir "D:/SF/ma"
# 指定参数,跳过优化
"$PY" "$S" --strategy rsi_revert --params period=14 lower=30 upper=70 \
--symbol 600519 --stop-loss 0.08 --take-profit 0.2
# 自定义信号表达式(无需改代码)
"$PY" "$S" --strategy custom --symbol 600519 \
--entry "cross_up(ma5, ma30)" --exit "cross_down(ma5, ma30)" --stop-loss 0.08
# 指数
"$PY" "$S" --strategy turtle --symbol sh000300 --is-index --optimize
# 多标的扫描(**判断策略是否真的有效,至少要扫 3~5 个标的**)
"$PY" "$S" --strategy turtle --symbols 600519,000858,601318,600036,601899 \
--start 2016-01-01 --end 2026-08-30 --optimize --mc-sims 2000 \
--outdir "D:/SF/sweep_turtle"
# 扫描里含指数时加 --all-index
"$PY" "$S" --strategy turtle --symbols 600519,sh000300 --all-index --optimize
耗时:单标的 2500 根 K 线,全流程约 5 秒(含 2000 次蒙特卡洛)。首次取数需联网。
参数
| 参数 | 默认 | 说明 |
|---|---|---|
--strategy |
sma_cross | 模板名,或 custom 走自定义表达式 |
--symbol |
600519 | 标的代码(A股/指数/ETF) |
--is-index |
关 | 标的是指数时加上 |
--symbols |
— | 多标的扫描:逗号分隔,对每个标的独立跑全流程并汇总成对比表(sweep.md / sweep.xlsx) |
--all-index |
关 | 配合 --symbols:列表里的全是指数 |
--start / --end |
6年前 / 今天 | 回测区间,建议 ≥3 年(<120 根 K 线无法做样本内外切分) |
--freq |
D | D/W/M |
--init-cash |
1000000 | 初始资金 |
--commission |
0.0003 | 双边手续费率 |
--slippage |
0.0005 | 滑点(成交价比例) |
--position-pct |
1.0 | 单次建仓资金比例 |
--stop-loss / --take-profit |
— | 止损 / 止盈比例 |
--trailing-stop / --max-hold |
— | 移动止损 / 最大持仓 K 线数 |
--entry / --exit |
— | 自定义信号表达式(--strategy custom) |
--params |
— | 指定参数,形如 fast=10 slow=30 |
--optimize |
关 | 执行参数搜索(样本内选参 → 样本外验证) |
--oos |
0.3 | 优化时的样本外比例 |
--max-evals |
300 | 搜索组数上限,超出改随机采样 |
--min-trades |
5 | 样本内最少交易数,过滤「几乎不交易却夏普很高」的伪优解 |
--mc-sims |
1000 | 蒙特卡洛模拟次数 |
--no-mc / --no-bt |
关 | 跳过蒙特卡洛 / 跳过 Backtrader 交叉校验 |
--bench |
sh000300 | 基准指数(算超额收益) |
内置策略模板(12 个)
| 名称 | 中文名 | 类型 | 参数 |
|---|---|---|---|
sma_cross |
双均线交叉 | 趋势跟踪 | fast slow |
ema_cross |
EMA 交叉 | 趋势跟踪 | fast slow |
macd_cross |
MACD 金叉死叉 | 趋势跟踪 | fast slow signal |
donchian |
唐奇安通道突破 | 趋势跟踪 | period |
turtle |
海龟交易法 | 趋势跟踪 | entry_n exit_n |
atr_breakout |
ATR 通道突破 | 趋势跟踪 | period mult |
vol_breakout |
放量突破 | 趋势跟踪 | period k |
dual_mom |
双周期动量共振 | 动量 | short long |
rsi_revert |
RSI 均值回归 | 均值回归 | period lower upper |
boll_revert |
布林带均值回归 | 均值回归 | period k |
boll_breakout |
布林带突破 | 突破 | period k |
kdj_cross |
KDJ 金叉死叉 | 均值回归 | n |
参数优化时会把风控参数(stop_loss / take_profit)一起纳入搜索空间。
自定义信号表达式
--strategy custom 后直接写表达式,可用 sf_indicators.build_all() 里的全部指标:
ma5/10/20/30/60/120、ema12/26、rsi14/6、atr14、bb_up/mid/dn/width/pos、dc_hi/lo、
macd/macd_signal/macd_hist、kdj_k/d/j、mom20/60、vol20、zscore20,以及指标函数
sma ema rsi atr bollinger donchian macd kdj cross_up cross_down ...。
--entry "cross_up(ma5, ma30)"
--exit "rsi14 > 70"
--entry "(close > ma20) & (volume > volume.rolling(20).mean() * 1.5)"
求值在 {"__builtins__": {}} 的受限命名空间里做,没有内置函数。
输出
| 文件 | 内容 |
|---|---|
report.md |
六章标准报告(概述/逻辑/引擎校验/绩效/优化/稳健性+结论) |
strategy.xlsx |
绩效汇总、逐笔交易、净值序列、市场周期、成本敏感性、参数邻域、参数搜索、蒙特卡洛分布 |
sweep.md / sweep.xlsx |
多标的扫描时的逐标的对比表 + 跨标一致性判读 |
equity.png |
净值曲线 + 回撤(含基准对照) |
montecarlo.png |
蒙特卡洛分布直方图(标出真实收益与 95% 分位) |
strategy_<name>.py |
可独立运行的 Backtrader 策略脚本,用户拿去改的就是这个 |
架构:为什么是双引擎
| 向量化引擎 | Backtrader 引擎 | |
|---|---|---|
| 速度 | 0.66 ms/次 | 327 ms/次(慢 500 倍) |
| 用途 | 参数搜索、蒙特卡洛、敏感性扫描 | 最终对外口径、代码交付 |
| 保真度 | 自研撮合 | 事件驱动,含完整撮合语义 |
交叉校验:同一套信号跑两个引擎比对。
关键认知:历史上所有真 bug 都表现为交易笔数对不上(29 vs 10 信号错位、
33 vs 8 空头累积、18 vs 17 挂单引用未清)或荒谬数值(-3032 亿)。因此判据是:
- 笔数必须完全相等,不等直接判失败(不管终值差多少)
- 笔数一致时容差随笔数轻微放宽:
min(2% + 0.05% × N, 10%)
—— 每笔股数取整差 0.1%~0.5% 会逐笔复利,但修复全部引擎 bug 后
180 组实测最大偏差仅 2.14%,所以容差可以定得很紧。
不要用容差掩盖笔数差异:曾出现"恒定差 1 笔",当时以为是边界效应而放 slack 绕过,
实为挂单防护 bug(用 _exit_order is not None 判断,但该引用成交后没被清空,
下一根误触发防护分支、吃掉一次真实入场)。改成以 o.alive() 判断后笔数完全对齐。
踩过的坑(改代码前必读)
- Backtrader 的 cheat-on-open 必须用
next_open(),不是next()。
写在next()里的订单仍在收盘后提交、次日开盘成交。配合读[-1]信号就变成
整整晚两根 K 线 —— 表现为「BT 入场日期比向量化晚 2~3 天」,偏差 8%~24%。
同时需要Cerebro(cheat_on_open=True)和broker.set_coo(True)。 order_target_percent(1.0)+ 滑点会被 Margin 拒单:它按下单日估值算股数,
却在次日开盘成交,跳空高开就现金不够。改成按可用现金显式算股数
(与向量化引擎同公式)后解决。- 风控逻辑只实现一次(
apply_risk_exits),两个引擎都只做执行。
早期版本在两边各写了一遍止损,Backtrader 侧把self.sell()当成开空单,交易笔数 0。 - pandas 3.x 索引对齐陷阱:
pd.DataFrame(index=日期索引)后赋值
pd.to_numeric(raw["open"])(RangeIndex)会按索引对齐成全 NaN,
表现为「取到 0 根 K 线」。必须用.to_numpy()按位置赋值。 rf_annual只用于绩效折算,不是执行引擎参数,sf_backtest.run()里必须剥掉。- 平仓单必须有挂单防护,且必须用
o.alive()判断:
止盈限价单挂在那里不成交,下一根又提交一笔全额卖出单,重复几次就累积成巨额空头
—— 实测出现过 -3032 亿的终值(多头策略不该有负值)。
防护逻辑:每根先查上一根平仓单,not alive()就清引用放行,alive()才撤单改市价。
曾只靠is not None判断,订单成交后引用没被清空 → 下一根误触发防护分支,
把一次真实入场吃掉(恒定差 1 笔)。 - 止损价只决定「是否触发」,不应压低成交价。曾写成
min(次日开盘, 止损价),
开盘价高于止损价时按更低的止损价成交,属无谓的悲观假设,
导致向量化系统性低于 Backtrader(实测 17%~30%)。止损/移动止损一律按次日开盘市价出场。 - 止盈价要校验可达性:向量化无条件按止盈价成交,而 Backtrader 受 K 线区间限制
(要求 成交bar最高价 >= 止盈价)。够不到就顺延一日按开盘市价平仓,两边才对得上。
三层验证,以及该信哪一层
| 层级 | 做什么 | 判据 |
|---|---|---|
| ① 交叉校验 | 两引擎对同一策略比对 | 偏差 ≤3% |
| ② 参数优化 | 样本内搜索 → 样本外冻结验证 | 样本外夏普 >0 且 ≥ 样本内 50% |
| ③ 稳健性四项 | 市场周期 / 成本敏感 / 参数邻域 / 蒙特卡洛 | 见下 |
蒙特卡洛是最狠的一关:保持交易次数与持仓周期分布不变,随机化出入场时点,
看真实收益在随机分布里排第几。排在 95% 分位以下 → 收益无法与运气区分。
买入持有才是最该比的基准(不是「随机」):单向做多的择时策略如果跑不赢
「一直拿着」,说明择时没创造价值,还多付了手续费。长期上涨的标的上,
随机持仓一半时间就能吃到一半涨幅,随机基准天然偏高 —— 只看随机分位会误判。
多标的扫描:单一标的的结论不能外推
在单一标的上调出来的漂亮曲线,往往只是拟合了那只股票的历史形态。
用 --symbols 一次扫多个标的,输出 sweep.md / sweep.xlsx,看的是一致性:
| 判据 | 通过标准 |
|---|---|
| 跑赢买入持有 | ≥70% 的标的 |
| 蒙特卡洛通过 | ≥70% 的标的 |
| 样本外夏普为正 | ≥70% 的标的 |
任一项大面积失守 → 判定「只在部分标的上有效」或「不应作为通用策略」。
跨标一致性判读(海龟策略,6 标的,2016-01~2026-08,2000 次蒙特卡洛)
| 标的 | 年化 | 买入持有 | 跑赢 | 样本外夏普 | 蒙特卡洛 | 引擎校验 |
|---|---|---|---|---|---|---|
| 600519 | 13.30% | 19.39% | ✗ | −1.122 | ✗ 90.2% | ✓ 0.50% |
| 000858 | 7.58% | 4.15% | ✓ | 0.288 | ✗ 89.3% | ✓ 4.31% |
| 601318 | 6.71% | 4.93% | ✓ | −0.082 | ✗ 94.6% | ✓ 0.47% |
| 600036 | 1.08% | 8.34% | ✗ | −0.315 | ✗ 21.8% | ✓ 0.46% |
| 601899 | 13.07% | 25.75% | ✗ | 0.547 | ✗ 72.0% | ✓ 0.70% |
| sh000300 | 1.86% | 2.80% | ✗ | −0.208 | ✗ 56.9% | ✓ 0.65% |
→ 跑赢买入持有 2/6,蒙特卡洛通过 0/6 → ❌ 不应作为通用策略。
引擎校验 6/6 通过(0.46%~4.31%),说明这不是代码问题,是策略本身缺乏跨标的一致性。
同一套策略在不同标的上的表现天差地别,这正是必须扫描的原因。
单标的实测(茅台 600519,2017-01 ~ 2026-08,12 个模板横评)
12 个内置模板全部跑通,交叉校验 12/12 通过(偏差 0.00% ~ 1.06%),但:
| 策略 | 年化 | 蒙特卡洛百分位 | 结论 |
|---|---|---|---|
| turtle | 11.62% | 78.4% | 最强,仍未通过 |
| donchian | 10.27% | 55.7% | 未通过 |
| rsi_revert | 7.52% | 74.5% | 未通过 |
| kdj_cross | 2.83% | 5.7% | 最差 |
| dual_mom | −2.31% | 7.2% | 亏损 |
| 买入持有 | 18.33% | — | 全部 12 个策略无一跑赢 |
在长牛个股上做择时,大概率是在给券商打工。 这不是代码有 bug(交叉校验已证明
两引擎一致),而是这类策略在这类标的上的真实表现。换标的/换区间要重跑,别外推。
边界条件(必须遵守)
- 数据取不到 → 直接报错退出,绝不编造数据或回退到假数据。
- 数据体检:缺失值过半 / 零成交过半 / 零波动过半 / 非正价格 → 拒绝产出无意义回测。
含 NaN 场景不再默默跑出"0 笔交易"的报告,而是中止并说明原因。 - 样本量门槛:<10 笔交易 → 绩效指标全部标注"不具统计意义";<30 笔 → 提示置信度有限。
- 检验没跑成 ≠ 检验通过:蒙特卡洛失败时综合判读强制降级为
weak,
并明确写出「在补跑蒙特卡洛之前,本报告不足以支撑实盘决策」。 - 样本内交易次数 <
--min-trades的参数组合直接判无效,不参与选优。 - 所有结论必须附带样本外 / 蒙特卡洛证据,只报样本内收益视为不合格。
已知局限
- 只做多、单标的:不支持做空、不支持多标的组合与资金分配。
- 无行业/市值中性化:单标的场景下不适用。
- 蒙特卡洛的
shuffle法保持持仓比例随机化持仓时点,random法完全随机生成进出场;
均未考虑自相关,属保守估计。 - Backtrader 侧未实现逐笔复权细节(分红送转),长周期回测请以向量化口径为准。