Higher-timeframe kit

View as MarkdownOpen the editor

Two modules build a higher timeframe without a second data feed. ./sdk/resample folds the chart's own bars: a Resampler for the eight timeframes the built-in indicators' Timeframe setting offers, chart to 1W, and a Bucket for any span Pine spells, "120", "2D", "2W" or "3M". ./sdk/candles reads a candles stream instead: Periods folds its candles into calendar periods, a CandleList keeps the newest of them. Feed each class every chart bar. Each section writes its rules out: how buckets are keyed, when one closes, what a read shows. The clock, session and market-session classes beside them are the Clock and sessions kit; the fold itself, and the interval pin for a real coarser feed, are on Multi-timeframe.

Which one

ClassModuleReadsSpansReaches back
Resampler./sdk/resamplethe chart's own barsthe built-ins' eight timeframes: chart, 5m, 15m, 30m, 1h, 4h, 1D, 1Wthe loaded history
Bucket./sdk/resamplethe chart's own bars, on 24/7 marketsany span Pine spells: "120", "2h", "2D", "2W", "3M"the loaded history
Periods./sdk/candlesa candles streamcalendar day, week, month, quarter, yearthe stream's bars
CandleList./sdk/candlesa candles streamthe newest N closed candles of the stream's timeframethe stream's bars

From the chart bars: Resampler

The ./sdk/resample module builds a higher timeframe from the chart's own bars, the way the built-in indicators' Timeframe setting does: a 20-bar average at 1h on a 5m chart averages twenty closed 1h candles, never twenty 5m bars. Feed every chart bar to a Resampler; when a selected bar closes, push its value into a ClosedWindow or commit it to a Smoothed; then read either the closed bars alone (confirmed) or the closed bars plus the forming one as a trial step that is never stored (developing).

ExportSignatureAnswers
Resamplernew Resampler(option: i32, waitForClose: bool = true)the selected timeframe, numbered like the built-ins' Timeframe setting: tf.CHART, tf.FIVE_MINUTES, tf.FIFTEEN_MINUTES, tf.THIRTY_MINUTES, tf.HOUR, tf.FOUR_HOURS, tf.DAY, tf.WEEK (0..7); any other number aborts with Resampler: unknown timeframe <x>
updater.update(t, tradeDate, open, high, low, close, volume): voidfolds one chart bar: t is bar.time(), tradeDate is time.trade_date; call once per bar
closedr.closed(): boola selected bar closed on this chart bar, and r.last is its candle
newBucketr.newBucket(): boolthis chart bar starts a selected bar
last, formingr.last.close, r.forming.highthe last closed candle and the forming one: open, high, low, close, volume and startSec, NaN until each exists
sourcer.source(which: i32, forming: bool): f64one price of either candle: field.CLOSE, field.OPEN, field.HIGH or field.LOW, numbered like the built-ins' Source setting
confirmedr.confirmed(): boolread the committed state alone: waitForClose is on, or the timeframe is the chart's
refusedr.refused(): boolyour indicator must write NaN to every output while this is true (nothing is blanked for you): the selected bar is narrower than the chart's bars, or a 1D or 1W bar came without a trade date
widthSec, prevStartSecr.widthSec(): f64the selected bar's width in seconds; the start of the selected bar before the forming one
ClosedWindownew ClosedWindow(cap: i32, keepNa: bool = false)the last cap closed values: push(x), count(), back(n, x, withX), then mean, wma, stdev, highest, lowest, highestOffset, lowestOffset and sum, each (p, x, withX), and vwma(volumes, p, x, v, withX)
Smoothednew Smoothed(kind: string, period: i32)an "ema" or "rma" over closed values: commit(x), value(), peek(x) (one more step, not stored) and copyFrom(other) for a scratch copy

