---
title: Multi-Source & Aggregation
description: >-
  Combine multiple data sources in one script: other symbols, other venues,
  other data types, aggregated metrics like cross-exchange CVD, and the
  request(), ltf(), and requestBars() helpers.
---

<div class="flex gap-3 mb-6">
  <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-50 text-purple-600 text-sm font-medium">
    Advanced
  </span>
  <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-sm font-medium">
    12 min read
  </span>
</div>

> **Verified against engine 3.0.26.** The per-source deep-fetch behavior of higher-timeframe `request()` (and the source-count shift that comes with it) is **since engine 3.0.26 (in-flight)** and corrects an earlier "bounded by the chart window" claim. The depth and source-count facts are backed by the engine's passing `g6-request-source-discovery.test.ts` suite.

## Introduction

A kScript script is not limited to its chart's data. You can load **any number of sources** in one script: other symbols, other exchanges, other data types (open interest, funding, order book, buy/sell volume), and combine them with ordinary math. "Aggregated" indicators (one metric summed across venues) are not a special feature; they fall out of this plus [collections](collections.md).

```javascript
timeseries btc = source("ohlcv", "BTCUSDT", "BINANCE_FUTURES")
timeseries eth = source("ohlcv", "ETHUSDT", "BINANCE_FUTURES")

timeseries ratio = eth.close / btc.close
plotLine(ratio, label=["ETH/BTC"])
```
## How multiple sources coexist

Three engine rules make cross-source math safe by default:

1. **One unified timeline.** The engine unions all sources' timestamps into the chart's bar sequence. Every series is readable on every bar.
2. **Values forward-fill.** Reading a series at a bar where *that* source has no row (slower cadence, late listing, different venue calendar) returns its last known value, so arithmetic keeps working. Funding (8h cadence) against 1h candles just works.
3. **Events never fabricate.** The strictness rules from the [TA conventions](../functions/ta-library.md) apply: a missing condition row is false, and crosses never fire on `na`. Stale venue data cannot invent signals.

## Multi-symbol

Any source call can name a symbol other than the chart's:

```javascript
timeseries spy = source("ohlcv", "ETHUSDT", "BINANCE_FUTURES")
timeseries lead = htf(spy, "4h")           // other-symbol HTF, still no-repaint
var leading = rsi(source=spy.close, period=14)
```

Everything downstream is source-agnostic: indicators, `htf()`, drawings, reducers. For the common case there is sugar:
```javascript
timeseries eth4h = request("ETHUSDT", "4h")        // = htf(source("ohlcv", "ETHUSDT", currentExchange), "4h")
timeseries ethNow = request("ETHUSDT")             // raw, chart timeframe
```

