Market decision dashboard

A six-row decision table the package keeps beside the chart: the close against its EMA, RSI, ATR as a share of price, volume against its own average, the open-…

A six-row decision table the package keeps beside the chart: the close against its EMA, RSI, ATR as a share of price, volume against its own average, the open-interest change, and the direction of cumulative volume delta, each with a reading and a colour-coded state, refreshed on the live bar. The kScript (legacy) precedent is MMT's market decision dashboard, a text block drawn over the candles; here the table is a panel placed at the side of the price pane and fed by one frame, with an EMA line on price and a bullish count as a metric on your machine.

A frame is one JSON snapshot the package writes for the whole run, and a panel is a view the chart draws from it (Drawing primitives). panel.table declares three columns (Factor, Reading, State), and place: "side" puts the table beside the candles instead of under them. The rows are built as a plain string in the code and written with writeFrame only when bar.isLast() is true; a closed bar just emits its two outputs. The count of bullish factors is a data-only output, so om metric series, a watch or a screen can read the same verdict the table shows. This is also the market-dashboard template: om wrun create scaffolds it, and it compiles as written.

The Indicator

// A decision dashboard beside the chart: six factor rows in a side table refreshed on the live bar, an EMA line on price, and a bullish count as a metric.
import { frame, input, line, none, ohlcv, oi, output, overlay, panel, param, trades } from "./sdk/declare"; // the declaring words
import { bar } from "./gen/draw"; // the last-bar signal
import { FRAME_DASHBOARD, writeFrame } from "./gen/frames"; // generated: the frame's slot constant and its writer
import { in_buy, in_close, in_high, in_low, in_oi, in_sell, in_volume } from "./gen/inputs"; // generated: an in_ reader for each input declared below
import { emitRow, out_ema50, out_score } from "./gen/outputs"; // generated: an out_ writer per output, plus emitRow
import { p_ema_len, p_length, p_window } from "./gen/params"; // generated: a p_ reader for each param declared below
import { Atr, Change, Ema, Roc, Rsi, Sma } from "./sdk/ta"; // the average, momentum, range and change helpers

param("ema_len", 50, { min: 2, max: 500 }); // the trend line's length in bars
param("length", 14, { min: 2, max: 200 }); // the RSI and ATR length in bars
param("window", 20, { min: 2, max: 200 }); // the comparison window: the volume average, the open-interest change, the CVD change
input("close", ohlcv.close); // the primary input: the chart's own candles define the grid every other input lines up on
input("high", ohlcv.high); // the bar's high, one leg of the true range
input("low", ohlcv.low); // the bar's low, the other leg
input("volume", ohlcv.volume); // the bar's volume, read against its own average
input("oi", oi.close, { missing: "nan" }); // open interest at the 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("ema50", line, overlay, { color: "#f59e0b", description: "EMA of the close over ema_len bars" }); // the trend line, drawn on price
output("score", none, overlay, { description: "Bullish factors this bar, 0 to 6" }); // data-only: the count is a metric on your machine; the table is the chart look
const dashboard = frame("dashboard", { max_bytes: 8192 }); // one JSON snapshot for the run: the table's rows, rewritten on the live bar
panel.table({ name: "decision", title: "Decision", x: "category", place: "side", frame: dashboard, series: [{ name: "Factor" }, { name: "Reading" }, { name: "State" }] }); // the view: one name space for frames and panels, so the panel is the decision and the frame its rows

