How Backtesting Works#

A QuantScript backtest replays your strategy bar-by-bar over historical candles using the SimEngine — a simulated order-matching and position-tracking engine. There is no broker connection; the engine fills your orders against the price series, tracks cash, positions, and equity, and produces a full performance and risk report at the end.

Backtests run in Run1 semantics: the script executes once over the whole series, then exits. The same script can later be promoted to forwardtest or live mode without changing its trading logic (see the Guide).

Every run produces two outputs:

  • A human-readable console report — progress, fills, and a performance & risk summary.
  • A machine-readable run journal — a JSONL file (one event per line) that is the canonical record of the run, plus an optional --summary-json block on stdout.

Declaring a Backtest#

A backtest needs three declarations: the mode, the simulated broker, and the data to replay.

QuantScript
mode(mode_type: "backtest", script_name: "My Backtest");

broker(adapter: "sim", config: {
  initial_capital: 10000,
  commission: 0.05,
  commission_type: "percent"
});

candles(symbol: "XAUUSD", resolution_type: "15m", bars: 2000, adapter: "twelvedata");

event.onCandle((candle) => {
  // ... strategy logic, trade.buy / trade.sell / trade.closePosition ...
});

broker(adapter: "sim") selects the SimEngine. Its config object controls capital, costs, fill behaviour, and margin — detailed in the next section.

Sim Engine Config#

The config object passed to broker(adapter: "sim") drives how realistic the simulation is. All keys are optional; sensible defaults apply.

KeyTypeMeaning
initial_capitalnumberStarting account balance (default 10000)
commissionnumberCommission amount; interpreted with commission_type
commission_type"percent"Treat commission as a percentage of notional
slippagenumberAdverse slippage applied to market-order fills (ticks/points)
spreadnumberSimulated spread; requires fill_model: "bar-touch"
candle_price"bid" | "mid"Which side of the candle fills are marked against
leveragenumberAccount leverage for margin computation
currencystringAccount currency
execution_strategystringPosition model: bar-match-hedging, bar-match-netting, crypto-spot, crypto-futures, us-equities
fill_model"bar-close" | "bar-touch"How orders are matched within a bar (see Fill & Cost Models)
fee_model"none" | "commission" | "per-lot"Cost model applied per fill
per_lot_feenumberFixed fee per lot when fee_model: "per-lot"
margin_model"simple"Margin computation model
stop_out_levelnumberMargin level (%) that triggers forced liquidation
fill_timeframestringFiner timeframe for intrabar fill simulation (e.g. "1m")
QuantScript
broker(adapter: "sim", config: {
  initial_capital: 10000,
  execution_strategy: "bar-match-hedging",
  fee_model: "none",
  slippage: 0,
  fill_timeframe: "1m"
});

Data Sources#

The candles() declaration's adapter chooses where historical bars come from. Window the data with bars and/or from_timestamp / to_timestamp (Unix seconds).

AdapterSourceNotes
twelvedataTwelve Data REST APIForex, metals, crypto, indices, stocks. Needs TWELVEDATA_API_KEY
demotrader.netDemoTrader broker feedBroker symbols
CSV (--file)Local CSV fileNetwork-free, reproducible runs

For reproducible, network-free backtests, load candles from a local CSV with the CLI --file flag (and --fill-file for the intrabar series):

bash
quantscript run1 strategy.qs --file data/XAUUSD_15m.csv --fill-file data/XAUUSD_1m.csv

CSV Backtest Workflow#

Loading bars from CSV gives you fast, deterministic, network-free backtests. Because the price path is frozen in a file, every run is byte-for-byte reproducible — ideal for regression testing a strategy change, or running parameter sweeps without re-fetching (and without API rate limits).

1. Point the Script at a File

Set adapter: "csv" and a file: path in candles(). Relative paths resolve against the script directory; absolute paths pass through. For intrabar fills, add fill_file: (see Granular Fills).

QuantScript
candles(
  adapter: "csv",
  file: "./data/XAUUSD_15m.csv",
  fill_file: "./data/XAUUSD_1m.csv"
);