`request(symbol, timeframe?, type?, exchange?, opts?)` keeps every `htf` guarantee (confirmed buckets, no repaint) and counts toward the source budget like any fetch. When the timeframe is a statically-known string coarser than the chart (e.g. `"4h"` on a 1h chart), `request` fetches that interval **natively** (a distinct source keyed by interval) and step-projects it onto the chart timeline, rather than aggregating chart bars. It falls back to chart-bar aggregation only when the venue serves no native data for that interval, when the timeframe is dynamic (a variable or input, which cannot be prefetched), or when the source is array-celled (footprint and similar). The `opts` object (`mode`/`offset`/`bars`) is documented under [Multi-Timeframe opts](/kscript/core-concepts/multi-timeframe#opts-mode-offset-and-bars).

## Higher-timeframe requests fetch their own depth

> **Corrected since engine 3.0.26 (in-flight).** Earlier docs said a higher-timeframe `request()` was "bounded by the chart's loaded window" and could not reach a far-back anchor. That is **no longer true**: a coarser `request()` now fetches its **own deep history**, scaled by the requested interval rather than the chart interval.

A `1d` source on a `1m` chart is fetched as a deep daily window (the default is ~400 bars of the requested interval), not as the chart's few-bar span, so a daily, weekly, monthly, or yearly level **resolves even on a fine chart**. The base chart-interval sources keep their existing shallow fetch window unchanged; only the higher-timeframe request pulls deep.

- **Auto depth.** Omit `bars` and the request loads a period-sized window deep enough to reach the prior completed period (and the current forming one).
- **`opts.bars: N`** overrides that with exactly `N` bars **of the requested timeframe**: `request("ETHUSDT", "4h", { bars: 500 })` fetches 500 native 4h bars. For a calendar token the count is converted to the backing interval. `bars` is request-only.
- **Calendar backing.** Calendar tokens aggregate engine-side from a shared, deduped backing source: `1D`/`1W`/`1M` from a deep **daily** source, `1Q`/`1Y` from a coarser **weekly** source (about 5x fewer bars). Tokens that share a backing interval (plus a plain `request(sym, "1d")` or `request(sym, "1w")`) collapse to **one** cached fetch each, so a symbol's calendar requests use at most two backing sources.

**Source-count consequence.** Because a coarser request is keyed by its interval, `request("ETHUSDT")` (chart interval) and `request("ETHUSDT", "4h")` (native 4h) are now **two distinct sources**, not one. This is a budget shift from earlier versions where they shared a key. Duplicate identical requests still dedupe. This per-source deep fetch and its source-count behavior are verified by the engine's `src/__tests__/g6-request-source-discovery.test.ts` suite (the `EC1` deep-fetch-range and `B1a` calendar-backing cases), passing on 3.0.26.

For history depth on the chart's **own** series (cumulative `vwap()`, anchored VWAPs) that survives panning, you still declare it with [`define(..., maxBarsBack=N)`](/kscript/functions/script-definition); that is about the base series, separate from a higher-timeframe request's independent fetch.

## Multi-venue aggregation

The flagship pattern: one metric, summed across exchanges.

**Aggregated CVD:**

```javascript
define("Aggregated CVD", "offchart", true)

timeseries bsBinance = source("buy_sell_volume", "BTCUSDT", "BINANCE_FUTURES")
timeseries bsBybit = source("buy_sell_volume", "BTCUSDT", "BYBIT")
timeseries bsOkx = source("buy_sell_volume", "BTCUSDT", "OKX")

func delta(bs) {
  return bs.buy - bs.sell
}

timeseries aggDelta = delta(bsBinance) + delta(bsBybit) + delta(bsOkx)
timeseries aggCvd = cum(aggDelta)

plotLine(aggCvd, colors=[aggDelta > 0 ? "#16a34a" : "#dc2626"], label=["Aggregated CVD"], desc=["cumulative delta across venues"])
```

**Aggregated open interest, venue-weighted:**
```javascript
timeseries oiBinance = source("open_interest", "BTCUSDT", "BINANCE_FUTURES")
timeseries oiBybit = source("open_interest", "BTCUSDT", "BYBIT")

var weights = {}
weights.set("binance", 0.6)
weights.set("bybit", 0.4)

// open_interest is OHLC-shaped; .close is the current OI value
timeseries aggOI = oiBinance.close * weights.get("binance")
  + oiBybit.close * weights.get("bybit")

plotLine(aggOI)
plotLine(ema(source=aggOI, period=21), colors=["#94a3b8"], zOrder=0, label=["OI EMA"], desc=["smoothed aggregated open interest"])
```

**Venue dominance** (one venue's share of the aggregate):

```javascript
timeseries share = delta(bsBinance) / nz(aggDelta, 1)
```



The same shape covers aggregated funding, liquidation totals, and cross-venue spreads. Package the helpers as a [library](../functions/libraries.md) (`import "agg" as agg`) and the whole family becomes one import.

{% hint style="info" %}
**Higher-timeframe views of aggregates** compose directly: `htf(aggCvd, "4h")` resamples the cumulative series correctly (last-in-bucket), still repaint-free.

{% endhint %}
## Lower-timeframe data: ltf()

`ltf(interval)` fetches **finer** bars than the chart and delivers them as **cells on the chart's own bars** (the chart timeline is untouched):

```javascript
// on a 1h chart: each bar carries its four 15m bars
timeseries fine = ltf("15m")

// intrabar drift: sum of the finer bars' close-open
timeseries drift = fine.cells.map((c) => c[4] - c[1]).reduce((s, x) => s + x, 0)
plotLine(drift)
```



Cells use the same `.cells` machinery as order-flow sources, so reducers, structs, and the causality guarantees all apply. The interval must be a literal string, strictly finer than the chart, and (like everything here) it errors loudly with line:column when misused.
## requestBars(): raw bars for drawing

When you want to **draw** on top of a coarser timeframe (mark the last N daily highs/lows, box weekly ranges, label monthly opens) rather than feed a series into TA, reach for `requestBars(symbol, timeframe, type?, exchange?, opts)`. It returns the last `opts.bars` **native** bars of `symbol` at `timeframe` as a plain array of `[time, open, high, low, close, volume]` rows, oldest first. The timeframe is a coarse native interval string (`"1d"`, `"4h"`, `"1w"`) and is required; `opts.bars` is a required positive integer; `type` defaults to `"ohlcv"` and `exchange` defaults to the current venue. There is no `mode`/`offset` here (those are `request()`/`htf()` period concepts).

The result is **draw-only and inert**: a raw array indexed by position (`rows[i]`), not a chart-indexed timeseries. It carries no `[n]` bar-offset history and is not chainable into TA functions; you iterate it and draw. Crucially it is **independent of `maxBarsBack`**, reading the deep source directly, so a 1m chart can draw the last 20 daily candles even with a tiny lookback window.

This is the counterpart to `request()`. Where `request()` returns a chart-aligned, bar-indexed, TA-chainable timeseries (and `request(..., { bars: N })` only sets the fetch depth of that still-projected series), `requestBars()` hands you the raw native rows for drawing:

```javascript
define("daily levels", "onchart", true, maxBarsBack=50)
if (isLastBar) {
  var rows = requestBars(currentSymbol, "1d", { bars: 20 })
  for (var i = 0; i < 20; i = i + 1) {
    var r = rows[i]                 // r = [t, o, h, l, c, v]
    line.new(r[0], r[2], r[0], r[3], { color: "#ffffff", width: 1 })
  }
}
```

## Budgets and good citizenship

A script can subscribe to **at most 20 distinct sources**. The editor and the runtime enforce the same number, so a script that runs also passes validation; a 21st source fails with `Script exceeds the source budget (21/20)`. Identical `source(...)` calls dedupe to one, and `htf()` over a source you already opened is free since it transforms already-fetched data rather than fetching. A coarser `request(sym, "4h")`, however, is keyed by its interval, so it is its **own** source distinct from a chart-interval `request(sym)` (see [Higher-timeframe requests fetch their own depth](#higher-timeframe-requests-fetch-their-own-depth)); a symbol's calendar requests collapse onto at most two shared backing sources. Heavy source types count for more than one (`orderbook` counts as 3), so a handful of order books reaches the cap sooner. Twenty distinct streams is ample headroom for multi-venue aggregation.

## Availability

The language accepts any registered `(type, symbol, exchange)` triple; whether a venue serves a given data type is a platform question. The platform exposes a machine-readable catalog of servable source types, and an unavailable request fails loudly with the list of what exists, never with silent empty data.
