Multi-timeframe terminal

The chart's own trend on the price pane, a side window of the 4h context, and the last 4h swing levels docked on the price axis, all from one file. Two EMA…

The chart's own trend on the price pane, a side window of the 4h context, and the last 4h swing levels docked on the price axis, all from one file. Two EMA lines (21 and 55) sit on the candles and a bias histogram sits below them; a line panel placed at the side draws the 4h closes of every candle closed inside the last span chart bars (480 by default, up to 200 candles) with their 4h EMA 8 and EMA 21 on their own time axis; two levels docked on the right price axis mark the lowest 4h low and the highest 4h high of the last 12 4h candles, each lighting up on the bar that tags it. The kScript (legacy) precedent is MMT's Multi-timeframe terminal; here the coarse candles come in through three inputs pinned to 4h, and every view is a frame the package writes on the live bar. This is also the multi-timeframe-terminal template.

The 4h inputs follow the rule from Multi-timeframe: a coarse value lands on the first primary row whose close is at or after that 4h candle's close and is carried until the next one lands, so no forming candle ever leaks into the window, the EMAs or the levels. The ring below is keyed on that rule, so a candle carried across several rows is one entry, never one per row.

The Indicator

// A multi-timeframe terminal: the chart's own trend on the price pane, a side window of the 4h context, and the last 4h swing levels docked on the price axis.
import { frame, histogram, input, line, lower, ohlcv, output, overlay, panel, param, plot, time } from "./sdk/declare"; // the declaring words
import { bar } from "./gen/draw"; // the last-bar signal
import { FRAME_CONTEXT, FRAME_SWINGS, writeFrame } from "./gen/frames"; // generated: a slot constant per frame declared below, plus the writer
import { in_close, in_close_4h, in_high, in_high_4h, in_low, in_low_4h, in_t } from "./gen/inputs"; // generated: an in_ reader for each input
import { emitRow, out_bias, out_ema21, out_ema55 } from "./gen/outputs"; // generated: an out_ writer per output, plus emitRow
import { p_lookback, p_span } from "./gen/params"; // generated: a p_ reader per param declared below
import { Ema } from "./sdk/ta"; // the moving-average helper for the chart's own two EMAs

param("span", 480, { min: 56, max: 500 }); // chart bars a read covers: the side window lists every 4h candle closed inside them (up to the ring's 200), and a metric read fetches that many bars
param("lookback", 12, { min: 2, max: 100 }); // 4h candles the swing levels span
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); // this bar's high: it lights the swing high on the bar that tags it
input("low", ohlcv.low); // this bar's low: the same for the swing low
input("close_4h", ohlcv.close, { interval: "4h", missing: "carry" }); // the 4h close, landing on the first row whose close is at or after the candle's close, then carried
input("high_4h", ohlcv.high, { interval: "4h", missing: "carry" }); // the 4h high, the same landing rule
input("low_4h", ohlcv.low, { interval: "4h", missing: "carry" }); // the 4h low
input("t", time.bar_open_sec); // this bar's open time in epoch seconds; with the bar's width it gives the row's close, and so the last closed 4h candle
output("ema21", line, overlay, { color: "#38bdf8", width: 2 }); // the chart's own EMA 21
output("ema55", line, overlay, { color: "#f472b6", width: 2 }); // the chart's own EMA 55
output("bias", histogram, lower, { color: "#a3e635" }); // +1 above both EMAs with the 4h close above its 4h EMA 21, -1 the mirror, else 0
const context = frame("context", { max_bytes: 16384 }); // the side window's rows, one per closed 4h candle
const swings = frame("swings", { max_bytes: 2048 }); // the two docked levels
panel.line({ name: "context_window", title: "4h context", x: "time", place: "side", frame: context, series: [{ name: "4h close" }, { name: "4h EMA 8" }, { name: "4h EMA 21" }] });
plot.levels({ name: "swing_levels", frame: swings, dock: "right", labels: true, color: "#f5a623" });

