ChartnautDocs

Stochastic

The Stochastic indicator from the public library (stochastic), written against the current DSL.

What it plots

%K and %D in their own pane (0 to 100) with overbought, middle and oversold guide lines.

Key decisions

Piece

Why it matters

Raw %K from the window's high and low

Where the close sits inside the recent range.

Two smoothing stages

%K is the SMA of raw %K; %D is the SMA of %K.

w.sum(...) warmup

The three windows feed each other, so their lengths add up.

Guide lines from settings

The levels are line outputs, so they follow the settings.

Full script

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

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

const kLength = input.number({
  id: "kLength",
  label: "%K Length",
  default: 14,
  min: 1,
  step: 1,
});

const kSmoothing = input.number({
  id: "kSmoothing",
  label: "%K Smoothing",
  default: 3,
  min: 1,
  step: 1,
});

const dSmoothing = input.number({
  id: "dSmoothing",
  label: "%D Smoothing",
  default: 3,
  min: 1,
  step: 1,
});

const upperBandValue = input.number({
  id: "upperBandValue",
  label: "Overbought Level",
  default: 80,
  min: 50,
  max: 100,
  step: 1,
});

const middleBandValue = input.number({
  id: "middleBandValue",
  label: "Middle Level",
  default: 50,
  min: 0,
  max: 100,
  step: 1,
});

const lowerBandValue = input.number({
  id: "lowerBandValue",
  label: "Oversold Level",
  default: 20,
  min: 0,
  max: 50,
  step: 1,
});

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

const dColor = input.color({
  id: "dColor",
  label: "%D Color",
  default: "#FF6D00",
  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.sum(w.window(kLength), w.window(kSmoothing), w.window(dSmoothing)));

layout(
  tab({ id: "general", title: "General" },
    section({ id: "parameters", title: "Parameters" }, kLength, kSmoothing, dSmoothing),
    section({ id: "levels", title: "Signal Levels" }, upperBandValue, middleBandValue, lowerBandValue),
    section({ id: "display", title: "Display Options" }, labelsEnabled),
  ),
  tab({ id: "appearance", title: "Appearance" },
    section({ id: "lines", title: "Lines", columns: 2 }, kColor, dColor, lineStyle),
  ),
);

output.line({
  id: "k",
  lineWidth: 2,
  colorFrom: kColor,
  lineStyleFrom: lineStyle,
  labelFrom: labelsEnabled,
  titleWhenLabeled: "%K",
});

output.line({
  id: "d",
  lineWidth: 1,
  colorFrom: dColor,
  lineStyleFrom: lineStyle,
  labelFrom: labelsEnabled,
  titleWhenLabeled: "%D",
});

output.line({
  id: "upperBand",
  lineWidth: 1,
  titleWhenLabeled: "Overbought",
});

output.line({
  id: "middleBand",
  lineWidth: 1,
  titleWhenLabeled: "Middle",
});

output.line({
  id: "lowerBand",
  lineWidth: 1,
  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 kLen = ctx.params.kLength;
  const kSm = ctx.params.kSmoothing;
  const dSm = ctx.params.dSmoothing;

  const st = foldBar(ctx, "stoch", { highs: [], lows: [], rawKs: [], ks: [], k: null, d: null }, (prev) => {
    const highs = prev.highs.concat(ctx.high).slice(-kLen);
    const lows = prev.lows.concat(ctx.low).slice(-kLen);
    if (highs.length < kLen) return { highs, lows, rawKs: [], ks: [], k: null, d: null };

    // Raw %K: where the close sits inside the window's range.
    const hi = Math.max(...highs);
    const lo = Math.min(...lows);
    const rawK = hi !== lo ? ((ctx.close - lo) / (hi - lo)) * 100 : 50;
    const rawKs = prev.rawKs.concat(rawK).slice(-kSm);
    if (rawKs.length < kSm) return { highs, lows, rawKs, ks: [], k: null, d: null };

    // %K is the SMA of raw %K; %D is the SMA of %K.
    const k = mean(rawKs);
    const ks = prev.ks.concat(k).slice(-dSm);
    return { highs, lows, rawKs, ks, k, d: ks.length === dSm ? mean(ks) : null };
  });
  if (st.k == null) return;

  ctx.plot("k", st.k);
  ctx.plot("upperBand", ctx.params.upperBandValue);
  ctx.plot("middleBand", ctx.params.middleBandValue);
  ctx.plot("lowerBand", ctx.params.lowerBandValue);
  if (st.d != null) ctx.plot("d", st.d);
}

Indicator API for panes and guide lines, and Warmup and memory.