---
title: "Rotation map"
description: "Eight large coins on one map under the chart, placed by how they are doing against BTC. Left to right is strength: how far each coin's price in BTC sits above…"
order: 106
section: "cookbook"
---

<!-- source: docs/indicators/cookbook/rotation-map.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Rotation map

![Rotation map pane under a BTC chart with eight labelled coin dots and their fading trails in four tinted quadrants, a chip naming the leader and the terminal card at the top left](/wrun/images/rotation-map.png)

Eight large coins on one map under the chart, placed by how they are doing against BTC. Left to right is strength: how far each coin's price in BTC sits above or below its own longer average. Bottom to top is momentum: whether that strength is still rising or already fading. Two dashed guides through 100 cut the map into the four quadrants a rotation moves through, clockwise: improving (top left, sky), leading (top right, teal), weakening (bottom right, amber), lagging (bottom left, orange), each quietly tinted and named in its corner. One labelled dot per coin, all one size and in its quadrant's colour, and behind each a short tail of its earlier positions on a thin line, the tail's dots smaller and fainter toward the far end. Every position is read from closed hourly candles with the same averages on every chart, so a coin sits at the same place on a 1m, a 15m and a 1h chart and the map moves once an hour; only the tail's reach follows the chart: the last day on charts up to 5m, three days up to 30m, a week on 1h and coarser. The dot farthest top right is the leader: its name carries its move against BTC over the tail ("SOL +4.1%"), and a chip on the panel's title row names it with its quadrant ("SOL leading"). A card at the top left of the price pane, in the terminal look (an amber title bar, monospace type, labels in capitals), answers first: the leader and its move against BTC as the headline, in its quadrant's colour, then how many coins are improving and how many weakening. The card's title says what the tail covers ("Rotation vs BTC, last 24h", "last 3d" or "last 7d").

