Getting Started¶
This guide will help you get started with backtesting trading strategies using QTrade.
Strategy¶
Let’s create a simple SMA-crossover strategy. Custom strategies inherit from qtrade.Strategy and implement prepare() (one-time setup) and on_bar_close() (called on every bar):
from qtrade.backtest import Strategy
class SMAStrategy(Strategy):
def prepare(self):
# Indicators are computed up-front so on_bar_close can read them cheaply.
# self._data is dict[str, DataFrame]; for single-asset there's only
# one entry — iterate to support both single- and multi-asset.
for df in self._data.values():
df['SMA3'] = df['Close'].rolling(3).mean()
df['SMA10'] = df['Close'].rolling(10).mean()
def on_bar_close(self):
# self.data is the slice up to the current bar (no look-ahead).
sma3 = self.data['SMA3']
sma10 = self.data['SMA10']
# Golden cross → go long
if sma3.iloc[-2] < sma10.iloc[-2] and sma3.iloc[-1] > sma10.iloc[-1]:
self.buy()
# Death cross → flatten
elif sma3.iloc[-2] > sma10.iloc[-2] and sma3.iloc[-1] < sma10.iloc[-1]:
self.close()
Data¶
QTrade doesn’t ship data loaders — bring your own OHLCV pandas.DataFrame
with Open, High, Low, Close (plus optional Volume) columns and a
DatetimeIndex. Column names are case-insensitive (open, Open, etc.).
In this guide, we’ll use yfinance:
$ pip install yfinance
import yfinance as yf
# Download gold data with daily intervals
data = yf.download(
"GC=F",
start="2023-01-01",
end="2024-01-01",
interval="1d",
multi_level_index=False,
)
Indicators can be added in the strategy’s prepare() (as shown above) or
on the DataFrame before passing it to Backtest. Indicators added to the
input DataFrame are visible to the strategy via self.data.
Backtest¶
Now let’s backtest our stratgy on prepared data.
from qtrade.backtest import Backtest
bt = Backtest(
data=data,
strategy_class=SMAStrategy,
cash=10000,
)
bt.run()
# Show backtest results
bt.show_stats()
Start : 2023-01-03 00:00:00
End : 2023-12-29 00:00:00
Duration : 360 days 00:00:00
Start Value : 5000.0
End Value : 5504.3994140625
Total Return [%] : 10.08798828125
Total Commission Cost[%] : 0
Buy & Hold Return [%] : 12.10523221626531
Return (Ann.) [%] : 18.074280342264217
Volatility (Ann.) [%] : 7.44
Max Drawdown [%] : -3.8180767955781354
Max Drawdown Duration : 186 days 00:00:00
Total Trades : 14
Win Rate [%] : 42.857142857142854
Best Trade [%] : 239.0
Worst Trade [%] : -58.0
Avg Winning Trade [%] : 126.69986979166667
Avg Losing Trade [%] : -31.9749755859375
Avg Winning Trade Duration : 21 days 00:00:00
Avg Losing Trade Duration : 4 days 15:00:00
Profit Factor : 2.9718522251363866
Expectancy : 36.028529575892854
Sharpe Ratio : 2.271145457445316
Sortino Ratio : 2.6654676881799224
Calmar Ratio : 4.733870299098423
Omega Ratio : 1.7873724100138564
Plot¶
Qtrade uses Bokeh to plot result charts. You can generate a plot of your backtest results with the following command:
bt.plot()
Trades¶
You can also check all trades by using the following commands:
trade_details = bt.get_trade_history()
print(trade_details)
Asset Type Size Entry Price Exit Price Entry Time Exit Date Profit Tag Exit Reason Duration
0 default Long 2.0 1833.500000 1812.699951 2023-03-02 2023-03-08 -41.600098 None signal 6 days
1 default Long 2.0 1862.000000 2007.400024 2023-03-10 2023-04-18 290.800049 None signal 39 days
... (rows omitted)
The Asset column is "default" for single-asset backtests and the
asset symbol when running on a portfolio (see Multi-asset / portfolio backtests).
Optimizing parameters (and avoiding overfitting)¶
Backtest.optimize runs a grid search across the parameter space:
best_params, best_stats, all_results = bt.optimize(
n1=[3, 5, 10],
n2=[10, 20, 30],
maximize='Sharpe Ratio',
constraint=lambda p: p['n1'] < p['n2'],
)
The catch: those “best” params are picked on the same data they’re
evaluated on. They will look great in the backtest and frequently
disappoint live. Use walk_forward_optimize instead to get an
out-of-sample estimate:
result = bt.walk_forward_optimize(
train_window=120, # bars used for parameter selection per window
test_window=30, # bars of out-of-sample evaluation that follow
maximize='Sharpe Ratio',
n1=[3, 5, 10],
n2=[10, 20, 30],
constraint=lambda p: p['n1'] < p['n2'],
)
print(result['summary'])
# {'n_windows': 7, 'mean_oos_return': 0.84, 'hit_rate': 0.57, ...}
Each window picks its own best params on the train slice and is
evaluated on the immediately following test slice; summary reports
aggregate out-of-sample stats and windows has per-window details.