The rules, written out:

  • Buckets. 5m to 4h are rolling, floor(t / width) from the epoch, on every market. 1D is the exchange trade date and 1W a Monday week of trade dates, both read from time.trade_date: a CME bar from the 17:00 Chicago open already belongs to the next day's candle, and a Sunday-evening bar to Monday's week.
  • Closing. A selected bar closes on the first chart bar of the next one, and a gap simply skips the empty buckets. Its candle holds the first bar's open and the last bar's close as those bars gave them, the highest high, the lowest low and the summed volume; a NaN high, low or volume on any bar carries into that field. A candle whose last bar closed at NaN never closes, while a NaN close on an earlier bar does not matter.
  • Confirmed and developing. Confirmed: every chart bar inside selected bar k shows the state after bar k - 1, so a value never changes once it is drawn. Developing: that state plus one step on the forming candle, which is withX = true with the forming value on a window and peek on a smoother.
  • Warm-up counts closed selected bars: a 20-bar average at 1h needs 20 closed hours inside the loaded history (19 and the forming one when developing). The first bucket may be partial; it still closes and counts.
  • Refusal. refused() blanks nothing by itself: while it is true, your indicator must write NaN to every output, as the sample below does. The Resampler keeps folding underneath, so its candles, windows and smoothers still answer and would draw numbers that mean nothing. It turns true when the selected bar is narrower than the chart's bars (known from the second bar on, once the bar spacing is measured), and stays true from a 1D or 1W bar without a trade date on.

A moving average and an EMA of the selected timeframe's closes, with the built-ins' Timeframe and Wait for close settings. The default reads 1h on a 1h chart or a finer one, and refuses on a coarser one:

param("timeframe", 4, { min: 0, max: 7, description: "0 chart, 1 5m, 2 15m, 3 30m, 4 1h, 5 4h, 6 1D, 7 1W" });
param("period", 20, { min: 1, max: 200, description: "Length in bars of the selected timeframe" });
param("wait_for_close", 1, { min: 0, max: 1, description: "1 confirmed values, 0 developing values" });
input("close", ohlcv.close);
input("trade_date", time.trade_date);
output("htf_sma", line, overlay, { color: "#2563eb", width: 2, description: "Average of the selected timeframe's closes" });
output("htf_ema", line, overlay, { color: "#f59e0b", width: 2, description: "EMA of the selected timeframe's closes" });

let r = new Resampler(tf.HOUR, true);
let closes = new ClosedWindow(20);
let ema = new Smoothed("ema", 20);
let period: i32 = 20;

function onStart(): void {
  // Constructed here so a bad setting reports in the Console.
  period = i32(p_period());
  r = new Resampler(i32(p_timeframe()), p_wait_for_close() >= 0.5);
  closes = new ClosedWindow(period);
  ema = new Smoothed("ema", period);
}

