Core Concepts

Multi-Timeframe

Read higher and lower timeframes from any script with htf(), ltf(), and request(). Higher-timeframe values are confirmed and repaint-free by default, with calendar periods (1D/1W/1M/1Q/1Y) and confirmed/developing/offset modes.

Intermediate 8 min read

Verified against engine 3.0.26. The calendar-period tokens (1D/1W/1M/1Q/1Y), the request()/htf() opts object (mode/offset/bars), and per-source deep history are since engine 3.0.26 (in-flight): they ship on the perf/stateful-ta branch and are not yet on a tagged release. Every example and error message below is copied from a probe run against 3.0.26.

Introduction

A kScript script is not locked to its chart's timeframe. From a 1h chart you can read the 4h trend, pull a daily level, or break each bar into its 15m pieces. Three functions cover it:

  • htf(source, timeframe, opts?) reads a higher timeframe (4h on a 1h chart).
  • ltf(interval) reads a lower timeframe and hands you the finer bars inside each chart bar.
  • request(symbol, timeframe?, type?, exchange?, opts?) is shorthand for loading another symbol, optionally at a higher timeframe.

The headline property is what htf() does not do: it never repaints.

htf() is confirmed by default

In most charting languages, a higher-timeframe lookup is a repaint trap. You ask for the 4h close on a 1h chart, and while the 4h candle is still forming, the function hands back its live, not-yet-final value. Your signal looks perfect in backtest and fires a bar early in production, because history got a value that the live chart never had.

kScript closes that trap. htf() returns the confirmed higher-timeframe value: the 4h reading on any 1h bar uses only the 4h candles that had already closed by that bar. The value you see in history is the value the script saw live. No look-ahead, no special flag to remember, no lookahead=barmerge.lookahead_off incantation. Confirmed is the default and the safe choice is the one you get for free.

//@version=2
define(title="4h regime on a 1h chart", position="overlay", axis=true)

timeseries d = ohlcv(symbol=currentSymbol, exchange=currentExchange)
timeseries h4 = htf(source=d, timeframe="4h")

// 4h trend filter, evaluated on every 1h bar with no repaint
var bullish4h = h4.close > ema(source=h4.close, period=20)
var dot = bullish4h ? d.low : d.high

plotLine(value=ema(source=h4.close, period=20), colors=["#2563eb"], width=2, label=["4h EMA20"], desc=["confirmed 4h trend baseline"])
plotShape(value=dot, shape="circle", colors=[bullish4h ? "#16a34a" : "#dc2626"], label=["regime"], desc=["green when the confirmed 4h close is above its 4h EMA"])

The 4h EMA only steps when a 4h candle closes, so it draws as a staircase across four 1h bars. That flat-then-step shape is the visual signature of a correct, confirmed higher timeframe.

When you do want the live value

Sometimes you genuinely want the forming bar, for example a live 4h close ticking inside the current period. Opt in explicitly:

timeseries h4Live = htf(source=d, timeframe="4h", opts={mode: "developing"})

developing mode updates within the current higher-timeframe bucket. Use it for live readouts, never for historical signals: developing values change as the bar fills, so a cross built on them will not reproduce.

request(): another symbol, optionally higher

request() is the one-line way to pull a different symbol. With a timeframe it carries the same no-repaint discipline as htf(), and it accepts the same opts object (mode/offset, plus a request-only bars).

timeseries ethNow = request("ETHUSDT")          // ETH at the chart timeframe
timeseries eth4h  = request("ETHUSDT", "4h")    // confirmed 4h ETH, like htf()

Unlike a chart-bar resample, a coarser request() fetches its own deep history, scaled by the requested interval rather than the chart interval, so a daily or weekly level resolves even on a 1m chart. See Higher-timeframe requests fetch their own depth for the fetch-depth and source-count details, and opts: mode, offset, and bars below for the options.

This is the natural tool for relative-strength and lead/lag work. Read the full multi-symbol and aggregation story in Multi-Source & Aggregation.