The CLI flags --file and --fill-file override these (resolved against the current working directory), so one script can be reused across many data files.

2. The CSV Format

The parser is pandas-friendly: optional # metadata comments, a header row with flexible column names, then OHLCV rows.

csv
# symbol: XAUUSD
# timeframe: 15m
time,open,high,low,close,volume
1767225600,4310.5,4315.2,4308.1,4313.9,1240
1767226500,4313.9,4318.0,4311.4,4316.2,980
  • Metadata comments — leading # key: value lines: symbol, timeframe, display_name, asset_class, currency, exchange, source. The timeframe matters for intrabar matching (below).
  • Column aliases (case-insensitive): time/timestamp/date/datetime, open/o, high/h, low/l, close/c, volume/vol/v. Volume is optional.
  • Timestamps — Unix seconds (10 digits), Unix milliseconds (12–13 digits, auto-divided), ISO 8601 (2026-01-15T09:30:00Z), or date-only (2026-01-15 = midnight UTC).

If your columns have non-standard names, the programmatic adapter accepts a columnMap; the CLI relies on alias detection, so rename headers to a recognized alias if detection fails.

3. Windowing & Parameter Sweeps

Keep one large CSV per instrument/timeframe and slice it per run with from_timestamp / to_timestamp (Unix seconds) and bars. Filtering applies from/to first, then trims to the last bars candles — so the same file serves many windows:

bash
# January window
quantscript run1 strategy.qs --from 1767225600 --to 1769904000
# February window, same file
quantscript run1 strategy.qs --from 1769904000 --to 1772323200

4. Reproducible Comparisons

Because the data is fixed, two strategy variants run over the same CSV see an identical price path — making A/B comparisons apples-to-apples. (With a live API feed, each fetch can return a slightly different trailing window, muddying comparisons.) Pair CSV runs with --summary-json to diff metrics programmatically.

Intrabar with CSV needs both files. If broker(config: { fill_timeframe: "1m" }) is declared, a CSV backtest requires a matching fill_file — a missing file, or a fill file whose timeframe doesn't match fill_timeframe, is a fatal error. See the next section.

Granular Fills (Intrabar Simulation)#

The Concept

By default the engine evaluates orders against the primary bar (say 15m). A single 15m candle is a coarse view of reality: it tells you the open, high, low, and close, but not the path price took between them. That path determines whether a limit order filled, where a stop was hit, and how deep an open position went underwater mid-bar.

Setting fill_timeframe: "1m" gives the engine a finer-grained price path. For each primary bar, it replays the constituent 1-minute bars and evaluates pending orders, bracket exits, and margin stop-outs on them — while indicators and the equity curve stay on the primary timeframe. Your strategy code still runs once per primary bar, exactly as before; only the fill resolution changes.

This matters most for:

  • Pending limit/stop orders — a coarse bar can't tell you whether a limit $5 away was ever touched; the sub-bars can.
  • Bracket SL/TP — resolved at the sub-bar that actually hit the level, not approximated from the primary bar's range.
  • Margin / liquidation risk — a basket can dip below its stop-out level and recover within one primary bar. Intrabar simulation catches the stop-out; coarse fills miss it entirely.
  • Honest floating drawdown — the worst intra-bar excursion is visible, so the floating max drawdown reflects reality rather than the primary-bar sampling.

How It Works

The engine groups the fill bars into each primary bar's time window [bar.time, bar.time + primaryTimeframe) and processes the sub-bars in order. A primary bar with no sub-bar coverage (a feed gap) falls back to coarse bar-level fills for that bar, and the engine logs one warning naming how many bars were affected:

text
[WARN] Intrabar fill data: 12/2161 main bars have no 1m coverage — those bars fall back to coarse bar-level fills

