Regime Filter (4h Trend Gate)

A no-repaint higher-timeframe trend gate that tints a line by regime and only marks entries when the confirmed 4h trend agrees with the signal. The kScript…

A no-repaint higher-timeframe trend gate that tints a line by regime and only marks entries when the confirmed 4h trend agrees with the signal. The kScript (legacy) recipe and the Indicator it became, side by side.

The fastest way to cut false signals is to stop trading against the higher timeframe. This recipe reads a confirmed 4h trend, tints its 4h line green or red by that regime, and only marks an entry when a fast/slow EMA cross on your chart timeframe lines up with the 4h direction. The key word is confirmed: a 4h candle contributes only once it has closed, so a signal that shows up in history would have shown up live, at the same bar. No repaint, no hindsight.

The kScript (legacy) recipe

//@version=2
// ============================================================================
//  NO-REPAINT 4H TREND GATE
//  htf() reads a confirmed higher timeframe with no look-ahead. The 4h EMAs
//  are plotted so the script always has visible output; candle tinting and
//  entry markers are added when the confirmed 4h regime is available.
// ============================================================================
define(title="4H Trend Gate (no-repaint)", position="onchart", axis=false);

var fastLen = input(name="fastLen", type="number", defaultValue=9,  label="Fast EMA", constraints={min: 2, max: 100, step: 1});
var slowLen = input(name="slowLen", type="number", defaultValue=21, label="Slow EMA", constraints={min: 5, max: 200, step: 1});
var upCol   = input(name="upCol",   type="color",  defaultValue="#22d3a5", label="Bull Tint");
var dnCol   = input(name="dnCol",   type="color",  defaultValue="#ff5b7f", label="Bear Tint");

timeseries d  = ohlcv(symbol=currentSymbol, exchange=currentExchange);

// Confirmed 4h view of the SAME data: one row per chart bar, no repaint.
timeseries h4 = htf(d, "4h");

var h4Fast = ema(source=h4.close, period=fastLen);
var h4Slow = ema(source=h4.close, period=slowLen);
var bull4h = isnum(h4Fast) && isnum(h4Slow) && h4Fast > h4Slow;
var bear4h = isnum(h4Fast) && isnum(h4Slow) && h4Fast < h4Slow;
timeseries h4FastLine = h4Fast;
timeseries h4SlowLine = h4Slow;

// Chart-timeframe trigger, gated by the higher-timeframe regime.
timeseries fast = ema(source=d.close, period=fastLen);
timeseries slow = ema(source=d.close, period=slowLen);
var crossedUp   = isnum(fast[0]) && isnum(slow[0]) && fast[0] > slow[0] && fast[1] <= slow[1];
var crossedDown = isnum(fast[0]) && isnum(slow[0]) && fast[0] < slow[0] && fast[1] >= slow[1];

plotLine(h4FastLine, colors=[upCol], width=1, label=["4H Fast EMA"], desc=["Confirmed 4h fast EMA"]);
plotLine(h4SlowLine, colors=[dnCol], width=1, label=["4H Slow EMA"], desc=["Confirmed 4h slow EMA"]);

// Tint the actual candles by regime. Needs enough loaded history for the 4h
// slow EMA: roughly slowLen * 4 chart hours on a 1h chart.
if (bull4h) { barcolor(opacity(upCol, 18)); }
if (bear4h) { barcolor(opacity(dnCol, 18)); }

if (crossedUp && bull4h) {
  plotShape(value=d.low[0], shape="triangleup", width=10, colors=[upCol], fill=true, location="belowBar", label=["Long (4h agrees)"], desc=["Chart-timeframe cross up confirmed by the 4h regime"]);
}
if (crossedDown && bear4h) {
  plotShape(value=d.high[0], shape="triangledown", width=10, colors=[dnCol], fill=true, location="aboveBar", label=["Short (4h agrees)"], desc=["Chart-timeframe cross down confirmed by the 4h regime"]);
}

The Indicator

import { input, line, none, ohlcv, output, overlay, param, shape, time } from "./sdk/declare";
import { in_bar_t, in_close, in_high, in_low } from "./gen/inputs";
import {
  emitRow,
  out_h4_fast,
  out_h4_slow,
  out_is_long,
  out_is_short,
  out_long,
  out_regime,
  out_short,
} from "./gen/outputs";
import { p_fast_len, p_slow_len } from "./gen/params";
import { Cross, Ema } from "./sdk/ta";

param("fast_len", 9, { min: 2, max: 100, description: "Fast EMA length, on the chart bars and on the 4h buckets" });
param("slow_len", 21, { min: 5, max: 200, description: "Slow EMA length" });
input("close", ohlcv.close);
input("high", ohlcv.high);
input("low", ohlcv.low);
input("bar_t", time.bar_open_sec);
output("h4_fast", line, overlay, {
  width: 1,
  color_by: "regime",
  colors: ["#94a3b8", "#22d3a5", "#ff5b7f"],
  description: "Confirmed 4h fast EMA, tinted by regime",
});
output("h4_slow", line, overlay, { color: "#94a3b8", width: 1, description: "Confirmed 4h slow EMA" });
output("regime", none, overlay, { description: "0 warming, 1 bull, 2 bear" });
output("long", shape, overlay, { color: "#22d3a5", shape_where: "is_long" });
output("short", shape, overlay, { color: "#ff5b7f", shape_where: "is_short" });
output("is_long", none);
output("is_short", none);

let h4Fast = new Ema(9);
let h4Slow = new Ema(21);
let fast = new Ema(9);
let slow = new Ema(21);
let cross = new Cross();
let bucket: f64 = NaN;
let bucketClose: f64 = NaN;
let h4FastValue: f64 = NaN;
let h4SlowValue: f64 = NaN;
let regime: f64 = 0.0;
let crossed: i32 = 0;
let high: f64 = NaN;
let low: f64 = NaN;

