Repainting

What repainting is, why it breaks backtests, and why an Indicator does not repaint by construction: state() sees one bar and nothing later, a higher timeframe…

What repainting is, why it breaks backtests, and why an Indicator does not repaint by construction: state() sees one bar and nothing later, a higher timeframe is folded in only after its candle closes, an interval pin is read as of close, and the forming bar is replayed through reset(). The one way to repaint an Indicator is a field reset() forgets, and this page shows what that looks like so you never ship it. kScript (legacy) got the same guarantee from a confirmed htf(); an Indicator gets it from the shape of the model.

What repainting is

Repainting is when a script's historical values change after the fact. The line you see today over old bars is not the line the script drew when those bars were live. A signal that looks like it fired one bar early in backtest fires one bar late in production, or never. The chart redraws itself once the future arrives, so your backtest is reading numbers that did not exist at the time.

That is the whole problem in one sentence: a repainting indicator lies about the past, so anything you measure on history (win rate, drawdown, signal timing) is fiction. You cannot trust a backtest you cannot reproduce.

Why it happens

Repainting comes from reading data that was not yet available at the bar you are computing. Three sources, two classic and one specific to a module with state:

  • An unclosed higher-timeframe candle. A naive 4h lookup on a 1h chart hands back the forming candle's live value; history backfills the finished number the live chart never had.
  • A future-leaking series. Anything that depends on a later bar: a centered smoother, "highest of the next N bars". History resolves it; live it does not exist yet.
  • Stale state on the replayed bar. The forming bar is evaluated on every tick; if an evaluation sees leftovers from the previous one, the newest value drifts with every tick and settles where history never will.

The tell is the same each time: the computation saw something during the backtest that it could not have seen live, or saw something live that history will not replay.

The good news: no lookahead by construction

state() receives exactly one bar: the inputs of the bar being evaluated, oldest first. There is no history array to index forward, no [−1], no way to reach the next bar. A value can only depend on bars at or before its own, so a mark that appears in history would have appeared live on the same bar. An Indicator cannot break this by accident; there is nothing to peek at.

The causal-prefix guarantee in one sentence: at bar t, every value the module holds was derived only from bars at or before t, so the past never changes when the future arrives.

Higher timeframes: confirmed by the fold

On the chart every input is the chart's own interval, so a higher timeframe is built inside the module: bucket bars by time.bar_open_sec, remember the running bucket's close, and fold it into the 4h statistic only when a bar from the next bucket arrives. A 4h candle contributes exactly once, after it closed. Rerun over history and it matches what it showed live, bar for bar. No look-ahead, no flag to remember.

import { input, line, ohlcv, output, overlay, param, time } from "./sdk/declare";
import { in_bar_t, in_close } from "./gen/inputs";
import { emitRow, out_close, out_h4_close, out_h4_developing } from "./gen/outputs";
import { p_bucket_hours } from "./gen/params";

param("bucket_hours", 4, { min: 1, max: 168, description: "Higher-timeframe bucket, in hours" });
input("close", ohlcv.close);
input("bar_t", time.bar_open_sec);
// The confirmed higher-timeframe close: flat across the bucket, stepping only when a candle closes.
output("h4_close", line, overlay, { color: "#7c3aed", width: 2, description: "The most recent fully closed 4h candle, no look-ahead" });
// The developing value, opt-in and clearly labeled: it moves with every bar inside the bucket.
output("h4_developing", line, overlay, { color: "#16a34a", width: 1, description: "The forming 4h candle's running close (repaints by design)" });
output("close", line, overlay, { color: "#94a3b8", width: 1, description: "The chart-timeframe close for comparison" });

let bucketSec: f64 = 14400.0;
let bucket: f64 = NaN; // the bucket the running candle belongs to
let running: f64 = NaN; // the running candle's latest close (developing)
let confirmed: f64 = NaN; // the last CLOSED candle's close
let close: f64 = NaN;

export function init(): void {
  bucketSec = p_bucket_hours() * 3600.0;
}