The parts are nine `candles.cells` streams of closed hourly candles on Binance Futures, BTC as the reference plus eight coins, beside the chart's own close ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream), [Data sources](../core-concepts/data-sources.md#celled-sources)), the chart's bar interval from `chart.interval_sec()` for the tail step ([Sessions and units](../settings/sessions-and-units.md#the-interval-and-the-colours)), a frame feeding a `panel.scatter` with `guides`, `quadrants`, `trails` and dot `labels` ([Cards, frames and panels](../presentation/cards-frames-panels.md)), four `param.color` quadrant inks read in `onStart()` and written into the frame as hex ([The Style page](../settings/style-page.md), [Colors kit](../functions/colors-kit.md)), a `render.hud` card in the terminal look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)), and a label handle for the one sentence ([Drawing objects](../presentation/drawing-objects.md)). This is also the `rotation-map` template: the **Rotation Map** card under **Beyond the time axis** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=rotation-map
// Rotation Map: eight large coins on a scatter below the chart, each placed by its strength against BTC (x) and the
// momentum of that strength (y), both read from closed hourly candles with the same averages on every chart, so a
// coin sits at the same place on a 1m, a 15m and a 1h chart. Per coin and per hourly candle: the ratio to BTC is
// smoothed by an EMA (14 days); the strength is 100 plus how far that smoothed ratio sits from its own longer average
// (18 days), in units of its usual distance from it; the momentum is 100 plus how far the strength sits from its own
// short average (9 days), in the same units. Two dashed guides cut the pane into four quadrants at 100/100, faintly
// tinted: leading (strong and still rising, teal), improving (weak but rising, sky), weakening (strong but fading,
// amber), lagging (weak and falling, orange). Every coin is a labelled dot in its quadrant's colour with a short tail
// of six earlier positions, one per tail step, small and faint at the far end. The tail step follows the chart, so a
// tail covers the last day on a 1m chart, three days on a 15m chart and a week on a 1h chart. The dot farthest
// top-right is the leader: its label carries its move against BTC over the tail, the chip on the title row names it,
// and a terminal card at the top left answers first: who leads, by how much, and how many coins are improving and
// weakening. The map and the words are written on the live bar only.

section("Map");
param.int("tail_points", 6, { min: 0, max: 12, label: "Tail points", description: "Earlier positions drawn behind each coin, one per tail step (0 = heads only)" });
param.int("smooth", 14, { min: 1, max: 21, label: "Smoothing in days", description: "Days the ratio to BTC is smoothed over (an EMA)" });
param.int("trend", 18, { min: 3, max: 28, label: "Trend average in days", description: "Days of the longer average the strength is measured from" });
param.int("momentum", 9, { min: 1, max: 14, label: "Momentum average in days", description: "Days of the short average the momentum is measured from" });
section("Quadrant colours");
param.color("leading_ink", "#2dd4bf", { label: "Leading", description: "Strong against BTC and still rising" });
param.color("improving_ink", "#38bdf8", { label: "Improving", description: "Weak against BTC but rising" });
param.color("weakening_ink", "#f8c000", { label: "Weakening", description: "Strong against BTC but fading" });
param.color("lagging_ink", "#f86800", { label: "Lagging", description: "Weak against BTC and falling" });

// The chart's bar interval in seconds, written by the chart before the first bar: it sets the tail step, 4 hours on
// charts up to 5m, 12 hours up to 30m, 28 hours on 1h and coarser (0, unknown, counts as a 15m chart).
chart.interval_sec();

// Inputs: the chart's own candles set the bar grid; then BTC and the eight coins as streams of closed hourly candles
// on Binance Futures, the newest 1500 of them on the first bar and each later one on the bar it closes with, whatever
// the chart's interval. To swap a coin: change the symbol in its input line and the name at the same position in
// COIN_NAMES below (keep eight). Ten sources is the chart's cap per indicator, so nothing else may read a source.
input("close", ohlcv.close);
input("btc", candles.cells, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500, description: "BTC hourly candles, the reference every coin is measured against" });
input("eth", candles.cells, { symbol: "ETHUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 });
input("sol", candles.cells, { symbol: "SOLUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 });
input("xrp", candles.cells, { symbol: "XRPUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 });
input("bnb", candles.cells, { symbol: "BNBUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 });
input("doge", candles.cells, { symbol: "DOGEUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 });
input("ada", candles.cells, { symbol: "ADAUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 });
input("avax", candles.cells, { symbol: "AVAXUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 });
input("link", candles.cells, { symbol: "LINKUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 });

// Readings, data only: the card's rows read the counts, the Console and a watch can read them on every bar.
output("leading", none, overlay, { description: "Coins in the leading quadrant: strength above 100 and momentum above 100" });
output("improving", none, overlay, { description: "Coins in the improving quadrant: strength below 100, momentum above 100" });
output("weakening", none, overlay, { description: "Coins in the weakening quadrant: strength above 100, momentum below 100" });
output("lagging", none, overlay, { description: "Coins in the lagging quadrant: strength and momentum both below 100" });
output("leader_quadrant", none, overlay, { description: "The quadrant of the dot farthest top-right, the headline's colour: 0 leading, 1 improving, 2 weakening, 3 lagging" });
string("leader", { max_bytes: 32 }); // the headline: the leader and its move against BTC over the tail
string("window_text", { max_bytes: 16 }); // the card's title reads it: "last 24h"
string("notice", { max_bytes: 64 }); // the one slate line while the hourly candles load

// The one slate line: a label handle pinned at the top right of the price pane, clear of the chart's own chrome.
handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 12, style: "plain", safe_area: true });

// The map: one scatter frame rewritten on the live bar, rows [coin, x, y, size, colour, label]. Rows that share a coin
// key form its tail (oldest first, the head last), a thin line fading toward the far end; the dashed guides cut the
// four quadrants, each faintly tinted and named in its corner; the chip and the tints are rewritten per run.
const rotationRows = frame("rotation_rows", { max_bytes: 16384 });
panel.scatter({
  name: "rotation",
  title: "Rotation vs BTC",
  x: "number",
  place: "below",
  frame: rotationRows,
  height_frac: 0.45,
  chrome: "grid",
  labels: true,
  label_overlap: "leader",
  legend_style: "none",
  hover_card: true,
  format: "0.0",
  x_title: "Strength vs BTC",
  y_title: "Momentum",
  guides: [
    { axis: "x", value: 100, color: "theme.muted", line_style: "dashed" },
    { axis: "y", value: 100, color: "theme.muted", line_style: "dashed" },
  ],
  quadrants: { x: 100, y: 100, colors: ["#2dd4bf0c", "#38bdf80c", "#f868000c", "#f8c0000c"], labels: ["Leading", "Improving", "Lagging", "Weakening"] },
  trails: true,
  trail_width: 1,
  trail_fade: true,
});

// The card, in the terminal look (an amber title bar, monospace type): the leader and its move against BTC as the
// headline, coloured by its quadrant, then the two counts the rotation is made of. {{window_text}} reads the slot.
render.hud("rotation_card", {
  position: "top_left",
  look: "terminal",
  title: "Rotation vs BTC, {{window_text}}",
  columns: 1,
  width: 210,
  safe_area: true,
  tiles: [
    tile.pill("Leader", "leader", { headline: true, color_by: "leader_quadrant", colors: ["#2dd4bf", "#38bdf8", "#f8c000", "#f86800"] }),
    tile.rows([
      ["Improving", "improving", "int"],
      ["Weakening", "weakening", "int"],
    ]),
  ],
});

// The coins, in the order of the inputs above.
const COINS = 8;
const COIN_NAMES: StaticArray<string> = ["ETH", "SOL", "XRP", "BNB", "DOGE", "ADA", "AVAX", "LINK"];
const QUADRANT_WORDS: StaticArray<string> = [" leading", " improving", " weakening", " lagging"]; // the chip's word by quadrant
const HEX1: StaticArray<string> = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]; // hex digits, written one at a time so a colour never allocates
const TUPLE = 6; // one candle in a stream block: [offset_ms, open, high, low, close, volume]

// Every coin's (strength, momentum, log ratio) per hourly candle, in a ring as long as the longest tail can reach:
// 12 points at the 28-hour step, plus the head.
const MAX_STEP = 28;
const MAX_TAIL = 12;
const HIST = MAX_TAIL * MAX_STEP + 1;
const histX = new StaticArray<f64>(COINS * HIST);
const histY = new StaticArray<f64>(COINS * HIST);
const histR = new StaticArray<f64>(COINS * HIST);

// Per coin: the last close (a missing hour carries it), the smoothed log ratio, its longer average and the average
// squared distance from it (with the share of weight the average has seen so far, so an early reading is not
// inflated), the short average of the strength and the average squared momentum (with its weight), and the counts.
const lastClose = new StaticArray<f64>(COINS);
const smoothed = new StaticArray<f64>(COINS);
const trendMean = new StaticArray<f64>(COINS);
const trendVar = new StaticArray<f64>(COINS);
const trendWeight = new StaticArray<f64>(COINS);
const momMean = new StaticArray<f64>(COINS);
const momVar = new StaticArray<f64>(COINS);
const momWeight = new StaticArray<f64>(COINS);
const seen = new StaticArray<i32>(COINS); // hourly candles folded with a ratio
const zSeen = new StaticArray<i32>(COINS); // of those, candles with a strength
const cursor = new StaticArray<i32>(COINS); // where this bar's merge stands in the coin's block

// The live bar's map, per coin.
const strength = new StaticArray<f64>(COINS); // the head's x, NaN without values
const momentum = new StaticArray<f64>(COINS); // the head's y
const move = new StaticArray<f64>(COINS); // the coin's move against BTC over the tail, percent
const quadrant = new StaticArray<i32>(COINS); // 0 leading, 1 improving, 2 weakening, 3 lagging, -1 without values
const order = new StaticArray<i32>(COINS); // coin indices by distance top-right, the leader first
const tailDrawn = new StaticArray<i32>(COINS); // tail points the live bar draws behind each head
const inkR = new StaticArray<i32>(4); // the four quadrant inks from the settings, one channel each
const inkG = new StaticArray<i32>(4);
const inkB = new StaticArray<i32>(4);

const HEAD_SIZE = 5.0; // the head's radius, px, the same for every coin
const TAIL_MIN = 1.5; // tail dot radii, the far end to the newest
const TAIL_MAX = 2.5;
const TAIL_ALPHA_MIN = 0x26; // tail dot alpha, the far end to the newest (the line between them fades on its own)
const TAIL_ALPHA_MAX = 0x8c;
const TINT_ALPHA = 0x0c; // the quadrant tints, quiet
const REACH_MIN = 0.5; // each axis reaches at least half a usual distance past 100 on both sides: a calm hour still shows a cross
const SIDE_MIN = 0.3; // the axes fit the dots, and the side of a guide with no dots still keeps 30% of the axis
const PAD_X = 0.08; // room past the farthest dot, as a share of the axis, so no head sits on the pane's edge; more
const PAD_Y = 0.15; // above and below, where the quadrant words sit in the corners

const notice = draw.label(0); // the one slate line; a handle allocates once at module load

let step = 12; // hours between two tail points, from the chart interval
let tailPoints = 6; // settings, read in onStart()
let smoothLen = 336; // the three averages, in hourly candles
let trendLen = 432;
let momLen = 216;
let aSmooth = 0.0;
let aTrend = 0.0;
let aMom = 0.0;
let histHead = 0; // the ring slot of the newest hourly candle
let hours = 0; // hourly candles folded so far
let rows = 0; // scatter rows written so far on this live bar
let noticeShown = false;
let axisLo = 0.0; // fitAxis() writes its two ends here
let axisHi = 0.0;

// The coin's stream by position, in the order of the inputs.
function coinCells(c: i32): i32 {
  switch (c) {
    case 0: return in_eth_cells();
    case 1: return in_sol_cells();
    case 2: return in_xrp_cells();
    case 3: return in_bnb_cells();
    case 4: return in_doge_cells();
    case 5: return in_ada_cells();
    case 6: return in_avax_cells();
    default: return in_link_cells();
  }
}
function coinView(c: i32): StaticArray<f64> {
  switch (c) {
    case 0: return in_eth_view();
    case 1: return in_sol_view();
    case 2: return in_xrp_view();
    case 3: return in_bnb_view();
    case 4: return in_doge_view();
    case 5: return in_ada_view();
    case 6: return in_avax_view();
    default: return in_link_view();
  }
}

// The coin's close for the hour that opened at 'offset' (ms from this bar's open), NaN when its block has no candle
// for that hour. Both blocks run oldest first, so one cursor per coin walks its block once per bar.
function coinCloseAt(c: i32, offset: f64): f64 {
  const n = coinCells(c);
  if (n <= 0) return NaN;
  const view = coinView(c);
  let j = unchecked(cursor[c]);
  while (j + TUPLE <= n && unchecked(view[j]) < offset) j += TUPLE;
  unchecked((cursor[c] = j));
  return j + TUPLE <= n && unchecked(view[j]) == offset ? unchecked(view[j + 4]) : NaN;
}

// One closed hour: every coin's ratio to BTC, smoothed; its strength and momentum; kept in the ring.
function foldHour(offset: f64, btc: f64): void {
  histHead = (histHead + 1) % HIST;
  hours += 1;
  for (let c = 0; c < COINS; c += 1) {
    let px = coinCloseAt(c, offset);
    if (isNaN(px) || px <= 0.0) px = unchecked(lastClose[c]);
    else unchecked((lastClose[c] = px));
    const at = c * HIST + histHead;
    let x: f64 = NaN;
    let y: f64 = NaN;
    let r: f64 = NaN;
    if (btc > 0.0 && px > 0.0) {
      r = Math.log(px / btc);
      const n = unchecked(seen[c]);
      if (n == 0) {
        unchecked((smoothed[c] = r));
        unchecked((trendMean[c] = r));
      } else {
        const s = unchecked(smoothed[c]) + aSmooth * (r - unchecked(smoothed[c]));
        unchecked((smoothed[c] = s));
        const dev = s - unchecked(trendMean[c]);
        unchecked((trendMean[c] = trendMean[c] + aTrend * dev));
        unchecked((trendVar[c] = (1.0 - aTrend) * (trendVar[c] + aTrend * dev * dev)));
        unchecked((trendWeight[c] = (1.0 - aTrend) * trendWeight[c] + aTrend));
      }
      unchecked((seen[c] = n + 1));
      const v = unchecked(trendWeight[c]) > 0.0 ? unchecked(trendVar[c]) / unchecked(trendWeight[c]) : 0.0;
      if (n + 1 >= trendLen && v > 0.0) {
        const z = (unchecked(smoothed[c]) - unchecked(trendMean[c])) / Math.sqrt(v);
        x = 100.0 + z;
        const k = unchecked(zSeen[c]);
        if (k == 0) {
          unchecked((momMean[c] = z));
        } else {
          unchecked((momMean[c] = momMean[c] + aMom * (z - momMean[c])));
          const d = z - unchecked(momMean[c]);
          unchecked((momVar[c] = (1.0 - aTrend) * momVar[c] + aTrend * d * d));
          unchecked((momWeight[c] = (1.0 - aTrend) * momWeight[c] + aTrend));
          const mv = unchecked(momVar[c]) / unchecked(momWeight[c]);
          if (k + 1 >= momLen && mv > 0.0) y = 100.0 + d / Math.sqrt(mv);
        }
        unchecked((zSeen[c] = k + 1));
      }
    }
    unchecked((histX[at] = x));
    unchecked((histY[at] = y));
    unchecked((histR[at] = r));
  }
}

// The ring slot of the candle 'back' hours before the newest, -1 when the ring does not reach it.
function slotBack(back: i32): i32 {
  return back < hours && back < HIST ? (histHead - back + HIST) % HIST : -1;
}

// The quadrant of a position: strong means x above 100, rising means y above 100.
function quadrantOf(x: f64, y: f64): i32 {
  const strong = x > 100.0;
  const rising = y > 100.0;
  return strong ? (rising ? 0 : 2) : rising ? 1 : 3;
}

// How far top-right a dot sits: its distance along the diagonal from the centre.
function diagonal(c: i32): f64 {
  return strength[c] - 100.0 + (momentum[c] - 100.0);
}

// ── Frame writers: a colour as "#rrggbb" or "#rrggbbaa", written digit by digit (no allocation on the live bar) ──
function fbHexByte(v: i32): void {
  fb_text(unchecked(HEX1[(v >> 4) & 15]));
  fb_text(unchecked(HEX1[v & 15]));
}
function fbInk(q: i32, alphaByte: i32): void { // the quadrant's ink from the settings; alpha 255 writes the opaque form
  fb_text("\"#");
  fbHexByte(unchecked(inkR[q]));
  fbHexByte(unchecked(inkG[q]));
  fbHexByte(unchecked(inkB[q]));
  if (alphaByte < 255) fbHexByte(alphaByte);
  fb_text("\"");
}
// One scatter row: [coin, x, y, size, colour] and, on a head, the label.
function writeRow(c: i32, x: f64, y: f64, size: f64, alphaByte: i32, label: i32): void { // label: 0 none, 1 the name, 2 the name and the move
  if (rows > 0) fb_text(",");
  fb_text("[");
  fb_int(<i64>c);
  fb_text(",");
  fb_f64(x, 3);
  fb_text(",");
  fb_f64(y, 3);
  fb_text(",");
  fb_f64(size, 1);
  fb_text(",");
  fbInk(quadrant[c], alphaByte);
  if (label > 0) {
    fb_text(",\"");
    fb_text(unchecked(COIN_NAMES[c]));
    if (label == 2 && !isNaN(move[c])) {
      fb_text(move[c] >= 0.05 ? " +" : " "); // a move that rounds to 0.0 carries no sign
      fb_f64(Math.abs(move[c]) < 0.05 ? 0.0 : move[c], 1);
      fb_text("%");
    }
    fb_text("\"");
  }
  fb_text("]");
  rows += 1;
}

// One axis fitted to the dots: from the lowest to the highest value, 100 always inside, at least REACH_MIN past 100
// on each side, the side of the guide with no dots kept at SIDE_MIN of the axis (its quadrants stay readable), then
// 'pad' of room at both ends. Writes axisLo and axisHi.
function fitAxis(lo: f64, hi: f64, pad: f64): void {
  let below = Math.max(100.0 - lo, REACH_MIN);
  let above = Math.max(hi - 100.0, REACH_MIN);
  if (below < SIDE_MIN * (below + above)) below = (SIDE_MIN / (1.0 - SIDE_MIN)) * above;
  if (above < SIDE_MIN * (below + above)) above = (SIDE_MIN / (1.0 - SIDE_MIN)) * below;
  const total = below + above;
  axisLo = 100.0 - below - pad * total;
  axisHi = 100.0 + above + pad * total;
}

// The map on the live bar: every coin's tail, its far end smallest and faintest, then its head with its name (the
// leader's with its move); the axes fitted to the dots with the guides at 100; the chip naming the leader; the four
// quadrant tints in the inks the settings hold.
function writeMap(lead: i32): void {
  let loX = 100.0;
  let hiX = 100.0;
  let loY = 100.0;
  let hiY = 100.0;
  for (let c = 0; c < COINS; c += 1) {
    unchecked((tailDrawn[c] = 0));
    if (quadrant[c] < 0) continue;
    loX = Math.min(loX, strength[c]);
    hiX = Math.max(hiX, strength[c]);
    loY = Math.min(loY, momentum[c]);
    hiY = Math.max(hiY, momentum[c]);
    for (let i = 1; i <= tailPoints; i += 1) { // tail points with values, counted back from the head: a tail never jumps a gap
      const slot = slotBack(i * step);
      if (slot < 0) break;
      const tx = unchecked(histX[c * HIST + slot]);
      const ty = unchecked(histY[c * HIST + slot]);
      if (isNaN(tx) || isNaN(ty)) break;
      loX = Math.min(loX, tx);
      hiX = Math.max(hiX, tx);
      loY = Math.min(loY, ty);
      hiY = Math.max(hiY, ty);
      unchecked((tailDrawn[c] = i));
    }
  }
  fb_clear();
  fb_text("{\"rows\":[");
  rows = 0;
  // The leader first: where two labels would collide the pane moves the later one onto a leader line, so a crowded
  // cluster nudges the names of the coins farther bottom-left, never the leader's.
  for (let k = 0; k < COINS; k += 1) {
    const c = order[k];
    if (quadrant[c] < 0) continue;
    const drawn = unchecked(tailDrawn[c]);
    for (let i = drawn; i >= 1; i -= 1) { // the far end first
      const slot = slotBack(i * step);
      const age = f64(drawn - i + 1) / f64(drawn + 1); // near 0 at the far end, near 1 next to the head
      const size = TAIL_MIN + (TAIL_MAX - TAIL_MIN) * age;
      const alphaByte = TAIL_ALPHA_MIN + i32(Math.round(f64(TAIL_ALPHA_MAX - TAIL_ALPHA_MIN) * age));
      writeRow(c, unchecked(histX[c * HIST + slot]), unchecked(histY[c * HIST + slot]), size, alphaByte, 0);
    }
    writeRow(c, strength[c], momentum[c], HEAD_SIZE, 255, c == lead ? 2 : 1);
  }
  fitAxis(loX, hiX, PAD_X);
  fb_text("],\"x_min\":");
  fb_f64(axisLo, 3);
  fb_text(",\"x_max\":");
  fb_f64(axisHi, 3);
  fitAxis(loY, hiY, PAD_Y);
  fb_text(",\"y_min\":");
  fb_f64(axisLo, 3);
  fb_text(",\"y_max\":");
  fb_f64(axisHi, 3);
  fb_text(",\"badge\":{\"text\":\"");
  fb_text(unchecked(COIN_NAMES[lead]));
  fb_text(unchecked(QUADRANT_WORDS[quadrant[lead]]));
  fb_text("\",\"color\":");
  fbInk(quadrant[lead], 255);
  fb_text("},\"quadrants\":{\"x\":100,\"y\":100,\"colors\":[");
  fbInk(0, TINT_ALPHA);
  fb_text(",");
  fbInk(1, TINT_ALPHA);
  fb_text(",");
  fbInk(3, TINT_ALPHA);
  fb_text(",");
  fbInk(2, TINT_ALPHA);
  fb_text("],\"labels\":[\"Leading\",\"Improving\",\"Lagging\",\"Weakening\"]}}");
  writeFrameBuffer(rotationRows);
}

// onStart() runs once before the first bar: the tail step from the chart interval, the three averages from days to
// hourly candles, the four inks unpacked, the ring emptied.
function onStart(): void {
  const interval = p_chart_interval_sec();
  step = interval > 0.0 && interval <= 300.0 ? 4 : interval <= 1800.0 ? 12 : MAX_STEP;
  tailPoints = i32(p_tail_points());
  smoothLen = i32(p_smooth()) * 24;
  trendLen = i32(p_trend()) * 24;
  momLen = i32(p_momentum()) * 24;
  aSmooth = 2.0 / f64(smoothLen + 1);
  aTrend = 2.0 / f64(trendLen + 1);
  aMom = 2.0 / f64(momLen + 1);
  for (let q = 0; q < 4; q += 1) {
    const packed = fromPacked(q == 0 ? p_leading_ink() : q == 1 ? p_improving_ink() : q == 2 ? p_weakening_ink() : p_lagging_ink());
    inkR[q] = red(packed);
    inkG[q] = green(packed);
    inkB[q] = blue(packed);
  }
  for (let i = 0; i < COINS * HIST; i += 1) {
    unchecked((histX[i] = NaN));
    unchecked((histY[i] = NaN));
    unchecked((histR[i] = NaN));
  }
}

// onBar() runs once per chart bar: fold every hourly candle that closed with it (the first bar carries the backlog),
// then read the newest hour's map, sort the coins, count the quadrants; on the live bar write the map and the card.
function onBar(): void {
  const n = in_btc_cells();
  if (n > 0) {
    const view = in_btc_view();
    for (let c = 0; c < COINS; c += 1) unchecked((cursor[c] = 0));
    for (let i = 0; i + TUPLE <= n; i += TUPLE) foldHour(unchecked(view[i]), unchecked(view[i + 4]));
  }
  const head = slotBack(0);
  const span = (tailPoints > 0 ? tailPoints : 1) * step;
  const back = slotBack(span);
  for (let c = 0; c < COINS; c += 1) {
    let s: f64 = NaN;
    let m: f64 = NaN;
    let mv: f64 = NaN;
    if (head >= 0) {
      s = unchecked(histX[c * HIST + head]);
      m = unchecked(histY[c * HIST + head]);
      const r = unchecked(histR[c * HIST + head]);
      const r0 = back >= 0 ? unchecked(histR[c * HIST + back]) : NaN;
      if (!isNaN(r) && !isNaN(r0)) mv = (Math.exp(r - r0) - 1.0) * 100.0;
    }
    strength[c] = s;
    momentum[c] = m;
    move[c] = mv;
    quadrant[c] = isNaN(s) || isNaN(m) ? -1 : quadrantOf(s, m);
  }

  // The coins by distance top-right, the leader first; coins without values last.
  for (let c = 0; c < COINS; c += 1) {
    let k = c;
    while (k > 0) {
      const prev = order[k - 1];
      if (quadrant[c] < 0 || (quadrant[prev] >= 0 && diagonal(prev) >= diagonal(c))) break;
      order[k] = prev;
      k -= 1;
    }
    order[k] = c;
  }
  let leading = 0;
  let improving = 0;
  let weakening = 0;
  let lagging = 0;
  let ready = 0;
  for (let c = 0; c < COINS; c += 1) {
    const q = quadrant[c];
    if (q < 0) continue;
    ready += 1;
    if (q == 0) leading += 1;
    else if (q == 1) improving += 1;
    else if (q == 2) weakening += 1;
    else lagging += 1;
  }
  const lead = order[0];
  if (ready > 0) {
    out_leading(f64(leading));
    out_improving(f64(improving));
    out_weakening(f64(weakening));
    out_lagging(f64(lagging));
    out_leader_quadrant(f64(quadrant[lead]));
  }

  if (!bar.isLast()) return;
  // The card's title: the span the tail covers, in hours up to a day and a half, else in days.
  sb_clear();
  sb_text("last ");
  if (span < 36) {
    sb_int(<i64>span);
    sb_text("h");
  } else {
    sb_int(<i64>Math.round(f64(span) / 24.0));
    sb_text("d");
  }
  str_window_text_sb();
  if (ready == 0) {
    // The hourly candles have not arrived yet: one line of words, no map, no headline.
    sb_clear();
    sb_text("Loading the hourly candles for the rotation map");
    notice.set(16.0, 12.0).text(str_notice_sb);
    noticeShown = true;
    return;
  }
  if (noticeShown) {
    notice.delete();
    noticeShown = false;
  }
  writeMap(lead);
  // The headline: the leader and its move against BTC over the tail.
  sb_clear();
  sb_text(unchecked(COIN_NAMES[lead]));
  if (!isNaN(move[lead])) {
    sb_text(move[lead] >= 0.05 ? " +" : " ");
    sb_f64(Math.abs(move[lead]) < 0.05 ? 0.0 : move[lead], 1);
    sb_text("%");
  }
  str_leader_sb();
}
```

## How it works

**Hourly streams, not chart bars.** `input("close", ohlcv.close)` keeps the chart's own candles first, as the bar grid. `input("btc", candles.cells, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 })` streams BTC's closed hourly candles, the reference every coin is measured against, and eight more streams, `eth` to `link`, read the coins on the same venue at the same depth. Each stream starts at least 1500 hourly candles back (about two months): the first bar carries that backlog and each later bar the hours that closed with it, whatever the chart's interval. Streams, not `ohlcv` pins: a pin hands each chart bar one candle, so it reaches back only over the chart's own bars, too short on a 1m chart for averages of weeks, and a pin finer than the chart is refused, so a `1h` pin would refuse the whole run on a 4h or a 1d chart; a stream reaches back its own depth on any interval ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream)). An indicator counts against the plan's per-chart indicator limit once per distinct source it reads ([Limits](../reference/limits.md)), and these ten sources use the whole of a Plus chart's ten, so nothing else here reads a source: `bar.time()` would be an eleventh. The tail step reads the chart's interval from `chart.interval_sec()` instead, a hidden setting rather than a source, and the hours pair by offset, not by clock time.

**Hours pair by offset, with BTC as the clock.** A block lists its hourly candles oldest first, six numbers each, `[offset_ms, open, high, low, close, volume]` (`TUPLE`), `offset_ms` being the candle's open minus this bar's open. `onBar()` walks BTC's block and folds one hour per BTC candle with `foldHour`, and `coinCloseAt` finds each coin's candle with the same `offset_ms` in that coin's block: both blocks run oldest first, so one cursor per coin walks its block once per bar. A coin hour missing from its block carries the coin's last close (`lastClose`). The first bar folds the whole backlog; after it, a bar folds the hours that closed with it: one every 60 bars on a 1m chart, one per bar on 1h, four per bar on 4h.

**Strength and momentum, smoothed in days.** `onStart()` turns the three settings from days into hourly candles (`smooth` 14 is 336, `trend` 18 is 432, `momentum` 9 is 216) and each length n into an EMA weight of 2 / (n + 1). Per coin and per hour, `foldHour` takes r = ln(coin / BTC), the log of the coin's close over BTC's, and keeps s, its EMA over `smooth` days (`smoothed`). The strength is 100 plus how far s sits from its exponentially weighted mean over `trend` days, in units of its usual distance from it, the exponentially weighted standard deviation; the momentum is 100 plus how far the strength sits from its EMA over `momentum` days, in units of that gap's exponentially weighted root mean square, weighted over the same `trend` days. Both variances are divided by the share of weight their average has seen so far (`trendWeight`, `momWeight`), so the first readings are not inflated. The averages keep no list of past values: each hour updates them in a few steps, whatever the lengths. A coin has a strength once it has folded `trend` days of hours, and a momentum once it has `momentum` days of strength on top (27 days at the defaults); the largest settings with the longest tail need 1344 hours, inside the 1500 each stream brings. Every chart folds the same hours with the same averages, so a coin sits at the same place on 1m, 15m and 1h, and the map moves once an hour, when an hourly candle closes. Strength 100 is a smoothed ratio right on its longer average, so a coin crosses the vertical guide when its smoothed ratio moves above that average, and the horizontal one when the strength turns.

**The tail follows the chart.** Every coin's strength, momentum and log ratio per hour sit in a ring (`histX`, `histY`, `histR`) of `HIST` slots a coin, 12 points at the 28-hour step plus the head, so the longest tail always fits; `slotBack(back)` finds the hour `back` hours before the newest, or -1 when the ring does not reach it. `onStart()` reads `p_chart_interval_sec()` and sets the tail step: 4 hours on charts up to 5m, so six points cover the last day; 12 hours up to 30m, three days; 28 hours on 1h and coarser, a week (0, an unknown interval, counts as a 15m chart). The tail is sampled back from the head, one point every step, `tail_points` points, so the spacing stays even up to the head. The move is the ratio's change over the same span, `tail_points` times the step (one step at 0 points), in percent, and the card's title names that span from the `window_text` slot, in hours under 36 and in days from 36 on: "last 24h", "last 3d" or "last 7d" at the defaults.

**The leader and the counts.** On every bar `onBar()` reads the newest hour from the ring: each coin's strength, momentum and move. `quadrantOf` files each coin under 0 leading, 1 improving, 2 weakening or 3 lagging (strong is x above 100, rising is y above 100), or -1 while either number is NaN, and an insertion sort orders the coins by how far top right they sit, the largest (x - 100) + (y - 100) first (`diagonal`), coins without values last: the first is the leader. The counts `leading`, `improving`, `weakening` and `lagging` are data-only outputs (`none`), written with `leader_quadrant`, the leader's quadrant, on every bar where at least one coin has values.

**One frame is the map.** On the live bar only, `writeMap` builds `frame("rotation_rows")` in the frame buffer with `fb_clear`, `fb_text`, `fb_int` and `fb_f64` and sends it with `writeFrameBuffer`: one row `[coin, x, y, size, colour, label]` per tail point and per head, and rows that share a coin form its trail, oldest first and the head last. Every head has a radius of 5 px (`HEAD_SIZE`), the same for every coin; tail dots grow from `TAIL_MIN` (1.5 px) at the far end toward `TAIL_MAX` (2.5 px) next to the head, and their alpha from `TAIL_ALPHA_MIN` (`0x26`) toward `TAIL_ALPHA_MAX` (`0x8c`). A tail stops at its first point without values, so it never jumps a gap. Labels sit on heads only: the leader's carries its move ("SOL +4.1%"), the others the name alone, and a move inside plus or minus 0.05% prints "0.0%" with no sign. `panel.scatter` draws the rows in a pane below the chart (`height_frac: 0.45`): `labels: true` with `label_overlap: "leader"` names each head, and since the leader is written first, where two names collide the pane moves the later one onto a leader line, never the leader's; `trails: true`, `trail_width: 1` and `trail_fade: true` draw a thin line between a coin's rows, fading toward the far end; two dashed `guides` at 100 cross the pane; and `quadrants` tints the four corners and names them.

**The axes fit the dots.** Beside `rows`, the frame carries `x_min`, `x_max`, `y_min` and `y_max` from `fitAxis`: each axis spans the lowest to the highest head or tail point with 100 always inside and reaches at least 0.5 past 100 on both sides (`REACH_MIN`, half a usual distance), so a calm hour still shows a cross; the side of a guide with no dots still keeps 30% of the axis (`SIDE_MIN`), so its two quadrants stay readable; then 8% of room is added at both ends on x (`PAD_X`) and 15% on y (`PAD_Y`), where the quadrant words sit in the corners. The guides stay at 100 wherever the dots put them. The frame's `badge` is the chip on the title row, the leader and its quadrant word ("SOL leading") in that quadrant's ink, and its `quadrants` rewrites the four tints per run.

**Four inks, written as hex.** `leading_ink`, `improving_ink`, `weakening_ink` and `lagging_ink` are `param.color` settings under the Quadrant colours section. A dot's colour is run data in the frame's rows, so the module writes it: `onStart()` reads the four picks with `fromPacked` and splits each into channels with `red`, `green` and `blue`, and `fbInk` writes a pick into the frame as `#rrggbb` (or `#rrggbbaa` for a tail dot or a tint), digit by digit from a 16-entry table, so a colour never allocates on the live bar. The dots, the trails, the chip and the tints follow the picks, the tints at alpha `0x0c` (`TINT_ALPHA`) in place of the declaration's literal ones. A colour word of the declaration may name a `param.color` instead, as `"@leading_ink"` ([Panel words](../presentation/cards-frames-panels.md#panel-words)); this source writes the picks into the frame, where the dots need them anyway. The card's headline keeps the house ladder: a tile's `colors` takes literals, since `look` is the only word of a card a setting can drive.

**The card reads slots and outputs.** `render.hud("rotation_card", { position: "top_left", look: "terminal", title: "Rotation vs BTC, {{window_text}}", columns: 1, width: 210, safe_area: true, tiles: [...] })` is the card. `tile.pill("Leader", "leader", { headline: true, color_by: "leader_quadrant", colors: [...] })` reads the `leader` string slot, the leader and its move written with the `sb_*` builders on the live bar, and colours it by the quadrant ladder; `tile.rows` reads the `improving` and `weakening` outputs as integers. `{{window_text}}` in the title reads the slot the live bar writes ("last 24h", "last 3d" or "last 7d"), and `safe_area: true` starts the card under the legend stack.

**One handle is the sentence.** `handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 12, style: "plain", safe_area: true })` declares the defaults and `draw.label(0)` makes the one handle at module load. While no coin has both a strength and a momentum, the live bar writes "Loading the hourly candles for the rotation map" into the `notice` slot and `notice.set(16.0, 12.0).text(str_notice_sb)` draws it in slate 16 px in from the pane's top-right corner and 12 px down, past the price-axis tags, and skips the map and the headline. On the first live bar with a map, `notice.delete()` removes the line.

## Where it runs

Any chart, any interval. The coins are always read from Binance Futures, so the chart's own market only changes the candles above the map, and the averages count the streams' hours, not the chart's bars, so the dots sit at the same place on every interval, and 4h and 1d charts show the 1h map with its week-long tail. On a daily chart the first draw takes longer, since a stream starts earlier when the chart has loaded more: the hourly candles reach back to the chart's first day. The map sits in its pane below the chart and the card at the top left of the price pane, under the legend stack (`safe_area: true`).

## When data is missing

While no coin has both a strength and a momentum, one slate line at the top right of the price pane reads "Loading the hourly candles for the rotation map" and nothing else is drawn: no map, no chip, no headline. The card's title still names the span, since the `window_text` slot is written before the check; the `leader` slot and the five data outputs wait for a coin with both numbers, and the line goes on the first live bar that draws the map. Each stream brings at least 1500 hours, more than the 27 days a coin needs at the defaults and the 1344 hours of the largest settings with the longest tail, so the line shows while the hourly candles are still on their way. A coin hour missing from its block carries the coin's last close, so a short gap never empties the map; an hour missing from BTC's block is not folded at all, since BTC is the clock. A coin without values has no dot and is not counted, a tail stops at its first point without values, and when the hour the tail reaches back to has no ratio, the leader's label and the headline carry the name alone.

## Customize it

- **Tail length.** `tail_points` (6, 0 to 12; 0 draws heads only) sets how many earlier positions each coin trails. The step between them follows the chart (4, 12 or 28 hours), and the move, the headline and the card's title follow the span the tail covers (one step at 0).
- **The three averages.** `smooth` (14 days, 1 to 21) is the EMA on the ratio to BTC, `trend` (18 days, 3 to 28) the longer average the strength is measured from, and `momentum` (9 days, 1 to 14) the short average the momentum is measured from. Shorter averages stay smooth on a day-long tail but zigzag on a week-long one: a week of tail needs averages of weeks. Even the largest settings, with the longest tail, need only 1344 of the 1500 hours the streams bring.
- **Quadrant colours.** The four `param.color` rows under Quadrant colours (`leading_ink` teal, `improving_ink` sky, `weakening_ink` amber, `lagging_ink` orange) recolour the dots, the trails, the chip and the tints. The card's headline keeps the house ladder, since a tile's `colors` takes literals.
- **Other coins.** Change the `symbol` in a coin's input line and the name at the same position in `COIN_NAMES` (keep eight); the venue ids and the spellings are in [Exchange and symbol format](../reference/symbol-format.md).
- **Change the look.** The Look row on the indicator's Style page switches the card's look without code, As made (terminal) first ([The Style page](../settings/style-page.md#the-look-row)); glass, for one, is a dark translucent card with round corners and rounded type, light on a light chart. The file writes `look: "terminal"`; bind `look` to a `param.choice` over looks instead (`look: "@card_look"`) and the look becomes a setting, whose own row replaces the Look row ([Looks](../presentation/hud-and-hover-cards.md#looks)).

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Rotation Map** under **Beyond the time axis**.
2. Press **Run** on any chart, such as BTCUSDT on Binance Futures at 15m: once the hourly candles arrive, the map opens below the chart with the chip on its title row, and the terminal card, titled "Rotation vs BTC, last 3d", appears at the top left of the price pane under the legend. Rest the pointer on a dot for its readout.
3. At the editor's Console prompt, type `last 20 leading` to read how many coins sat in the leading quadrant on each of the last 20 bars; on a 15m chart the count can change only once an hour, when an hourly candle closes.

## Concepts used

- [Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream) for the `candles` stream, its `interval` and `bars`, the backlog on the first bar, and the pins a chart refuses
- [Data sources](../core-concepts/data-sources.md#celled-sources) for celled sources, the `[offset_ms, open, high, low, close, volume]` tuple and the `in_<name>_cells()` / `in_<name>_view()` readers
- [Sessions and units](../settings/sessions-and-units.md#the-interval-and-the-colours) for `chart.interval_sec()`, the chart's bar interval as a hidden setting
- [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames and the frame buffer, `panel.scatter` with `guides`, `quadrants`, `trails`, `labels`, the frame's axis ends and the title-row `badge`, and the pixel offsets of an anchored handle
- [Panel words](../presentation/cards-frames-panels.md#panel-words) for the colour words a `param.color` can drive as `"@name"`
- [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, `columns`, `width` and `safe_area`
- [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "terminal"` and a look bound to a `param.choice`
- [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill` with its ladder and `tile.rows`
- [Drawing objects](../presentation/drawing-objects.md) for `handles.label`, `draw.label(id)` and its `set`, `text` and `delete`
- [The Style page](../settings/style-page.md#the-look-row) for the Look row and `param.color`
- [Colors kit](../functions/colors-kit.md) for `fromPacked`, `red`, `green` and `blue`
