FAQ

Common questions and pitfalls. If you hit something not covered here, open an issue.

Backtesting

Why does my strategy underperform Buy & Hold?

Most simple strategies do. Buy & Hold extracts the asset’s underlying trend with zero commission and zero timing risk. To beat it your strategy needs to either:

  1. Avoid drawdowns — sit out the worst periods (timing).

  2. Add diversification — multi-asset reduces variance even with B&H-like returns per asset.

  3. Use leverage — explicit via margin_ratio or position sizing.

  4. Trade with edge — short-side gains, mean reversion, etc.

If your strategy has a positive Sharpe but loses to B&H, it might still be valuable as a portfolio component (uncorrelated with passive long).

How do I avoid look-ahead bias?

QTrade prevents the most common form by construction: self.data (and self.data_by_asset[asset]) is sliced to [:current_time], so you cannot read future bars from inside on_bar_close.

Where look-ahead can still creep in:

  • In prepare(), you have access to the full DataFrame. If you compute an indicator that uses future information (e.g. df['Close'].shift(-1)), on_bar_close will read it.

  • Limit / stop fills assume the price actually traded at your level inside the bar. For thinly-traded assets this overstates fill probability.

  • trade_on_close=True lets a market order fill at the current bar’s close — the same bar your signal was computed from. For some signals this is realistic (close-of-bar signals), for others it’s effectively cheating. If unsure, set trade_on_close=False and fills happen at the next bar’s open.

What’s the difference between trade_on_close=True and False?

Setting

Market order placed in on_bar_close() for bar N fills at…

True

Bar N’s close (same bar)

False

Bar N+1’s open

True is convenient for backtests where your signal genuinely arrives at end-of-bar (e.g. you compute on close prices). False is the realistic setting if your signal could in practice take a tick to act on — slightly conservative, recommended unless you have a reason.

My strategy fires no trades. Why?

A few common causes:

  1. Indicators are NaN at the start — the first window_size bars are warmup. Backtest._start_idx skips them automatically; if your data is shorter than the warmup, you get zero trades.

  2. available_margin is 0margin_ratio < 1 lets you take leveraged positions, but available_margin shrinks fast as you stack trades. Lower the size or raise the ratio.

  3. size=0 quietly does nothingOrder(size=0) raises now (good!), but if you compute size = available_margin // close with too-small cash, default Strategy.buy() resolves to size 0 and Order construction errors.

Why does my SL/TP fire at a price different from what I set?

The SL/TP triggers when the bar’s low (for long stops) or high (for long take-profits) crosses your level. The synthetic exit is recorded at your SL/TP level, not at the bar’s close. So if you set sl=99 and the bar dips to low=98, your fill is recorded at 99, even though the bar closed at 102.

Caveat: ordinary stop orders (placed via Order(stop=...)) currently fill at the bar’s close, not the trigger price — a known fidelity gap tracked as a future fix. SL/TP attached to a Trade work as described.

Optimization

Why does my strategy look great in optimize() but bad live?

In-sample overfitting. optimize() picks the params that look best on the same data they’re tested on — which is a textbook recipe for fitting noise.

Use walk_forward_optimize instead — see Walk-forward optimization. Each parameter set is evaluated only on data the strategy was never tuned on.

If walk-forward Sharpe is much lower than optimize Sharpe, that gap is your overfit. Common; expected; treat the walk-forward number as the realistic one.

How do I pick the maximize metric?

  • Total Return [%]: simple, but punishes nothing for risk. Strategies with huge drawdowns can win.

  • Sharpe Ratio: most common default. Good for normal-ish return distributions.

  • Sortino Ratio: better for asymmetric / fat-tailed returns (trend-followers).

  • Calmar Ratio: rewards smooth equity curves; good if you care about drawdown experience.

Start with Sharpe unless you have a specific reason.

My grid search is too slow.

Two paths:

  1. Reduce grid size — many strategies have one or two truly important parameters; ranges of 4–6 values per param are usually enough. Don’t sweep everything at fine resolution.

  2. Use vectorbt instead — for thousands of param combinations, QTrade’s event loop is the wrong tool. See COMPARISON.md.

Multi-asset

How do I align data from different sources?

QTrade requires every asset’s DataFrame to share an identical DatetimeIndex. Use a pandas join on the indexes before passing in:

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

For data with gaps (corporate actions, illiquid days) you might prefer union + forward-fill — but be careful: forward-filled bars give the impression of liquidity that wasn’t there.

Can I have different commission per asset?

Not currently. Commission is a single instance shared across the broker. To approximate per-asset cost, wrap your strategy logic to add asset-specific slippage manually before placing orders.

Why does self.position raise in multi-asset mode?

Because there’s no single Position to return — you have one per asset. Use self.positions["AAPL"] (dict access) instead. Same for self.dataself.data_by_asset["AAPL"].

Reinforcement Learning

My agent learns to do nothing (reward = 0).

The default RewardScheme only rewards realized PnL on bars where a trade closed. If your agent never closes trades, reward stays 0 and the gradient signal is too sparse.

Try one of:

  • An equity-based reward that fires every bar (see Customizing Trading Environment).

  • An action scheme that forces flat at episode end (so trades close for sure).

  • A higher episode count — sparse reward + long episodes is hard.

What does info contain in env.step() returns?

Per-step dict from TradingEnv.step():

Key

Meaning

equity

broker.equity

unrealized_pnl

broker.unrealized_pnl

cumulative_return

broker.cumulative_returns (multiplicative since start)

position

broker.position.size (signed)

total_trades

len(broker.closed_trades)

trades_profit

Sum of all closed trade profits

avg_trade_duration

Mean exit_index − entry_index in bars

is_success

trades_profit > 0

is_success is intended for SB3’s success-rate logging in EvalCallback.

random_start=True errors with a cryptic message.

Fixed in v0.3.0 — now raises ValueError if len(data) <= window_size + max_steps. The default max_steps=3000 needs at least ~3000 bars of data; lower it for shorter datasets.

Misc

How do I save the backtest result for later?

There’s no built-in serializer, but the broker holds everything. Pickle works as a quick-and-dirty save:

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

For something less brittle (cross-version safe), pull what you need into DataFrames first: bt.broker.equity_history, bt.get_trade_history(), calculate_stats(bt.broker).

Where do I report bugs?

https://github.com/gguan/qtrade/issues. Minimal repro + the QTrade version (qtrade-lib --version doesn’t exist; use pip show qtrade-lib) helps a lot.