export function state(): i32 {
  close = in_close();
  const b = Math.floor(in_bar_t() / bucketSec);
  if (b != bucket) {
    // A bar from the next bucket has arrived: the running candle is now closed, and only now is it confirmed.
    if (!isNaN(bucket)) confirmed = running;
    bucket = b;
  }
  running = close;
  return isNaN(confirmed) ? 0 : 1;
}

export function finalize(): void {
  out_h4_close(confirmed);
  out_h4_developing(running);
  out_close(close);
  emitRow();
}

export function reset(): void {
  bucket = NaN;
  running = NaN;
  confirmed = NaN;
  close = NaN;
}

Read the staircase

The purple h4_close line is flat across four 1h bars, then steps to a new level, then holds flat again. That staircase is the visual signature of a correct, confirmed higher timeframe: the value only changes when a 4h candle actually closes, and between closes there is no new confirmed information. The green h4_developing line tracks the grey chart close inside each bucket: that is what a repainting value looks like, and it is drawn here on purpose so you can see the difference. A cross built on the green line would not survive into production; a cross built on the purple one reproduces exactly.

Requesting a live value is opt-in

Sometimes you genuinely want the forming bucket: a live 4h close ticking in a readout. In a module that is just the running variable, as above, and it is a deliberate choice you make by reading running instead of confirmed. A developing value is fine for a display; it is the wrong thing for a signal, because it changes as the bucket fills, so a cross or threshold built on it will not reproduce on history. Reach for it only when you want to show the live edge, never when you want to act on it.

Pins are read as of close

On your machine an interval pin reads a real coarser feed and the host applies the same rule for you: a coarser source contributes to a primary row only as of its candle's CLOSE, live or historical, so a forming 4h candle never leaks into the 1h rows under it (multi-timeframe.md). The cost is a value that steps once per source candle: the same staircase.

The forming bar and reset()

The forming bar is the one exception to "one call per bar": it re-evaluates on every tick. The host does not re-run history for that; it snapshots the module after the last closed bar and replays the revised forming bar into the snapshot. Because the snapshot holds every module-level variable, the module's reset() is what puts the state back before each replay, and a stale field is the one way an Indicator repaints:

let barsSeen: f64 = 0.0;
let cvd: f64 = 0.0;

export function reset(): void {
  cvd = 0.0;
  // barsSeen is not reset: the forming bar's replays keep incrementing it,
  // so the newest row reads 1, then 2, then 3 as ticks arrive, and history
  // (which counted it once) will never show those values.
}

Every module-level let is reassigned in reset(), every TA object gets .reset(), and a class you wrote gets a reset() of its own. Allocation does not belong there (the module never frees memory; reset the fields in place). If a value on the newest bar drifts as you watch it while the closed bars stay put, reset() is the first place to look.

The host decides which bars the module sees

Run-level renderers and drawings are selected from the finished row set with no backward scan (the newest ready row that satisfies the kind's rule wins), and they never change a metric value. On your machine om metric series returns the forming bar as its newest row; bar-evaluated metric signals compute AS OF the last closed primary bar instead, dropping the forming rows before the module folds them. The module is the same either way.

How to stay repaint-safe

A short checklist:

  • Fold higher timeframes on the next bucket. The confirmed value is the one you act on; the running value is a readout.
  • Do not act on the still-forming bar. Its high, low, and close are moving until it closes. Gate confirmed signals on settled data, or let a closed-bar consumer cut the forming bar for you.
  • Reset everything. Every module-level variable, every TA object, every class you wrote. A forgotten field is the one repaint an Indicator can have.
  • Keep the primary selector-following and the pins honest. A pinned coarse input is read as of close on your machine; on the chart, pins are not honored and every input is the chart's own interval, so build the higher timeframe with the fold.

Stick to confirmed folds and a complete reset() and your backtest will mean something: what you measured on history is what the Indicator would have done live.

See also

  • Multi-timeframe for the full bucket-fold and interval-pin story, calendar buckets included.
  • Execution model for the bar loop, warm-up, and the three hosts.