Two exponential averages on the price pane, cumulative volume delta as an area and the bar's own delta as columns together in the package's lower pane, and two histogram windows placed below the chart: the spread of open-interest change and of volume delta over the last 200 traded bars. The kScript (legacy) precedent is MMT's "Plots and panes" example, one script drawing into the one pane its define() named; here every plot is an output that names its pane, and each further window is a panel the package feeds through a frame. This is the page that teaches where things draw.
The chart has three places. The price pane holds the candles, every overlay output, and drawings unless they say panel: "lower". The package's lower pane holds every lower output together: one lower pane per package, one scale for everything in it. A panel is a separate window with its own axis, fed by a frame the package writes as JSON and placed below the chart or at its side. Outputs cost one number per bar; a frame costs one JSON write per refresh. This is also the plots-and-panes template: om wrun create scaffolds it, and it compiles as written.
The Indicator
// Plots and panes: two averages on the price pane, CVD and the bar's delta together in the package's lower pane, two histogram windows below the chart.
import { area, frame, histogram, input, line, lower, ohlcv, oi, output, overlay, panel, param, trades } from "./sdk/declare"; // the declaring words
import { bar } from "./gen/draw"; // the last-bar signal
import { FRAME_DELTA_ROWS, FRAME_OI_ROWS, writeFrame } from "./gen/frames"; // generated: a slot constant per frame declared below, plus the writer
import { in_buy, in_close, in_oi, in_sell, in_volume } from "./gen/inputs"; // generated: an in_ reader for each input declared below
import { emitRow, out_cvd, out_delta, out_ema_fast, out_ema_slow } from "./gen/outputs"; // generated: an out_ writer per output, plus emitRow
import { p_fast, p_slow, p_window } from "./gen/params"; // generated: a p_ reader for each param declared below
import { Ema } from "./sdk/ta"; // the running exponential average
param("fast", 9, { min: 2, max: 200 }); // the fast average's length in bars
param("slow", 21, { min: 2, max: 400 }); // the slow average's length in bars
param("window", 200, { min: 20, max: 2000 }); // bars each histogram window counts; a window param also sizes the daemon's fetch for reads
input("close", ohlcv.close); // the primary input: the chart's own candles define the grid every other input lines up on
input("volume", ohlcv.volume); // the bar's traded volume: the gate that keeps bars with no trades out of the windows
input("oi", oi.close, { missing: "nan" }); // open interest at the bar's close; a bar without a reading, or a market without open interest, reads NaN
input("buy", trades.volume, { side: "BUY", missing: "zero" }); // aggressive buy volume; a bar with no prints reads 0
input("sell", trades.volume, { side: "SELL", missing: "zero" }); // aggressive sell volume, the other half of the tape
output("ema_fast", line, overlay, { color: "#38bdf8", width: 2 }); // overlay: drawn on the price pane, over the candles
output("ema_slow", line, overlay, { color: "#f59e0b", width: 2 });
output("cvd", area, lower, { color: "#22d3a5", opacity: 0.5 }); // lower: the package's one lower pane, shared by every lower output
output("delta", histogram, lower, { color: "#94a3b8" }); // the same pane and the same scale as cvd; a second pane is a panel
const oi_rows = frame("oi_rows", { max_bytes: 16384 }); // a frame: one JSON snapshot for the run, rewritten on the live bar (a name is one space across outputs, frames and panels)
const delta_rows = frame("delta_rows", { max_bytes: 16384 });
panel.histogram({ name: "oi_change", title: "OI change", x: "category", place: "below", frame: oi_rows, bins: 24 }); // a panel: its own window under the chart, fed by the frame
panel.histogram({ name: "vol_delta", title: "Volume delta", x: "category", place: "below", frame: delta_rows, bins: 24 });
const MAX_WINDOW = 2000; const BINS = 24; // the rings are sized for the largest window; init() reads the one in use
const oiRing = new StaticArray<f64>(MAX_WINDOW); const deltaRing = new StaticArray<f64>(MAX_WINDOW); // the last window traded bars' values
const counts = new StaticArray<i32>(BINS); // one count per column, refilled for each histogram
let fast = new Ema(9); let slow = new Ema(21); let window = 200; // init() rebuilds these from the params
let emaFast: f64 = NaN; let emaSlow: f64 = NaN; let cvd: f64 = 0.0; let delta: f64 = 0.0; let prevOi: f64 = NaN; // module state
let head = 0; let filled = 0; // the rings' write cursor and how many slots hold a value
export function init(): void { fast = new Ema(i32(p_fast())); slow = new Ema(i32(p_slow())); window = i32(p_window()); }
export function state(): i32 { // once per bar: both averages, the bar's delta into the running sum, one slot in each ring
const close = in_close(); emaFast = fast.update(close); emaSlow = slow.update(close);
delta = in_buy() - in_sell(); cvd += delta;
const oiNow = in_oi(); const oiChange = isNaN(prevOi) ? NaN : oiNow - prevOi; prevOi = oiNow; // the first bar, and a bar without a reading, has no change: NaN, which the binning skips
if (in_volume() > 0.0) { // a bar with no trades reads a zero delta by policy and would only pile onto the zero column
oiRing[head] = oiChange; deltaRing[head] = delta; head = (head + 1) % window; if (filled < window) filled += 1;
}
return 1;
}
// One window's rows: the ring's finite values split into BINS equal-width columns from the lowest to the highest,
// one ["label", count] pair per column, the label being the column's lower edge with just enough decimals to tell neighbours apart.
function histogramJson(ring: StaticArray<f64>): string {
let lo = Infinity; let hi = -Infinity;
for (let i = 0; i < filled; i += 1) { const v = ring[i]; if (isNaN(v)) continue; if (v < lo) lo = v; if (v > hi) hi = v; }
if (lo > hi) return '{"rows":[]}'; // nothing finite yet: empty rows are legal and draw no columns
const width = hi > lo ? (hi - lo) / f64(BINS) : 1.0; // one flat value: the first column holds everything
let scale = 1.0; let decimals = 0; while (decimals < 8 && width * scale < 1.0) { scale *= 10.0; decimals += 1; }
for (let b = 0; b < BINS; b += 1) counts[b] = 0;
for (let i = 0; i < filled; i += 1) { const v = ring[i]; if (isNaN(v)) continue; let b = i32((v - lo) / width); if (b >= BINS) b = BINS - 1; counts[b] += 1; }
let json = '{"rows":[';
for (let b = 0; b < BINS; b += 1) {
const edge = lo + f64(b) * width;
const label = decimals == 0 ? i64(Math.round(edge)).toString() : (Math.round(edge * scale) / scale).toString();
json += (b > 0 ? ',["' : '["') + label + '",' + counts[b].toString() + "]";
}
return json + "]}";
}
// finalize() runs after state(): one number per output on every bar, and on the live bar only, one snapshot per frame.
export function finalize(): void {
out_ema_fast(emaFast); out_ema_slow(emaSlow); out_cvd(cvd); out_delta(delta);
if (bar.isLast()) { writeFrame(FRAME_OI_ROWS, histogramJson(oiRing)); writeFrame(FRAME_DELTA_ROWS, histogramJson(deltaRing)); }
emitRow();
}
// reset() runs when the host replays the forming bar: back to what init() built, the running sum and both rings cleared.
export function reset(): void { fast.reset(); slow.reset(); emaFast = NaN; emaSlow = NaN; cvd = 0.0; delta = 0.0; prevOi = NaN; head = 0; filled = 0; }How it works
Where things draw. Three places, and every declaration names one. The price pane: the candles, every output that says overlay (ema_fast, ema_slow), and drawings unless they say otherwise. The package's lower pane: every output that says lower (cvd, delta), together, on one scale; a package has one lower pane, and its numeric outputs never open another. Panels: separate windows fed by frames, placed below the chart or at its side, each with its own axis and its own shape (the two histogram windows). Cost follows the shape: an output is one number per bar, read by every host and by om metric series; a frame is one JSON write per refresh.
Two lines over price. ema_fast and ema_slow say overlay, so they share the candles' price axis. Each is an Ema from ./sdk/ta, rebuilt in init() from the fast and slow params. The average is NaN until its seed of fast or slow closes is complete, and a NaN output draws nothing, so each line starts where its average is real.
One lower pane, two outputs, one scale. cvd and delta both say lower, so both land in the package's lower pane, the area and the columns in one pane on one scale. That is the rule, not a choice: a package's numeric outputs share one lower pane, and a second pane is a panel. cvd is a module-level running sum of buy minus sell over the loaded history (the Aggregated CVD accumulator), drawn as an area in a fixed color; delta is the same bar's difference, drawn as a histogram, columns from zero. Sharing works here because both are in the same unit, net traded volume; a series on another scale (a percentage, a count) would flatten against them and belongs in a panel, or in an inset strip through out.inset.
Two windows below the chart. frame("oi_rows", { max_bytes: 16384 }) reserves one JSON snapshot for the run, and panel.histogram({ name: "oi_change", title: "OI change", x: "category", place: "below", frame: oi_rows, bins: 24 }) binds a window to it; a name is one space across outputs, frames and panels, so the frame holds the rows and the panel is the window. The window does not bin. Its rows are already columns, one ["label", count] pair each (a label of 1 to 24 characters, a count of zero or more, 2000 rows at most), and bins: 24 is the number of columns the window is declared for, which is why the package writes exactly 24 rows. histogramJson finds the ring's lowest and highest finite values, splits that range into 24 equal-width columns, counts each value into its column, and labels each column with its lower edge, with as many decimals as it takes to tell neighbours apart. Empty rows are legal: before the first open-interest reading, and on a market with no open interest at all (spot, for one), the OI window is empty and draws no columns while the four plots and the delta window draw as usual. The last write per frame wins for the run, so both writes sit under bar.isLast(): a closed bar writes nothing and builds no strings, and the live bar rewrites both windows on every refresh.
The rings, the gate and the reset. The two rings are StaticArray<f64> objects made at module level from the window param's maximum (2000 slots each); init() reads the window in use, and the cursor wraps there. state() writes one slot in each ring per bar that traded: volume is the gate, because a bar with no trades reads a zero delta by policy (missing: "zero" on buy and sell) and would only pile onto the zero column of the delta window. Open interest is missing: "nan": a bar without a reading, and every bar of a market without open interest, reads NaN, so its change is NaN and the binning skips it, which is what leaves the OI window empty on such a market instead of withholding every row (a carry abstains the whole row until a first observation). The first bar has no previous open interest, so its change is NaN too. reset() puts everything back to what init() built when the host replays the forming bar: both averages, the running sum, and both rings.
What kScript (legacy) could not do
- One pane per script.
define(position="onchart")or"offchart"placed the whole script, so averages over price and a flow pane were two scripts on one chart. Here each output names its pane in one file:overlayfor the averages,lowerfor the flow. - No window with its own axis.
plotHistogramwas a column per bar on the time axis. The spread of the last 200 open-interest changes, binned, had nowhere to land. Here it is a frame and apanel.histogram: a separate window below the chart whose axis is the bins. - No missing policy on a feed.
open_interestandbuy_sell_volumereadnawhere a bar had no observation, and every use needed anisnumguard (the Aggregated CVD recipe'spairDelta). Heremissing: "nan"onoi.closeandmissing: "zero"on the side-split tape are declared once and hold on every bar. - A plot was only a picture. Every output here is also a metric:
om metric seriesreadsdeltaorcvdon your machine and an alert can read it, while the chart draws the same number.
Customize it
- Lengths.
fast(2..200) andslow(2..400) rebuild the twoEmaobjects ininit(). - Window.
window(20..2000) is how many traded bars each histogram counts; a larger maximum needs a largerMAX_WINDOWring, and because it is a declared window param it also sizes the daemon's fetch for a metric read. - Columns. Change
BINSand thebins:value on both declarations together (2..200): the rows the package writes and the columns the window is declared for must agree. - Color the CVD by sign. Add
output("sign", none, lower)written ascvd >= 0.0 ? 1.0 : 0.0and givecvdcolor_by: "sign", colors: ["#ff5b7f", "#22d3a5"], the Aggregated CVD shape; a palette index has to be its own output, which is why this recipe keeps a fixed color and four outputs. - A window beside the chart.
place: "side"on either declaration;title(1 to 40 characters) is what the window is called.
Scaffold, build, install, and read the bar's delta on your machine:
om wrun create @you/plots-and-panes ./plots-and-panes --template plots-and-panes
om wrun build ./plots-and-panes
om wrun install ./plots-and-panes --replace
om metric series --metric wrun/@you/plots-and-panes/delta --symbol BTCUSDT --exchange BINANCE_FUTURES --bars 20Concepts used
- Plotting for
output(name, plot, panel, options), theareaandhistogramplots, andoverlayagainstlower - Data types for placement per output: the price pane or the package's lower pane
- Drawing primitives for frames, the panel declarations and the row grammar
- Data sources for
oi.close,trades.volumewith aside, and themissingpolicies - Execution model for the module-level rings,
reset()and the forming bar - Moving averages for
Ema