FAQ

常见问题和坑。如果碰到本页没覆盖的,开个 issue

回测

为什么我的策略跑不过 Buy & Hold?

大多数简单策略都跑不过。Buy & Hold 以零佣金、零择时风险捕获标的的趋势。要击败它,你的策略需要满足以下之一:

  1. 避开回撤 —— 在最差的时段空仓(择时)。

  2. 增加分散 —— 即便每个资产收益和 B&H 接近,多资产也能降低方差。

  3. 使用杠杆 —— 通过 margin_ratio 或仓位规模显式实现。

  4. 有真正 edge —— 做空收益、均值回归等。

如果你的策略 Sharpe 为正但跑不过 B&H,作为组合的一个分量仍可能有价值(和被动多头不相关)。

怎么避免未来函数?

QTrade 在结构上阻断最常见的一种:self.data(和 self.data_by_asset[asset])被切到 [:current_time],所以你在 on_bar_close根本读不到未来的 bar。

未来函数仍可能从这些地方溜进来:

  • prepare()你能访问完整 DataFrame。如果你用了未来信息计算指标(比如 df['Close'].shift(-1)),on_bar_close 会读到它。

  • Limit / stop 成交假设价格在 bar 内确实触及你的价位。对流动性差的资产,这高估了成交概率。

  • trade_on_close=True 让市价单在当前 bar 的收盘价成交 —— 也就是你计算信号使用的那根 bar。对收盘类信号是合理的,对其他信号实际上等于作弊。不确定的话设 trade_on_close=False,让成交发生在下一根 bar 的开盘。

trade_on_close=TrueFalse 有什么区别?

设置

在第 N 根 bar 的 on_bar_close() 里下的市价单成交价...

True

第 N 根 bar 的收盘价(同一根 bar)

False

第 N+1 根 bar 的开盘价

True 适合信号确实在收盘时到达的回测(比如你用收盘价计算)。False 更接近现实,因为信号在实盘中通常需要一个 tick 才能行动 —— 偏保守,没特别理由就用这个。

我的策略一笔交易都没下,为什么?

几个常见原因:

  1. 开头的指标是 NaN —— 前 window_size 根 bar 是热身。Backtest._start_idx 自动跳过它们;如果数据比热身期还短,就一笔交易都没有。

  2. available_margin 是 0 —— margin_ratio < 1 允许你加杠杆,但堆叠交易后available_margin 缩水很快。降低 size 或调高 margin_ratio。

  3. size=0 静默无效 —— Order(size=0) 现在会抛错(好事!),但如果你用 size = available_margin // close 算出来 size=0(现金太少),默认的 Strategy.buy() 会触发 Order 构造时报错。

为什么 SL/TP 触发的价格和我设的不一样?

SL/TP 在 bar 的 low(多头止损)或 high(多头止盈)触及你设置的价位时触发。成交价记录的是你设置的 SL/TP 价位,不是 bar 的收盘价。所以你设 sl=99、bar 跌到 low=98,成交价被记录为 99,哪怕这根 bar 收盘在 102。

注意:通过 Order(stop=...) 下的普通 stop 订单目前在 bar 收盘价成交,而不是触发价 —— 这是已知的真实度差距,留作未来修复。挂在 Trade 上的 SL/TP 按上面描述工作。

参数优化

为什么 optimize() 里看起来很美的策略实盘很差?

样本内过拟合。optimize() 在同一份数据上既挑参又测试 —— 这是教科书级别的「拟合噪声」配方。

改用 walk_forward_optimize —— 详见 Walk-forward 优化。每组参数只在策略从未调过参的数据上评估。

如果 walk-forward 的 Sharpe 远低于 optimize 的 Sharpe,差距就是你的过拟合程度。很常见、很正常 —— 把 walk-forward 数字当成实际表现。

怎么选择 maximize 指标?

  • Total Return [%]:简单,但完全不惩罚风险。回撤巨大的策略可能胜出。

  • Sharpe Ratio:最常见的默认选择。适合接近正态的收益分布。

  • Sortino Ratio:适合非对称 / 厚尾收益(趋势跟随)。

  • Calmar Ratio:奖励平滑的净值曲线;适合关心回撤体验的场景。

没有特殊理由就从 Sharpe 开始。

我的网格搜索太慢了。

两条路:

  1. 缩小网格 —— 多数策略只有一两个真正重要的参数;每个参数 4–6 个候选值通常够。不要把所有东西都细粒度扫一遍。

  2. 改用 vectorbt —— 上千参数组合时,QTrade 的事件循环不是合适工具。详见 COMPARISON.md

多资产

怎么对齐不同来源的数据?

QTrade 要求每个资产的 DataFrame 共享完全一致的 DatetimeIndex。传入前用 pandas 在索引上做 join:

common = aapl.index.intersection(msft.index)
data = {"AAPL": aapl.loc[common], "MSFT": msft.loc[common]}

对于有缺口的数据(公司行为、流动性差的日子),你可能更想要 union + 前向填充 —— 但小心:前向填充的 bar 会让没有的流动性看起来像有。

可以为每个资产设置不同佣金吗?

暂不支持。Commission 是 broker 共享的单一实例。如要近似按资产收费,在下单前手动在策略逻辑里加上资产特定的滑点。

为什么多资产模式下 self.position 会抛错?

因为没有单一 Position 可返回 —— 每个资产一个。改用 self.positions["AAPL"](dict 访问)。self.data 同理 → self.data_by_asset["AAPL"]

强化学习

我的 agent 学会了什么都不做(reward = 0)。

默认的 RewardScheme 只在有交易平仓的 bar 给已实现盈亏的奖励。如果 agent 从不平仓,reward 一直是 0,梯度信号过于稀疏。

试试以下方案之一:

  • 基于权益的奖励,每根 bar 都触发(详见自定义交易环境)。

  • 在 episode 结束时强制平仓的 action scheme(保证交易最终都关闭)。

  • 增加 episode 数量 —— 稀疏奖励 + 长 episode 很难学。

env.step() 返回的 info 里有什么?

TradingEnv.step() 的每步 dict:

含义

equity

broker.equity

unrealized_pnl

broker.unrealized_pnl

cumulative_return

broker.cumulative_returns(自起点的乘性增长)

position

broker.position.size(带符号)

total_trades

len(broker.closed_trades)

trades_profit

所有已平仓交易盈亏之和

avg_trade_duration

平均 exit_index − entry_index(按 bar 数)

is_success

trades_profit > 0

is_success 是给 SB3 EvalCallback 的成功率日志用的。

random_start=True 报了一条看不懂的错误。

v0.3.0 已修复 —— 现在 len(data) <= window_size + max_steps 时抛 ValueError。默认 max_steps=3000 需要至少 ~3000 根 bar;数据较短就调小这个值。

杂项

怎么把回测结果保存起来稍后用?

没有内置序列化,但 broker 持有一切。Pickle 是简单粗暴的保存方式:

import pickle
with open("run.pkl", "wb") as f:
    pickle.dump(bt.broker, f)

如果想要更稳健(跨版本安全),先把需要的内容拉成 DataFrame:bt.broker.equity_historybt.get_trade_history()calculate_stats(bt.broker)

怎么提 bug?

https://github.com/gguan/qtrade/issues。最小复现 + QTrade 版本号(qtrade-lib --version 不存在;用 pip show qtrade-lib)会帮上大忙。