How QuantScript backtests work: the SimEngine order-matching engine, fill and cost models, intrabar simulation, the run1 CLI, the JSONL run journal, and the performance & risk report (including floating max drawdown).
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:
--summary-json block on stdout.A backtest needs three declarations: the mode, the simulated broker, and the data to replay.
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.
The config object passed to broker(adapter: "sim") drives how realistic the simulation is. All keys are optional; sensible defaults apply.
| Key | Type | Meaning |
|---|---|---|
initial_capital | number | Starting account balance (default 10000) |
commission | number | Commission amount; interpreted with commission_type |
commission_type | "percent" | Treat commission as a percentage of notional |
slippage | number | Adverse slippage applied to market-order fills (ticks/points) |
spread | number | Simulated spread; requires fill_model: "bar-touch" |
candle_price | "bid" | "mid" | Which side of the candle fills are marked against |
leverage | number | Account leverage for margin computation |
currency | string | Account currency |
execution_strategy | string | Position 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_fee | number | Fixed fee per lot when fee_model: "per-lot" |
margin_model | "simple" | Margin computation model |
stop_out_level | number | Margin level (%) that triggers forced liquidation |
fill_timeframe | string | Finer timeframe for intrabar fill simulation (e.g. "1m") |
broker(adapter: "sim", config: {
initial_capital: 10000,
execution_strategy: "bar-match-hedging",
fee_model: "none",
slippage: 0,
fill_timeframe: "1m"
});The candles() declaration's adapter chooses where historical bars come from. Window the data with bars and/or from_timestamp / to_timestamp (Unix seconds).
| Adapter | Source | Notes |
|---|---|---|
twelvedata | Twelve Data REST API | Forex, metals, crypto, indices, stocks. Needs TWELVEDATA_API_KEY |
demotrader.net | DemoTrader broker feed | Broker symbols |
CSV (--file) | Local CSV file | Network-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):
quantscript run1 strategy.qs --file data/XAUUSD_15m.csv --fill-file data/XAUUSD_1m.csvLoading 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).
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).
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.
The parser is pandas-friendly: optional # metadata comments, a header row with flexible column names, then OHLCV rows.
# 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# key: value lines: symbol, timeframe, display_name, asset_class, currency, exchange, source. The timeframe matters for intrabar matching (below).time/timestamp/date/datetime, open/o, high/h, low/l, close/c, volume/vol/v. Volume is optional.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.
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:
# January window
quantscript run1 strategy.qs --from 1767225600 --to 1769904000
# February window, same file
quantscript run1 strategy.qs --from 1769904000 --to 1772323200Because 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.
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:
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:
[WARN] Intrabar fill data: 12/2161 main bars have no 1m coverage — those bars fall back to coarse bar-level fillsbar-touch. Intrabar simulation is meaningless with bar-close fills; the engine ignores fill_timeframe and warns if the model isn't bar-touch.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.| Primary source | Fill 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 |
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.
| Model | Behaviour |
|---|---|
bar-close | Market orders fill at the bar close. Simple and fast; optimistic about execution. |
bar-touch | Orders fill when price touches their level within the bar's high–low range. Enables limit/stop fills, spread simulation, and intrabar (fill_timeframe) evaluation. |
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 command executes a script once and prints the report plus the journal location:
quantscript run1 strategy.qs [flags]| Flag | Effect |
|---|---|
--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 / --to | Window 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-journal | Disable the JSONL file entirely |
--summary-json | Print 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:
Journal: .qs/runs/2026-07-31_201351_XAU-USD_15min_backtest.jsonl (1072 events)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:
{ "seq": 805, "ts": "...", "bar_time": 1785000000, "run_id": "...", "mode": "backtest",
"level": "NOTICE", "type": "order.filled", "source": "engine",
"symbol": "XAU/USD", "timeframe": "15min", "data": { ... } }| Type | Level | Meaning |
|---|---|---|
run.started | INFO | Run begins; config and manifest |
run.summary | INFO | Machine-readable final results: full metrics, capital, bar count, ISO times, inputs |
run.ended | INFO | Event / error / closed-trade counts and execution time |
bar.closed | DEBUG | Bar processed (captured at --journal-level debug) |
trade.requested / trade.rejected | INFO | Strategy trade intent and rejection |
order.submitted | DEBUG | Order queued by the engine |
order.filled | NOTICE | Entry order fill |
order.cancelled | DEBUG | Order cancellation |
trade.filled | NOTICE | Position close fill with realized pnl |
position.closed | NOTICE | Position close with pnl and a reason (e.g. liquidation:*, end_of_data_auto_close) |
broker.warning | NOTICE | Engine warnings, including the end-of-data auto-close notice |
Trade and order payloads use standardized field names: orderId, ticket, side, qty, price, pnl, barIndex.
The console report has three blocks. Backtest Results covers return and trade statistics:
Risk Metrics covers the risk parameters used to evaluate a strategy:
| Metric | Meaning |
|---|---|
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 Duration | Longest time (bars) spent below a floating-equity peak before recovering |
Sharpe Ratio | Risk-adjusted return from per-trade returns (√252-scaled) |
Sortino Ratio | Like Sharpe, but only downside (losing) volatility is penalized |
Calmar / Recovery Factor | Net 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.
This distinction is the single most important thing to understand when reading a backtest report.
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.
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.
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.
mode("backtest").quantscript run1 strategy.qs --summary-json and inspect the JSONL journal.trade.* and risk.* API.