ChartnautDocs

Bars and clocks

Every script runs against a clock made of bars. This page sets out what a bar is, when your code runs, what a higher or lower timeframe can know at a given bar, and how the time grids are laid. Each rule is something Chartnaut guarantees or something your script must do to keep the guarantees on What Chartnaut guarantees.

What a bar is

A bar is one bucket of time on one timeframe: its open, high, low, close and volume, stamped with the bucket's start in ctx.time (unix seconds, UTC). A bar is either confirmed, meaning its bucket has ended and a later bar has arrived, or forming, meaning it is the newest bar on a live chart and is still printing.

Confirmed bar

Forming bar

Where

Every bar in history, every bar in a run over history

The right edge of a live chart only

Values

Fixed

Change on every tick

onBar runs

Once

Once per update, until the next bar arrives

ctx.barConfirmed

true

false

Runs over history, dry runs and replays of past bars have no live edge: every bar is confirmed.

When onBar runs

onBar(ctx) runs once per bar, oldest first. On a live chart it runs again for the forming bar every time that bar updates, and once more when the next bar arrives and confirms it.

The engine handles the re-runs for what it owns: the forming bar's plots replace what the previous run plotted for that bar, and higher-timeframe buckets never close on a bar that is still forming. State you keep yourself is different. ctx.accum is not rolled back between re-runs of the same bar, so an update function runs once per tick on the forming bar, each time starting from what the previous tick left:

  • An array that appends fills with copies of the forming bar and pushes real history out, so pivots, rolling highs and ranges computed from it read the wrong bars.

  • An EMA updated with prev + k * (close - prev) applies its step once per tick, so the live value runs ahead of what a fresh load of the same bars computes.

  • A counter counts ticks instead of bars.

The rule: fold state once per bar, on confirmed bars, and compute the forming bar's value from that state without storing it. Three patterns cover it.

Fold on confirmed bars, show a live value

meta({ shortName: "EMA", kind: "overlay" });
const length = input.number({ id: "length", label: "Length", default: 20, min: 1 });
warmup((w) => w.ema(length));
output.line({ id: "ema" });

export function onBar(ctx) {
  const k = 2 / (ctx.params.length + 1);
  const st = ctx.accum("ema", () => ({ done: NaN }), (s) => s);
  const live = Number.isFinite(st.done) ? st.done + k * (ctx.close - st.done) : ctx.close;
  if (ctx.barConfirmed !== false) st.done = live;   // store once, when the bar is closed
  ctx.plot("ema", live);                             // every tick shows the right value
}

When the next bar arrives, the previous bar runs once more as a confirmed bar and its value is stored. The forming bar's ticks never touch st.done.

Replace the tip, keep a live reading

export function onBar(ctx) {
  const st = ctx.accum("swings", () => ({ buf: [], lastT: null }), (s) => s);
  if (st.lastT === ctx.time) st.buf.pop();      // same bar again: overwrite it
  st.buf.push({ t: ctx.time, h: ctx.high, l: ctx.low });
  st.lastT = ctx.time;
  if (st.buf.length > 50) st.buf.shift();
  // read st.buf here: the forming bar appears once, as its latest values
}

Act only on closed bars

export function onBar(ctx) {
  if (ctx.barConfirmed === false) return;       // skip the forming bar
  const st = ctx.accum("swings", () => ({ buf: [] }), (s) => s);
  st.buf.push({ t: ctx.time, h: ctx.high, l: ctx.low });
  if (st.buf.length > 50) st.buf.shift();
}

The last pattern draws nothing on the forming bar. Use it for signals you only want on a close: a level created from a closed swing, an event that must not appear and then vanish.

Time is the bar's time

Read time from ctx.time. Date.now() is refused in definitions and on the server, and new Date() without arguments is flagged, because the wall clock differs between a live chart, a replay and a run over last year's bars. ctx.barIndex counts bars from the start of the current pass, so the same bar has a different index on a chart, in replay and on the server. Key anything that must be stable, such as ids, dedupe keys and "last seen" markers, on ctx.time.

Higher timeframes: what a value knows at a bar

A timeframe("1d") handle on a 5m chart gives you three reads, each with a fixed promise:

Read

What it holds at the 14:35 bar

Can it change later?

daily.last

Yesterday's complete daily bucket

No

daily.bars(n)

The last n complete daily buckets

No

daily.forming

Today from the open up to and including the 14:35 bar

Yes, until today closes

None of them includes a price that printed after the current chart bar. last stays undefined until the first bucket closes, so guard it.

A bucket closes on the chart bar whose end reaches the bucket's end: the daily on the 23:55 bar of a 5m chart. Every bucket that ends at one instant closes in the same step: at Monday 00:00 the 1m, the 4h, the day, the week and the chart bar close together. Act on a close with closedThisBar. The full reference for handles is Timeframe handles and runOn.

Reading a higher timeframe safely

meta({ shortName: "D-EMA20", kind: "overlay" });
warmup(600);                                     // 20-day EMA: about 30x its length
output.line({ id: "ema" });

const daily = timeframe("1d");
daily.onBar((ctx) => {
  const k = 2 / 21;
  ctx.accum("ema", NaN, (e) => (Number.isFinite(e) ? e + k * (ctx.close - e) : ctx.close));
});

export function onBar(ctx) {
  const e = daily.state.ema;                     // advanced through the last closed day
  if (Number.isFinite(e)) ctx.plot("ema", e);
}

The EMA is folded in the handler, once per closed day, so it never sees today's unfinished close. Folding daily.forming.close into state from onBar would re-fold on every chart bar and every tick.

Lower timeframes

A timeframe("1m") handle on a 5m chart runs its handler once for each 1m bucket that closed inside the chart bar, oldest first. At the live edge the newest 1m bucket is still forming and is not handed out as closed, so a 1m value read from a 5m chart matches the 1m chart tick for tick. There is no forming for a lower timeframe.

The time grids

All grids are UTC.

Timeframe

Buckets start

1m 5m 15m 30m

On the minute grid from midnight UTC

1h 2h

On the hour grid from midnight UTC

4h

00:00, 04:00, 08:00, 12:00, 16:00, 20:00 UTC

1d

00:00 UTC

1w

Monday 00:00 UTC, the one offset grid

Every pair of timeframes tiles: each higher bucket is a whole number of lower buckets, and their boundaries line up. That is why a handle reads the same value on every chart. A daily bucket is a UTC day, which is not the same as an exchange's trading day. If your logic needs the New York cash session, use a session.

Sessions and time zones

ctx.session(opts) tracks a window inside a day, week or month in any IANA time zone, and handles daylight saving:

const s = ctx.session({ period: "daily", open: { hour: 9, minute: 30 }, close: { hour: 16 }, tz: "America/New_York" });
if (s.didOpen) { /* reset per-session state here */ }

The default time zone is "UTC". Gate logic on didOpen, didClose and isOpen, never on counting bars, because the number of bars in a session changes with the chart timeframe and with market holidays. A session reset also shortens the warmup a script needs, see Warmup and memory. The session API is on Sessions.

Rules for the script author

  • Key stable things on ctx.time, never on ctx.barIndex.

  • Fold state once per bar: store on confirmed bars, replace the tip in buffers, or act only when ctx.barConfirmed is not false.

  • Fold higher-timeframe state in the handle's handler, from closed buckets.

  • Read forming only for display of the unfinished bucket, and never fold it into state.

  • Read time from the bar. Keep Date.now(), new Date() and Math.random() out of scripts.

  • Use sessions for exchange hours; the daily bucket is a UTC day.