ChartnautDocs

Donchian Channels

The Donchian Channels indicator from the public library (donchian_channels), written against the current DSL.

What it plots

Three lines on price: the highest high, the lowest low, and their midpoint over period bars.

Key decisions

Piece

Why it matters

Highs and lows kept separately

The channel edges come from the extremes, not the close.

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

A true window: nothing older than period bars can change the answer, so the warmup is period.

Middle is (highest + lowest) / 2

Derived from the two edges, not a third calculation.

Full script

meta({
  shortName: "DC",
  kind: "overlay",
  dataSource: "candles",
  category: "custom",
});

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

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

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

const middleColor = input.color({
  id: "middleColor",
  label: "Middle Color",
  default: "#FF6D00",
  allowAlpha: true,
});

const lowerColor = input.color({
  id: "lowerColor",
  label: "Lower 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.window(period));

layout(
  tab({ id: "general", title: "General" },
    section({ id: "parameters", title: "Parameters" }, period),
    section({ id: "display", title: "Display Options" }, labelsEnabled),
  ),
  tab({ id: "appearance", title: "Appearance" },
    section({ id: "lines", title: "Lines", columns: 2 }, upperColor, middleColor, lowerColor, lineStyle),
  ),
);

output.line({
  id: "upper",
  lineWidth: 1,
  colorFrom: upperColor,
  lineStyleFrom: lineStyle,
  labelFrom: labelsEnabled,
  titleWhenLabeled: "DC Upper",
});

output.line({
  id: "middle",
  lineWidth: 1,
  colorFrom: middleColor,
  lineStyleFrom: lineStyle,
  labelFrom: labelsEnabled,
  titleWhenLabeled: "DC Mid",
});

output.line({
  id: "lower",
  lineWidth: 1,
  colorFrom: lowerColor,
  lineStyleFrom: lineStyle,
  labelFrom: labelsEnabled,
  titleWhenLabeled: "DC Lower",
});

// 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, "dc", { highs: [], lows: [] }, (prev) => ({
    highs: prev.highs.concat(ctx.high).slice(-p),
    lows: prev.lows.concat(ctx.low).slice(-p),
  }));
  if (st.highs.length < p) return;

  const highest = Math.max(...st.highs);
  const lowest = Math.min(...st.lows);
  ctx.plot("upper", highest);
  ctx.plot("lower", lowest);
  ctx.plot("middle", (highest + lowest) / 2);
}

Series types for the band fill, and Warmup and memory for why a lookback needs warmup.