function onBar(): void {
  r.update(bar.time(), in_trade_date(), bar.open(), bar.high(), bar.low(), bar.close(), bar.volume());
  if (r.closed()) {
    // A selected bar just closed: its candle is final, so it joins the state.
    closes.push(r.last.close);
    ema.commit(r.last.close);
  }
  // Developing reads add the forming bar as one trial step, never stored.
  const live = !r.confirmed();
  let sma = closes.mean(period, r.forming.close, live);
  let smoothed = live ? ema.peek(r.forming.close) : ema.value();
  if (r.refused()) {
    sma = NaN;
    smoothed = NaN;
  }
  out_htf_sma(sma);
  out_htf_ema(smoothed);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Confirmed, both lines step once per closed hour and hold between; with Wait for close off they follow the forming hour on every chart bar. A chain reads the same way: a signal line over a closed MACD commits each closed MACD value, and its developing read peeks with the trial MACD.

Pine equivalents

PineHere
request.security(syminfo.tickerid, "60", ta.sma(close, 20)[1], lookahead = barmerge.lookahead_on): the last closed hour, never repaintingnew Resampler(tf.HOUR, true) and closes.mean(20) over a ClosedWindow pushed on closed()
request.security(syminfo.tickerid, "60", ta.ema(close, 20)) on the realtime bar: the forming hournew Resampler(tf.HOUR, false) and ema.peek(r.forming.close)
barstate.isconfirmed before using a higher-timeframe valueconfirmed reads (waitForClose on), which only ever show closed selected bars
timeframe.change("60")r.newBucket()
request.security(syminfo.tickerid, "D", close[1], lookahead = barmerge.lookahead_on)r.last.close on tf.DAY: the exchange trade date's candle

Any span: Bucket

A Bucket folds the chart's own bars into a span the chart does not serve, keyed the way TradingView aligns a multi-period timeframe, so request.security(syminfo.tickerid, "120", close) and its "2D", "2W" and "3M" cousins port without a pinned input. The constructor takes the span and the chart's bar interval in seconds: declare chart.interval_sec(); at top level and pass p_chart_interval_sec() from onStart(), as the sample below does, so the module knows where each bar closes instead of guessing from the gaps between bars. The span is Pine's timeframe string plus the kit's own letters: digits alone or with m are minutes ("120", "45", "5m"), s seconds, h or H hours ("2h"), d or D days ("2D"; a bare "D" is one), w or W weeks ("2W") and M months ("3M"): the capital M is months as in Pine, the small m minutes as in the kit's interval pins. Anything else, a count of 0 or an intraday span past a day aborts with Bucket: bad span <x>, so construct in onStart().

ExportSignatureAnswers
Bucketnew Bucket(spec: string, chartIntervalSec: f64)buckets of one span over the chart's bars, whose interval is p_chart_interval_sec()
updateb.update(t, open, high, low, close, volume): voidfolds one chart bar: t is bar.time(); call once per bar
closed, newBucketb.closed(): boola bucket closed on this chart bar (b.last is its candle); this chart bar starts a bucket
isLastBarb.isLastBar(): boolthis chart bar's close (its open plus the chart interval) reaches the bucket's end: the bucket's last bar, where b.forming already holds its final candle
completeb.complete(): Candlethe newest bucket complete at this bar's close: forming on the bucket's last bar, last on every other bar; what Pine's request.security shows on historical bars with lookahead off
last, formingb.last.close, b.forming.highthe last closed bucket and the forming one: open, high, low, close, volume and startSec, NaN until each exists
startSec, endSec, widthSec, lastEndSecb.endSec(): f64the forming bucket's bounds in epoch seconds and its width (shorter than the span on the last bucket of a day or year); the last closed bucket's end
lastWhole, formingWholeb.lastWhole(): boolthe candle's bucket started at or after the first bar fed, so it holds every bar of the bucket (the first bucket may have started before the history)
refusedb.refused(): boolthe chart's bars cannot build the span (the rule below); your indicator must write NaN to every output while it is true
refusedReasonb.refusedReason(): stringwhy, as readable text (Bucket: span 45 is not a whole multiple of the chart's 1800s bars); the empty string while the span is served
resetb.reset(): voidback to the freshly constructed state; the span, the interval and a refusal decided at construction stay, a straddle refusal clears

The rules, written out (TradingView's bar alignment on a 24/7 market):

  • The chart interval decides what the bars can build. The span is refused from the first bar, with its reason, when the interval is unknown (chart.interval_sec() reads 0; every place an indicator runs fills it today), when the span is narrower than the bars, or when it is not a whole multiple of them: "45" on a 30-minute chart would put the 00:30 bar astride 00:45, a candle Pine serves from finer data the chart does not have (a month counts as 28 days for both tests). Where the bars' own grid cannot meet a boundary, the first bar that straddles one (hourly bars opening on the half hour under "2h", a month boundary that is not a Monday on a weekly chart) refuses the span from that bar on, until reset(). A monthly chart's bars have no fixed width, so a Bucket refuses them.
  • Intraday spans count from 00:00 UTC of each day, so "2h", "3h", "4h", "6h", "8h" and "12h" are the epoch floor the Resampler uses, while a span that does not divide the day ("7h", "50") ends its last bucket of the day short, at midnight.
  • Day spans count calendar days from January 1st and restart every year: on "3D" the bucket after 2026-12-30 is two days long, and 2027-01-01 starts a new one.
  • Week spans count Monday weeks from the year's first Monday, and the days before that Monday belong to the last bucket of the year before: 2027 starts on a Friday, so January 1st to 3rd 2027 sit in the "2W" bucket that began on Monday 2026-12-21.
  • Month spans count from January and restart every year: "3M" is the quarter, and a "5M" year ends with a two-month bucket.
  • Closing is the Resampler's rule: a bucket closes on the first chart bar of a later bucket, when its last bar's close is finite, and b.last is never revised; gaps skip empty buckets; the first bucket may be partial, and lastWhole() says so.
  • Sessions are not modelled: Pine anchors its intraday buckets at a venue's session open and counts trading days only, which differs from the UTC day on a session venue, and a session-anchored grid (bars opening on the half hour) refuses as a straddle. Use a Bucket on 24/7 markets.

The close, high and low of the newest two-hour bucket complete at each bar, the way request.security(syminfo.tickerid, "120", close) shows them on historical bars, with the span as a setting and the chart's bar interval passed in:

param("hours", 2, { min: 1, max: 24, description: "Hours per bucket" });
chart.interval_sec();
output("htf_close", line, overlay, { color: "#2563eb", width: 2, description: "Close of the newest bucket complete at this bar" });
output("htf_high", line, overlay, { color: "#16a34a", description: "Its high" });
output("htf_low", line, overlay, { color: "#dc2626", description: "Its low" });
output("bucket_start", none, overlay, { description: "1 on the bar that starts a bucket" });

let b = new Bucket("2h", 0.0);

function onStart(): void {
  // Built here from the setting and the chart's interval: a bad span aborts
  // with a readable message, a span the bars cannot build refuses with one.
  b = new Bucket(i32(p_hours()).toString() + "h", p_chart_interval_sec());
}

function onBar(): void {
  b.update(bar.time(), bar.open(), bar.high(), bar.low(), bar.close(), bar.volume());
  // complete() is the forming bucket on its last bar and the last closed one otherwise.
  const c = b.complete();
  const blank = b.refused() || isNaN(c.close);
  out_htf_close(blank ? NaN : c.close);
  out_htf_high(blank ? NaN : c.high);
  out_htf_low(blank ? NaN : c.low);
  out_bucket_start(b.newBucket() ? 1.0 : 0.0);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

b.last alone is Pine's request.security(..., close[1], lookahead = barmerge.lookahead_on), the previous bucket on every bar of the current one, and b.forming its repainting reading of the realtime bar. A ClosedWindow or a Smoothed pushed on b.closed() gives an indicator on the span, exactly as with a Resampler. On a chart whose bars cannot build the span (a 3-hour setting on a 2-hour chart) every output reads NaN and b.refusedReason() says why; show it in a label or a HUD when the port should explain itself.

PineHere
request.security(syminfo.tickerid, "120", close) on historical barsb.complete().close with new Bucket("120", p_chart_interval_sec())
request.security(syminfo.tickerid, "2D", high[1], lookahead = barmerge.lookahead_on)b.last.high with new Bucket("2D", p_chart_interval_sec())
request.security(syminfo.tickerid, "2W", close) on the realtime barb.forming.close with new Bucket("2W", p_chart_interval_sec())
timeframe.change("120")b.newBucket()

Calendar periods and candle lists

The ./sdk/candles module reads a candles stream, the closed candles of one timeframe reaching back bars deep (Multi-timeframe). A Periods folds the candles into calendar periods; a CandleList keeps the newest of them. Feed each one every bar, an empty block included, with load(in_<name>_view(), in_<name>_cells(), bar.time()): the bar moves the clock even when no candle arrives. Construct them at module start when the period is a constant, or in onStart() when a setting picks it.

ExportSignatureAnswers
periodperiod.DAY, period.WEEK, period.MONTH, period.QUARTER, period.YEARthe period a Periods folds, numbered 0..4 so a choice setting's index passes straight through
Periodsnew Periods(kind: i32, keep: i32 = 1, leg: i32 = period.DAY)calendar periods of one kind, keeping the newest keep closed ones readable; leg is the stream's interval, period.WEEK for a 1w stream and period.DAY for 1d or finer; an unknown kind aborts with Periods: unknown period <x>, any other leg with Periods: a stream's leg is period.DAY (1d or finer) or period.WEEK (1w), not <x>
loadp.load(cells: StaticArray<f64>, count: i32, t: f64): i32moves the clock to the bar's open t, then folds this bar's block; returns the candles taken
confirmedp.confirmed(n: i32 = 0): PeriodCandlethe n-th newest closed period, 0 the last one; all NaN for an n past keep - 1 or before n + 1 periods have closed
developingp.developing(): PeriodCandlethe bar's own period, folded from the candles closed so far
isNew, startSec, endSec, completep.isNew(): boolthe bar starts a new period; the period's bounds in epoch seconds; whether the stream reaches back to its start
add, at, resetp.add(t, open, high, low, close, volume): boolfolds one candle by hand; moves the clock to a bar opening at t (call it before that bar's add calls, as load does); back to the constructed state
PeriodCandleopen, high, low, close, volume, startSec, endSec, pv, pvVolume, count; vwap()one period: the first open, the highest high, the lowest low, the last close, the summed volume, and its VWAP sums
CandleListnew CandleList(cap: i32)the newest cap closed candles of the stream
load, countlist.load(cells, count, t): i32, list.count(): i32keeps this bar's candles; how many it holds, at most cap
openSec, open, high, low, close, volumelist.high(i: i32): f64candle i, 0 the oldest kept and count() - 1 the newest; NaN outside the list

The rules, written out:

  • Buckets. Every boundary is 00:00 UTC: a week starts on Monday, a month on the 1st, a quarter on January, April, July or October 1st, a year on January 1st. A candle belongs to the period holding its open, and a daily candle of a market with sessions is stamped with its trade date.
  • Confirmed. A period is confirmed on every bar that opens at or after its end. All of its candles have arrived by then, so a confirmed period never changes. developing() never holds the forming candle, so it reads NaN until the period's first candle closes.
  • Never partial. A period that began before the stream's first candle reads NaN from confirmed() and developing(), and complete() says whether the bar's own period is whole. Ask for enough bars to reach the start of every period you read: 366 daily candles always hold the current year.
  • Any bar width. On a monthly or yearly chart one bar carries a month or a year of candles. Periods keeps every period a read can still reach, the keep newest closed ones and the bar's own, whatever a bar spans, in the memory it allocated at construction.
  • Weekly streams. Construct a Periods fed a 1w stream with period.WEEK as its third argument. Its clock then runs on the start of the bar's week, so a week that straddles a month, quarter or year boundary counts in the period it opens in, as calendar requests over weekly candles count it. The stream's interval is declared, never read from the candles, so a 1d stream with days missing keeps its daily clock. Feed a 1d stream for exact periods.
  • The list. On the newest bar, a stream declared bars: N read through CandleList(N) holds the newest N closed candles: the rows requestBars(sym, tf, { bars: N + 1 }) returns, without its last, the forming candle. That one is the interval's forming view, folded from the chart's own bars. On an earlier bar the list holds the candles delivered through that bar, so it serves per-bar math as well as drawing.

Last month's high and low, and the average close of the last 20 closed days, on any chart interval:

input("close", ohlcv.close);
input("d", candles.cells, { interval: "1d", bars: 70, description: "The newest 70 closed days" });
output("pm_high", line, overlay, { color: "#16a34a", description: "Last month's high" });
output("pm_low", line, overlay, { color: "#dc2626", description: "Last month's low" });
output("avg20", line, overlay, { color: "#2563eb", description: "Average close of the last 20 closed days" });

const month = new Periods(period.MONTH);
const days = new CandleList(20);

function onBar(): void {
  const block = in_d_view();
  const n = in_d_cells();
  const t = bar.time();
  month.load(block, n, t);
  days.load(block, n, t);
  const last = month.confirmed(0); // NaN until a whole month has closed inside the stream
  out_pm_high(last.high);
  out_pm_low(last.low);
  let avg: f64 = NaN;
  if (days.count() == 20) {
    let sum = 0.0;
    for (let i = 0; i < 20; i++) sum += days.close(i);
    avg = sum / 20.0;
  }
  out_avg20(avg);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

bars: 70 reaches back past the start of last month on every day of the year, so even a short chart shows it whole. On a chart that loaded more than 70 days, the stream starts just before the first loaded bar instead, and the first month or two read NaN: a month that began before the stream is never shown partial.