ltf(): the bars inside each bar

ltf() goes the other direction. It fetches finer bars than the chart and attaches them to each chart bar as a list of cells, leaving the chart's own timeline untouched. On a 1h chart, ltf("30m") gives every bar its two 30m candles. Reduce the cells to turn intrabar detail into a single value:

//@version=2
define(title="Intrabar close drift", position="offchart", axis=true)

// each 1h bar carries its 30m cells; cell layout is [time, open, high, low, close, volume]
timeseries fine = ltf("30m")
timeseries drift = fine.cells.map((c) => c[4] - c[1]).reduce((s, x) => s + x, 0)

plotLine(value=drift, colors=["#7c3aed"], width=2, label=["30m drift"], desc=["sum of finer-bar close minus open within each chart bar"])

Cells use the same .cells machinery as order-flow sources, so the reducers from Lambdas & Reducers apply directly.

All three together

This example loads a confirmed 4h view, its developing counterpart, the same 4h close through request(), and a 30m ltf() reduction in a single script.

scripts/probes/mtf/mtf_views.ks
//@version=2
define(title="Verified MTF Views", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)
timeseries h4 = htf(source=trade, timeframe="4h")
timeseries h4Developing = htf(source=trade, timeframe="4h", opts={mode: "developing"})
timeseries requested = request(symbol=currentSymbol, timeframe="4h")
timeseries lower = ltf(interval="30m", type="ohlcv", symbol=currentSymbol, exchange=currentExchange)
var lowerCloseSum = lower.cells.map((row) => row[4]).reduce((sum, x) => sum + x, 0)

plotLine(value=h4.close, colors=["#2563eb"], width=2, label=["HTF close"], desc=["confirmed 4h close from htf"])
plotLine(value=h4.open, colors=["#64748b"], width=2, label=["HTF open"], desc=["confirmed 4h open from htf member access"])
plotLine(value=h4Developing.close, colors=["#16a34a"], width=2, label=["Developing close"], desc=["developing 4h close from htf"])
plotLine(value=requested.close, colors=["#f97316"], width=2, label=["Request close"], desc=["4h close from request shorthand"])
plotLine(value=lowerCloseSum, colors=["#7c3aed"], width=2, label=["LTF close sum"], desc=["lower timeframe close values reduced from cells"])

What to expect: the confirmed h4 series reads one closed 4h candle per group of chart bars and steps as a staircase. The developing series moves on every bar inside the current 4h bucket. request(..., "4h") tracks the confirmed htf() series exactly, since it is the same machinery over the same symbol. The ltf() reduction produces a value on every chart bar.

Timeframe tokens: rolling vs calendar

The timeframe string you pass to htf() and request() comes in two flavours, and the difference is where the bucket boundaries fall.

Rolling tokens are Nm, Nh, and Nd (a count plus minute, hour, or day). Each bucket is exactly N units wide, floored from the Unix epoch. "4h" buckets start wherever epoch 4-hour slots land; "1d" starts at 00:00 UTC purely because that is where epoch days fall. A positive integer (milliseconds) is the same epoch-floor rule.

Calendar tokens are the five uppercase strings "1D", "1W", "1M", "1Q", "1Y". They anchor to real UTC calendar boundaries instead of epoch slots:

TokenBucketUTC anchor
"1D"day00:00
"1W"weekMonday 00:00
"1M"calendar monthfirst of the month
"1Q"quarterJan / Apr / Jul / Oct 1
"1Y"calendar yearJan 1

Case is significant. "1m" is a one-minute rolling bucket; "1M" is a calendar month. "1d" (rolling) and "1D" (calendar day) happen to share a 24h span, but only "1D" is calendar-anchored. Calendar tokens only support a count of 1, so "2W", "3M", and the like are rejected (see boundaries). For a fixed multi-unit span, use a rolling token such as "14d" or "720h".