Requirements

  • Fill model must be bar-touch. Intrabar simulation is meaningless with bar-close fills; the engine ignores fill_timeframe and warns if the model isn't bar-touch.
  • The fill series must match fill_timeframe. For CSV, the fill file's timeframe (from its # timeframe: comment or {SYMBOL}_{tf}.csv filename) must equal the declared fill_timeframe, or the run fails fast.
  • Indicators stay on the primary timeframe. Sub-bars drive fills and margin only — your signal logic never sees them, so there's no look-ahead or repainting.

Getting the Fill Data

Primary sourceFill series
API (twelvedata, demotrader.net)Fetched automatically in ≤3000-bar chunks over the primary window when fill_timeframe is set
CSV (--file)Supply --fill-file / fill_file:; it is sliced to the primary window automatically
QuantScript
broker(adapter: "sim", config: {
  initial_capital: 10000,
  execution_strategy: "bar-match-hedging",
  fill_model: "bar-touch",      // required for intrabar
  fill_timeframe: "1m",          // evaluate fills on 1m sub-bars
  margin_model: "simple",
  stop_out_level: 50            // per-sub-bar stop-out checks
});

Cost of granularity: finer fills need more data (a month of 15m bars is ~2,000 primary bars but ~30,000 one-minute fill bars) and more matching work per bar. Use intrabar when fill path or margin risk matters; plain bar-close on the primary bars is fine for simple market-order strategies.

Fill & Cost Models#

Fill Model

ModelBehaviour
bar-closeMarket orders fill at the bar close. Simple and fast; optimistic about execution.
bar-touchOrders fill when price touches their level within the bar's high–low range. Enables limit/stop fills, spread simulation, and intrabar (fill_timeframe) evaluation.

Cost Model

  • fee_model: "none" — zero-cost (useful for isolating strategy edge; flatters results).
  • commission + commission_type: "percent" — percentage of notional per fill.
  • fee_model: "per-lot" + per_lot_fee — fixed fee per lot.
  • slippage — deterministic adverse price offset on market orders.
  • spread — simulated bid/ask spread (requires bar-touch).

No model captures every real-world cost. Commission, swap, spread widening, and requotes are only partially modelled — treat backtest PnL as an upper bound and stress-test the cost assumptions.

The run1 CLI#

The run1 command executes a script once and prints the report plus the journal location:

bash
quantscript run1 strategy.qs [flags]
FlagEffect
--symbol <ticker>Override the declared symbol
--timeframe <tf>Override the declared timeframe
--bars <count>Override the bar count
--adapter <name>Override the data adapter
--from / --toWindow start / end (Unix seconds)
--file <csv>Load primary candles from a CSV file
--fill-file <csv>Load the intrabar fill series from a CSV file
--journal-dir <dir>Directory for JSONL run files (default .qs/runs)
--journal-level <level>Minimum level captured in the journal (default info; debug adds order queue/cancel and per-bar events)
--no-journalDisable the JSONL file entirely
--summary-jsonPrint the final metrics as one JSON line after === SUMMARY JSON ===
--log-level <level>Console verbosity only — independent of the journal level (default info)

After the report, the CLI prints one line pointing at the journal:

text
Journal: .qs/runs/2026-07-31_201351_XAU-USD_15min_backtest.jsonl (1072 events)

The Run Journal (JSONL)#

Every run writes a JSONL file — one JSON event per line, in timeline order — to .qs/runs/<date>_<time>_<symbol>_<timeframe>_<mode>.jsonl. This is the canonical machine-readable record of the run; the console is for humans. Scripts and AI agents should parse the journal (and the --summary-json block), not scrape stdout.

Each line has a standard envelope:

json
{ "seq": 805, "ts": "...", "bar_time": 1785000000, "run_id": "...", "mode": "backtest",
  "level": "NOTICE", "type": "order.filled", "source": "engine",
  "symbol": "XAU/USD", "timeframe": "15min", "data": { ... } }

Event Vocabulary

