QQ 301, 5 min read
How a backtest runs here
1.1 The strategy contract
A strategy is an ordinary Python file. There is no framework, no base class and nothing to import from the terminal. The engine executes the file with three names already defined and reads one name back when it finishes.
Names the engine puts in scope:
| Name | What it is |
|---|---|
df | The candles as a pandas DataFrame with columns ts (epoch seconds), open, high, low, close, volume. |
params | A dict of overrides, empty on a normal run. Read tunables from it with a default, fast = params.get("fast", 9). Walk-forward and the parameter grid sweep values through this dict without editing the file. |
data | A dict of the other datasets in the library, keyed by name. Each file is read from disk only when the script first asks for it, then cached for the run. |
Names the engine reads back:
| Name | Required | What it is |
|---|---|---|
trades | yes | A list of dicts, one per trade (a DataFrame of trades is also accepted and converted to dicts). |
plots | no | A dict of name to series, drawn under the results. One number per bar, or explicit [ts, value] pairs. At most 8 panes; long series are thinned to 20,000 points. |
Everything else the script defines is ignored. A script that finishes without
a trades list is an error.
1.2 Every key a trade may carry
A trade needs an entry, an exit and where in time each happened. Several spellings are accepted for each field.
| Field | Accepted keys | Rule |
|---|---|---|
| Entry bar | entry_i, entry_bar, entry_index | Whole number, 0 <= i < number of bars. A fractional, NaN or boolean index is an error. |
| Entry time | entry_ts, entry_time | Epoch seconds (a value above 1e11 is read as milliseconds). Resolves to the last bar that started at or before that time. A time before the first bar is an error. Used only when no bar index is given. |
| Exit bar | exit_i, exit_bar, exit_index | As entry. Must not be before the entry bar. |
| Exit time | exit_ts, exit_time | As entry time. A time after the last bar resolves to the last bar. |
| Direction | dir, direction, side | "long" or "short". Also accepted: buy, l, 1 (long) and sell, s, -1 (short). Default: long. |
| Entry price | entry, entry_price | Default: the open of the entry bar. Must be finite and above zero. |
| Exit price | exit, exit_price | Default: the open of the exit bar. |
| Size | qty, size | Absolute quantity in units of the instrument, finite and >= 0. Default: the whole free cash balance at entry (see 2.8). A short is "dir": "short", never a negative quantity. |
Minimal forms that work:
{"entry_i": 10, "exit_i": 25, "dir": "long"} # bar indexes
{"entry_ts": 1700000000, "exit_ts": 1700086400} # epoch seconds
{"entry": 101.5, "exit": 104.0, "entry_i": 10, "exit_i": 25}
After the run, trades are sorted by entry bar, then exit bar, so the account compounds in the order things happened. Overlapping trades are allowed. A trade may enter and exit on the same bar.
1.3 Bar indexing, and why fills default to the next bar's open
Bars are numbered from 0. A trade that names only its bars is filled at the
open of those bars. This is the reason the house pattern is "decide on
bar i, fill on bar i + 1":
for i in range(slow + 1, len(df) - 1):
if entry_i is None and up[i]:
entry_i = i + 1 # decide at i, fill at i+1's open
At the close of bar i you know bar i's close. The first price you can
actually trade at is the next bar's open. Filling at bar i's own close (or
its open) uses a price that was decided with information the bar had not yet
produced, which is the most common look-ahead leak in hand-written backtests.
The engine does not enforce this. If your script writes
{"entry_i": i} after reading close[i], the engine will fill at
open[i] and report the result. Two integrity checks in section 4 exist to
catch that: the truncation test and the one-bar delay test.
1.4 The # run: pin
A strategy can name its own dataset in a header comment:
# run: EURUSD 1h
- The line is recognised only in the first 12 lines of the file.
# run-on:is accepted as well, and the match is case-insensitive.- A trailing token that looks like a timeframe (
1m,30min,4h,1d,1w,1mo) is split off as the timeframe; the rest is the symbol, so symbols with spaces keep working. - Every runner resolves its target from this line when no explicit dataset is given, so a file reproduces the same numbers wherever it runs.
The strategy's identity for the holdout counter and the trial registry is a hash of its code with the pin line and trailing whitespace removed, so stamping a pin on a file does not reset either counter.
1.5 Lookbacks in days, not bars
A lookback of 20 bars is 20 hours on hourly gold and 20 trading days on daily stocks. The blank-file template converts horizons from days using the dataset's own bar spacing:
bar_s = float(np.median(np.diff(df["ts"].to_numpy()[:20000])))
bars_per_day = max(1.0, 86400.0 / bar_s)
fast = max(3, int(round(float(params.get("fast_days", 5)) * bars_per_day)))
This is a convention the template and the seven starter strategies follow. The engine does not enforce it.
Note that bars_per_day here is 86400 / median bar spacing, so hourly data
gives 24 whether or not the market trades 24 hours.
1.6 Limits on a run
- A run is stopped after 120 seconds by default (environment variable
QQT_BACKTEST_TIMEOUT, or thetimeoutrun option). In walk-forward and the grid, the limit applies to each rerun, not the whole sweep. - The script runs as plain
execin the engine process. It is your own code on your own machine; there is no sandbox. - A result containing a non-finite number (overflowed price, size or cost) is refused with an error rather than returned.
Source. Contract and injected names: backtest/runner.py:1-61,
backtest/runner.py:539-544. trades read-back and DataFrame conversion:
backtest/runner.py:574-585. Trade keys and validation:
backtest/runner.py:650-743 (direction 682-691, prices 693-713, qty
720-727, sort 742). Bar index and timestamp resolution:
backtest/runner.py:745-797. Plots: backtest/runner.py:587-646. Lazy
data map: backtest/runner.py:87-118. Pin: backtest/contract.py:31-52;
code hash: backtest/contract.py:55-62. Template and days convention:
backtest/runner.py:229-284. Timeout: backtest/runner.py:127,
backtest/runner.py:386-387, backtest/runner.py:555-568. Finite check:
backtest/runner.py:488-520. Guaranteed by tests:
tests/test_known_answers.py:66-203 (buy and hold, short, explicit prices,
index and timestamp entries agreeing, same-bar round trip).