A regime readout the package draws itself: one box and four left-aligned rows pinned to the top-left corner of the price pane (the close's distance from EMA 50, RSI 14, ATR as a percent of the close, and a BULLISH, BEARISH or NEUTRAL line coloured by regime), plus a meter for RSI at the top-right. The numbers come from three plain recurrences in state(); the readout is two handle kinds, an anchor word, an align word and one draw.meter declaration. The kScript (legacy) precedent is the MMT-style viewport HUD script, whose labels sat at a time and a price and scrolled away with the chart; here the readout is pinned to the pane. The HUD terminal recipe is the sibling: it reads the tape (buy and sell prints) and shows a flow readout, while this one reads only the chart's candles and shows the regime its own indicator numbers describe.
Boxes and labels are handles: objects the Indicator creates, moves and re-words by an integer id (Drawing objects). anchor: "top_left" on both declarations turns their coordinates into pixels from the pane's corner, so the HUD stays put when the chart pans or zooms; align: "left" makes every row's text start at its x, which is what lines the columns up. The two plotted outputs are ema50 on the price pane and rsi14 in the lower pane; atr_pct and rsi_frac are data-only (none), so the lower pane holds RSI alone and the meter reads rsi_frac without a second line being drawn. Three params (ema_len, rsi_len, atr_len) size the recurrences in init(), and the output names and the meter's label spell their defaults. This is also the viewport-hud template: om wrun create scaffolds it, and it compiles as written.
The Indicator
// A regime HUD from indicator numbers: one anchored box and four left-aligned rows pinned to the pane's top-left corner, plus an RSI meter.
import { draw, handles, input, line, lower, none, ohlcv, output, overlay, param, string } from "./sdk/declare"; // the declaring words; draw declares the meter here
import { ALIGN_LEFT, bar, draw as handle, LabelHandle, rgba } from "./gen/draw"; // the handle constructors under another name, the align word, colours, the last-bar signal
import { in_close, in_high, in_low } from "./gen/inputs"; // generated: an in_ reader for each input declared below
import { emitRow, out_atr_pct, out_ema50, out_rsi14, out_rsi_frac } from "./gen/outputs"; // generated: an out_ writer per output, plus emitRow
import { p_atr_len, p_ema_len, p_rsi_len } from "./gen/params"; // generated: a p_ reader for each param declared below
import { sb_clear, sb_f64, sb_int, sb_text, str_text_sb } from "./gen/strings"; // the allocation-free line builder and the slot's sender
param("ema_len", 50, { min: 2, max: 400 }); // the EMA's length in bars; the largest of the three periods is how many bars the daemon reads for a metric or a watch
param("rsi_len", 14, { min: 2, max: 200 }); // the RSI's Wilder length in bars
param("atr_len", 14, { min: 2, max: 200 }); // the ATR's Wilder length in bars
input("close", ohlcv.close); // the primary input: the chart's own candles define the grid; unpinned, so it follows the symbol selector
input("high", ohlcv.high); // the bar's high and low feed the true range
input("low", ohlcv.low);
output("ema50", line, overlay, { color: "#38bdf8", description: "EMA of the close over ema_len bars" }); // a line on the price pane; the name spells the default period
output("rsi14", line, lower, { color: "#a78bfa", description: "RSI over rsi_len bars with Wilder smoothing" }); // the only line in the lower pane
output("atr_pct", none); // data-only: ATR over atr_len bars as a percent of the close, a row in the HUD and a metric on your machine
output("rsi_frac", none); // data-only: rsi14 / 100, the fraction the meter reads
string("text", { max_bytes: 32 }); // one bounded slot every row is written through, one row at a time
handles.box({ anchor: "top_left", color: "#0f172a", borderColor: "#334155", opacity: 0.85, borderWidth: 1 }); // the box behind the rows, in pane pixels
handles.label({ anchor: "top_left", align: "left", color: "#e2e8f0", size: 12 }); // rows whose text starts at x, so the columns line up
draw.meter({ name: "rsi", label: "RSI 14", fraction: { output: "rsi_frac" }, ramp: ["#e5484d", "#8b8f98", "#30a46c"], anchor: "top_right" }); // the meter widget; its label spells the default too
// One recurrence serves all three numbers: a plain average over the first n values seeds it, then value += (x - value) * alpha.
// alpha = 1 / n is Wilder smoothing (what the Rma helper does); alpha = 2 / (n + 1) is the EMA. The value stays NaN until seeded.
class Smoother {
n: i32; alpha: f64; sum: f64 = 0.0; count: i32 = 0; value: f64 = NaN;
constructor(n: i32, alpha: f64) { this.n = n; this.alpha = alpha; }
update(x: f64): f64 {
if (this.count < this.n) { this.sum += x; this.count += 1; if (this.count == this.n) this.value = this.sum / f64(this.n); }
else this.value += (x - this.value) * this.alpha;
return this.value;
}
reset(): void { this.sum = 0.0; this.count = 0; this.value = NaN; }
}
let emaLen = 50; let rsiLen = 14; let atrLen = 14; // the periods, read from the params in init(); the row labels are built from them, so they never lie
let emaOfClose = new Smoother(emaLen, 2.0 / f64(emaLen + 1)); // EMA of the close; init() rebuilds it from the param
let gains = new Smoother(rsiLen, 1.0 / f64(rsiLen)); let losses = new Smoother(rsiLen, 1.0 / f64(rsiLen)); // RSI's two Wilder averages
let ranges = new Smoother(atrLen, 1.0 / f64(atrLen)); // ATR: Wilder smoothing of the true range
const backdrop = handle.box(0); // handle objects allocate once; ids are one space across boxes, labels, lines and polylines
const rows: LabelHandle[] = [handle.label(1), handle.label(2), handle.label(3), handle.label(4)]; // EMA, RSI, ATR%, the state row
let close: f64 = NaN; let prev: f64 = NaN; let ema50: f64 = NaN; let rsi14: f64 = NaN; let atrPct: f64 = NaN; // this bar's numbers
export function init(): void { // read the params (f64) as periods (i32) and build the smoothers from them
emaLen = i32(p_ema_len()); rsiLen = i32(p_rsi_len()); atrLen = i32(p_atr_len());
emaOfClose = new Smoother(emaLen, 2.0 / f64(emaLen + 1)); gains = new Smoother(rsiLen, 1.0 / f64(rsiLen)); losses = new Smoother(rsiLen, 1.0 / f64(rsiLen)); ranges = new Smoother(atrLen, 1.0 / f64(atrLen));
}
export function state(): i32 { // once per bar: read the bar, fold it into the three recurrences, say whether every number is ready
prev = close; close = in_close(); const high = in_high(); const low = in_low();
ema50 = emaOfClose.update(close);
if (isNaN(prev)) return 0; // the first bar has no change and no true range
const change = close - prev;
const gain = gains.update(change > 0.0 ? change : 0.0); const loss = losses.update(change < 0.0 ? -change : 0.0);
rsi14 = loss == 0.0 ? 100.0 : 100.0 - 100.0 / (1.0 + gain / loss); // NaN while the averages seed (NaN == 0.0 is false)
const range = Math.max(high - low, Math.max(Math.abs(high - prev), Math.abs(low - prev))); // the true range
atrPct = (ranges.update(range) / close) * 100.0;
return isNaN(ema50) || isNaN(rsi14) || isNaN(atrPct) ? 0 : 1; // 0 = a warmup row: the chart draws nothing for this bar
}
// finalize() runs after state() returns 1: write the outputs on every bar; draw the HUD on the live bar only.
// A row is one line: build it with sb_* (numbers through sb_f64, periods through sb_int, 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_ema50(ema50); out_rsi14(rsi14); out_atr_pct(atrPct); out_rsi_frac(rsi14 / 100.0);
if (bar.isLast()) { // the newest bar only: history bars never paint the HUD, and each refresh draws it once
const bullish = close > ema50 && rsi14 > 50.0; const bearish = close < ema50 && rsi14 < 50.0; // the regime
const tone = bullish ? rgba(48, 164, 108, 255) : bearish ? rgba(229, 72, 77, 255) : rgba(139, 143, 152, 255); // green, red, grey
backdrop.set(12, 12, 172, 112).color(tone); // pane pixels from the top-left corner: left, top, right, bottom; the border takes the regime colour
const distance = ((close - ema50) / ema50) * 100.0; // the close's distance from the EMA, sign kept
sb_clear(); sb_text("EMA "); sb_int(emaLen); sb_text(" "); if (distance >= 0.0) sb_text("+"); sb_f64(distance, 1); sb_text("%"); // "EMA 50 +1.2%"
rows[0].set(22, 30).text(str_text_sb).align(ALIGN_LEFT); // an explicit align call: it wins over the declared default for this label
sb_clear(); sb_text("RSI "); sb_int(rsiLen); sb_text(" "); sb_f64(rsi14, 1); rows[1].set(22, 50).text(str_text_sb);
sb_clear(); sb_text("ATR% "); sb_f64(atrPct, 2); rows[2].set(22, 70).text(str_text_sb);
sb_clear(); sb_text(bullish ? "BULLISH" : bearish ? "BEARISH" : "NEUTRAL"); rows[3].set(22, 94).text(str_text_sb).color(tone);
}
emitRow();
}
// reset() runs when the chart restarts the series: back to the start; the host rolls the handles back beside the module state.
export function reset(): void {
emaOfClose.reset(); gains.reset(); losses.reset(); ranges.reset(); close = NaN; prev = NaN; ema50 = NaN; rsi14 = NaN; atrPct = NaN;
}How it works
Anchor: pixels from the pane edge. handles.box({ anchor: "top_left", ... }) and handles.label({ anchor: "top_left", ... }) enable the two kinds and set the defaults every new box and label starts from. With the anchor word, backdrop.set(12, 12, 172, 112) is a box from 12 px to 172 px across and 12 px to 112 px down from the pane's top-left corner, and rows[1].set(22, 50) puts a label 22 px in and 50 px down; the chart can pan to last year or zoom to a minute and the HUD does not move. Without the word the same numbers would be epoch seconds and prices, and the readout would scroll away with the bars. The nine corner and centre words pin both axes; top, bottom, left and right pin one axis and leave the other on chart time or price (Drawing objects lists them).
Align: the text starts at x. A label is placed by one point, and by default its text is centred on that point, so RSI 14 61.4 and BULLISH would centre on different widths and the columns would drift. align: "left" makes the text start at x instead; the four rows share x = 22, so their first characters sit on one vertical line and the gaps after the names read as columns. The declaration is the default every new label is created with. rows[0].set(22, 30).text(str_text_sb).align(ALIGN_LEFT) is the per-handle call (it needs a live label, so it follows text(...)), and the call wins for that handle: a new label is created with the sheet's word copied in, and the call overwrites the handle's own word from then on. Here both say left, so nothing changes; ALIGN_RIGHT on that row would right-align it while the other three stay left, and ALIGN_DEFAULT clears the word on that handle and centres it again, sheet default or not. anchor(...) works the same way (prop 7 on the style channel, beside align's prop 8): a per-handle call overrides the declared default for that one handle, and ANCHOR_CHART puts it back on chart time and price.
The live bar only. bar.isLast() is true on the newest bar the host holds (the last row of a full run, the forming bar live) and false on every earlier bar. The whole HUD sits behind it, so a history bar writes its four outputs, emits its row and draws nothing; only the newest bar creates the box and the four labels, and each refresh draws them once. When the forming bar ticks, the host rolls the handles back beside the module state and replays that bar, so the box is created again, never stacked. Without the guard every history bar would paint the same HUD on top of itself, once per bar, for nothing.
Three numbers, one recurrence. Smoother seeds with a plain average over the first n values, then folds each new value in with value += (x - value) * alpha. With alpha = 1 / n that is Wilder smoothing, what the Rma helper and every RSI and ATR use; with alpha = 2 / (n + 1) it is the EMA, seeded the way the Ema helper in ./sdk/ta seeds. RSI is two of them, over the bar's gains and over its losses, then 100 - 100 / (1 + gain / loss); ATR is one of them over the true range, divided by the close and scaled to a percent. The periods are the three params, read in init() as p_ema_len(), p_rsi_len() and p_atr_len() (params are f64, periods i32) and used to build the smoothers there. A code-first source has no other way to say how many bars it needs: a metric read or a watch on your machine fetches the largest param default plus the ready row, 51 bars here, where a module without params would read three and never leave warmup. state() returns 0 until all three are seeded, so the bars before the ema_lenth (the 50th by default) are warmup rows the chart leaves empty. The helpers in Moving averages and Oscillators do the same in one line each; the recurrence is written out here so the numbers behind the HUD are visible.
The meter. draw.meter({ name: "rsi", label: "RSI 14", fraction: { output: "rsi_frac" }, ramp: [...], anchor: "top_right" }) declares a meter the chart draws at the pane's top-right. It reads the last ready value of rsi_frac, which the module emits as rsi14 / 100, a fraction from 0 to 1 the host does not clamp, and colours it along the ramp from red through grey to green. The declaration is validated and erased before the AssemblyScript build, so there is no accessor to call: emitting the output is the whole job. Its keys are name, label, fraction, ramp, and the optional text (a short literal or { slot }), anchor (one of the nine pane positions), offset and z (Drawing primitives).
The regime row. BULLISH when the close is above EMA 50 and RSI 14 is above 50, BEARISH when it is below both, NEUTRAL otherwise. tone is an rgba(...) value (green, red or grey) and .color(tone) restyles one handle on the bar it is drawn: the state row's text, and the box's border. The distance row keeps its sign: sb_f64 writes the minus for a negative and the code adds the plus, so +1.2% and -0.8% read alike. The row names are built from the params (sb_text("EMA "); sb_int(emaLen)), so a chart user who sets ema_len to 100 reads EMA 100 in the HUD, never a stale number. Every row goes through one 32-byte slot: build it with sb_clear, sb_text, sb_int and sb_f64, then text(str_text_sb) sends the slot and draws the label at its last set(x, y); the host copies the bytes at each draw, so one slot serves four labels (String functions).
What kScript (legacy) could not do
- No pixel-anchored drawings. A label or box lived at a time and a price and scrolled away with the chart; the only thing that stayed put was a table with fixed cells. Here
anchor: "top_left"turns a handle's coordinates into pixels from the pane edge, andANCHOR_CHARTputs the same handle back on chart coordinates. - No text alignment word. Text centred on its point, so a column of numbers never lined up.
align: "left"on the declaration, oralign(ALIGN_LEFT)on one label, starts the text at x. - No meter widget. A fraction was a number in a table cell.
draw.meterdraws it as a meter along a colour ramp, fed by one data-only output. - No per-handle style calls. Colours were set once, on a plot or a table declaration.
.color(rgba(...)),.align(...)and.anchor(...)restyle one live handle on the bar it changes, which is how the state row turns green, red or grey by regime.
Customize it
- Change the periods.
ema_len(2 to 400),rsi_lenandatr_len(2 to 200) are params: the chart user sets them in the settings dialog andom metric series --param ema_len=100reads them on your machine, with no rebuild. The HUD's row names are built from them, so they always say what they show; the output names (ema50,rsi14) and the meter'slabelspell the defaults, so rename those if you change a default in the source. - Move the HUD. Change the anchor word on both handle declarations to
top_rightorbottom_left; the pixel offsets are then measured from that corner. Move the meter with its ownanchorand anoffset: [x, y]. - Right-align the numbers. Import
ALIGN_RIGHT, draw each row's value as a second label at the box's right edge (x = 162) and call.align(ALIGN_RIGHT)on it after itstext(...); the names stay left-aligned at 22. - Plot ATR% too.
output("atr_pct", line, lower)draws it in the lower pane beside RSI;nonekeeps it data-only. Either wayom metric seriesreads it. - Colours. The box declaration takes
color,borderColor,opacityandborderWidth, the label declarationcolorandsize; the meter'sramptakes 2 to 5 hex colours; the threergba(...)values infinalize()are the regime tones.
Scaffold, build, install, and read RSI 14 on your machine:
om wrun create @you/viewport-hud ./viewport-hud --template viewport-hud
om wrun build ./viewport-hud
om wrun install ./viewport-hud --replace
om metric series --metric wrun/@you/viewport-hud/rsi14 --symbol BTCUSDT --exchange BINANCE_FUTURES --bars 20Concepts used
- Drawing objects for handles, the anchor and align words, the per-handle setters and
bar.isLast() - Drawing primitives for
draw.meter, pane pixel placement and the style props behindanchor(...)andalign(...) - HUD terminal for the sibling readout over the tape, framed once and refreshed on the live bar
- String functions for the
sb_*builder and the slot sender - Moving averages and Oscillators for the
Ema,RmaandRsihelpers this recipe writes out by hand