The distinction is observable. In this probe a Monday-anchored "1W" and a rolling "7d" over the same chart produce different closes (the probe's last bar reads 121.64 for 1W versus 113.55 for 7d), because their week boundaries fall on different days. The request("…", "1W") series matches the htf(…, "1W") series exactly.

scripts/probes/mtf/calendar_views.ks
//@version=2
define(title="Verified Calendar HTF Views", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

// Calendar tokens are UPPERCASE and UTC-anchored. "1W" is the Monday-anchored
// calendar week; "7d" is a rolling 7-day epoch-floor bucket. They are DISTINCT.
timeseries dayView = htf(source=trade, timeframe="1D")
timeseries weekView = htf(source=trade, timeframe="1W")
timeseries rollingWeek = htf(source=trade, timeframe="7d")
timeseries weekRequested = request(symbol=currentSymbol, timeframe="1W")

plotLine(value=dayView.close, colors=["#2563eb"], width=2, label=["1D close"], desc=["confirmed prior UTC calendar day close"])
plotLine(value=weekView.close, colors=["#16a34a"], width=2, label=["1W close"], desc=["confirmed prior Monday-week close"])
plotLine(value=rollingWeek.close, colors=["#f97316"], width=2, label=["7d close"], desc=["rolling epoch-floor 7-day close, distinct from 1W"])
plotLine(value=weekRequested.close, colors=["#7c3aed"], width=2, label=["1W via request"], desc=["same calendar week through request shorthand"])

The probe ran green on engine 3.0.26 (ok: true, all four series finite and moving). 1D close had 298 finite values across 13 distinct steps; 1W close and 1W via request were byte-identical (last value 121.64466637730584), while the rolling 7d close last value was 113.54876096708485. That difference is the proof the calendar and rolling weeks bucket differently.

opts: mode, offset, and bars

htf() and request() both take a final opts object: { mode?, offset?, bars? }.

  • mode: "confirmed" (the default) reads the prior completed period. mode: "developing" reads the current forming period: a live, still-moving value. Use developing for readouts, never for historical signals.
  • offset: N selects N completed periods back: offset: 0 is the most recently completed period (the confirmed default), offset: 1 the one before that. offset is confirmed-only; developing requires offset: 0.
  • bars: N loads exactly N bars of the requested timeframe (a precise fetch depth, see Multi-Source). bars is request-only: htf() operates on an already-fetched source and ignores it. N must be a positive integer.

A bare options object placed directly in the type/exchange slot is coerced to opts, mirroring htf(source, tf, { mode }). Those slots are always strings in real usage, so request("ETHUSDT", "1W", { mode: "developing" }) reads the developing week rather than treating {…} as a source type.

scripts/probes/mtf/request_opts.ks
//@version=2
define(title="Verified request() opts", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

// mode / offset / bars and the bare-opts coercion all live in the opts object.
timeseries confirmed = request(symbol=currentSymbol, timeframe="4h")
timeseries developing = request(symbol=currentSymbol, timeframe="4h", opts={mode: "developing"})
timeseries prior = request(symbol=currentSymbol, timeframe="4h", opts={offset: 1})
timeseries deep = request(symbol=currentSymbol, timeframe="4h", opts={bars: 500})
// A bare options object placed in the type/exchange slot is coerced to opts.
timeseries coerced = request(currentSymbol, "4h", {mode: "developing"})

plotLine(value=trade.close, colors=["#94a3b8"], width=1, label=["chart close"], desc=["base chart close"])
plotLine(value=confirmed.close, colors=["#2563eb"], width=2, label=["confirmed"], desc=["prior completed 4h close (default)"])
plotLine(value=developing.close, colors=["#16a34a"], width=2, label=["developing"], desc=["current forming 4h close"])
plotLine(value=prior.close, colors=["#f97316"], width=2, label=["offset 1"], desc=["one completed 4h bucket further back"])
plotLine(value=deep.close, colors=["#7c3aed"], width=2, label=["bars 500"], desc=["explicit fetch depth of 500 native 4h bars"])
plotLine(value=coerced.close, colors=["#dc2626"], width=2, label=["coerced opts"], desc=["bare opts object coerced from the type slot"])

The probe ran green on 3.0.26. The three modes produced distinct values at the last bar (confirmed 118.41, developing 115.98, offset: 1 121.54), confirming they read different buckets. bars: 500 matched the plain confirmed series exactly (depth changes what is fetched, not the resampled value), and the coerced {mode: "developing"} matched the keyword-opts developing series (115.98), proving the type-slot coercion.

Precision caveat: quarter and year open/close

Calendar periods are aggregated engine-side from a deep backing source, because venues do not serve weekly or monthly candles and a fine chart's own bars are far too shallow to reach a prior month or year. Day, week, and month back to a deep daily source; quarter and year back to a coarser weekly source (about 5x fewer bars). Because of that backing choice:

  • 1D, 1W, 1M are exact: daily bars line up cleanly with day, week, and month boundaries.
  • 1Q and 1Y have exact high and low, but their open and close come from weekly buckets and can be off by up to ~1 week. A calendar week can straddle a quarter or year boundary, and the boundary week is attributed to the period its start falls in. If you need a precise quarter/year open or close, derive it from a daily series yourself.

This depth/precision behavior is exercised by the engine's vitest suites src/__tests__/g6-request-source-discovery.test.ts (the B1a per-token backing-interval cases and the EC1 deep-fetch range cases) and src/__tests__/g6-htf-core.test.ts (the EC2 calendar-bucket cases), which pass on 3.0.26. They cannot be reproduced through the doc probe harness because it cannot inject multi-year deep daily/weekly fixtures; the engine tests are the proof.

Boundaries and exact errors

htf() and request() reject misuse loudly, with a line:column location. Every message below was captured from a probe run against engine 3.0.26.

The timeframe must be at least the chart interval. htf() only goes up; asking it for a finer bucket is caught immediately. On a 1h chart, htf(source=d, timeframe="30m") fails at run with:

htf() timeframe 1800000ms must be >= base interval 3600000ms at 5:20

(durations are reported in milliseconds: 30m is 1,800,000ms, 1h is 3,600,000ms). To go finer than the chart, reach for ltf() instead. A timeframe finer than the chart in request() is rejected the same way.

An unknown or malformed timeframe is rejected with the accepted-token list:

Invalid htf() timeframe '4x'; expected Nm, Nh, Nd, or a calendar token 1D/1W/1M/1Q/1Y at 5:18

A multi-count calendar token ("2W", "3M", and so on) is rejected, because calendar tokens only support a count of 1:

Invalid htf() timeframe '2W'; calendar tokens (D/W/M/Q/Y) only support a count of 1 at 6:22

Bad opts each carry their own message:

optsError
{lookahead: 1} (unknown key)htf() unknown option 'lookahead' at L:C
{mode: "live"}htf() mode must be 'confirmed' or 'developing' at L:C
{offset: 1.5} or {offset: -1}htf() offset must be a non-negative integer at L:C
{mode: "developing", offset: 1}htf() offset must be 0 when mode is 'developing' at L:C
{bars: -5} or {bars: 0}htf() bars must be a positive integer at L:C

request() shares the htf() aggregation path, so its opts are validated with the same wording: request("ETHUSDT", "4h", { bars: -5 }) fails with htf() bars must be a positive integer, and request("ETHUSDT", "1W", { mode: "developing", offset: 1 }) with htf() offset must be 0 when mode is 'developing'. These are exercised by the probes scripts/probes/mtf/htf_too_small_boundary.ks, htf_invalid_token_boundary.ks, cal_multicount_boundary.ks, htf_unknown_opt_boundary.ks, htf_offset_integer_boundary.ks, htf_developing_offset_boundary.ks, request_bars_integer_boundary.ks, and request_developing_offset_boundary.ks.