无需 API Key 的可复现 Python 回测
使用固定版本和确定性示例数据,在无需 API Key 的情况下运行可复现的 Python 回测,并诚实解读一条收益曲线无法说明什么。
可复现的 Python 回测,是指他人在一台干净的机器上无需账户或 API key,就能重新运行并得到完全相同的结果。大多数教程在第一行就无法通过这一检验,因为实时下载会让下一位读者获得与作者略有不同的价格历史数据。本指南固定使用一个开源引擎:quantjourney-bt,版本为 0.12.4。指南先在不提供任何凭据的情况下运行其自带示例,然后使用真实市场数据,说明一次规范运行仍然无法告诉您什么。
什么样的回测才具备可复现性?
这里的“可复现性”有一个明确且可验证的含义:第二个人在一台干净的机器上运行一条命令,就能将您的结果精确复现到小数位。通常有两类问题会破坏这一点。
第一类是代码。版本号为 0.x 的库通常不会承诺小版本之间保持兼容。一次重命名、默认设置的改变或列顺序的调整,都可能让脚本继续运行,却在不知不觉中报告不同的结果。
第二类是数据。若教程的第一行代码就是实时下载数据,它就不具备真正的可复现性。数据供应商会修订历史数据、进行拆股调整并补回缺失数据,因此同一脚本在一个月后可能输出不同的数字。事后您无法区分结果变化究竟来自代码还是数据。
这个项目值得借鉴的做法,是将固定版本的引擎与一份随软件包提供的小型内置数据集配套使用。
固定安装版本:pip install quantjourney-bt==0.12.4
quantjourney-bt 是 QuantJourney 回测器,采用 Apache License 2.0 发布,要求 Python 3.11 或更高版本。0.12.4 版本于 2026 年 7 月 21 日发布。以下所有命令均指向该版本,相关文档记录于 2026 年 8 月。
请在隔离环境中操作。python3 -m venv .venv 用于创建隔离环境,source .venv/bin/activate 用于进入该环境,python -m pip install -U pip 用于在其中更新安装程序。然后安装精确版本:pip install quantjourney-bt==0.12.4。
项目文档使用未固定版本的写法:pip install quantjourney-bt。==0.12.4 部分由您负责;在 0.x 版本阶段,固定版本尤其重要。请将版本固定信息写在后续使用者能找到的位置:pip freeze > requirements.txt 会记录所有已解析的依赖项,包括您未明确指定的依赖项。
项目提供两个可选扩展。pip install "quantjourney-bt[wf]" 会添加用于滚动前向测试和优化示例的 Optuna。pip install "quantjourney-bt[data]" 会添加用于基准测试的 yfinance 备用数据源。
Apache-2.0 许可较为宽松。您可以将代码用于商业用途并进行修改;重新分发时,须保留许可文件和声明文件;贡献者还会明确授予专利权。
如何在没有 API 密钥的情况下运行内置 SMA 示例
该代码库附带一个启动脚本,并提供五十个可运行的示例策略。这些策略分为基于权重和基于订单两条路径,其中包括五个走步式工作流。./strategy.sh --list会打印策略目录。./strategy.sh example_weights_01_sma_daily --check只导入一个策略,不读取任何数据,是最快的安装检查方式。
实际运行演示只需一行命令:./strategy.sh example_weights_01_sma_daily --sample-data --output /tmp/qj-sample
--sample-data标志是关键。项目对其背后的数据集作了如下说明:
该示例数据集规模较小,并且可重复生成。它适用于安装检查、报告生成,以及在无需创建账户的情况下了解引擎流程。
来源:quantjourney-bt README,版本 0.12.4,读取日期:2026年8月6日。
运行结果会写入目录,而不是在控制台给出结论:summary.txt和summary.json、一个metrics.csv、一个与equity_curve.png放在一起的equity_curve.csv、一个dashboard.html、一个plots/文件夹,以及一个记录运行配置的run_metadata.json。最后这个文件最容易被忽略,但它能让您在一年后仍可审计运行结果。
请如实解读生成的指标。内置数据集规模较小,仅用于演示,因此summary.txt中打印的夏普比率和最大回撤只反映一个示例文件。它们不能证明某项策略有效。把这些指标当成策略结果,是最容易犯的第一个错误。
这次运行能够确认几件重要的事情:安装成功;从信号到目标权重,再到重建组合价值的完整引擎流程,可以在您的机器上生成相关文件,而且全程无需任何凭据。项目也提供了需要凭据的数据服务路径,可用于基于真实历史数据进行回测。该路径已有文档说明。本指南到此结束,因为上述流程不需要向任何人获取任何内容。
一次样本内权益曲线无法告诉您的信息
样本运行只绘制一条权益曲线。以下内容基于真实市场数据,而非演示文件,说明这条曲线无法反映什么。
下方面板采用示例策略中的相同思路:将20个交易日移动平均线与50个交易日移动平均线进行交叉,并将其应用于SPY,分别报告2017年至2025年的每个日历年表现。每个交易日的持仓由前一交易日收盘价确定,因此该规则不会根据当时尚未获得的数据进行交易。
每个数字背后的完整 SQL
WITH daily AS
(
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
toFloat64(argMax(close, window_start)) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= '2016-01-01 00:00:00'
AND window_start < '2026-01-01 05:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY d
),
averaged AS
(
SELECT
d,
px,
avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
row_number() OVER (ORDER BY d) AS session_no
FROM daily
),
positioned AS
(
SELECT
d,
px,
if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
lagInFrame(px, 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
FROM averaged
)
SELECT
toYear(d) AS year,
round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
round((exp(sum(log(px / px_prior))) - 1) * 100, 1) AS hold_pct,
countIf(long_today != long_prior) AS crossover_count
FROM positioned
WHERE px_prior > 0
AND toYear(d) >= 2017
GROUP BY year
ORDER BY year同一条规则,分别测算9次。先阅读两列百分比数据,再看其他内容。在2017,该规则全年收益为16%,同期持有SPY的收益为19.4%。在2025,这两列数据分别为10.4%和16.4%。两行使用的代码完全相同,只有时间窗口发生了变化。
交叉次数一栏显示了底层证据有多薄弱。4次持仓变动分布在2025个交易日中,意味着整年的权益曲线只建立在少数几次决策之上。这样的样本规模太小,不能据此认定策略有效。
同一规则在其他标的上也会表现相同吗?
调整日期窗口,是检验单条曲线的一种方式。调整标的范围,则是另一种方式。下方面板固定参数,并将完全相同的规则应用于五个流动性较高的标的,覆盖2021年至2025年这五个完整自然年。
每个数字背后的完整 SQL
WITH daily AS
(
SELECT
ticker,
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
toFloat64(argMax(close, window_start)) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'QQQ', 'AAPL', 'MSFT', 'KO')
AND window_start >= '2020-07-01 00:00:00'
AND window_start < '2026-01-01 05:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY ticker, d
),
averaged AS
(
SELECT
ticker,
d,
px,
avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
row_number() OVER (PARTITION BY ticker ORDER BY d) AS session_no
FROM daily
),
positioned AS
(
SELECT
ticker,
d,
px,
lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
lagInFrame(px, 1)
OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
FROM averaged
)
SELECT
ticker AS symbol,
round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
round((exp(sum(log(px / px_prior))) - 1) * 100, 1) AS hold_pct,
round(avg(long_prior) * 100, 0) AS days_long_pct
FROM positioned
WHERE px_prior > 0
AND d >= toDate('2021-01-01')
GROUP BY symbol
ORDER BY rule_pct DESCQQQ位于面板顶部,数值为40.2%;底行的KO为3.8%。days_long_pct列显示各版本在整个窗口期间实际持有仓位的时间比例,顶行对应67%。同一组参数、五个标的范围,结果差距足够大,因此事后挑出赢家,并不能说明尚未进行的那次回测会如何表现。
这并不是建议您交易交叉策略。交叉信号只是回测的测量工具,而我们真正要评估的是回测结果。
权重回测中前视偏差的潜入位置
基于权重的引擎会将信号转换为目标权重,按这些权重模拟成交,然后根据最终持仓重建组合价值。问题隐藏在信号与权重的衔接处。如果当日权重来自当日收盘价,随后又用该权重计算当日收益,那么回测实际上使用了下单时尚不存在的信息。这就是前视偏差,而且不会触发任何错误。它只会让所有结果看起来更好。
下方表格基于同一段 SPY 历史数据,运行同一规则的两个版本。
每个数字背后的完整 SQL
WITH daily AS
(
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
toFloat64(argMax(close, window_start)) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= '2016-01-01 00:00:00'
AND window_start < '2026-01-01 05:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY d
),
averaged AS
(
SELECT
d,
px,
avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
row_number() OVER (ORDER BY d) AS session_no
FROM daily
),
positioned AS
(
SELECT
d,
px,
if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
lagInFrame(px, 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
FROM averaged
)
SELECT
toYear(d) AS year,
round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS next_bar_pct,
round((exp(sum(log(if(long_today = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS same_bar_pct,
round(abs(exp(sum(log(if(long_today = 1, px / px_prior, 1.0))))
- exp(sum(log(if(long_prior = 1, px / px_prior, 1.0))))) * 100, 1) AS gap_pp
FROM positioned
WHERE px_prior > 0
AND toYear(d) >= 2017
GROUP BY year
ORDER BY year在 2017 中,前一交易时段版本的结果为 16%,而同一交易时段版本的结果为 17.1%,两者相差 1.1 个百分点。在 2025 中,两者相差 1.6 个百分点。只有其中一列可能由一台事先不知道该交易时段收盘位置的机器生成;两者之间的差异纯粹是会计处理造成的,背后没有交易观点、技能或实际交易。
该引擎明确说明了自身对交易时点的处理方式,这比口头保证更有价值:
对于开盘价成交,区间敏感型滑点只使用上一根已完成K线,成交量容量则根据滞后观测值进行预测;引擎不会使用该交易日随后出现的最高价、最低价、收盘价或全天成交量。
来源:quantjourney-bt README,版本 0.12.4,阅读日期:2026年8月6日。
有文档记录的假设,可以与您已经安装的源代码进行核对。没有文档记录的假设,只能算是猜测。
为什么会有 walk-forward 扩展
WF01 到 WF05 这几个 walk-forward 示例随 [wf] 扩展及其 Optuna 依赖一并提供。Walk-forward 方法在一段历史数据上拟合参数,在紧接其后的数据区间上进行测量,然后将这两个区间向前滚动并重复操作。滚动窗口和扩展窗口的区别在于:窗口向前移动时,拟合区间是否会移除最早的数据。另一个示例会在每个边界处加入 purge 和 embargo,移除接缝附近的观测值,避免拟合区间的信息泄漏到用于测量的区间。
这些方法都不能把一个薄弱的思路变成可行策略。它们只是用一组可以进行分析和质疑的数值分布,取代单一数值,而这正是其全部改进。下一步仍然不是投入真实资金交易:在投入真实资金前进行模拟交易,用于衡量回测在结构上无法观察到的因素,首先要看您的订单成交价是否接近模拟器假设的价格;交易机器人的熔断机制则应对代码在异常情况下的行为。关于底层统计方法,我们在开源量化交易书籍中的笔记有更深入的说明。
常见问题
无需 API 密钥也能回测策略吗?
可以。quantjourney-bt 在 --sample-data 标志后附带一组示例数据,其示例策略无需账户或凭据即可运行。数据集规模较小,仅用于演示。因此,这次运行应视为安装和流程检查,而不是判断策略有效性的依据。
为什么要固定 Python 回测包的版本?
0.x 版本的程序包不保证小版本之间的兼容性,默认设置的改变或指标更名也不会主动提示。使用 pip install quantjourney-bt==0.12.4 固定版本,并在 requirements 文件中记录运行环境,意味着您今天生成的结果,明年仍可在生成该结果的引擎上重建。
quantjourney-bt 采用什么许可证?
Apache License 2.0。该许可证允许商业使用和修改,要求您在重新分发时保留许可证及声明文件,并明确授予贡献者提供的专利许可。0.12.4 版本于 2026 年 7 月 21 日发布,要求使用 Python 3.11 或更高版本。
回测结果出色,是否意味着策略有效?
不意味着。回测只是在一个时间窗口、一个股票池上进行的一次测量。上方图表显示,同一条未改变的规则在一个 ticker 上会产生差异很大的年度数据,在五个不同标的之间也会产生差异很大的结果。这正是滚动验证和样本外测试要揭示的问题。
上方图表的计算方式
每日收盘价取自每个日期常规交易时段最后一分钟的成交记录,时间按纽约时间计算,范围为上午 9:30 至下午 4:00。这样可以正确处理提前收市的半日交易日,而无需硬编码交易时段长度。快速均线覆盖 20 个交易日,慢速均线覆盖 50 个交易日,二者均为简单移动平均线。每个序列最初的 49 个交易日为预热期,不持有头寸。年度数据将策略持有多头头寸期间各交易日的收盘到收盘变动复合计算;持有列则将同一年全部交易日的变动复合计算,以便比较。横截面中的五个标的均选取在窗口内具有连续历史数据且没有发生拆股的标的,因此无需调整收盘价序列。时间窗口固定在过去,因此每次重新生成时,这些图表都会返回相同的数据。
这里的每个图表下方都附有完整的 SQL,因此本页数据既可重新运行,固定版本的安装环境也同样可复现。若您想在编写任何回测代码前,先测量某条规则在自定义时间窗口内的表现,请在 Strasmore terminal 中用自然语言提出问题。