const GREEN = "#22d3a5"; const RED = "#ff5b7f"; const GREY = "#8b949e"; const INK = "#e6edf3"; // the regime palette and the reading ink
let emaLen = 50; let len = 14; let bars = 20; // the params, read once in init()
let ema = new Ema(50); let rsi = new Rsi(14); let atr = new Atr(14); let atrMean = new Sma(20); let volMean = new Sma(20); // per-bar helpers
let oiChange = new Roc(20); let pxChange = new Roc(20); let cvdChange = new Change(20); // the window comparisons
let close: f64 = NaN; let prev: f64 = NaN; let cvd: f64 = 0.0; let score: f64 = 0.0; // module state: the running CVD survives from bar to bar
let emaV: f64 = NaN; let rsiV: f64 = NaN; let atrPct: f64 = NaN; let atrAvg: f64 = NaN; // this bar's readings
let volRatio: f64 = NaN; let oiPct: f64 = NaN; let pxPct: f64 = NaN; let cvdDelta: f64 = NaN; // this bar's readings, continued
const tones = new StaticArray<i32>(6); // one verdict per factor row: 1 bullish, -1 bearish, 0 neutral

export function init(): void { // read the params and size every helper to them
  emaLen = i32(p_ema_len()); len = i32(p_length()); bars = i32(p_window());
  ema = new Ema(emaLen); rsi = new Rsi(len); atr = new Atr(len); atrMean = new Sma(bars); volMean = new Sma(bars);
  oiChange = new Roc(bars); pxChange = new Roc(bars); cvdChange = new Change(bars);
}
// state() runs once per bar: read the bar, update every helper, then judge each factor. A helper still warming reads NaN and its row stays neutral.
export function state(): i32 {
  prev = close; close = in_close(); emaV = ema.update(close); rsiV = rsi.update(close);
  atrPct = (atr.update(in_high(), in_low(), close) / close) * 100.0; atrAvg = atrMean.update(atrPct);
  const volume = in_volume(); volRatio = volume / volMean.update(volume);
  oiPct = oiChange.update(in_oi()); pxPct = pxChange.update(close);
  cvd += in_buy() - in_sell(); cvdDelta = cvdChange.update(cvd);
  tones[0] = !isFinite(emaV) ? 0 : close > emaV ? 1 : close < emaV ? -1 : 0; // trend: which side of the EMA the close sits on
  tones[1] = !isFinite(rsiV) ? 0 : rsiV >= 50.0 ? 1 : -1; // momentum: which side of 50
  tones[2] = !isFinite(atrAvg) ? 0 : atrPct < atrAvg ? 1 : atrPct > atrAvg * 1.5 ? -1 : 0; // volatility: calm below its average, stressed past 1.5x
  tones[3] = !isFinite(volRatio) || volRatio < 1.0 || !isFinite(prev) ? 0 : close > prev ? 1 : close < prev ? -1 : 0; // participation: above-average volume confirms the bar's direction
  tones[4] = !isFinite(oiPct) || !isFinite(pxPct) || oiPct <= 0.0 ? 0 : pxPct > 0.0 ? 1 : pxPct < 0.0 ? -1 : 0; // positioning: rising open interest with price up is longs building, with price down shorts
  tones[5] = !isFinite(cvdDelta) ? 0 : cvdDelta > 0.0 ? 1 : cvdDelta < 0.0 ? -1 : 0; // flow: the CVD's direction over the window
  score = 0.0; for (let i = 0; i < 6; i += 1) if (tones[i] > 0) score += 1.0;
  return isNaN(emaV) ? 0 : 1; // the first ema_len bars are warmup rows: nothing emitted, nothing drawn
}
// The table's text is plain string building: AssemblyScript strings concatenate with +, and fix() writes a number
// with a fixed decimal count (no locale, no exponent). "n/a" stands in for a reading whose helper is not ready.
function fix(v: f64, decimals: i32): string {
  if (!isFinite(v)) return "n/a";
  let scale = 1.0; for (let i = 0; i < decimals; i += 1) scale *= 10.0;
  const scaled = Math.round(Math.abs(v) * scale); const whole = Math.floor(scaled / scale);
  let frac = i64(scaled - whole * scale).toString(); while (frac.length < decimals) frac = "0" + frac;
  return (v < 0.0 && scaled > 0.0 ? "-" : "") + i64(whole).toString() + (decimals > 0 ? "." + frac : "");
}
function signed(v: f64, decimals: i32): string { return v > 0.0 ? "+" + fix(v, decimals) : fix(v, decimals); }
function cell(text: string, color: string): string { return '{"text":"' + text + '","color":"' + color + '"}'; } // a styled table cell
function row(factor: string, reading: string, tone: i32): string { // one table row: the factor, its reading in ink, its state coloured by regime
  const state = tone > 0 ? cell("bullish", GREEN) : tone < 0 ? cell("bearish", RED) : cell("neutral", GREY);
  return '["' + factor + '",' + cell(reading, INK) + "," + state + "]";
}
function rows(): string { // the six factor rows, in the table's order
  return row("Trend vs EMA " + emaLen.toString(), (close >= emaV ? "above by " : "below by ") + fix(Math.abs(close / emaV - 1.0) * 100.0, 1) + "%", tones[0]) + "," +
    row("RSI " + len.toString(), fix(rsiV, 1) + (rsiV > 70.0 ? " overbought" : rsiV < 30.0 ? " oversold" : " neutral"), tones[1]) + "," +
    row("ATR% " + len.toString(), fix(atrPct, 1) + "% vs avg " + fix(atrAvg, 1) + "%", tones[2]) + "," +
    row("Volume vs " + bars.toString() + "-bar avg", fix(volRatio, 2) + "x", tones[3]) + "," +
    row("Open interest " + bars.toString() + " bars", isNaN(oiPct) ? "n/a" : signed(oiPct, 1) + "%", tones[4]) + "," +
    row("CVD " + bars.toString() + " bars", (cvdDelta > 0.0 ? "rising " : cvdDelta < 0.0 ? "falling " : "flat ") + signed(cvdDelta, 1), tones[5]);
}
// finalize() runs after state() returns 1: the line and the count go out every bar; the table is one frame written on the live bar only.
export function finalize(): void {
  out_ema50(emaV); out_score(score);
  if (bar.isLast()) writeFrame(FRAME_DASHBOARD, '{"rows":[' + rows() + "]}");
  emitRow();
}
// reset() runs when the chart restarts the series: back to what init() built, the running CVD at zero.
export function reset(): void {
  ema.reset(); rsi.reset(); atr.reset(); atrMean.reset(); volMean.reset(); oiChange.reset(); pxChange.reset(); cvdChange.reset();
  close = NaN; prev = NaN; cvd = 0.0; score = 0.0; emaV = NaN; rsiV = NaN; atrPct = NaN; atrAvg = NaN; volRatio = NaN; oiPct = NaN; pxPct = NaN; cvdDelta = NaN;
  for (let i = 0; i < 6; i += 1) tones[i] = 0;
}