export function init(): void {
  h4Fast = new Ema(i32(p_fast_len()));
  h4Slow = new Ema(i32(p_slow_len()));
  fast = new Ema(i32(p_fast_len()));
  slow = new Ema(i32(p_slow_len()));
  cross = new Cross();
}

export function state(): i32 {
  const close = in_close();
  high = in_high();
  low = in_low();
  // 4h buckets on UTC boundaries. A bucket's close folds into the 4h EMAs only once the NEXT bucket
  // has started, so the regime never uses a 4h candle that is still forming: no repaint.
  const b = Math.floor(in_bar_t() / 14400.0);
  if (b != bucket) {
    if (!isNaN(bucketClose)) {
      h4FastValue = h4Fast.update(bucketClose);
      h4SlowValue = h4Slow.update(bucketClose);
    }
    bucket = b;
  }
  bucketClose = close;
  regime = isNaN(h4FastValue) || isNaN(h4SlowValue)
    ? 0.0
    : h4FastValue > h4SlowValue
      ? 1.0
      : h4FastValue < h4SlowValue
        ? 2.0
        : 0.0;
  crossed = cross.update(fast.update(close), slow.update(close));
  return isNaN(h4SlowValue) ? 0 : 1;
}

export function finalize(): void {
  out_h4_fast(h4FastValue);
  out_h4_slow(h4SlowValue);
  out_regime(regime);
  out_long(low);
  out_short(high);
  out_is_long(crossed == 1 && regime == 1.0 ? 1.0 : 0.0);
  out_is_short(crossed == -1 && regime == 2.0 ? 1.0 : 0.0);
  emitRow();
}

export function reset(): void {
  h4Fast.reset();
  h4Slow.reset();
  fast.reset();
  slow.reset();
  cross.reset();
  bucket = NaN;
  bucketClose = NaN;
  h4FastValue = NaN;
  h4SlowValue = NaN;
  regime = 0.0;
  crossed = 0;
  high = NaN;
  low = NaN;
}

How it works

The higher-timeframe view. Math.floor(bar_open_sec / 14400) puts every chart bar in a UTC 4h bucket. The module remembers the last close it saw in the current bucket, and when a bar from the NEXT bucket arrives it folds that remembered close into the two 4h EMAs. A 4h candle therefore contributes exactly once, after it closed. That is what makes the gate trustworthy: the tints you see on old bars are what you would have seen live. htf(d, "4h") promised the same thing in the kScript; here the promise is fourteen lines you can read, and the Execution model explains why an Indicator cannot break it by accident.

The regime. Fast 4h EMA above slow is bull (1), below is bear (2), and warming is 0. It is a data-only output, and the h4_fast line indexes its three-entry palette with it: grey, green, red. Both 4h EMAs are guarded with isNaN so the warm-up region counts as neither regime, rather than a coin flip.

The trigger. Separately, the module computes the same fast/slow EMAs on the chart timeframe and detects the moment they cross. Cross.update(fast, slow) returns +1 on the one bar where the fast EMA moves from at-or-below to above the slow one, -1 for the opposite, 0 otherwise, including on any bar where either side is still NaN. That is the entry idea on its own.

The gate. is_long is 1 only when the cross and the regime agree; long is a shape output at the bar's low, gated by it. A bullish cross during a 4h downtrend is silently dropped, which is the whole point: most chop happens when you fight the higher timeframe.

Two ways to see the state. The 4h line carries the regime through color_by, so the context is always visible, and the two shape outputs drop a mark only on confirmed, gated entries. The 4h EMA lines are drawn even before a single cross fires, so there is always something on screen.

What changed in the port

  • htf(d, "4h") became bucketing. On the chart every input is the chart's own interval, so the higher timeframe is built in the module; the confirm-on-next-bucket rule is the no-repaint contract written out. On your machine an interval: "4h" pin on a second close input does the same with a real 4h feed, read as of the coarser bar's close (Multi-timeframe).
  • barcolor() has no candle-tint form. The regime tints the 4h line through color_by instead; a background tint per bar is render.bgcolor (Drawing primitives).
  • plotShape(..., location="belowBar") is a shape output whose value is the bar's low, so the mark sits where the kScript put it; aboveBar is the high.
  • The color inputs became colors and color on the declarations.

Customize it

  • EMA speed. fast_len and slow_len drive both the regime and the trigger. Widen the gap (21 / 55) for fewer, slower signals, tighten it for more. Because the same lengths feed the 4h regime, changing them reshapes the whole gate.
  • Regime timeframe. Change 14400.0 to 86400.0 for a daily regime (stricter, fewer entries) or 3600.0 for a looser gate on lower-timeframe charts. Bucketing works for any multiple of the chart interval.
  • Loaded history. The 4h regime needs enough loaded bars for its slow EMA to warm up, roughly slow_len * 4 chart hours on a 1h chart. If the line never tints, load more history.
  • Different trigger. Swap the cross for an RSI level or a breakout and keep the regime == 1.0 / regime == 2.0 gates to filter it by trend.
  • Make it actionable. After publishing and installing, a watch on is_long crossing above 0 fires only on trend-aligned entries; the edge operator needs the interval in the selector:
om watch create "Trend-aligned long" --condition '{"metric":"wrun/@you/regime-filter/is_long","selector":{"exchange":"BINANCE_FUTURES","symbol":"BTCUSDT","interval":"HOUR"},"op":"crosses_above","value":0}'

Concepts used