HTF trend filter
Take chart-timeframe EMA crosses only when the confirmed 1h and 4h trends agree, and show which signals the filter rejected.
Fast crossovers on a 15-minute chart produce many signals, and most of the losing ones point against the larger trend. A higher-timeframe filter removes them: a long signal counts only if the hourly and four-hour trends are both up.
This script computes trend state on two higher timeframes from their confirmed bars, combines it into an alignment value, and colours the chart’s EMA pair by that alignment. A light background marks bars where both timeframes agree. Crosses in the direction of the alignment get a signal marker and an alert. Crosses against it get a small grey cross, so you can judge what the filter is keeping you out of.
script "HTF trend filter"
input (
midTf = input.timeframe(1h, title: "Middle timeframe")
topTf = input.timeframe(4h, title: "Top timeframe")
trendFast = input.int(20, title: "HTF fast EMA", min: 2, max: 200)
trendSlow = input.int(50, title: "HTF slow EMA", min: 3, max: 400)
sigFast = input.int(9, title: "Signal fast EMA", min: 2, max: 100)
sigSlow = input.int(21, title: "Signal slow EMA", min: 3, max: 200)
showRejected = input.bool(true, title: "Show rejected crosses")
)
data (
chart = subscribe(data.ohlcv)
mid = subscribe(data.ohlcv, timeframe: midTf)
top = subscribe(data.ohlcv, timeframe: topTf)
)
type SignalPayload {
side: string
price: float
}
state (
midTrend: int = 0
topTrend: int = 0
)
plot (
fast = plot.line(title: "Fast EMA", width: 2)
slow = plot.line(title: "Slow EMA", color: color.gray)
agree = plot.bg(title: "Trends agree")
longSig = plot.marker(title: "Long", color: color.green, shape: shape.up, size: 5.0)
shortSig = plot.marker(title: "Short", color: color.red, shape: shape.down, size: 5.0)
rejected = plot.marker(title: "Rejected", color: color.gray, shape: shape.cross, size: 3.0)
)
alert signal = alert(title: "Filtered EMA cross", onClose: true, payload: SignalPayload)
// +1 up, -1 down, 0 undecided: EMAs stacked and price on the right side.
fn trendOf(price: float?, f: float?, s: float?) -> int {
if price != null && f != null && s != null {
if f > s && price > s {
return 1
}
if f < s && price < s {
return -1
}
}
return 0
}
on mid.close {
let f = ta.ema(mid.close, trendFast)
let s = ta.ema(mid.close, trendSlow)
midTrend = trendOf(mid.close, f, s)
}
on top.close {
let f = ta.ema(top.close, trendFast)
let s = ta.ema(top.close, trendSlow)
topTrend = trendOf(top.close, f, s)
}
on chart.close {
let c = chart.close
let f = ta.ema(c, sigFast)
let s = ta.ema(c, sigSlow)
let crossUp = ta.crossover(f, s)
let crossDown = ta.crossunder(f, s)
let atr = chart.atr(14)
let aligned = midTrend == topTrend ? midTrend : 0
let tone = match aligned {
1 => color.green
-1 => color.red
_ => color.gray
}
fast.plot(f, color: tone)
slow.plot(s)
if aligned != 0 {
agree.plot(color: color.withAlpha(tone, 22))
}
let goLong = crossUp && aligned == 1
let goShort = crossDown && aligned == -1
let offset = (atr ?? 0.0) * 0.5
longSig.plot(goLong ? chart.low - offset : null)
shortSig.plot(goShort ? chart.high + offset : null)
let skipped = showRejected && (crossUp || crossDown) && !goLong && !goShort
rejected.plot(skipped ? c : null)
if c != null && (goLong || goShort) {
signal.trigger(SignalPayload { side: goLong ? "long" : "short", price: c })
}
}How it works
Trend state per timeframe
Each higher timeframe has its own subscription and its own close handler. Inside on mid.close, mid.close is the hourly bar that just confirmed, so ta.ema(mid.close, trendFast) advances once per hour, exactly like an EMA on an hourly chart. The result is reduced to a single integer in state:
on mid.close {
let f = ta.ema(mid.close, trendFast)
let s = ta.ema(mid.close, trendSlow)
midTrend = trendOf(mid.close, f, s)
}The alternative, calling ta.ema(mid.close, ...) from the chart’s handler, would feed the same hourly value into the EMA four times per hour on a 15-minute chart and give a different, wrong result. Compute higher-timeframe indicators in the higher timeframe’s own handler.
Confirmed values only
Because the trend state changes only when a higher-timeframe bar closes, the filter never looks at a forming hourly or four-hour candle. A signal that appears on the chart stays there; it cannot be revoked by the higher timeframe changing its mind later in the hour. Multiple timeframes and repainting explain the rules behind this.
On a chart, the hourly and four-hour bars are built from the chart’s own bars, so both timeframes must be whole multiples of the chart’s interval. The slow EMAs need trendSlow higher-timeframe bars to warm up; until then the trend is undecided and the left part of the chart shows no alignment.
Alignment
aligned is +1 or -1 only when both timeframes agree, and 0 otherwise. A match maps it to a colour for the fast EMA and the background. The signal rule is then one line per side:
let goLong = crossUp && aligned == 1Rejected crosses
Crosses that the filter blocks are plotted as small grey crosses on the close. Watching them for a while tells you whether the filter is too strict for the current market; if most rejected crosses would have worked, the higher-timeframe EMAs are probably too slow.
Adapting it
- Any signal. Replace the EMA cross with your own entry condition. The filter part, the two
closehandlers andaligned, stays the same. - Different trend definitions. Use the slope of a single EMA (
ta.rising(ema, 3)) or the close againstta.highest/ta.lowestof the last N higher-timeframe bars. - Allow partial agreement. Accept a signal when the top timeframe agrees and the middle one is neutral:
topTrend == 1 && midTrend >= 0. - Make it a strategy. Add a
strategydeclaration and callstrategy.entryongoLongandgoShort. See the EMA and ATR strategy.
Related
- MTF dashboard shows the same trend states in a table.
- Market structure builds zones on a higher timeframe.
inputreference,tareference