const H4: f64 = 14400.0; const MAX = 200; // the candle width in seconds; the ring holds the last 200 closed candles
const times = new StaticArray<f64>(MAX); const closes = new StaticArray<f64>(MAX); const highs = new StaticArray<f64>(MAX); const lows = new StaticArray<f64>(MAX);
const ema8s = new StaticArray<f64>(MAX); const ema21s = new StaticArray<f64>(MAX); // the 4h EMAs, one value per ring entry, stepped from the entry before
const bytes = new StaticArray<u8>(16384); let used = 0; // the window's JSON, appended as UTF-8 bytes row by row; a growing string or an Array join copies the whole text again per row
let ema21 = new Ema(21); let ema55 = new Ema(55); // the chart's own EMAs, fed the primary close every bar
let span = 480.0; let lookback = 12; let head = -1; let count = 0; let candle: f64 = NaN; // the ring: head is the newest entry, count the entries in use, candle the newest entry's id
let t: f64 = NaN; let prevT: f64 = NaN; // this bar's open time and the previous one: their gap is the bar's width
let close: f64 = NaN; let high: f64 = NaN; let low: f64 = NaN; let fast: f64 = NaN; let slow: f64 = NaN; let bias: f64 = 0.0; // this bar

function ema(prev: f64, x: f64, n: f64): f64 { const a = 2.0 / (n + 1.0); return isNaN(prev) ? x : x * a + prev * (1.0 - a); } // one EMA step; the first candle seeds it
function put(text: string): void { // append one piece of JSON to the buffer; a window past the frame's cap is left short, and the host refuses it by name
  if (used + String.UTF8.byteLength(text) <= bytes.length) used += i32(String.UTF8.encodeUnsafe(changetype<usize>(text), text.length, changetype<usize>(bytes) + used));
}

// init() runs once before the first bar: read the params (the window's span in chart bars, the swing span in candles), then reserve two pages
// (128 KiB) of headroom for the live bar's write, about 72 KiB at 200 rows: the stub runtime never frees a string and memory may not grow after init().
export function init(): void { span = p_span(); lookback = i32(p_lookback()); memory.grow(2); }
// state() runs once per bar: feed the chart's EMAs, then fold the 4h inputs into the ring. A 4h value lands as of its candle's close and is carried,
// so the ring is keyed on the last CLOSED candle as of this row's close (the open time plus the bar's width): a new id appends an entry, the
// same id replaces the newest entry's close, high and low with the delivered candle. The 4h EMAs step once per entry, never per primary bar.
export function state(): i32 {
  prevT = t; t = in_t(); close = in_close(); high = in_high(); low = in_low(); fast = ema21.update(close); slow = ema55.update(close);
  const c4 = in_close_4h(); const h4 = in_high_4h(); const l4 = in_low_4h();
  if (!isNaN(prevT) && !isNaN(c4) && !isNaN(h4) && !isNaN(l4)) { // the first row has no width yet, and nothing has landed at the start of a grid: the ring waits
    const id = Math.floor((t + (t - prevT)) / H4) - 1.0; // the candle that closed last as of this row's close; its open time is id * H4
    if (id != candle) { candle = id; head = (head + 1) % MAX; if (count < MAX) count += 1; times[head] = id * H4; }
    closes[head] = c4; highs[head] = h4; lows[head] = l4; // one closed candle per entry: the delivered values replace, nothing folds
    const prev = count > 1 ? (head + MAX - 1) % MAX : -1;
    ema8s[head] = ema(prev < 0 ? NaN : ema8s[prev], c4, 8.0); ema21s[head] = ema(prev < 0 ? NaN : ema21s[prev], c4, 21.0);
  }
  const c4Now = count > 0 ? closes[head] : NaN; const e21Now = count > 0 ? ema21s[head] : NaN; // the newest 4h candle and its EMA 21
  bias = isNaN(fast) || isNaN(slow) || isNaN(e21Now) ? 0.0 : close > fast && close > slow && c4Now > e21Now ? 1.0 : close < fast && close < slow && c4Now < e21Now ? -1.0 : 0.0;
  return isNaN(slow) ? 0 : 1;
}
// finalize() runs after state() returns 1: write the outputs, and on the live bar only, the two frames the side window and the
// docked levels read. A frame is a snapshot of the run (its last write wins), so closed bars never pay for the JSON.
export function finalize(): void {
  out_ema21(fast); out_ema55(slow); out_bias(bias);
  if (bar.isLast() && count > 0) {
    const from = t + (t - prevT) * (1.0 - span); // the span's start: this row's close minus span bar widths; the window lists the candles closed after it
    let shown = 0; while (shown < count && times[(head + MAX - shown) % MAX] + H4 > from) shown += 1; // newest first, stopping at the first candle closed before the span
    used = 0; put('{"rows":['); // oldest first: [the candle's open in epoch seconds, 4h close, 4h EMA 8, 4h EMA 21]
    for (let k = shown - 1; k >= 0; k -= 1) {
      const i = (head + MAX - k) % MAX; put(k == shown - 1 ? "[" : ",[");
      put(i64(times[i]).toString()); put(","); put(closes[i].toString()); put(","); put(ema8s[i].toString()); put(","); put(ema21s[i].toString()); put("]");
    }
    put("]}"); writeFrame(FRAME_CONTEXT, String.UTF8.decodeUnsafe(changetype<usize>(bytes), used)); // one string from the bytes, then the frame
    let hi = -Infinity; let lo = Infinity; const n = lookback < count ? lookback : count; // the last lookback candles, newest first
    for (let k = 0; k < n; k += 1) { const i = (head + MAX - k) % MAX; hi = Math.max(hi, highs[i]); lo = Math.min(lo, lows[i]); }
    if (lo < hi) { // prices must be strictly increasing, so two equal levels skip the write and the last snapshot stands
      const lowColor = low <= lo ? "#f8fafc" : "#f5a623"; const highColor = high >= hi ? "#f8fafc" : "#f5a623"; // lit on the bar that tags the level
      writeFrame(FRAME_SWINGS, '{"prices":[' + lo.toString() + "," + hi.toString() + '],"values":[1,1],"colors":["' + lowColor + '","' + highColor + '"]}');
    }
  }
  emitRow();
}
// reset() runs when the chart restarts the series: back to what init() built, the ring emptied (its arrays need no clearing).
export function reset(): void { ema21.reset(); ema55.reset(); head = -1; count = 0; candle = NaN; t = NaN; prevT = NaN; close = NaN; high = NaN; low = NaN; fast = NaN; slow = NaN; bias = 0.0; }

