ChartnautDocs

RSI

The RSI indicator from the public library (rsi), written against the current DSL.

What it plots

A 0 to 100 oscillator in its own pane (Wilder RSI), with overbought and oversold guide lines.

Key decisions

Piece

Why it matters

Seed from the first period changes, then Wilder smoothing

Classic RSI, not a plain average of changes.

warmup((w) => w.wilder(period))

Wilder smoothing needs about 50 times its period before the starting point stops showing.

Guide levels plotted as lines

overbought and oversold are line outputs fed from the settings on every bar.

avgLoss === 0 gives 100

No down moves means RSI 100, not a division by zero.

Full script

meta({
  shortName: "RSI",
  kind: "pane",
  dataSource: "candles",
  category: "custom",
});

dialog({
  title: "RSI Configuration",
  description: "Configure Relative Strength Index (RSI) settings",
  width: 400,
  height: 400,
  resizable: false,
  showEnabledCheckbox: true,
  resetToDefaults: {
    enabled: true,
    confirmationMessage: "Are you sure you want to reset all RSI settings to their default values?",
  },
});

const period = input.number({
  id: "period",
  label: "Period",
  default: 14,
  min: 1,
  step: 1,
});

const overbought = input.number({
  id: "overbought",
  label: "Overbought Level",
  default: 70,
  min: 0,
  max: 100,
  step: 1,
});

const oversold = input.number({
  id: "oversold",
  label: "Oversold Level",
  default: 30,
  min: 0,
  max: 100,
  step: 1,
});

const lineColor = input.color({
  id: "lineColor",
  label: "Line Color",
  default: "#2962FF",
  allowAlpha: true,
});

const lineStyle = input.lineStyle({
  id: "lineStyle",
  label: "Line Style",
  default: LineStyle.Solid,
});

const labelsEnabled = input.boolean({
  id: "labelsEnabled",
  label: "Show Labels",
  default: true,
});

warmup((w) => w.wilder(period));

layout(
  tab({ id: "general", title: "General" },
    section({ id: "parameters", title: "Parameters" }, period, overbought, oversold),
    section({ id: "display", title: "Display Options" }, labelsEnabled),
  ),
  tab({ id: "appearance", title: "Appearance" },
    section({ id: "line", title: "Line", columns: 2 }, lineColor, lineStyle),
  ),
);

output.line({
  id: "rsi",
  lineWidth: 2,
  colorFrom: lineColor,
  lineStyleFrom: lineStyle,
  labelFrom: labelsEnabled,
  titleWhenLabeled: "RSI",
});

output.line({
  id: "overbought",
  lineWidth: 1,
  colorFrom: lineColor,
  lineStyleFrom: lineStyle,
  labelFrom: labelsEnabled,
  titleWhenLabeled: "Overbought",
});

output.line({
  id: "oversold",
  lineWidth: 1,
  colorFrom: lineColor,
  lineStyleFrom: lineStyle,
  labelFrom: labelsEnabled,
  titleWhenLabeled: "Oversold",
});

// onBar runs again for the forming bar on every tick. The chart rolls ctx.accum back between
// ticks; foldBar does not depend on that, so the same code is right on every runtime.
// foldBar keeps two states: `done` (as of the last closed bar) and `tip` (this bar). `step`
// builds the tip from `done` without mutating it, so a repeat of the same bar recomputes the tip
// instead of stacking on top of it. A new bar time commits the tip.
function foldBar(ctx, id, init, step) {
  const s = ctx.accum(id, () => ({ t: null, done: init, tip: init }), (s) => {
    if (s.t !== ctx.time) {
      s.done = s.tip;
      s.t = ctx.time;
    }
    s.tip = step(s.done);
    return s;
  });
  return s.tip;
}

function mean(values) {
  let sum = 0;
  for (const v of values) sum += v;
  return sum / values.length;
}

/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
  const p = ctx.params.period;

  const st = foldBar(ctx, "rsi", { close: null, gains: [], losses: [], avgGain: null, avgLoss: null }, (prev) => {
    if (prev.close == null) {
      return { close: ctx.close, gains: [], losses: [], avgGain: null, avgLoss: null };
    }
    const change = ctx.close - prev.close;
    const gain = Math.max(change, 0);
    const loss = Math.max(-change, 0);

    if (prev.avgGain == null) {
      // Collect the first `p` changes, then seed both averages with their mean.
      const gains = prev.gains.concat(gain);
      const losses = prev.losses.concat(loss);
      if (gains.length < p) return { close: ctx.close, gains, losses, avgGain: null, avgLoss: null };
      return { close: ctx.close, gains: [], losses: [], avgGain: mean(gains), avgLoss: mean(losses) };
    }

    // Wilder smoothing from then on.
    return {
      close: ctx.close,
      gains: [],
      losses: [],
      avgGain: (prev.avgGain * (p - 1) + gain) / p,
      avgLoss: (prev.avgLoss * (p - 1) + loss) / p,
    };
  });
  if (st.avgGain == null) return;

  ctx.plot("overbought", ctx.params.overbought);
  ctx.plot("oversold", ctx.params.oversold);
  // No down moves in the window: RSI is 100, not a division by zero.
  ctx.plot("rsi", st.avgLoss === 0 ? 100 : 100 - 100 / (1 + st.avgGain / st.avgLoss));
}

Warmup and memory for the Wilder warmup, and Indicator API for drawing in its own pane.