Triple EMA Pullback Scalping Strategy

EMA ATR PULLBACK SCALPING
Created on: 2025-09-30 13:04:41 Modified on: 2025-09-30 13:04:41
Copy: 0 Number of hits: 444
avatar of ianzeng123 ianzeng123
2
Follow
319
Followers

Triple EMA Pullback Scalping Strategy Triple EMA Pullback Scalping Strategy

25/50/100 EMA Triple Filter - Real Trend Pullback Trading

Stop using single moving averages for trading. This strategy builds a complete trend identification system with 25/50/100 EMAs, requiring proper EMA sequence and same-direction slopes, plus 0.10×ATR minimum spacing requirement. Data shows this triple filtering mechanism effectively avoids false breakouts in choppy markets, only entering during genuine trending conditions.

The key is “clean EMA alignment”: bullish when 25>50>100 with all sloping up, bearish when 25<50<100 with all sloping down. Spacing filter ensures sufficient trend strength, avoiding ineffective signals during EMA convergence states.

Precise Pullback Logic with 15-Period Reversal Confirmation

The strategy’s core is the pullback detection mechanism. Bullish pullbacks require price touching 25 or 50 EMA while staying above 100 EMA, bearish pullbacks require touching 25 or 50 EMA while staying below 100 EMA. This design is more precise than traditional “buy the dip after support break” approaches.

The 15-period pullback window is well-calibrated. Backtest data indicates genuine trend pullbacks typically complete reversal within 10-15 periods. Pullbacks exceeding this timeframe often signal potential trend changes. Strategy immediately disarms when timeout occurs or price breaches 100 EMA.

Strict Entry Confirmation - Entire Candle Must Clear 25 EMA

Entry trigger conditions are extremely rigorous: after confirmed bar close, the entire candle (open, high, low, close) must be completely on the correct side of 25 EMA. This design eliminates false breakouts and intraday noise, ensuring entries only after genuine reversal confirmation.

Long entry requirements: open>25EMA, low>25EMA, close>25EMA. Short entry requirements: open<25EMA, high<25EMA, close<25EMA. This “whole candle confirmation” method significantly improves entry quality and reduces ineffective trades.

10% Position + 0.05% Commission - Optimized for High-Frequency Scalping

The default 10% position sizing strikes a balance between sufficient returns and controlled single-trade risk. 0.05% commission setting reflects realistic trading costs, making backtest results more reliable. Supports both directional and bidirectional trading to adapt to different market environments.

Important reminder: Strategy includes entry logic only, no take-profit/stop-loss. Live trading requires strict risk management - recommend 2-3×ATR stops and 1.5-2× risk-reward ratio targets.

Clear Use Cases - Excels in Trending Markets, Caution in Choppy Conditions

Strategy performs excellently in clear trending markets, particularly suitable for buying pullbacks in directional moves. However, in sideways choppy markets, EMA alignment conditions are rarely met, resulting in fewer trading opportunities. This is actually a strength, avoiding overtrading in unfavorable environments.

Risk warning: Historical backtests don’t guarantee future returns, strategy carries consecutive loss risks. Choppy markets may produce extended no-signal periods requiring patient waiting for suitable market conditions. Recommend thorough paper trading validation before live implementation.

Strategy source code
/*backtest
start: 2025-01-01 00:00:00
end: 2025-09-27 08:00:00
period: 1d
basePeriod: 1d
exchanges: [{"eid":"Futures_Bybit","currency":"ETH_USDT","balance":500000}]
*/

//@version=6
strategy("Clean 25/50/100 EMA Pullback Scalper — Entries Only (Side Select)",
     overlay=true, calc_on_every_tick=true, calc_on_order_fills=true,
     initial_capital=10000, commission_type=strategy.commission.percent, commission_value=0.05,
     pyramiding=0, default_qty_type=strategy.percent_of_equity, default_qty_value=10)

// === Side selector ===
side = input.string("Both", "Trade Side", options=["Both", "Long Only", "Short Only"])
longsEnabled  = side == "Both" or side == "Long Only"
shortsEnabled = side == "Both" or side == "Short Only"

// === Inputs ===
lenFast   = input.int(25,  "Fast EMA (pullback)", minval=1)
lenMid    = input.int(50,  "Mid EMA (filter)",    minval=1)
lenSlow   = input.int(100, "Slow EMA (safety)",   minval=1)

useSlope  = input.bool(true,  "Require EMAs sloping same way?")
useSpread = input.bool(true,  "Require clean spacing (min spread)?")
spreadPct = input.float(0.10, "Min spread vs ATR (0.10 = 0.10×ATR)", step=0.01, minval=0.0)

