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 |
| Once | Once per update, until the next bar arrives |
|
|
|
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? |
|---|---|---|
| Yesterday's complete daily bucket | No |
| The last | No |
| 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 |
|---|---|
| On the minute grid from midnight UTC |
| On the hour grid from midnight UTC |
| 00:00, 04:00, 08:00, 12:00, 16:00, 20:00 UTC |
| 00:00 UTC |
| 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 onctx.barIndex.Fold state once per bar: store on confirmed bars, replace the tip in buffers, or act only when
ctx.barConfirmedis notfalse.Fold higher-timeframe state in the handle's handler, from closed buckets.
Read
formingonly for display of the unfinished bucket, and never fold it into state.Read time from the bar. Keep
Date.now(),new Date()andMath.random()out of scripts.Use sessions for exchange hours; the daily bucket is a UTC day.