How it works

Seven inputs, one grid. close is the primary input and defines the grid every other input lines up on. high and low feed the true range, volume is read against its own average, and oi is oi.close with missing: "nan", so a bar without an open-interest reading reads NaN there instead of the row being withheld: Roc turns a NaN into a NaN change, the row reads n/a and stays neutral. On a market with no open interest at all (spot, for one) that is every bar, so the other five rows and the EMA line draw as usual and the score tops out at 5; a carry would abstain every row until a first observation that never comes. buy and sell are the two halves of the tape, trades.volume with a side and missing: "zero", the Aggregated CVD shape. Three params size every helper in init(): ema_len for the trend line, length for RSI and ATR, window for the volume average and the open-interest and CVD changes.

Six verdicts and a count. state() updates the helpers, then gives each factor a tone in tones: 1 bullish, -1 bearish, 0 neutral. Trend is the side of the EMA the close sits on. RSI is the side of 50; the zone word (oversold, neutral, overbought) is the reading, not the verdict. ATR% is calm, and bullish, below its own window-bar average, and stressed, bearish, past 1.5 times it. Volume counts only above its average, and then takes the bar's direction against the previous close. Open interest counts only when it rose over the window: bullish with price up over the same window (longs building), bearish with price down (shorts building). CVD is its own direction over the window. A helper still warming reads NaN and its row stays neutral. score is the number of bullish tones, 0 to 6, and state() returns 0 until the EMA is seeded, so the first ema_len bars are warmup rows.

