Why Multiple Datasets#

Real trading strategies rarely rely on a single price series. A trend-following strategy might use a higher-timeframe moving average for direction, a lower-timeframe for entry timing, and a cross-symbol volatility index (VIX, DXY) for regime filtering. Each dataset has its own bar calendar, its own alignment rules, and its own staleness characteristics.

QuantScript’s data.loadCandles() loads secondary OHLCV data from any symbol and timeframe, aligns it to your primary chart’s timeline, and exposes it as series you can plot, compute on, and gate your trading logic with. The alignment is closed-bar by default — a secondary bar’s values only become visible once that bar has fully closed — so your strategy never accidentally looks ahead.

  • Same-symbol higher-timeframe — ETHUSD 15m primary + ETHUSD 1h trend filter.
  • Cross-symbol regime — XAUUSD primary + VIX daily volatility gate.
  • Cross-calendar — different symbols with different trading hours, holidays, and bar counts.

All three cases use the same data.loadCandles() API. The only difference is the alignment contract you declare.

Loading Additional Datasets#

Your primary chart is declared with candles(). Every additional dataset is loaded with data.loadCandles(), which has two modes:

Mode 1: Raw feed (returns FeedAccessor)

Without a callback, data.loadCandles() returns a FeedAccessor with .open, .high, .low, .close, .volume, and .age columns. Each column is a series aligned to your primary timeline:

QuantScript
// Load VIX daily data as a raw feed
var vix = data.loadCandles({ adapter: "demotrader.net", symbol: "VIX", resolution_type: "1D" });

event.onCandle((candle) => {
    // vix.close, vix.high, vix.age — all aligned to primary timeline
    const calm = vix.close < 20;
});

Mode 2: Callback (evaluates expression on native bars)

With a callback, the expression is evaluated on the secondary timeframe’s native bars (not forward-filled), then the result is aligned back to your primary chart. This is equivalent to Pine Script’s request.security():

QuantScript
// Compute SMA on native 1h bars, then align to 15m timeline
var htfSma = data.loadCandles({ adapter: "demotrader.net", symbol: "ETHUSD", resolution_type: "1h", callback: (c) => ta.sma(c.close, 50) });

// Object callback: bind multiple named columns at once
var htf = data.loadCandles({ adapter: "demotrader.net", symbol: "ETHUSD", resolution_type: "1h", callback: (c) => ({
    sma: ta.sma(c.close, 50),
    mom: c.close - c.close[1]
}) });
// htf.sma, htf.mom — both aligned independently

Both modes support [n] historical lookback on the aligned result. Callback results are forward-filled by default; raw feeds can be configured with fill, fill_limit, and lookahead options to control staleness explicitly.

Alignment & Staleness#

Every secondary dataset is aligned to your primary chart’s timeline using closed-bar visibility: a secondary bar’s values only become visible once that bar has fully closed. A 1h feed on a 15m chart shows the 09:00 bar’s values from the 09:45 primary bar onward — never the forming bar’s final values at 09:00. This is the backtest-correct default (Pine’s lookahead_off).

By default, values are forward-filled across gaps: the last visible value carries forward until a new secondary bar arrives. You can control staleness with fill_limit — the maximum number of primary bars a value may be carried past its arrival before becoming null:

QuantScript
// Daily VIX feed: carry each value at most 3 primary bars past arrival
var vix = data.loadCandles({ adapter: "demotrader.net", symbol: "VIX", resolution_type: "1D", fill_limit: 3 });

event.onCandle((candle) => {
    // vix.age: 0 on arrival bar, increments while carried, null when expired
    const fresh = vix.age !== null;   // visible AND within the staleness cap
    const calm = fresh && vix.close < 20;
});

The feed.age column tells you how many primary bars have elapsed since the currently visible value first arrived. Use it to gate your logic on data freshness — a stale regime reading can never silently drive entries.

Raw feeds can also be re-viewed under a different alignment contract via data.align(feed, { fill_limit: 3 }), and current staleness queried with data.age(feed). See the API Reference for details.

Example 1: Cross-Symbol Regime Filter#

A gold (XAUUSD) strategy that trades on a 15m chart, uses a 1h trend filter for direction, and gates entries on a daily VIX regime. Three datasets, three alignment contracts:

QuantScript
mode(mode_type: "forwardtest", script_name: "VIX Regime Gating XAUUSD");
candles(symbol: "XAUUSD", resolution_type: "15m", bars: 500, adapter: "demotrader.net");

// Dataset 2: XAUUSD 1h trend (object callback → two aligned columns)
var htf = data.loadCandles({ adapter: "demotrader.net", symbol: "XAUUSD", resolution_type: "1h", callback: (c) => ({
    sma: ta.sma(c.close, 20),
    mom: c.close - c.close[1]
}) });

// Dataset 3: VIX 1D regime (raw feed with staleness cap)
var vix = data.loadCandles({ adapter: "demotrader.net", symbol: "VIX", resolution_type: "1D", fill_limit: 3 });

event.onCandle((candle) => {
    // Section 1: signals (named booleans over series)
    const trendUp = htf.sma !== null && htf.mom > 0 && candle.close > htf.sma;
    const calmVix = vix.age !== null && vix.close < 25;
    const longEntry = ta.crossover(candle.close, ta.sma(candle.close, 20)) && trendUp && calmVix;

    // Section 2: execution (mechanical signal → order mapping)
    // ... trade.buy / trade.closePosition ...
});

The vix.age !== null check ensures that if the VIX feed goes stale (no new daily bar has arrived within 3 primary bars), the strategy stops opening new positions until a fresh reading arrives. This is staleness as data, not imperative logic.

See the full annotated source in the Declarative Signals example (ETHUSD variant) or the VIX Regime Gating example (XAUUSD variant).

Example 2: VIX Regime Gating#

The same pattern applied to a different primary: trade gold on a 15m chart, use a 1h gold trend for direction, and gate on VIX calm. The complete strategy:

QuantScript — vix-regime-gating-xauusd.qs
mode(mode_type: "forwardtest", script_name: "VIX Regime Gating XAUUSD");
candles(symbol: "XAUUSD", resolution_type: "15m", bars: 500, adapter: "demotrader.net");

var htf = data.loadCandles({ adapter: "demotrader.net", symbol: "XAUUSD", resolution_type: "1h", callback: (c) => ({
    sma: ta.sma(c.close, 20),
    mom: c.close - c.close[1]
}) });

var vix = data.loadCandles({ adapter: "demotrader.net", symbol: "VIX", resolution_type: "1D", fill_limit: 3 });

event.onCandle((candle) => {
    const trendUp = htf.sma !== null && htf.mom > 0 && candle.close > htf.sma;
    const calmVix = vix.age !== null && vix.close < 25;
    const longEntry = ta.crossover(candle.close, ta.sma(candle.close, 20)) && trendUp && calmVix;
    const longExit = ta.crossunder(candle.close, ta.sma(candle.close, 20)) || !trendUp;

    // ... execution ...
});

Run the full script with the CLI: npm -w @quantscript/lang run dev -- run1 qs-examples/vix-regime-gating-xauusd.qs --log-level info

Next Steps#