Step-by-step guide to writing automated trading strategies in QuantScript. Learn to build indicators, backtest on historical data, and deploy live trading bots with JavaScript-like syntax.
QuantScript is a domain-specific language for creating, testing, and deploying automated trading strategies. You can start writing and running QuantScript code directly in your browser at quantscript.com/playground — no installation required.
QuantScript code can be executed in two channels, each suited to a different stage of your strategy's lifecycle. Every channel supports two execution semantics — Run1 and Run — and each mode has specific rules about where it can run.
| Channel | Description | When to use |
|---|---|---|
| Playground | Browser-based IDE at quantscript.com/playground with live chart rendering | Development, debugging, visual inspection, backtesting |
| Cloud Deployment | Cloud deployment via the QuantScript platform | Continuous forwardtest/live trading 24/7 |
| Run1 | Run | |
|---|---|---|
| Behaviour | Execute the script once, then exit | Loop: execute, wait for next candle close, execute again |
| Lifecycle | Parse → execute → output → exit | Parse → execute → poll/wait → execute → poll/wait → ... |
| Use case | Backtests, indicator calculations, one-shot trades | Continuous forwardtest or live trading |
| Playground | ✅ "Run" button | ✅ "Run (Live Loop)" button |
| Cloud | ❌ Not applicable | ✅ Continuous deployment |
QuantScript has four execution modes, declared with mode():
| Mode | Declaration | Run1 | Run | Cloud |
|---|---|---|---|---|
| Indicator | mode("indicator") or omitted | ✅ | ❌ | ❌ |
| Backtest | mode("backtest") | ✅ | ❌ | ❌ |
| Forwardtest | mode("forwardtest") | ✅ | ✅ | ✅ |
| Live | mode("live") | ✅ (auto-downgrades to forwardtest) | ✅ (connects to real broker) | ✅ (real broker) |
Cloud deployment is available via the QuantScript platform. Each deployed strategy runs in its own isolated environment with persistent storage.
mode("forwardtest") and mode("live") are accepted for cloud deployment.mode("indicator") and mode("backtest") are rejected at deploy time.Create a file called hello.qs in the Playground:
console.log("Hello, World!");Click "Run" to execute. Output:
── QuantScript Indicator ──
Hello, World!
Execution complete.// 1. Mode declaration (optional — defaults to indicator mode)
mode(mode_type: "indicator", script_name: "My Script Name");
// 2. Global code (executed once)
let myVariable = 10;
// 3. DataFrame iteration (executed per bar)
event.onCandle((candle) => {
// Process each bar
});mode(mode_type: "indicator", script_name: "Math Example");
let x = 10;
const PI = 3.14159;
let sum = x + 5;
let product = x * 2;
let power = 2 ** 8;
console.log("Sum: " + sum);
console.log("Product: " + product);
console.log("Power: " + power);QuantScript includes the standard JavaScript utility namespaces and instance methods with full ES6 parity. If you know JavaScript, you already know these:
Math.* — Math.abs(), Math.max(), Math.sqrt(), Math.round(), etc.JSON.* — JSON.parse(text), JSON.stringify(value)Date.* — Date.now(), Date.parse(str), Date.UTC(y, m, d)"hello".toUpperCase(), .includes(), .split(), .replace(), etc.arr.push(), arr.map(fn), arr.filter(fn), arr.reduce(fn, init), etc.See the API Reference for the full list, or consult any JavaScript reference (e.g. MDN) — the behaviour is identical.
event.onCandle((candle) => {
let openPrice = candle.open;
let highPrice = candle.high;
let lowPrice = candle.low;
let closePrice = candle.close;
let volume = candle.volume;
});event.onCandle((candle) => {
let midpoint = candle.hl2; // (high + low) / 2
let avgThree = candle.hlc3; // (high + low + close) / 3
let avgFour = candle.ohlc4; // (open + high + low + close) / 4
});let currentClose = candle.close;
let previousClose = close[1]; // 1 bar ago
let twoBarsAgo = close[2]; // 2 bars agomode(mode_type: "indicator", script_name: "SMA Indicator");
const SMA_LENGTH = 20;
event.onCandle((candle) => {
let sma = ta.sma(candle.close, SMA_LENGTH);
chart.plot(sma, "SMA", "#FF0000");
});event.onCandle((candle) => {
let fastSMA = ta.sma(candle.close, 10);
let slowSMA = ta.sma(candle.close, 20);
let ema = ta.ema(candle.close, 20);
chart.plot(fastSMA, "Fast SMA", "#00FF00");
chart.plot(slowSMA, "Slow SMA", "#FF0000");
chart.plot(ema, "EMA", "#0000FF");
if (ta.crossover(fastSMA, slowSMA)) {
console.log("BULLISH CROSSOVER");
}
});candles(symbol: "BTCUSD", resolution_type: "1h", bars: 500);
mode(mode_type: "backtest", script_name: "SMA Crossover");
broker(adapter: "sim", config: { initial_capital: 100000 });
event.onCandle((candle) => {
let fastSMA = ta.sma(candle.close, 10);
let slowSMA = ta.sma(candle.close, 20);
if (ta.crossover(fastSMA, slowSMA)) {
trade.buy({ symbol: "BTCUSD", qty: 1 });
}
if (ta.crossunder(fastSMA, slowSMA)) {
trade.closePosition({});
}
});candles(symbol: "BTCUSD", resolution_type: "1h", bars: 500);
mode(mode_type: "backtest", script_name: "Strategy with Exits");
broker(adapter: "sim", config: { initial_capital: 100000 });
event.onCandle((candle) => {
let sma = ta.sma(candle.close, 20);
let crossUp = ta.crossover(candle.close, sma);
if (crossUp && strategy.position_size == 0) {
trade.buy({ symbol: "BTCUSD", qty: 1, stopLoss: 2.0, takeProfit: 4.0 });
}
});Instead of specifying a raw qty, you can use the size object for more intuitive position sizing across different asset classes:
// Trade 0.5 lots of EURUSD
trade.buy({ symbol: "EURUSD", size: { lots: 0.5 } });
// Crypto: spend exactly $500
trade.buy({ symbol: "BTCUSDT", size: { cash: 500 } });
// Risk 2% of account equity
trade.buy({ symbol: "AAPL", size: { equity_percent: 2 } });Available sizing modes: units (direct quantity), lots (standardized lot units), cash (quote-currency amount), equity_percent (% of equity). Exactly one must be set. See the API Reference for details.
Structure each event.onCandle body as two sections: named boolean signals computed fresh from series data on every bar, then a mechanical signal-to-order mapping. No flags, no counters, no cross-frame memory — entry/exit edges come from ta.crossover / ta.crossunder / ta.barssince, which are true only on the bar where the event occurs.
event.onCandle((candle) => {
// Section 1: signals (named booleans over series)
const sma = ta.sma(candle.close, 20);
const longEntry = ta.crossover(candle.close, sma);
const longExit = ta.crossunder(candle.close, sma);
// Section 2: execution (mechanical signal → order mapping)
let longPosition = null;
for (const pos of trade.positions()) {
if (pos.symbol == "BTCUSD" && pos.side == "buy") longPosition = pos;
}
if (longPosition !== null && longExit) {
trade.closePosition({ positionId: longPosition.id });
}
if (longPosition === null && longEntry) {
trade.buy({ symbol: "BTCUSD", qty: 1 });
}
});Avoid setting boolean flags on one bar and checking them on later bars, or counting bars manually — ta.barssince(condition) expresses the same fact as data. See the Declarative Signals example for a complete three-dataset strategy in this style.
Grid and basket strategies often need to query specific subsets of positions and orders. Use filter objects to narrow results, then bulk operations to act on them:
// Ownership tag for this strategy's entities
const posTag = "MyGrid|";
// Filtered introspection: only this strategy's tagged positions
let basket = trade.positions({ tag: posTag });
// Working limit orders only (terminal statuses excluded by default)
let ladder = trade.orders({ tag: posTag, type: "limit" });
// Basket aggregation via native array methods
let qty = basket.reduce((s, p) => s + p.qty, 0);
let vwap = basket.reduce((s, p) => s + p.avgEntryPrice * p.qty, 0) / qty;
// Bulk operations: cancel all ladder limits, then flatten basket
if (shouldExit) {
trade.cancelAll(ladder);
trade.closeAll(basket, { reason: "trend flatten", tags: ["exit"] });
}Filter fields: tag (comment-prefix match), symbol, side. Orders additionally: type ("limit", "stop", etc.), includeTerminal (boolean). All fields are ANDed; unset fields match everything. Filtered orders exclude terminal statuses by default.
Bulk operations: trade.cancelAll(orders) returns { cancelled, cancelFailed }; trade.closeAll(positions, meta?) returns { closed, closeFailed }. Both are best-effort: individual failures are logged and counted, but the call succeeds. HISTORICAL_BAR rejections (replay artifacts) are skipped silently.
See the API Reference for full signatures.
QuantScript supports analyzing multiple symbols and resolutions in a single script using data.loadCandles() with a callback. This evaluates indicators on a secondary resolution's native bars, then aligns results back to your primary chart — like Pine Script's request.security().
mode(mode_type: "indicator", script_name: "Multi-TF");
candles(symbol: "BTCUSD", resolution_type: "1h", bars: 300, adapter: "demotrader.net");
// ETH 4h SMA computed on native 4h bars
var ethSma = data.loadCandles(adapter: "demotrader.net", symbol: "ETHUSD", resolution_type: "4h", callback: (c) => ta.sma(c.close, 50));
event.onCandle((candle) => {
chart.plot(candle.close, "BTC Close");
chart.plot(ethSma, "ETH 4h SMA", "#FF9800");
});Use a higher timeframe for trend filtering and a lower timeframe for entry timing:
mode(mode_type: "forwardtest", script_name: "Cross-TF Strategy");
candles(symbol: "ETHUSD", resolution_type: "15m", bars: 200, adapter: "demotrader.net");
broker(adapter: "sim", config: { initial_capital: 10000 });
// Daily SMA as trend filter
var dailySma = data.loadCandles(adapter: "demotrader.net", symbol: "ETHUSD", resolution_type: "1D", callback: (c) => ta.sma(c.close, 50));
event.onCandle((candle) => {
const rsi = ta.rsi(candle.close, 14);
if (dailySma !== null && candle.close > dailySma && rsi !== null && rsi < 35) {
trade.buy({ symbol: "ETHUSD", qty: 0.1 });
}
});Results support [n] historical lookback and are forward-filled to align with your primary chart.
Secondary values align using closed-bar visibility — a secondary bar's values become visible only once that bar has fully closed — and are forward-filled by default. Declare how stale a feed may get with fill / fill_limit, and query staleness as data via feed.age (0 on the arrival bar, incrementing while carried, null when invisible or expired):
// Daily regime feed: carry each value at most 3 primary bars past arrival
var xau = data.loadCandles({ adapter: "demotrader.net", symbol: "XAUUSD", resolution_type: "1D", fill_limit: 3 });
event.onCandle((candle) => {
const fresh = xau.age !== null; // visible AND within the staleness cap
const calm = fresh && xau.close < 3000;
});Raw feeds (loaded without a callback) can also be re-viewed under a different contract via data.align(feed, { fill_limit: 3 }), and current staleness queried with data.age(feed). See the API Reference for details.
For a full walkthrough with two complete multi-dataset strategies (ETHUSD + XAUUSD regime, and XAUUSD + VIX volatility gating), see the Multi-Dataset Analysis feature page.
event.onCandle((candle) => { ... }).close directly. QuantScript uses candle.close inside the event handler.close[1] syntax."#0000FF".ta. for technical analysis.For more on the design philosophy, see the About QuantScript page.
When running multiple strategies on a single broker account, each strategy needs its own isolated view of capital. Virtual Trading Accounts solve this by giving each strategy an independent balance, equity, and margin — even though they all share the same real broker account.
Set allocated_capital in your broker() config to opt in:
broker(adapter: "demotrader.net", email: "...", password: "...", config: {
allocated_capital: 10000
});This creates a VirtualAccount for the strategy, seeded with $10,000. From there:
When a virtual account is active, all risk() parameters are enforced against the virtual account — not the broker-wide account:
min_balance and min_equity check against virtual valuesmax_position_pct and max_risk_pct_per_trade compute percentages from virtual equitymax_margin_pct uses virtual margin and balanceThis means one strategy hitting its risk limit does not block the other strategies sharing the same broker account.
Two strategies sharing one $50,000 account, each with independent capital and risk limits:
// Strategy A: $30,000 allocated for BTC momentum
candles(symbol: "BTCUSD", resolution_type: "1h", bars: 200);
mode(mode_type: "live", script_name: "Momentum");
broker(adapter: "demotrader.net", email: "...", password: "...", config: {
allocated_capital: 30000
});
risk(min_equity: 25000, max_position_pct: 20);// Strategy B: $20,000 allocated for ETH mean reversion
candles(symbol: "ETHUSD", resolution_type: "15m", bars: 200);
mode(mode_type: "live", script_name: "Mean Reversion");
broker(adapter: "demotrader.net", email: "...", password: "...", config: {
allocated_capital: 20000
});
risk(min_equity: 17000, max_position_pct: 15);Each strategy’s performance (equity curve, drawdown, ROI) is measured against its own allocated_capital, giving you accurate per-strategy metrics.
Virtual accounts are automatically rebuilt from your strategy’s historical fills and open positions on cold start (e.g., after a server restart). This ensures balance and equity tracking continues seamlessly without manual intervention.
When your strategy runs in forwardtest or live mode, the runtime keeps an incomplete (forming) candle that updates with every incoming tick. This gives you access to the freshest market price between confirmed bar closes.
Confirmed candle (candle.close) | Forming candle (trade.last()) | |
|---|---|---|
| Mutability | Immutable — never changes after bar close | Updates with every tick until the bar closes |
| Use for | Indicators, signal logic, trend detection | Entry checks, SL/TP placement, position sizing |
| Repainting risk | None | Value changes intra-bar (by design) |
Rule of thumb: Compute your signals from confirmed data, then execute using the live price.
trade.last(symbol?) — Freshest last-traded price (forming bar close, or latest quote)trade.formingCandle(symbol?) — Full forming candle object (open, high, low, close, volume, incomplete)trade.bid(symbol?) — Current best bid from the quote streamtrade.ask(symbol?) — Current best ask from the quote streamSee the API Reference for full details on each function.
In backtest mode no forming bar exists, so trade.last() returns 0. Always include a fallback so the same script works everywhere:
let livePrice = trade.last();
if (livePrice == 0) { livePrice = candle.close; }Signal from confirmed data, execution at live price:
event.onCandle((candle) => {
let sma = ta.sma(candle.close, 20); // signal: confirmed data
if (candle.isLive) {
let livePrice = trade.last();
if (livePrice == 0) { livePrice = candle.close; }
if (candle.close > sma) {
let sl = livePrice * 0.995; // SL relative to live price
trade.buy({ symbol: "ETHUSD", size: { equity_percent: 5 }, stopLoss: sl });
}
}
});Notice: the SMA is computed from candle.close (confirmed), but the stop-loss is anchored to livePrice (forming). This gives you stable signals with accurate execution.