The table is one frame, written on the live bar. frame("dashboard", { max_bytes: 8192 }) declares the snapshot and panel.table({ ... frame: dashboard }) binds the decision panel to it; names are one space across outputs, frames and panels, which is why the frame and the panel carry different names. The rows are JSON built with plain string concatenation: cell() makes a { text, color } object, row() puts a factor, its reading in ink and its state in the regime colour side by side, and rows() joins the six. finalize() writes the two outputs on every bar and, when bar.isLast() is true, calls writeFrame(FRAME_DASHBOARD, ...). The last write per slot is the snapshot for the run, so on a closed bar nothing is built and nothing is written.

Numbers without a locale. fix(v, decimals) rounds to a fixed number of decimals and pads the fraction (1.05, -0.8), one decimal for percentages and two for ratios, and reads n/a for a value that is not finite; signed() adds the plus sign on the open-interest and CVD changes. Every cell stays under 24 characters, and the four colours are hex strings at the top of the file.

A metric beside the look. ema50 is a line on the price pane. score is data-only (plot none): the chart never draws it, but om metric series reads it on every bar, and a watch can act on it, so the table's verdict is also a number your machine can compare.

What kScript (legacy) could not do

  • No table beside the chart. plotTable drew a text block over the candles at a corner position, and nothing could sit at the side of the price pane. Here the table is a declared panel with place: "side", outside the candles.
  • No per-cell colour. A kScript table had one text colour and one background for every cell, so a state had to be spelled out in words. Here a cell is a { text, color } object, and the State column is green, red or grey by regime.
  • No open-interest input. The old sources were candles and trade volume; open interest could not be read at all. Here oi.close is one more input line, with missing: "nan" saying what a bar without a reading shows.
  • No side-split tape as an input. The old dashboard read the candle's one volume number; a buy/sell split meant a separate source(type="buy_sell_volume") timeseries with its own na-guards (Aggregated CVD shows the form). Here buy and sell are two trades.volume inputs with a side and a missing policy, on the same grid as the candles.
  • The old dashboard recipes repainted every bar: the table was rebuilt on each evaluation, isLastBar or not. Here the rows are built and written once per refresh, on the live bar only, and a closed bar costs two outputs and nothing else.

Customize it

  • Change a rule. Each factor's verdict is one line in state(), tones[0] to tones[5]. A contrarian RSI is rsiV < 30.0 ? 1 : rsiV > 70.0 ? -1 : 0; the stress multiplier for ATR% is the 1.5 on the tones[2] line.
  • Change a window. ema_len, length and window are params with their ranges on the param lines; the row labels are built from them, so EMA 50 becomes EMA 100 on its own.
  • Add a row. Add a helper, a reading and a tone (size tones to 7), then append one more row(...) in rows(); the score's ceiling rises with it, and a table panel takes up to 32 rows.
  • Move or restyle the table. place: "below" puts it under the chart. Pass the regime colour instead of INK to tint the reading, or give a cell a bar (0 to 1) or a spark array beside its text (Drawing primitives lists the cell fields).
  • Plot the score. output("score", line, lower) draws the count in its own pane; the none plot keeps it data-only.

Scaffold, build, install, and read the score on your machine:

om wrun create @you/market-dashboard ./market-dashboard --template market-dashboard
om wrun build ./market-dashboard
om wrun install ./market-dashboard --replace
om metric series --metric wrun/@you/market-dashboard/score --symbol BTCUSDT --exchange BINANCE_FUTURES --bars 20

Concepts used