pullLookback = input.int(15, "Max bars after pullback", minval=1, maxval=100)
showSignals  = input.bool(true, "Show entry markers?")

// === Series ===
ema25  = ta.ema(close, lenFast)
ema50  = ta.ema(close, lenMid)
ema100 = ta.ema(close, lenSlow)
atr    = ta.atr(14)

// === Trend & spacing ===
isUpStack   = ema25 > ema50 and ema50 > ema100
isDownStack = ema25 < ema50 and ema50 < ema100
slopeUp     = ema25 > ema25[1] and ema50 > ema50[1] and ema100 > ema100[1]
slopeDown   = ema25 < ema25[1] and ema50 < ema50[1] and ema100 < ema100[1]

minGap = atr * spreadPct
spreadUpOK   = (ema25 - ema50) > minGap and (ema50 - ema100) > minGap
spreadDownOK = (ema100 - ema50) > minGap and (ema50 - ema25) > minGap

trendLongOK  = isUpStack   and (useSlope ? slopeUp   : true) and (useSpread ? spreadUpOK   : true)
trendShortOK = isDownStack and (useSlope ? slopeDown : true) and (useSpread ? spreadDownOK : true)

// === Pullback detection state ===
var bool  pullArmedLong   = false
var bool  pullArmedShort  = false
var int   pullBarIdxLong  = na
var int   pullBarIdxShort = na
var float pullMinLong     = na
var float pullMaxShort    = na

// Long pullback state
if trendLongOK
    touched25 = low <= ema25
    touched50 = low <= ema50
    stayedAbove100 = low > ema100
    if (touched25 or touched50) and stayedAbove100
        pullArmedLong  := true
        pullBarIdxLong := bar_index
        pullMinLong    := na(pullMinLong) ? low : math.min(pullMinLong, low)
    else if pullArmedLong
        pullMinLong := na(pullMinLong) ? low : math.min(pullMinLong, low)
        if low <= ema100 or (bar_index - pullBarIdxLong > pullLookback)
            pullArmedLong := false
            pullMinLong   := na
else
    pullArmedLong := false
    pullMinLong   := na

// Short pullback state
if trendShortOK
    touched25s = high >= ema25
    touched50s = high >= ema50
    stayedBelow100 = high < ema100
    if (touched25s or touched50s) and stayedBelow100
        pullArmedShort  := true
        pullBarIdxShort := bar_index
        pullMaxShort    := na(pullMaxShort) ? high : math.max(pullMaxShort, high)
    else if pullArmedShort
        pullMaxShort := na(pullMaxShort) ? high : math.max(pullMaxShort, high)
        if high >= ema100 or (bar_index - pullBarIdxShort > pullLookback)
            pullArmedShort := false
            pullMaxShort   := na
else
    pullArmedShort := false
    pullMaxShort   := na

// === Entry triggers (confirmed bar & whole candle outside 25 EMA) ===
longEntryRaw  = pullArmedLong  and barstate.isconfirmed and (open > ema25 and low > ema25 and close > ema25) and (na(pullMinLong)  or pullMinLong  > ema100)
shortEntryRaw = pullArmedShort and barstate.isconfirmed and (open < ema25 and high < ema25 and close < ema25) and (na(pullMaxShort) or pullMaxShort < ema100)

longEntry  = longsEnabled  and longEntryRaw
shortEntry = shortsEnabled and shortEntryRaw

// Disarm after trigger
if longEntry
    pullArmedLong := false
    pullMinLong   := na
if shortEntry
    pullArmedShort := false
    pullMaxShort   := na

// === Orders (entries only; no TP/SL) ===
if longEntry and strategy.position_size <= 0
    strategy.entry("Long", strategy.long)

if shortEntry and strategy.position_size >= 0
    strategy.entry("Short", strategy.short)

// === Plots & visuals ===
plot(ema25,  "EMA 25",  color=color.new(color.teal, 0))
plot(ema50,  "EMA 50",  color=color.new(color.orange, 0))
plot(ema100, "EMA 100", color=color.new(color.purple, 0))

bgcolor(trendLongOK  ? color.new(color.green, 92) : na)
bgcolor(trendShortOK ? color.new(color.red, 92)   : na)

if showSignals and longEntry
    label.new(bar_index, low, "▲ BUY\nFull candle above 25 EMA", style=label.style_label_up, textcolor=color.white, color=color.new(color.green, 0))
if showSignals and shortEntry
    label.new(bar_index, high, "▼ SELL\nFull candle below 25 EMA", style=label.style_label_down, textcolor=color.white, color=color.new(color.red, 0))