---
title: Multi-Timeframe
description: 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.
---

<div class="flex gap-3 mb-6">
  <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 text-blue-600 text-sm font-medium">
    Intermediate
  </span>
  <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-sm font-medium">
    8 min read
  </span>
</div>

> **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.

```javascript
//@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:

```javascript
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`).

```javascript
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](/kscript/core-concepts/multi-source#higher-timeframe-requests-fetch-their-own-depth) for the fetch-depth and source-count details, and [`opts`: mode, offset, and bars](#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](/kscript/core-concepts/multi-source).
## 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:

```javascript
//@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](/kscript/core-concepts/lambdas-and-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.

```javascript title="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:

| Token | Bucket | UTC anchor |
| --- | --- | --- |
| `"1D"` | day | `00:00` |
| `"1W"` | week | **Monday** `00:00` |
| `"1M"` | calendar month | first of the month |
| `"1Q"` | quarter | Jan / Apr / Jul / Oct 1 |
| `"1Y"` | calendar year | Jan 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](#boundaries-and-exact-errors)). 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.

```javascript title="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](/kscript/core-concepts/multi-source#higher-timeframe-requests-fetch-their-own-depth)). `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.

```javascript title="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:

| `opts` | Error |
| --- | --- |
| `{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`.
