Multi-source and aggregation

Combine several markets, venues, and data types in one Indicator: other symbols and other venues through symbol + exchange pins, other feeds through their…

Combine several markets, venues, and data types in one Indicator: other symbols and other venues through symbol + exchange pins, other feeds through their sources, side-split flow through side, sparse feeds through missing, and aggregation through ordinary arithmetic across inputs. kScript (legacy) did this with source(...) calls under a ten-slot budget plus request(), ltf(), and requestBars(); an Indicator does it with input declarations, and the last two have no counterpart yet.

Introduction

An Indicator is not limited to its chart's data. On your machine it can declare any number of inputs: other symbols, other exchanges, other data types (open interest, funding, side-split trades, a book snapshot), and combine them with ordinary math. "Aggregated" indicators (one metric summed across venues) are not a special feature; they fall out of this plus a few lines of arithmetic.

input("close", ohlcv.close);                                                   // the primary: the selector's market
input("btc", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES" }); // a pinned reference market
// in finalize(): out_ratio(close / btc);

How multiple sources coexist

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

  1. One grid, defined by the primary. The first input's market and interval are the grid; every other input is aligned onto it, row for row. Every input is readable on every bar.
  2. Values align by policy. An equal-or-finer source aligns by bar open. A coarser source contributes only as of its candle's close (multi-timeframe.md). A scalar source with no observation on a bar delivers the latest eligible value carried forward (missing: "carry", the default), NaN ("nan"), or 0 ("zero"), and you choose per input. Funding (an 8h cadence) against 1h candles just works.
  3. Events never fabricate. Every comparison with NaN is false, and Cross.update() answers 0 when either side is NaN, so a venue that has not reported cannot invent a signal.

Multi-symbol

Any input can name a market other than the chart's. The two halves pin together (a lone symbol or a lone exchange is refused at the sheet), and everything downstream is source-agnostic: classes, folds, gates, shapes. The ETH/BTC ratio and the relative-strength reading between them:

import { input, line, lower, ohlcv, output, param } from "./sdk/declare";
import { in_btc, in_close } from "./gen/inputs";
import { emitRow, out_lead_rsi, out_ratio, out_ratio_sma } from "./gen/outputs";
import { p_period } from "./gen/params";
import { Rsi, Sma } from "./sdk/ta";

param("period", 14, { min: 2, max: 200, description: "Lookback for the ratio average and the leader's RSI" });
// The primary follows the selector (ETHUSDT on an ETH chart); the reference is pinned.
input("close", ohlcv.close);
input("btc", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", description: "BTC close, the reference market" });
output("ratio", line, lower, { color: "#2563eb", width: 2, description: "Selector close divided by the BTC close" });
output("ratio_sma", line, lower, { color: "#94a3b8", description: "Average of the ratio" });
output("lead_rsi", line, lower, { color: "#f59e0b", description: "RSI of the reference market: the leader's momentum" });

let sma = new Sma(14);
let rsi = new Rsi(14);
let ratio: f64 = NaN;
let ratioSma: f64 = NaN;
let leadRsi: f64 = NaN;

export function init(): void {
  sma = new Sma(i32(p_period()));
  rsi = new Rsi(i32(p_period()));
}

export function state(): i32 {
  const close = in_close();
  const btc = in_btc();
  ratio = btc > 0.0 ? close / btc : NaN;
  ratioSma = isNaN(ratio) ? NaN : sma.update(ratio);
  leadRsi = rsi.update(btc);
  return isNaN(ratioSma) || isNaN(leadRsi) ? 0 : 1;
}

export function finalize(): void {
  out_ratio(ratio);
  out_ratio_sma(ratioSma);
  out_lead_rsi(leadRsi);
  emitRow();
}

export function reset(): void {
  sma.reset();
  rsi.reset();
  ratio = NaN;
  ratioSma = NaN;
  leadRsi = NaN;
}

For the common cases kScript spelled request("ETHUSDT") and request("ETHUSDT", "4h"), the declaration adds interval to the pin: input("eth_4h", ohlcv.close, { symbol: "ETHUSDT", exchange: "BINANCE_FUTURES", interval: "4h" }), read as of the 4h close (multi-timeframe.md). Cross-symbol arithmetic is only dimensionally sane under the shared default USD quote.

Multi-venue aggregation

The flagship pattern: one metric, summed across exchanges. The cookbook's cumulative volume delta sums side-split trades volume over four venues (missing: "nan" so a venue that did not report reads NaN, counts as not live, and contributes zero). Open interest, venue-weighted, is the same shape with oi.close and a pair of weights:

import { input, line, lower, ohlcv, oi, output, param } from "./sdk/declare";
import { in_oi_binance, in_oi_bybit } from "./gen/inputs";
import { emitRow, out_agg_oi, out_binance_share, out_oi_ema } from "./gen/outputs";
import { p_w_binance, p_w_bybit } from "./gen/params";
import { Ema } from "./sdk/ta";

param("w_binance", 0.6, { min: 0, max: 1, description: "Weight of Binance open interest" });
param("w_bybit", 0.4, { min: 0, max: 1, description: "Weight of Bybit open interest" });
// The chart's own candles are the grid; every venue aligns onto it.
input("close", ohlcv.close);
input("oi_binance", oi.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", missing: "nan", description: "Binance BTC open interest" });
input("oi_bybit", oi.close, { symbol: "BTCUSDT", exchange: "BYBIT", missing: "nan", description: "Bybit BTC open interest" });
output("agg_oi", line, lower, { color: "#2563eb", width: 2, description: "Venue-weighted open interest" });
output("oi_ema", line, lower, { color: "#94a3b8", description: "Smoothed aggregated open interest" });
output("binance_share", line, lower, { color: "#f59e0b", unit: "%", description: "Binance share of the reporting venues' open interest" });

const weights = new Map<string, f64>();
let ema = new Ema(21);
let agg: f64 = NaN;
let smoothed: f64 = NaN;
let share: f64 = NaN;

// A venue with no observation this bar contributes nothing, and its weight drops out of the sum.
function weighted(value: f64, weight: f64): f64 {
  return isNaN(value) ? 0.0 : value * weight;
}

function liveWeight(value: f64, weight: f64): f64 {
  return isNaN(value) ? 0.0 : weight;
}

export function init(): void {
  weights.set("binance", p_w_binance());
  weights.set("bybit", p_w_bybit());
  ema = new Ema(21);
}

export function state(): i32 {
  const binance = in_oi_binance();
  const bybit = in_oi_bybit();
  const wb = weights.get("binance");
  const wy = weights.get("bybit");
  const totalWeight = liveWeight(binance, wb) + liveWeight(bybit, wy);
  agg = totalWeight > 0.0 ? (weighted(binance, wb) + weighted(bybit, wy)) / totalWeight : NaN;
  smoothed = isNaN(agg) ? NaN : ema.update(agg);
  const both = (isNaN(binance) ? 0.0 : binance) + (isNaN(bybit) ? 0.0 : bybit);
  share = both > 0.0 && !isNaN(binance) ? (100.0 * binance) / both : NaN;
  return isNaN(agg) ? 0 : 1;
}

export function finalize(): void {
  out_agg_oi(agg);
  out_oi_ema(smoothed);
  out_binance_share(share);
  emitRow();
}

export function reset(): void {
  ema.reset();
  agg = NaN;
  smoothed = NaN;
  share = NaN;
}

Venue dominance is the last output: one venue's share of the aggregate, NaN rather than a fake 100% when only one venue reported. The same shape covers aggregated funding, liquidation totals, and cross-venue spreads: one pinned input per venue, one term per input, and a missing policy that says what a silent venue means. Package the helpers as a class and the whole family is one paste.

A note on the chart: every input on the chart reads the chart's own market, so both venue inputs above would read the same feed there. The multi-venue variants are packages for your machine (om wrun install, then om metric series or a watch condition); the cookbook's CVD page shows the single-venue chart form beside the aggregated one.

Higher-timeframe views of aggregates

A fold composes with any input: bucket the aggregated series by time.bar_open_sec and fold it on the next bucket, and the 4h view of a cross-venue sum is confirmed and repaint-free exactly like a single-source one (multi-timeframe.md).

Lower-timeframe data and raw bars

Not in Indicators yet. kScript's ltf(interval) delivered the finer bars inside each chart bar as cells, and requestBars(symbol, timeframe, { bars }) returned the last N native bars of another market as a raw array for drawing. There is no lower-timeframe celled class and no raw-bar request: the celled classes slice the same bar by price (volume_profile), by level (book), or by live print (tape, daemon only), and a drawing's coordinates come from outputs the module computes on the primary grid (functions/drawing-objects.md). To mark the last twenty daily highs, keep them in a ring buffer from a daily fold or a 1d pin and draw them as segments.

Sparse flow sources align to the grid

Some market-flow feeds are naturally sparse: a liquidation, a funding print, or a side-split trade bucket may simply have no event for a candle. The missing policy is how you say what that means, per input:

PolicyA bar with no observation readsUse it for
"carry" (default)the latest eligible value, carried forwardlevels and states: open interest, a coarse close, a funding rate between prints
"nan"NaN"did this venue report?" logic: a NaN counts as not live and never fires a comparison
"zero"0flows to be summed: liquidation volume, side-split volume, where nothing happened means zero

On the primary input, "nan" and "zero" densify the grid itself: the package computes on a dense grid of bar opens at the primary's interval, which is how a sparse series such as liquidations becomes a dense primary. The practical rule from kScript still holds: keep an ohlcv spine as the first input in a multi-source flow package, then read the flow sources beside it. The candle grid stays stable, sparse "nothing happened" bars become 0 or NaN as you choose, and truly unavailable history stays distinguishable from zero activity.

Budgets and good citizenship

kScript budgeted ten weighted source slots per script. An Indicator has no slot budget: every input is one series requirement, and the practical cost is fetch time on the widest window. Declare the inputs the computation needs, pin symbol and exchange together, keep the primary selector-following (a fully pinned package computes the same value for every selector symbol, which mislabels screens and legends), and put the sparse feeds on the policy that matches their meaning.

Availability

Whether a venue serves a feed for a market is a platform question, answered by name before the module runs, never with silent empty data (data-sources.md).