How it works

Three inputs at 4h, one landing rule. close_4h, high_4h and low_4h are the chart's own market read at a coarser interval: { interval: "4h", missing: "carry" } on each. The host fetches the real 4h feed and lands each candle's value on the first primary row whose close is at or after that candle's close, then carries it forward until the next candle lands, so inside a 4h period the three values hold one closed candle and never the forming one. On a 1h chart the candle that opened at 00:00 closes at 04:00, lands on the row that closes at 04:00 (the 03:00 row) and is carried across the 04:00, 05:00 and 06:00 rows; the 07:00 row brings the next one. The primary input close stays unpinned and selector-following, as always: it defines the grid the 4h values are projected onto.

One entry per closed candle. t is time.bar_open_sec, the bar's open time in epoch seconds; the gap between two consecutive readings is the bar's width, so t + width is the row's close and floor((t + width) / 14400) - 1 is the id of the last 4h candle closed at or before it, the candle the landing rule delivers on that row. The ring is four module-level arrays (times, closes, highs, lows) plus two for the 4h EMAs, 200 entries indexed by head and count, so it survives from bar to bar exactly like the accumulator in Aggregated CVD. A row whose id differs from the newest entry's appends an entry keyed by the candle's open time (id * 14400); a row with the same id replaces the newest entry's close, high and low with the delivered values, because the delivered candle is one closed candle and not a running fold. That is what keeps the ring honest under the carry: the four 1h rows that hold the 00:00 candle are one entry, never a second entry holding the same candle, and a value that lands a row late corrects the entry it belongs to instead of merging two candles' highs and lows. The first row of a run has no width yet and skips the ring; the next row appends the same candle.

True 4h EMAs. The 4h EMA 8 and EMA 21 in the side window are computed from the ring, not from primary bars: when an entry is appended, its EMA is one step from the entry before it (ema(prev, x, n), seeded by the first candle), and a replaced close redoes that one step. They step once per 4h candle and never see a 1m close, which is what makes them 4h EMAs rather than resampled ones. The chart's own EMA 21 and EMA 55 are ordinary Ema helpers from ./sdk/ta fed the primary close every bar, and state() returns 0 until the slower one has warmed up.