TypeLevelMeaning
run.startedINFORun begins; config and manifest
run.summaryINFOMachine-readable final results: full metrics, capital, bar count, ISO times, inputs
run.endedINFOEvent / error / closed-trade counts and execution time
bar.closedDEBUGBar processed (captured at --journal-level debug)
trade.requested / trade.rejectedINFOStrategy trade intent and rejection
order.submittedDEBUGOrder queued by the engine
order.filledNOTICEEntry order fill
order.cancelledDEBUGOrder cancellation
trade.filledNOTICEPosition close fill with realized pnl
position.closedNOTICEPosition close with pnl and a reason (e.g. liquidation:*, end_of_data_auto_close)
broker.warningNOTICEEngine warnings, including the end-of-data auto-close notice

Trade and order payloads use standardized field names: orderId, ticket, side, qty, price, pnl, barIndex.

Performance & Risk Report#

The console report has three blocks. Backtest Results covers return and trade statistics:

  • Net Profit ($ and %), Gross Profit, Gross Loss, Profit Factor
  • Total / Winning / Losing Trades, Win Rate
  • Avg Win, Avg Loss, Largest Win, Largest Loss, Expectancy

Risk Metrics covers the risk parameters used to evaluate a strategy:

MetricMeaning
Max Drawdown (floating)Worst peak-to-trough on mark-to-market equity — the true risk measure (see below)
Max Drawdown (realized)Worst peak-to-trough on realized equity (closed PnL only)
Max DD DurationLongest time (bars) spent below a floating-equity peak before recovering
Sharpe RatioRisk-adjusted return from per-trade returns (√252-scaled)
Sortino RatioLike Sharpe, but only downside (losing) volatility is penalized
Calmar / Recovery FactorNet profit ÷ floating max drawdown

Account shows Initial Capital and Final Equity. The same metrics object is embedded in the journal's run.summary event and in the --summary-json stdout block for programmatic use.

Floating vs Realized Drawdown#

This distinction is the single most important thing to understand when reading a backtest report.

  • Realized drawdown samples equity only when positions close. An open basket sitting −$10,000 underwater for 200 bars contributes nothing until it closes.
  • Floating drawdown samples mark-to-market equity (cash + unrealized PnL) on every bar, so it captures the true worst peak-to-trough excursion — including losses that were never realized.

For trend strategies that close positions regularly, the two figures are close. For basket / martingale / grid strategies they diverge dramatically: the realized figure can stay near zero while the floating figure shows the account was effectively insolvent. Always read the floating figure as the risk measure.

A floating max drawdown approaching −100% of equity means the account was margin-called. Percentages are measured against peak equity, so a run that grew equity first can show a flattering percentage — read the dollar figure alongside it.

Margin & End-of-Data#

Margin Enforcement

With a margin_model and stop_out_level configured, the engine liquidates positions when margin falls below the stop-out level — per bar, or per sub-bar under intrabar simulation. Each liquidation emits a position.closed event with reason: "liquidation:*". Without margin enforcement, an account can trade while technically insolvent and produce meaningless past-−100% returns.

End-of-Data Auto-Close

Any positions still open at the final bar are auto-closed at the last close so the run can report final metrics. This converts floating PnL into realized PnL and is flagged by a broker.warning event plus per-trade position.closed events with reason: "end_of_data_auto_close". The last period's realized statistics are therefore partly an artifact of this liquidation — check the journal to see which closes were forced.

Reading Caveats#

  1. Realized drawdown is not a risk measure for basket strategies — use the floating figure.
  2. Sharpe can flatter a strategy that holds a deep underwater basket and later exits at a good price. Read it beside floating drawdown and Calmar, never alone.
  3. Path dependence. A result on one instrument, one feed, and one window does not generalise. Martingale results in particular are dominated by whether a single trend exceeded the basket's defence range.
  4. Cost models are optimistic. Commission, swap, spread widening, and requotes are only partially modelled.
  5. End-of-data auto-close distorts the final period's realized statistics.

Next Steps

  1. Write a strategy in the Guide, then set mode("backtest").
  2. See a full backtest example (SMA Trend Pullback on XAUUSD).
  3. Run it with quantscript run1 strategy.qs --summary-json and inspect the JSONL journal.
  4. Consult the Language Reference for the full trade.* and risk.* API.