The live trade tape filtered by size, in four surfaces: every print of 5 or more (in the market's own amount unit) reaches the Indicator as one block per bar; the bar's signed large-print delta is a histogram in the lower pane and the bar's print count is a data-only metric; the biggest prints of the live bar are drawn as boxes centred on their price, green for buys and red for sells, sized by amount; and the biggest prints of the last 20 bars are listed in a feed pinned to the top-right corner of the pane. The kScript (legacy) precedent is MMT's "Large prints" example, which the old scripting could only approximate from bar volume; here the tape itself is an input.
The parts are a celled input (tape.cells, read as a block in state()), box handles (objects the Indicator moves and deletes by id, Drawing objects), and one frame feeding a draw.feed (Drawing primitives). The boxes and the feed are refreshed on the live bar only, so a closed bar costs nothing beyond its two outputs. This is also the large-prints template: om wrun create scaffolds it, and it compiles as written.
The Indicator
// Large prints: the live tape filtered by size, the live bar's biggest prints boxed at their price, the biggest of the window listed in a feed, and the bar's signed large-print delta as a lower histogram.
import { draw, frame, handles, histogram, input, lower, none, ohlcv, output, param, tape, time } from "./sdk/declare"; // the declaring words
import { BoxHandle, bar, rgb } from "./gen/draw"; // the box handle class (what draw.box(id) returns), the last-bar signal, opaque colors
import { FRAME_FEED, writeFrame } from "./gen/frames"; // generated: the frame's slot and the writer that sends its JSON
import { in_close, in_t, in_tape_capacity, in_tape_cells, in_tape_read } from "./gen/inputs"; // generated: a reader per input declared below
import { emitRow, out_delta, out_prints } from "./gen/outputs"; // generated: an out_ writer per output, plus emitRow
import { p_bars, p_boxes } from "./gen/params"; // generated: a p_ reader per param declared below
param("bars", 20, { min: 1, max: 500 }); // the feed's window: the biggest prints of the last N bars
param("boxes", 8, { min: 1, max: 32 }); // how many of the live bar's prints get a box, biggest first
input("close", ohlcv.close); // the primary input: the chart's own candles define the grid; the close scales the boxes
input("tape", tape.cells, { max_cells: 2000, min_size: 5, history: "0" }); // live prints of size 5 or more as [offsetMs, price, size, side] tuples; history "0" is the contract
input("t", time.bar_open_sec); // each bar's open time in epoch seconds: the x axis of the boxes and the feed's timestamps
output("delta", histogram, lower, { color: "#38bdf8", description: "Buy size minus sell size of the bar's large prints" });
output("prints", none, lower, { description: "Large prints in the bar" }); // data-only: a count a watch or om metric can read
const feed = frame("feed", { max_bytes: 8192 }); // one JSON snapshot per run, rewritten on the live bar
draw.feed({ name: "largest", frame: feed, anchor: "top_right" }); // the feed widget lists the frame's lines in the top-right corner
handles.box({ opacity: 0.35, borderWidth: 1 }); // enable box handles in chart time and price; fill and border are set per box
const MAX_BOXES = 32; const FEED_LINES = 20; // the boxes param picks up to 32 slots; the feed lists up to 20 prints (the widget takes 50)
const MAX_BARS = 500; const PER_BAR = 8; // the feed ring: one slot per bar of the window, each keeping the bar's eight biggest prints
const block = new StaticArray<f64>(in_tape_capacity); // the bar's prints, max_cells x 4 f64s, filled by in_tape_read
// A short list of the biggest prints, biggest first, kept in a shared array from `base`: offer() slides a print into place and drops the smallest when the list is full.
class Top {
n: i32 = 0; readonly cap: i32; readonly base: i32; readonly v: StaticArray<f64>; // four f64s per print: ms, price, size, side
constructor(v: StaticArray<f64>, base: i32, cap: i32) { this.v = v; this.base = base; this.cap = cap; }
at(i: i32, field: i32): f64 { return this.v[this.base + i * 4 + field]; }
offer(ms: f64, price: f64, size: f64, side: f64): void {
if (this.n == this.cap && size <= this.at(this.n - 1, 2)) return; // smaller than every print kept
let i = this.n < this.cap ? this.n : this.n - 1; // the slot that opens: the end, or the smallest kept
for (; i > 0 && this.at(i - 1, 2) < size; i -= 1) for (let f = 0; f < 4; f += 1) this.v[this.base + i * 4 + f] = this.v[this.base + (i - 1) * 4 + f]; // smaller prints shift down
const at = this.base + i * 4; this.v[at] = ms; this.v[at + 1] = price; this.v[at + 2] = size; this.v[at + 3] = side;
if (this.n < this.cap) this.n += 1;
}
}
const barTop = new Top(new StaticArray<f64>(MAX_BOXES * 4), 0, MAX_BOXES); // the live bar's biggest prints, for the boxes
const feedTop = new Top(new StaticArray<f64>(FEED_LINES * 4), 0, FEED_LINES); // the window's biggest prints, rebuilt from the ring on the live bar
const ring = new StaticArray<f64>(MAX_BARS * PER_BAR * 4); const slots = new Array<Top>(MAX_BARS); // the ring's storage and one Top view per slot, made once in init()
const boxes = new Array<BoxHandle>(MAX_BOXES); // one handle per box slot, made once in init(); ids 0..31 in the one id space
let bars = 20; let nBoxes = 8; let slot = -1; let t: f64 = NaN; let prevT: f64 = NaN; let close: f64 = NaN; let delta = 0.0; let count = 0;
// init() runs once before the first bar: read the params, make the handle objects and the ring's views (they allocate once, never per bar), and
// reserve two pages (128 KiB) of headroom for the live bar's feed text and boxes (about 44 KiB at 20 lines and 32 boxes): the stub runtime never
// frees a string and memory may not grow after init(), so the live bar's allocations are paid for here.
export function init(): void {
bars = i32(p_bars()); nBoxes = i32(p_boxes()); memory.grow(2);
for (let i = 0; i < MAX_BOXES; i += 1) boxes[i] = new BoxHandle(i);
for (let s = 0; s < MAX_BARS; s += 1) slots[s] = new Top(ring, s * PER_BAR * 4, PER_BAR);
}
// state() runs once per bar: read the block, sum the signed size, keep the bar's biggest prints for the boxes and, eight of them, in the bar's ring slot.
export function state(): i32 {
prevT = t; t = in_t(); close = in_close(); delta = 0.0; count = 0; barTop.n = 0;
slot = (slot + 1) % bars; const mine = slots[slot]; mine.n = 0; // this bar takes the slot of the bar that just left the window, so the window is always the last `bars` bars
const n = in_tape_cells(); if (n <= 0 || in_tape_read(i32(changetype<usize>(block))) < 0) return 1; // no prints this bar: the outputs read 0
for (let i = 0; i + 3 < n; i += 4) { // [offsetMs, price, size, side]; side is +1 for a buy and -1 for a sell; the daemon kept only prints at or above min_size
const ms = t * 1000.0 + block[i]; const price = block[i + 1]; const size = block[i + 2]; const side = block[i + 3];
delta += side * size; count += 1; barTop.offer(ms, price, size, side); mine.offer(ms, price, size, side);
}
return 1;
}
// finalize() runs after state(): write the outputs; on the live bar only, box the biggest prints and rebuild the feed from the ring.
export function finalize(): void {
out_delta(delta); out_prints(f64(count));
if (bar.isLast()) {
const width = isNaN(prevT) ? 0.0 : t - prevT; // the bar's width in seconds, from two consecutive open times
for (let i = 0; i < MAX_BOXES; i += 1) { // slot i holds the i-th biggest print; ids are reused, so the count never grows past the param
if (i >= nBoxes || i >= barTop.n || width <= 0.0) { boxes[i].delete(); continue; } // an unused slot is deleted (a no-op when it is not live)
const price = barTop.at(i, 1); const half = 0.001 * close * (barTop.at(i, 2) / barTop.at(0, 2)); // the biggest print is 0.2% of price tall
const tint = barTop.at(i, 3) > 0.0 ? rgb(48, 164, 108) : rgb(229, 72, 77); // green for a buy, red for a sell
boxes[i].set(t, price + half, t + width, price - half).fill(tint).color(tint); // set() creates or moves the box: x in seconds, y in price
}
feedTop.n = 0; // the feed is the biggest prints across the window's slots: at most bars x 8 candidates, offered into a list of 20
for (let s = 0; s < bars; s += 1) { const top = slots[s]; for (let i = 0; i < top.n; i += 1) feedTop.offer(top.at(i, 0), top.at(i, 1), top.at(i, 2), top.at(i, 3)); }
if (feedTop.n > 0) writeFrame(FRAME_FEED, feedJson()); // an unwritten frame leaves the feed absent; an empty lines list would be refused
}
emitRow();
}
// The feed payload: {"lines":[[epochMs,"BUY 12.5 @ 64210.5","#30a46c"], ...]} biggest first, built on the live bar only.
function feedJson(): string {
let json = "{\"lines\":[";
for (let i = 0; i < feedTop.n; i += 1) {
const buy = feedTop.at(i, 3) > 0.0;
json += (i > 0 ? "," : "") + "[" + i64(Math.floor(feedTop.at(i, 0))).toString() + ",\"" + (buy ? "BUY " : "SELL ") + rounded(feedTop.at(i, 2), 1000.0) + " @ " + rounded(feedTop.at(i, 1), 100.0) + "\",\"" + (buy ? "#30a46c" : "#e5484d") + "\"]";
}
return json + "]}";
}
function rounded(value: f64, scale: f64): string { return (Math.round(value * scale) / scale).toString(); } // 1000 = three decimals, 100 = two
// reset() runs when the chart restarts the series: back to what init() built, every list and slot empty, the handles kept.
export function reset(): void { barTop.n = 0; feedTop.n = 0; for (let s = 0; s < MAX_BARS; s += 1) slots[s].n = 0; slot = -1; t = NaN; prevT = NaN; close = NaN; delta = 0.0; count = 0; }How it works
The tape is live only. tape.cells is the daemon's own trade stream, not a fetch. The daemon opens the market's tape the first time a package with a tape input is evaluated, keeps it streaming while evaluations keep touching it (two primary bars plus fifteen idle minutes close it), and holds prints from the moment it subscribed: at most two hours or 200,000 of them, only those at or above the floor. history: "0" is the contract, because there is no raw-trade lane on the REST side to backfill from, and every other history value is refused by name. So on a fresh chart every history bar reads an empty block: delta and prints are zero there, no box is drawn, the feed fills only as prints arrive, and a bar longer than the two-hour retention sees only the retained prints. A package that needs older prints does not exist yet; for historical flow, side-split trades.volume inputs are the Aggregated CVD shape, and Whale vs Retail CVD says what stays on the old engine.
One block per bar. input("tape", tape.cells, { max_cells: 2000, min_size: 5, history: "0" }) declares the celled input. max_cells caps the block at 2000 prints; a bar with more refuses the evaluation, it is never clipped. min_size is required, measured in the market's amount unit (5 BTC on a BTC market), and it is a literal: declaration options are read statically, so the size floor is set in the declaration, not by a param. In state(), in_tape_cells() says how many f64 values the bar's block holds (-1 when the bar carries none), in_tape_read(ptr) copies the block into block, a StaticArray<f64> sized in_tape_capacity (the generated max_cells times 4), and the loop walks it four at a time: [offsetMs, price, size, side], with offsetMs counted from the bar's open and side exactly +1 for a buy and -1 for a sell. The daemon already dropped everything under the floor, so every tuple counts: side * size summed is delta, the tuple count is prints. Both are outputs, so om metric series and a watch read them like any metric; the count is data-only (none) and draws nothing.
Short lists of the biggest prints. Top is a fixed-capacity list sorted biggest first, four numbers per print (time, price, size, side) in a shared array from a base offset: offer() slides a print into place and pushes the smallest out when the list is full, so no bar ever sorts its whole block. barTop (up to 32 entries) is emptied every bar and holds the live bar's biggest prints for the boxes. The feed keeps candidates per bar rather than one list across bars: ring is a bars-slot ring (up to 500 slots), each slot a Top of eight over its stretch of the shared array, and every bar takes the slot of the bar that just left the window and empties it, so the window is always the last bars bars. On the live bar feedTop (20 entries) is rebuilt from the ring, at most bars times eight candidates offered into a list of twenty, a partial selection, never a sort. A slot per bar is what keeps the feed honest when a big bar expires: with one list across bars, twenty size-100 prints would push every smaller print out as it arrived, and when their bar left the window the feed would empty although eligible prints remained; with the slots, the next bar's eight biggest are still there to list. All of it is module-level, allocated once in init(), and reset() empties every list and slot.
Boxes on the live bar. handles.box({ opacity: 0.35, borderWidth: 1 }) enables box handles in chart coordinates: no anchor word, so x is epoch seconds and y is price. The 32 handle objects are made once in init() (new BoxHandle(i), the object draw.box(i) returns; the class is imported directly because the declaring draw of the feed and the handle constructors share a name), on ids 0 to 31 in the one id space every handle kind shares. Under bar.isLast(), slot i is the i-th biggest print: set(t, price + half, t + width, price - half) spans the bar from its open time to the next open (width is the gap between two consecutive readings of t, the time.bar_open_sec input) and is centred on the print's price; the bar's biggest print is 0.2% of the close tall and the rest are proportional; fill() and color() tint it green for a buy and red for a sell. Every slot past the boxes param or past the bar's print count is delete()d, a no-op when the id is not live. Because the ids are reused, the number of boxes never grows past the param, and when the bar closes the next live bar takes the same ids: closed bars keep no boxes, the feed is where earlier prints stay visible.
The feed. frame("feed", { max_bytes: 8192 }) declares one JSON snapshot for the run (the last write wins; a frame in the sheet makes it the fourth ABI version, wrun-4) and draw.feed({ name: "largest", frame: feed, anchor: "top_right" }) binds a feed to it; the anchor word is any of the nine positions from top_left to bottom_right. The payload is { "lines": [[epochMs, text, color], ...] }: 1 to 50 lines, text of 1 to 80 characters, colors as #rrggbb. The feed is rebuilt on the live bar from the last bars bars' biggest prints, eight per bar, and feedJson() writes it biggest first, with an integer millisecond time (t * 1000 + offsetMs, floored), text like BUY 12.5 @ 64210.5 (the size to three decimals, the price to two) and the same green and red. It is written through writeFrame(FRAME_FEED, ...) from ./gen/frames, under bar.isLast() only and only when the list holds a print: an unwritten frame leaves the feed absent, while an empty lines list would be refused. The text is built with + on the live bar, twenty lines at most, and init() reserves two pages of memory for it and the boxes with memory.grow(2): the sandbox lets no memory grow after init() and the stub runtime never frees a string, so the live bar's allocations (about 44 KiB at twenty lines and 32 boxes) are paid for up front.
What kScript (legacy) could not do
- No raw trade tape input.
source(...)served bar-level aggregates (volume, buy and sell volume); a print's price, size, side and time never reached a script. Heretape.cellsis an input like any other, one block per bar. - No size floor. With only bar volume there was nothing to filter;
min_sizeon the declaration makes the daemon keep just the prints that matter, and the block arrives already filtered. - No boxes sized by amount. Shapes sat at a bar with a fixed size; a box handle takes a price height per print, so the box is as tall as the print is big, and it moves with the live bar by id.
- No feed. A
plotTableunderisLastBarcould list values on the last bar, not a timestamped, colored list that a frame keeps across bars. - No side-split flow per print. Buy and sell volume per bar was the finest grain;
deltahere is the signed sum of individual large prints, with the count beside it.
Customize it
- Raise or lower the floor.
min_size: 5in the tape declaration, in the market's amount unit; build again. A lower floor means more prints per bar, so raisemax_cellswith it (a block over the cap refuses the bar). - More or fewer boxes. The
boxesparam (1 to 32) at chart time; for more than 32, raiseMAX_BOXES, which adds ids. - A longer feed window. The
barsparam (1 to 500) sets the window;FEED_LINES(at most 50) sets how many prints the feed lists;PER_BAR(eight) is how many of each bar's prints stay candidates, and raising it grows the ring byMAX_BARStimes four numbers per extra candidate. - Color the histogram by sign. Add a data-only
signoutput (0or1) andcolor_by: "sign", colors: [red, green]ondelta, the Aggregated CVD shape; the sheet then has three outputs. - Keep boxes on closed bars. Give each bar its own block of ids and rotate through a few blocks, deleting the oldest set, the way the session zones in Drawing objects do.
Scaffold, build, install, and read the bar's delta on your machine:
om wrun create @you/large-prints ./large-prints --template large-prints
om wrun build ./large-prints
om wrun install ./large-prints --replace
om metric series --metric wrun/@you/large-prints/delta --symbol BTCUSDT --exchange BINANCE_FUTURES --bars 20Concepts used
- Drawing primitives for frames,
draw.feedand the feed payload - Drawing objects for box handles, chart coordinates and
bar.isLast() - Whale vs Retail CVD for the tape source's limits and the recipe that stays on the old engine
- Data sources for the celled classes and the live tape's lifecycle
- Execution model for module-level state and where
init(),finalize()andreset()run