Two frames, two views. context (16 KiB) holds the side window: one row per candle closed inside the last span chart bars (up to the ring's 200), oldest first, [the candle's open in epoch seconds, 4h close, 4h EMA 8, 4h EMA 21], and panel.line draws it beside the chart with place: "side", x: "time" and three named series. The rows are appended as UTF-8 bytes into one preallocated buffer through put(...) and decoded into a string once: the sandbox lets no memory grow after init() and the stub runtime never frees, so a string grown with += or an Array join, which copies the whole text again for every row, would cost megabytes at 200 rows, while the buffer costs one short string per number, about 72 KiB in all, which the two pages init() reserves with memory.grow(2) pay for. swings (2 KiB) holds { prices: [low, high], values: [1, 1], colors: [...] }, the lowest 4h low and the highest 4h high over the last lookback entries, and plot.levels docks it on the right price axis with labels. Both writes sit under bar.isLast(): a frame is a snapshot of the run whose last write wins, so closed bars build no JSON at all. Level prices must be strictly increasing, so when the two are equal the write is skipped and the previous snapshot stands. The two colors come from the primary high and low: a level turns white on the bar whose range reaches it, and amber otherwise.

The bias. bias is +1 when the close is above both EMAs and the newest 4h close is above its 4h EMA 21, -1 when the close is below both and the 4h close is below its 4h EMA 21, and 0 for anything mixed. It is a histogram in the lower pane and a metric on your machine, so a watch can read it without the chart.

What kScript (legacy) could not do

  • No side window of another timeframe: every plot sat on the chart's own time axis, so a 4h series could only be stretched across 1m bars as an overlay. Here panel.line with place: "side" draws the ring on its own time axis, one point per 4h candle.
  • No docked levels on the price axis: a level was a horizontal line across the pane. Here plot.levels docks the two swing prices on the right axis with labels and a color per level.
  • No ring of coarse candles inside the script: htf() handed back one value per bar. Here the module keeps the last 200 closed 4h candles itself and writes them as one snapshot.
  • No per-input interval: the script's inputs shared the chart's interval. Here { interval: "4h" } is a word on the input, honored as of each candle's close.

Customize it

  • Span and lookback. span (56 to 500) is how many chart bars a read covers: the side window lists every 4h candle closed inside them, up to the ring's 200 (480 bars of 1h hold 120 candles; a 4h chart fills the ring), and a metric read or a watch on your machine fetches span bars, so the primary EMA 55 is always warm (a read sizes itself to a window param up to 500 bars). lookback (2 to 100) is how many candles the swing levels span. Both are params, so the chart user changes them without a rebuild. A ring above 200 needs a larger MAX and a larger bytes buffer and context frame: a row is about 65 bytes, so 200 rows fit the 16 KiB frame.
  • EMA lengths. The chart's EMAs are new Ema(21) and new Ema(55); the 4h ones are the 8.0 and 21.0 passed to ema(...) in state(). Rename the outputs and the series titles to match.
  • Another candle. Change H4 to 86400.0 and the interval to "1d" on the three pinned inputs for a daily window; the candle id and the ring stay the same.
  • Where the views sit. place: "below" puts the window under the chart instead of beside it; dock: "left" moves the levels; width_frac (0.05 to 0.5) widens the docked strip and poc: true marks the larger of the two values.
  • A graded bias. Count the three conditions instead of requiring all of them: i32(close > fast) + i32(close > slow) + i32(c4Now > e21Now) minus the same three for below gives a value from -3 to +3, and the histogram shows how much of the stack agrees.

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

om wrun create @you/multi-timeframe-terminal ./multi-timeframe-terminal --template multi-timeframe-terminal
om wrun build ./multi-timeframe-terminal
om wrun install ./multi-timeframe-terminal --replace
om metric series --metric wrun/@you/multi-timeframe-terminal/bias --symbol BTCUSDT --exchange BINANCE_FUTURES --bars 20

Concepts used