A terminal-style readout the package draws itself: a title bar, a body box, and four left-aligned rows (last close, the bar's change, a rolling 20-bar signed flow, a status line) pinned to the top-left corner of the pane and refreshed on the live bar. The kScript (legacy) precedent is the MMT-style terminal script, a plotTable under isLastBar; here the readout is the package's own handles, so every pixel, word and refresh is under your control.
Boxes and labels are handles: objects the Indicator creates, moves, and re-words across bars by an integer id (Drawing objects). Two declarations set the defaults every new handle starts from: anchor: "top_left" puts them in pane pixels measured from the pane's top-left corner instead of chart time and price, and align: "left" makes a label's text start at its x. One bounded string slot carries every label's text, one row at a time. The frame is drawn once and stays; the rows are rewritten only when bar.isLast() is true, so a closed bar costs nothing. This is also the hud-terminal template: om wrun create scaffolds it, and it compiles as written.
The Indicator
// A terminal-style HUD the package draws itself: two anchored boxes and four left-aligned rows, refreshed on the live bar.
import { handles, input, none, ohlcv, output, param, string, trades } from "./sdk/declare"; // the declaring words
import { bar, draw, LabelHandle } from "./gen/draw"; // handle constructors, the label class, and the last-bar signal
import { in_buy, in_close, in_sell } from "./gen/inputs"; // generated: an in_ reader for each input declared below
import { emitRow, out_flow } from "./gen/outputs"; // generated: an out_ writer per output, plus emitRow
import { p_bars } from "./gen/params"; // generated: a p_ reader for the window param declared below
import { sb_clear, sb_f64, sb_text, str_text_sb } from "./gen/strings"; // the allocation-free line builder and the slot's sender
param("bars", 20, { min: 2, max: 200 }); // the flow window in bars; 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("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("flow", none); // data-only: the rolling signed flow is a metric on your machine; the HUD is the chart look
string("text", { max_bytes: 40 }); // one bounded slot every label is written through, one row at a time
handles.box({ anchor: "top_left" }); // enable box handles, placed in pane pixels from the top-left corner
handles.label({ anchor: "top_left", align: "left", size: 11 }); // enable label handles whose text starts at x
const MAX_BARS = 200; // the ring is sized for the largest window; init() reads the one in use
const deltas = new StaticArray<f64>(MAX_BARS); // the last bars per-bar deltas, a ring the rolling sum reads
const titleBar = draw.box(0); const body = draw.box(1); const title = draw.label(2); // handle objects allocate once
const rows: LabelHandle[] = [draw.label(3), draw.label(4), draw.label(5), draw.label(6)]; // ids are one space across kinds
let bars = 20; let close: f64 = NaN; let prev: f64 = NaN; let flow: f64 = 0.0; let head = 0; let framed = false; // module state
export function init(): void { bars = i32(p_bars()); } // the window param sizes the ring (the ring itself never reallocates)
export function state(): i32 { // once per bar: read the bar, fold its delta into the rolling sum (the ring drops the oldest)
prev = close; close = in_close(); const delta = in_buy() - in_sell();
flow += delta - deltas[head]; deltas[head] = delta; head = (head + 1) % bars; return 1;
}
// finalize() runs after state(): write the output, frame the HUD once, refresh the rows on the live bar only.
// A row is one line: build its text with sb_* (numbers through sb_f64, never a locale), then text(...) sends
// the slot and draws the label at the last set(x, y); the slot is reused row by row, the host copies each draw.
export function finalize(): void {
out_flow(flow);
if (!framed) { // a handle set on one bar stays until delete(), so the frame and its title are drawn once
titleBar.set(14, 12, 394, 34).opacity(0.6); body.set(14, 34, 394, 132);
sb_clear(); sb_text("HUD terminal"); title.set(24, 23).text(str_text_sb); framed = true;
}
if (bar.isLast()) { // the live rows: refreshed on the newest bar only, so closed bars draw nothing
sb_clear(); sb_text("close "); sb_f64(close, 2); rows[0].set(24, 52).text(str_text_sb);
sb_clear(); sb_text("change "); sb_f64(isNaN(prev) ? 0.0 : ((close - prev) / prev) * 100.0, 2); sb_text("%"); rows[1].set(24, 72).text(str_text_sb);
sb_clear(); sb_text("flow "); sb_f64(flow, 1); sb_text(" over "); sb_f64(f64(bars), 0); sb_text(" bars"); rows[2].set(24, 92).text(str_text_sb);
sb_clear(); sb_text(flow > 0.0 ? "status buyers lead" : flow < 0.0 ? "status sellers lead" : "status balanced"); rows[3].set(24, 118).text(str_text_sb);
}
emitRow();
}
// reset() runs when the chart restarts the series: back to what init() built, the ring cleared, the frame redrawn.
export function reset(): void { for (let i = 0; i < MAX_BARS; i += 1) deltas[i] = 0.0; close = NaN; prev = NaN; flow = 0.0; head = 0; framed = false; }How it works
Two declarations place everything. handles.box({ anchor: "top_left" }) and handles.label({ anchor: "top_left", align: "left", size: 11 }) enable the two handle kinds and set the defaults every new box and label starts from. With the anchor word, set(14, 12, 394, 34) is a box from 14 px to 394 px across and 12 px to 34 px down from the pane's top-left corner, whatever the chart is scrolled or zoomed to; without it the same four numbers would be times and prices. With align: "left", a label's text starts at its x (24 px) instead of being centred on it, which is what lines the rows up like a terminal. Both words are sheet defaults; a single handle can override them with anchor(...) and align(...) from ./gen/draw. Declaring a handle kind, or calling bar.isLast(), is what derives the third ABI version (wrun-3) in the generated sheet.
One slot, five labels. string("text", { max_bytes: 40 }) is the only text slot. Each row is built into the shared line buffer with sb_clear(), sb_text(...) and sb_f64(value, decimals), then text(str_text_sb) sends the slot and draws the label at its last set(x, y). The host copies the slot's bytes into the handle at the moment of the draw call, so writing the same slot five times on one bar yields five different labels. Nothing allocates per bar: the buffer is one static array sized to max_bytes, and sb_f64 formats the double digit by digit (124.00, 0.81, 330.0), never through a locale.
The frame once, the rows on the live bar. A handle set on one bar stays on the chart until delete(), so the two boxes and the title are drawn on the first bar and never touched again (framed remembers that). The four rows sit under bar.isLast(): true on the newest bar the host holds (the last row of a full run, the forming bar live), false on every earlier bar. On a closed bar finalize() writes the output and emits the row, with no string write and no draw call. On the live bar the rows are rewritten with the current close, the change against the previous close, the flow and its verdict.
The rolling flow. buy and sell are the two halves of the tape, trades.volume with side: "BUY" and side: "SELL", the Aggregated CVD shape; missing: "zero" makes a bar with no prints contribute nothing instead of repeating the last value. state() folds each bar's delta into a 20-slot ring: add the new delta, subtract the one falling out, so flow is always the sum of the last 20 deltas without a loop. The same figure goes out through out_flow, so the package is also a metric on your machine even though its chart look is the HUD alone.
What changed in the port
- The MMT-style terminal scripts, the kScript (legacy) precedent, drew their readout with
plotTable(...)underisLastBar, one table declaration with its rows, position and colors. Here the readout is boxes and labels the package owns: it chooses the ids, the pixel coordinates, the text of each row and the bar it redraws on. The Aggregated CVD recipe's venue table is the same construct in kScript form. isLastBarisbar.isLast(), a call answered instate()andfinalize(). The kScript engine evaluated the table on the last bar only; here the split is explicit, the frame once and the rows on the live bar, and the closed bars pay nothing.position="top_right"on a table becameanchor: "top_left"on the handle declarations, one word that turns the coordinates into pane pixels. Every placement word (top_right,bottom_left,middle_center, the single-axistop,right, and the rest) is in Drawing objects."".concat(...)andmath.round(...)became the string builder:sb_text,sb_f64with a decimal count,sb_int. There is no string concatenation per bar and no locale.- The table's implicit text alignment became the
alignword, declared once as a default or set per label withalign(ALIGN_RIGHT). - The venue-count row of the table became a metric: the flow figure is a data-only output, readable by
om metric series, a watch or a screen, while the HUD stays the only thing drawn on the chart.
Customize it
- Right-align a column. Import
ALIGN_RIGHTfrom./gen/drawand callrows[i].align(ALIGN_RIGHT)after the row's first draw (orstyle.align(rows[i], ALIGN_RIGHT)), then place its x at the right edge of the body box;ALIGN_DEFAULTclears the word and restores centred text. - Move the HUD. Change the anchor word on both declarations to
top_rightorbottom_left; the pixel offsets are then measured from that corner. A single-axis word such astopkeeps x on chart time and pins only y. - Add a row. Reserve one more id (
draw.label(7)), give it a y 20 px below the last row, extend the body box's bottom, and write it underbar.isLast()like the others. Ids are one space across boxes, labels, lines and polylines. - Change the window. The
barsparam (2..200) sizes the ring ininit()and, because it is a declared window param, sets how many bars the daemon fetches for a metric read; a larger ring only needs a largerMAX_BARS. - Plot the flow too.
output("flow", line, lower)draws the rolling sum in its own pane beside the HUD; thenoneplot keeps it data-only. - Colors. The box declaration takes
color,borderColor,opacityandborderWidth, the label declarationcolorandsize; per handle,fill(rgba(...)),color(rgb(...))andopacity(...)restyle one object.
Scaffold, build, install, and read the flow figure on your machine:
om wrun create @you/hud-terminal ./hud-terminal --template hud-terminal
om wrun build ./hud-terminal
om wrun install ./hud-terminal --replace
om metric series --metric wrun/@you/hud-terminal/flow --symbol BTCUSDT --exchange BINANCE_FUTURES --bars 20Concepts used
- Drawing objects for handles, the anchor and align words, and
bar.isLast() - String functions for the
sb_*builder and the slot senders - Data sources for
trades.volumewith asideand themissingpolicies - Execution model for the module-level ring and where the live bar is evaluated
- Aggregated CVD for the side-split tape this recipe folds into a rolling sum