Stochastic Pop Desk · Free & open-source

What turns a pullback into a fresh signal?

Follow price, momentum and the entry checklist.

An entry checklist for TradingView. The indicator does not place orders.

Pastel clay illustration of a momentum wave and four condition checks revealing the missing step before a setup is ready.
Workflow illustration
  1. Check the trend

    Direction and distance from the trend line.

  2. Wait for a fresh cross

    Reset, then momentum crossing its band.

  3. See the missing step

    Four checks, a trail and a drawn plan.

See the real chart

What is Stochastic Pop Desk?

Stochastic Pop Desk is a free TradingView momentum indicator that combines a smoothed stochastic cross with EMA trend, distance and timing checks. It shows momentum in a lower pane and the trend, checklist and reference plan on the price chart. An optional strategy script tests those rules separately.

Why is there no new POP while the oscillator stays above 55?

Staying above 55 is not a fresh cross. After an accepted long signal, the default reset requires a confirmed reading below 45 before another POP can qualify. Trend, distance, cooldown and open-trade checks still apply. Read POP and DROP conditions

Why does Stochastic Pop Desk use two chart panels?

The lower pane shows the smoothed stochastic and its trigger bands. Price-chart overlays show the EMA, trail, reference ticket and checklist. Read both together: the oscillator crossing a band does not satisfy the other entry checks. Read the two-panel guide

Does the sideways flag automatically block entries?

No. The sideways flag describes low directional efficiency relative to recent history. Blocking new entries while flagged is an optional setting and is off by default. Turn it on only when you intend to test that additional filter. Read the sideways filter rules

Three separate clues. One checklist.

A reader wanted three scripts combined: a price average, momentum line and checklist.

Price + momentumCandle 3 of 10
First, check the direction.Candles show price. The blue running average weights recent closes more. Its length is 200. Price is above it; the average is higher than ten candles ago. Long ideas are allowed. Green candles close above their open; red candles close below. Blue is EMA 200. The lower purple line is the calculated smoothed stochastic, with bands at 45 and 55. Both panels share candle positions and a white time cursor. Long reset still needed.
Blue = price average · Purple = momentum readingWhite dots = the same candle in both panels
Price close
108.0
Momentum
79.0 / 100
Long reset still needed
1 / 5 · Price

First, check the direction.

Candles show price. The blue running average weights recent closes more. Its length is 200. Price is above it; the average is higher than ten candles ago. Long ideas are allowed.

The full entry checklist still needs to pass.

Fictional prices; calculated lines. An earlier long has already used its reset.

How could this help a trade?

Repeatable entry and exit rules make the idea testable. Include costs; an entry can still lose.

A cross alone is not enough.

Reveal ten candles. Compare the final decision.

Price + momentumCandle 1 of 10
Waiting for a fresh reset.An earlier long used its reset. Staying above the average or above 55 does not create another entry. Green candles close above their open; red candles close below. Blue is EMA 200. The lower purple line is the calculated smoothed stochastic, with bands at 45 and 55. Both panels share candle positions and a white time cursor. Long reset still needed.
Blue = price average · Purple = momentum readingWhite dots = the same candle in both panels
Price close
108.0
Momentum
70.3 / 100
Long reset still needed
Candle 1 / 10

Waiting for a fresh reset.

An earlier long used its reset. Staying above the average or above 55 does not create another entry.

  • Price trend: passesAbove rising EMA
  • Distance: passes2.3 ATR · limit 3
  • Fresh reset: not metNot below 45 yet
  • New cross: not met63.7 → 70.3
  • Closed candle: passesClosed

Compare cases at the same candle. Pullback changes recalculate both panels.

What is supplied in this example?

330 generated bars warm up EMA 200, ATR 10 and stochastic 14 → SMA 5 → EMA 4. Supplied trade state: flat, cooldown complete, long reset already spent. The warm-up is not claimed to have produced that earlier signal. Optional agreement gates and exits are off. These are fictional prices, not live market data.

Does 55 mean a chance of success?

No. Stochastic describes price location in a recent range. Smoothing slows its changes. It does not measure buying volume or the chance of the next move.

Find the same clues on your chart.

Price above. Momentum below.

Real TradingView gold fifteen-minute chart with Stochastic Pop Desk: price trend trail, a short ticket and levels, lower stochastic pane and the checklist panel.
TradingView snapshot · gold · fifteen-minute candlesOpen full-size chart ↗
  1. 1 Price direction

    Compare price with its average. The colored trail is separate.

  2. 2 The lower line

    POP crosses up; DROP crosses down. Other checks must pass too.

  3. 3 The checklist

    Read Missing, then the trade card. READY alone is not entry.

Explore the full price simulator

Change the close, pullback, reset rule and confirmation. Lines recalculate from fictional prices.

CHANGE THE CANDLE. SEE WHY.

Can this close create a fresh pop?

Change the close or pullback. Candles, EMA and stochastic recalculate.

Earlier candles → test candleTest close 112
Paper entry112Current stop90.287134121.510996.584
Calculated stochastic EMA57.657
Reference 45Reference 55
EMA 200Drag the blue close ↕
Watch the same candle change
112
16
How the calculation works & more controls

Stochastic 14 → SMA 5 → EMA 4. Strict long reset below 45, cross above 55. EMA slope: ten bars. ATR: ten bars.

Entry 112.00; stop 90.29; targets 133.71 / 155.43. Closed bars replay chronologically, existing stop first.

Calculated events: candle 10: Long pop.

Illustrative inputs · changing a slider recalculates this example · no market feed

Paste it into TradingView.

Both complete Pine v6 scripts are free, under MIT.

  1. 1

    Open Pine Editor

    Create a new indicator on your chart.

  2. 2

    Paste the code

    Copy below and replace the sample.

  3. 3

    Add to chart

    Save it. Keep defaults initially.

The indicator

Opens in its own pane and draws on the price chart too.

Indicator code

Copy the code as it is. No coding needed.

View the full code
// ═══════════════════════════════════════════════════════════════════════════════
//  STOCHASTIC POP DESK — EMA 200 · Stochastic POP · Analysis panel · Co-Pilot  ·  Pine Script v6  ·  v1.5.0
//  One script that replaces three: the EMA 200 trend filter, the Stochastic POP (Jake Bernstein's method, the
//  ChrisMoody "Method 2" bands 55 / 45, with an EMA 4 on the stochastic line) and an analysis panel that walks the
//  four steps of the rule in order and tells you — in words — what is still missing before a signal can fire.
//
//  HOW IT READS THE MARKET (every decision commits on the CLOSED bar — nothing repaints):
//    1 TREND      price above a RISING EMA 200 = long side only · below a FALLING EMA 200 = short side only.
//    2 STRETCH    the close must sit within N ATR of the EMA — a pop that starts far away is a chase.
//    3 FRESH      the stochastic line must have visited the other band since the last signal (no re-entries).
//    4 CROSS      the EMA-4 of the smoothed stochastic (14, 5) crosses ABOVE 55 → long pop · BELOW 45 → short drop.
//    EXIT         the line crosses back through its band (momentum gone) · or a stop / target ladder · or both.
//    CONTEXT      MA stack 5·10·20·100·200, EMA 8 / 21, RSI, volume — shown as agreements, optionally used as gates.
//    SWITCHES     (all off by default, each measured — numbers in the tooltips) ATR trailing stop after TP1 · session
//                 filter · time stop · opposite-signal exit · higher-timeframe EMA agreement.
//
//  The stochastic lives in this pane; the EMA, the signals, the trade levels, the panel and the Co-Pilot are drawn
//  on the price chart with force_overlay — one script, one settings dialog, two views.
//  Paper ledger has NO costs — a description of the past on your chart, not a promise.
//
//  License: MIT — share freely, keep this header.  © 2026 ProEA Lab · built for r/pinescript
// ═══════════════════════════════════════════════════════════════════════════════
//@version=6
indicator("Stochastic Pop Desk — EMA 200 · Stochastic POP · Checklist", "StochPop", overlay = false, max_labels_count = 300, max_boxes_count = 160, max_lines_count = 100)

// ─────────────────────────────────────────────────────────────────── INPUTS · trend (the EMA 200 exactly as the built-in)
gT = "Trend · EMA 200"
inEmaLen   = input.int(200, "EMA length", minval = 1, group = gT, display = display.none)
inEmaSrc   = input.source(close, "Source", group = gT, display = display.none)
inEmaOff   = input.int(0, "Offset", minval = -500, maxval = 500, group = gT, display = display.none)
inSmType   = input.string("SMA", "Smoothing line", options = ["None", "SMA", "EMA", "SMMA (RMA)", "WMA", "VWMA"], group = gT, tooltip = "The built-in EMA's second line: a moving average OF the EMA. Drawn dotted; it does not affect signals.", display = display.none)
inSmLen    = input.int(5, "Smoothing length", minval = 1, group = gT, display = display.none)
inSlopeLen = input.int(10, "EMA must be rising / falling over N bars", minval = 1, maxval = 100, group = gT, tooltip = "Long side only while the EMA 200 is higher than N bars ago; short side only while it is lower.", display = display.none)
inMaxDist  = input.float(3.0, "Not stretched: price within N ATR of the EMA", minval = 0.5, maxval = 20, step = 0.5, group = gT, tooltip = "A pop that fires far above a rising EMA is a chase. Signals are skipped when the close is farther than this from the EMA.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · stochastic POP (CM_Stochastic POP Method 2 + EMA 4)
gS = "Stochastic POP (Method 2)"
inStLen    = input.int(14, "Stochastic length", minval = 1, group = gS, display = display.none)
inStSmooth = input.int(5, "Smoothing (%K)", minval = 1, group = gS, display = display.none)
inStEma    = input.int(4, "EMA on the stochastic", minval = 1, group = gS, tooltip = "The 'plus EMA 4' — the signal line is an EMA of the smoothed stochastic. It cuts the whipsaws; it adds a little lag.", display = display.none)
inUpper    = input.float(55, "Pop band (long above)", minval = 50, maxval = 95, group = gS, display = display.none)
inLower    = input.float(45, "Drop band (short below)", minval = 5, maxval = 50, group = gS, display = display.none)
inMustReset = input.bool(true, "Fresh pop only (the line must visit the other band between signals)", group = gS, tooltip = "A long pop counts only if the line has been below the drop band since the last long pop — no re-entries on every wiggle across 55.", display = display.none)
inCooldown = input.int(5, "Cooldown between signals (bars)", minval = 0, maxval = 200, group = gS, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · context components
gA = "Context (agreements)"
inUseStack = input.bool(true, "MA stack 5 · 10 · 20 · 100 · 200", group = gA, display = display.none)
inUse821   = input.bool(true, "EMA 8 / 21 cross", group = gA, display = display.none)
inUseRsi   = input.bool(true, "RSI above / below 50", group = gA, display = display.none)
inRsiLen   = input.int(9, "RSI length", minval = 2, group = gA, display = display.none)
inUseVol   = input.bool(true, "Volume above its average", group = gA, display = display.none)
inVolLen   = input.int(20, "Volume average length", minval = 2, group = gA, display = display.none)
inAtrLen   = input.int(10, "ATR length (stops, stretch, panel)", minval = 1, group = gA, display = display.none)
inGate     = input.string("Trend + POP only", "Signal needs", options = ["Trend + POP only", "Trend + POP + majority of agreements", "Trend + POP + all agreements"], group = gA,
     tooltip = "What must be true for a signal. 'Trend + POP only' is the classic Bernstein rule; the agreements are then context. The stricter modes use them as gates.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · trade plan
gE = "Trade plan (paper — no costs)"
inPlan     = input.bool(true, "Show entry / stop / targets", group = gE, display = display.none)
inStopMode = input.string("Swing low / high", "Stop", options = ["Swing low / high", "ATR"], group = gE, display = display.none)
inSwingN   = input.int(10, "Swing lookback (bars)", minval = 2, maxval = 100, group = gE, display = display.none)
inStopAtr  = input.float(2.0, "ATR stop (× ATR)", minval = 0.3, maxval = 10, step = 0.1, group = gE, display = display.none)
inStopBuf  = input.float(0.2, "Stop buffer (× ATR)", minval = 0, maxval = 3, step = 0.1, group = gE, display = display.none)
inTp1      = input.float(1.0, "TP1 (R) → stop to breakeven", minval = 0.3, maxval = 10, step = 0.1, group = gE, display = display.none)
inTp2      = input.float(2.0, "TP2 (R) final", minval = 0.5, maxval = 20, step = 0.1, group = gE, display = display.none)
inExitMode = input.string("Stop / targets only", "Exit", options = ["Stop / targets only", "Band cross back or TP2", "Band cross back only"], group = gE,
     tooltip = "Stop / targets only = the stop (swing or ATR), TP1 to breakeven, TP2 final — measured best on 2 of 3 test charts, tied on the third. Band cross back = the stochastic line crossing back through its band closes the trade early (the classic exit) — it cut winners short in the measurement. Both = the ladder plus the band exit.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · switches (all off — measured, see tooltips)
gX = "Switches (off by default)"
inTrail    = input.bool(false, "Trailing stop after TP1 (ATR)", group = gX, tooltip = "Once TP1 is reached the stop trails N ATR behind price instead of parking at breakeven. Measured with costs: BTCUSD 1H PF 1.51 → 1.58 (more winners, smaller), XAUUSD 1H 1.31 → 1.10 — market-dependent.", display = display.none)
inTrailAtr = input.float(2.0, "  Trail distance (× ATR)", minval = 0.5, maxval = 10, step = 0.5, group = gX, display = display.none)
inTrailStop = input.string("Off", "Stop follows the trend trail", options = ["Off", "After TP1", "From entry"], group = gX,
     tooltip = "The chandelier trend trail drawn on the chart becomes the stop: it ratchets with the trend and the trade closes when the trail flips to the other side of price. 'After TP1' keeps the swing / ATR stop until TP1 is paid, then hands the trade to the trail; 'From entry' uses the trail from the first bar. Uses the trail settings in Style. Measured with costs (profit factor, ladder default → switch): After TP1 — BTC 1H 1.51 → 1.48 (win rate 30 → 44 %), gold 1H 1.31 → 1.18, gold 15m 0.81 → 0.88. From entry — BTC 1H 1.51 → 1.81 (drawdown −27 %), gold 1H 1.31 → 0.96, gold 15m 0.81 → 0.55. It pays where trends run (bitcoin) and costs where price chops around the EMA 200 (gold) — read your chart first.", display = display.none)
inSessOn   = input.bool(false, "Session filter (signals only inside the sessions)", group = gX, tooltip = "Two windows, exchange time as set below. Off = any time. Measured: XAUUSD 1H PF 1.31 → 1.23 with a third of the trades; meaningless on 24/7 markets.", display = display.none)
inSessTz   = input.string("America/New_York", "  Session timezone (IANA)", group = gX, display = display.none)
inSess1    = input.session("0200-0500", "  Session 1", group = gX, display = display.none)
inSess2    = input.session("0830-1100", "  Session 2", group = gX, display = display.none)
inTimeStop = input.int(0, "Time stop: close after N bars if TP1 not reached (0 = off)", minval = 0, maxval = 500, group = gX, tooltip = "Measured at 30 bars: BTCUSD 1H PF 1.51 → 1.33, XAUUSD 1H 1.31 → 1.23 — it hurt on both.", display = display.none)
inOppExit  = input.bool(false, "Opposite-signal exit (a drop cross closes a long, a pop cross closes a short)", group = gX, tooltip = "Measured: BTCUSD 1H PF 1.51 → 1.68, XAUUSD 1H 1.31 → 1.02 — market-dependent.", display = display.none)
inHtfOn    = input.bool(false, "Higher-timeframe agreement (price on the same side of the EMA on the HTF)", group = gX, tooltip = "Read from the last CLOSED higher-timeframe bar — no lookahead. Adds a row to the readiness list. The one switch that helped on every chart tried (240 on 1H / 15m): BTCUSD 1H PF 1.51 → 1.89, XAUUSD 1H 1.31 → 1.41, XAUUSD 15m 0.81 → 0.98 — fewer trades each time.", display = display.none)
inHtf      = input.timeframe("240", "  Higher timeframe", group = gX, display = display.none)
inRangeBlock = input.bool(false, "Skip signals in a sideways regime", group = gX,
     tooltip = "The sideways detector is always shown (state line, step 1 text, the trail turns grey). With this on, step 1 also fails while the market is sideways, so no pop / drop fires inside a range. Sideways = Kaufman efficiency (net move ÷ path over N bars) in the choppiest N % of the last 200 bars, released 15 points higher so it does not flicker. Measured with costs (profit factor, ladder default → switch): BTC 1H 1.51 → 1.90 (27 → 13 trades), gold 1H 1.31 → 2.55 (83 → 42 trades, drawdown −60 %), gold 15m 0.81 → 0.50 (44 → 19 trades). Strong on the hourly charts, wrong on the 15-minute one — read your chart first.", display = display.none)
inRangeLen   = input.int(20, "  Sideways detector: bars", minval = 5, maxval = 200, group = gX, display = display.none)
inRangePct   = input.int(25, "  Sideways = the choppiest N % of the last 200 bars", minval = 5, maxval = 60, group = gX, tooltip = "Efficiency = |close − close[N]| ÷ Σ|close − close[1]| over N bars (100 % = a straight line, 0 % = pure chop). Instead of a fixed threshold — which is never portable between charts — the market is 'sideways' while its efficiency sits in the bottom N % of the last 200 bars, and stays flagged until it climbs above the (N + 15) % mark. Self-calibrating on any symbol and timeframe.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · style / panel / co-pilot / alerts
gV = "Style"
inTheme    = input.string("Aurora Neon", "Theme", options = ["Aurora Neon", "Royal Gold", "Colorblind-safe"], group = gV, display = display.none)
inShowSm   = input.bool(true, "Draw the EMA smoothing line", group = gV, display = display.none)
inShowBand = input.bool(false, "Draw the stretch band (EMA ± N ATR)", group = gV, display = display.none)
inTrendTrail = input.bool(true, "Trend trail line + fill (green under price in an uptrend, red over price in a downtrend)", group = gV,
     tooltip = "A chandelier-style trail: highest high − N ATR while the EMA 200 regime is up, lowest low + N ATR while it is down; it only ratchets with the trend and resets when the regime flips. The soft fill between the trail and price is the regime at a glance. Display only — signals still come from the four steps.", display = display.none)
inTrailMult2 = input.float(3.0, "  Trail distance (× ATR)", minval = 0.5, maxval = 10, step = 0.5, group = gV, display = display.none)
inZones    = input.bool(true, "Gradient risk / reward zones", group = gV, display = display.none)
inTint     = input.bool(true, "Tint candles while a trade is open", group = gV, display = display.none)
inShade    = input.bool(false, "Shade the pane while in a pop / drop", group = gV, display = display.none)
inCandleTrend = input.bool(false, "Colour candles by trend (green · red · grey)", group = gV, tooltip = "Soft green = price above a rising EMA 200 (long side) · soft red = below a falling EMA 200 (short side) · grey = no clean side. While a trade is open the candles turn accent-coloured instead, so the trade period is visible at a glance.", display = display.none)
gD = "Dashboard"
inDash     = input.bool(true, "Show the dashboard", group = gD, display = display.none)
inDashPos  = input.string("Top Right", "Position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = gD, display = display.none)
inSize     = input.string("Medium", "Size", options = ["Small", "Medium", "Large"], group = gD, display = display.none)
inShowCtx  = input.bool(false, "Show the context section (MA stack · EMA 8/21 · RSI · volume)", group = gD, display = display.none)
gN = "Narrative · Co-Pilot"
inNarr     = input.string("Brief", "Narrative depth", options = ["Off", "Brief", "Standard", "Detailed"], group = gN, tooltip = "Brief = one sentence (NOW). Standard adds WHY and PLAN. Detailed adds the track record and a glossary.", display = display.none)
inNarrPos  = input.string("Bottom Left", "Position", options = ["Bottom Left", "Bottom Right", "Top Left", "Top Right", "Middle Left", "Middle Right"], group = gN, display = display.none)
inNarrSize = input.string("Auto", "Size", options = ["Auto", "Small", "Medium", "Large"], group = gN, display = display.none)
inNarrWrap = input.int(64, "Line width (characters)", minval = 30, maxval = 120, group = gN, display = display.none)
gL = "Alerts"
inAlertJson = input.bool(true, "alert() payload as JSON (webhooks)", group = gL, tooltip = "Choose 'Any alert() function call' when creating the alert.", display = display.none)

// ─────────────────────────────────────────────────────────────────── THEME
type Theme
    color bg       = #0B0E14
    color frame    = #B8923A
    color accent   = #D8B14A
    color accentHi = #E8C25A
    color up       = #2BE7C7
    color down     = #FF3B5C
    color flat     = #6B7280
    color txt      = #D7DCE6
    color txtHi    = #ECEFF5
themeOf(string name) =>
    Theme t = Theme.new()
    if name == "Aurora Neon"
        t.bg       := #0E1118
        t.frame    := #2C3A5A
        t.accent   := #7C9CFF
        t.accentHi := #A9B8FF
        t.up       := #2AF0C8
        t.down     := #FF4D6D
        t.flat     := #5B6472
        t.txt      := #E6E9EF
        t.txtHi    := #F2F4F9
    else if name == "Colorblind-safe"
        t.bg       := #0E1014
        t.frame    := #3A4150
        t.accent   := #56B4E9
        t.accentHi := #8ED0F5
        t.up       := #E69F00
        t.down     := #0072B2
        t.flat     := #8C92A0
        t.txt      := #E8EAED
        t.txtHi    := #FFFFFF
    t
Theme th = themeOf(inTheme)
fmt(float x) => str.tostring(x, format.mintick)

// ═══════════════════════════════════════════════════════════════════ ENGINE INPUTS (ta.* at global scope)
float ema200 = ta.ema(inEmaSrc, inEmaLen)
float smSma  = ta.sma(ema200, inSmLen)
float smEma  = ta.ema(ema200, inSmLen)
float smRma  = ta.rma(ema200, inSmLen)
float smWma  = ta.wma(ema200, inSmLen)
float smVwma = ta.vwma(ema200, inSmLen)
float emaSm  = inSmType == "SMA" ? smSma : inSmType == "EMA" ? smEma : inSmType == "SMMA (RMA)" ? smRma : inSmType == "WMA" ? smWma : inSmType == "VWMA" ? smVwma : ema200
float atr    = ta.atr(inAtrLen)
float atrU   = math.max(atr, syminfo.mintick)
float kRaw   = ta.stoch(close, high, low, inStLen)
float kS     = ta.sma(kRaw, inStSmooth)
float kE     = ta.ema(kS, inStEma)
float ma5    = ta.ema(close, 5)
float ma10   = ta.ema(close, 10)
float ma20   = ta.ema(close, 20)
float ma100  = ta.ema(close, 100)
float ma200  = ta.ema(close, 200)
float ema8   = ta.ema(close, 8)
float ema21  = ta.ema(close, 21)
float rsi    = ta.rsi(close, inRsiLen)
float volMa  = ta.sma(nz(volume, 0.0), inVolLen)
float swLo   = ta.lowest(low, inSwingN)
float swHi   = ta.highest(high, inSwingN)
bool  inSess  = not inSessOn or not na(time(timeframe.period, inSess1, inSessTz)) or not na(time(timeframe.period, inSess2, inSessTz))
htfEmaFn() => ta.ema(close, inEmaLen)
float htfEma   = request.security(syminfo.tickerid, inHtf, htfEmaFn()[1], lookahead = barmerge.lookahead_on)
float htfClose = request.security(syminfo.tickerid, inHtf, close[1], lookahead = barmerge.lookahead_on)
bool  htfOk    = timeframe.in_seconds(inHtf) > timeframe.in_seconds()
bool  htfUp    = not inHtfOn or not htfOk or htfClose > htfEma
bool  htfDn    = not inHtfOn or not htfOk or htfClose < htfEma
bool  crossUp = ta.crossover(kE, inUpper)
bool  crossDn = ta.crossunder(kE, inLower)
bool  backDn  = ta.crossunder(kE, inUpper)     // long momentum gone
bool  backUp  = ta.crossover(kE, inLower)      // short momentum gone

// trend
bool emaRising  = ema200 > ema200[inSlopeLen]
bool emaFalling = ema200 < ema200[inSlopeLen]
bool trendUpRaw = close > ema200 and emaRising
bool trendDnRaw = close < ema200 and emaFalling
// sideways detector: Kaufman efficiency (net move ÷ path) with hysteresis; always shown, blocks signals only when the switch is on
float erNet   = math.abs(close - close[inRangeLen])
float erPath  = math.sum(math.abs(close - close[1]), inRangeLen)
float er      = erPath > 0 ? erNet / erPath : 0.0
float erLo    = ta.percentile_nearest_rank(er, 200, inRangePct)
float erHi    = ta.percentile_nearest_rank(er, 200, math.min(inRangePct + 15, 95))
var bool sideways = false
sideways := sideways ? er < erHi : er < erLo
bool rangeBlock = inRangeBlock and sideways
bool trendUp    = trendUpRaw and not rangeBlock
bool trendDn    = trendDnRaw and not rangeBlock
float distAtr   = (close - ema200) / atrU
bool stretchedUp = distAtr > inMaxDist
bool stretchedDn = -distAtr > inMaxDist

// context (agreements)
int stackUp = (close > ma5 ? 1 : 0) + (close > ma10 ? 1 : 0) + (close > ma20 ? 1 : 0) + (close > ma100 ? 1 : 0) + (close > ma200 ? 1 : 0)
int stackDn = 5 - stackUp
bool agStackL = stackUp >= 4
bool agStackS = stackDn >= 4
bool ag821L   = ema8 > ema21
bool ag821S   = ema8 < ema21
bool agRsiL   = rsi > 50
bool agRsiS   = rsi < 50
bool agVol    = volMa > 0 and nz(volume, 0.0) > volMa
int  agCountL = (inUseStack and agStackL ? 1 : 0) + (inUse821 and ag821L ? 1 : 0) + (inUseRsi and agRsiL ? 1 : 0) + (inUseVol and agVol ? 1 : 0)
int  agCountS = (inUseStack and agStackS ? 1 : 0) + (inUse821 and ag821S ? 1 : 0) + (inUseRsi and agRsiS ? 1 : 0) + (inUseVol and agVol ? 1 : 0)
int  agTotal  = (inUseStack ? 1 : 0) + (inUse821 ? 1 : 0) + (inUseRsi ? 1 : 0) + (inUseVol ? 1 : 0)
bool gateL = inGate == "Trend + POP only" ? true : inGate == "Trend + POP + all agreements" ? agCountL == agTotal : agCountL * 2 >= agTotal
bool gateS = inGate == "Trend + POP only" ? true : inGate == "Trend + POP + all agreements" ? agCountS == agTotal : agCountS * 2 >= agTotal

// trail state (filled in the display section; the state machine reads the PREVIOUS bar's values — no repaint)
var float trail = na
var int   trailDir = 0

// ═══════════════════════════════════════════════════════════════════ STATE (confirmed bars only)
type Trade
    int   dir      = 0        // 0 flat · 1 long · −1 short
    float entry    = na
    float sl       = na
    float sl0      = na
    float r        = na
    float tp1      = na
    float tp2      = na
    int   bar      = na
    bool  tp1Hit   = false
    int   lastSig  = -1000
    bool  resetL   = true     // a long pop is allowed (the line has visited the drop band since the last long)
    bool  resetS   = true
    float lastR    = na       // last closed trade's R (for the panel)
    string lastWhy = ""
type Stat
    int   n = 0
    int   wins = 0
    int   flat = 0
    float sumR = 0.0
    int   nBand = 0
    int   nStop = 0
    int   nTp = 0
type Ev
    bool   sigL = false
    bool   sigS = false
    bool   exit = false
    bool   tp1  = false
    string why  = ""
    float  r    = na
    int    dir  = 0
var Trade t  = Trade.new()
var Stat  st = Stat.new()
Ev ev = Ev.new()

closeTrade(float px, string why) =>
    float rr = (px - t.entry) * t.dir / t.r
    ev.exit := true
    ev.why := why
    ev.r := rr
    ev.dir := t.dir
    st.n := st.n + 1
    st.wins := st.wins + (rr >= 0.05 ? 1 : 0)
    st.flat := st.flat + (math.abs(rr) < 0.05 ? 1 : 0)
    st.sumR := st.sumR + rr
    if why == "BAND" or why == "FLIP" or why == "TIME"
        st.nBand := st.nBand + 1
    else if why == "TP2"
        st.nTp := st.nTp + 1
    else
        st.nStop := st.nStop + 1
    t.lastR := rr
    t.lastWhy := why
    t.dir := 0

openTrade(int d) =>
    float anchor = inStopMode == "ATR" ? (d == 1 ? close - inStopAtr * atrU : close + inStopAtr * atrU) : (d == 1 ? swLo - inStopBuf * atrU : swHi + inStopBuf * atrU)
    float slPx = d == 1 ? math.min(anchor, close - syminfo.mintick) : math.max(anchor, close + syminfo.mintick)
    t.dir := d
    t.entry := close
    t.sl := slPx
    t.sl0 := slPx
    t.r := math.abs(close - slPx)
    t.tp1 := close + d * inTp1 * t.r
    t.tp2 := close + d * inTp2 * t.r
    t.bar := bar_index
    t.tp1Hit := false
    t.lastSig := bar_index

if barstate.isconfirmed and not na(atr) and not na(kE) and bar_index > inEmaLen
    if kE < inLower
        t.resetL := true
    if kE > inUpper
        t.resetS := true
    // ── manage an open trade (stop first)
    if t.dir != 0
        int d = t.dir
        bool stopHit = d == 1 ? low <= t.sl : high >= t.sl
        bool useLadder = inExitMode != "Band cross back only"
        bool useBand   = inExitMode != "Stop / targets only"
        if useLadder and stopHit
            closeTrade(t.sl, (inTrailStop != "Off" or (inTrail and t.tp1Hit)) and t.sl != t.entry and t.sl != t.sl0 ? "TRAIL" : t.tp1Hit ? "BE" : "SL")
        else if useLadder and (d == 1 ? high >= t.tp2 : low <= t.tp2)
            closeTrade(t.tp2, "TP2")
        else
            if useLadder and not t.tp1Hit and (d == 1 ? high >= t.tp1 : low <= t.tp1)
                t.tp1Hit := true
                ev.tp1 := true
                t.sl := t.entry
            if inTrail and t.tp1Hit
                float trailPx = d == 1 ? close - inTrailAtr * atrU : close + inTrailAtr * atrU
                t.sl := d == 1 ? math.max(t.sl, trailPx) : math.min(t.sl, trailPx)
            if inTrailStop != "Off" and not na(trail) and (inTrailStop == "From entry" or t.tp1Hit)
                if trailDir == d
                    t.sl := d == 1 ? math.max(t.sl, trail) : math.min(t.sl, trail)
                else if bar_index - t.bar >= 1
                    closeTrade(close, "TRAIL")
            if useBand and (d == 1 ? backDn : backUp)
                closeTrade(close, "BAND")
            else if inOppExit and (d == 1 ? crossDn : crossUp)
                closeTrade(close, "FLIP")
            else if inTimeStop > 0 and not t.tp1Hit and bar_index - t.bar >= inTimeStop
                closeTrade(close, "TIME")
    // ── signals (only when flat)
    if t.dir == 0
        bool cool = bar_index - t.lastSig >= inCooldown
        bool okL = crossUp and trendUp and not stretchedUp and gateL and cool and (not inMustReset or t.resetL) and inSess and htfUp
        bool okS = crossDn and trendDn and not stretchedDn and gateS and cool and (not inMustReset or t.resetS) and inSess and htfDn
        if okL
            openTrade(1)
            t.resetL := false
            ev.sigL := true
        else if okS
            openTrade(-1)
            t.resetS := false
            ev.sigS := true

// ═══════════════════════════════════════════════════════════════════ READINESS (display only — the four steps of the rule, in order)
int  side  = t.dir != 0 ? t.dir : trendUp ? 1 : trendDn ? -1 : (close > ema200 ? 1 : -1)
bool s1    = side == 1 ? trendUp : trendDn
bool s2    = side == 1 ? not stretchedUp : not stretchedDn
bool s3    = not inMustReset or (side == 1 ? t.resetL : t.resetS)
bool s4    = side == 1 ? kE > inUpper : kE < inLower           // the line is on the pop side (the cross is the event)
bool s5    = side == 1 ? gateL : gateS
bool gateOn = inGate != "Trend + POP only"
bool s6    = side == 1 ? htfUp : htfDn
bool s7    = inSess
int  ready = (s1 ? 1 : 0) + (s2 ? 1 : 0) + (s3 ? 1 : 0) + (s4 ? 1 : 0) + (gateOn and s5 ? 1 : 0) + (inHtfOn and s6 ? 1 : 0) + (inSessOn and s7 ? 1 : 0)
int  readyMax = 4 + (gateOn ? 1 : 0) + (inHtfOn ? 1 : 0) + (inSessOn ? 1 : 0)
bool allPre = s1 and s2 and s3 and (not gateOn or s5) and (not inHtfOn or s6) and (not inSessOn or s7)
string sideWord = side == 1 ? "long" : "short"
string erTxt = "efficiency " + str.tostring(er * 100, "0") + " % over " + str.tostring(inRangeLen) + " bars" + (sideways ? ", the choppiest " + str.tostring(inRangePct) + " % of recent bars" : "")
string s1Base = side == 1 ? (close > ema200 ? (emaRising ? "above a rising EMA 200" : "above the EMA, but it is not rising") : "price is below the EMA 200") : (close < ema200 ? (emaFalling ? "below a falling EMA 200" : "below the EMA, but it is not falling") : "price is above the EMA 200")
string s1Txt = rangeBlock and (side == 1 ? trendUpRaw : trendDnRaw) ? "sideways — " + erTxt + " · signals skipped" : s1Base + (sideways ? " · sideways (" + erTxt + ")" : "")
string s2Txt = str.tostring(math.abs(distAtr), "0.0") + " ATR from the EMA" + (s2 ? " (≤ " + str.tostring(inMaxDist, "0.0") + ")" : " — too far, > " + str.tostring(inMaxDist, "0.0"))
string s3Txt = not inMustReset ? "not required" : (s3 ? "armed — the line visited " + str.tostring(side == 1 ? inLower : inUpper, "0") : "spent — needs a dip " + (side == 1 ? "below " + str.tostring(inLower, "0") : "above " + str.tostring(inUpper, "0")))
string s4Txt = "line " + str.tostring(kE, "0.0") + (s4 ? " · on the pop side" : (side == 1 ? " · waiting for a cross above " + str.tostring(inUpper, "0") : " · waiting for a cross below " + str.tostring(inLower, "0")))
string s5Txt = str.tostring(side == 1 ? agCountL : agCountS) + " / " + str.tostring(agTotal) + (inGate == "Trend + POP only" ? " agree · context only" : s5 ? " agree · gate passed" : " agree · gate blocks")
string missing = t.dir != 0 ? "in a trade" : allPre ? (s4 ? "nothing — a fresh cross is the trigger" : "only the cross itself") : (not s1 ? (rangeBlock ? "step 1 (sideways)" : "step 1 (trend)") : "") + (not s2 ? (not s1 ? " · " : "") + "step 2 (stretched)" : "") + (not s3 ? ((not s1 or not s2) ? " · " : "") + "step 3 (fresh pop)" : "") + (gateOn and not s5 ? ((not s1 or not s2 or not s3) ? " · " : "") + "agreements" : "") + (inHtfOn and not s6 ? " · HTF disagrees" : "") + (inSessOn and not s7 ? " · outside the sessions" : "")

// ── trend trail (display + optional stop): a chandelier-style trailing line that flips ONLY when price closes through
//    it — a pullback that hugs the EMA 200 does not flip it, so a range does not zig-zag the line. It seeds from the flip
//    bar's own extreme (always on the right side of price) and ratchets from the extreme reached since the flip.
//    Colour: full = the EMA 200 slope confirms the side (the rule's regime); muted = the slope does not confirm yet;
//    grey = the sideways detector is on.
var float regExt = na
if trailDir == 0
    if not na(atrU) and not na(ema200)
        trailDir := close > ema200 ? 1 : -1
        regExt   := trailDir == 1 ? high : low
        trail    := trailDir == 1 ? regExt - inTrailMult2 * atrU : regExt + inTrailMult2 * atrU
else if trailDir == 1
    if close < trail
        trailDir := -1
        regExt   := low
        trail    := low + inTrailMult2 * atrU
    else
        regExt := math.max(nz(regExt, high), high)
        float candU = regExt - inTrailMult2 * atrU
        trail  := math.max(nz(trail, candU), candU)
else
    if close > trail
        trailDir := 1
        regExt   := high
        trail    := high - inTrailMult2 * atrU
    else
        regExt := math.min(nz(regExt, low), low)
        float candD = regExt + inTrailMult2 * atrU
        trail  := math.min(nz(trail, candD), candD)
bool trailStrong = trailDir == 1 ? emaRising : emaFalling

// ═══════════════════════════════════════════════════════════════════ PANE · the stochastic
color kCol = color.from_gradient(kE, inLower, inUpper, th.down, th.up)
pU = plot(inUpper, "Pop band", color = color.new(th.up, 55), linewidth = 1, style = plot.style_circles, display = display.pane)
pL = plot(inLower, "Drop band", color = color.new(th.down, 55), linewidth = 1, style = plot.style_circles, display = display.pane)
plot(50, "Midline", color = color.new(th.flat, 70), linewidth = 1, style = plot.style_circles, display = display.pane)
fill(pU, pL, color = color.new(th.flat, 92), title = "Neutral band")
pKE  = plot(kE, "Stochastic · EMA", color = kCol, linewidth = 2)
pMid = plot(50, "Midline anchor", color = na, display = display.none)
fill(pKE, pMid, top_value = math.max(kE, 50), bottom_value = math.min(kE, 50),
     top_color = kE >= 50 ? color.new(th.up, 78) : color.new(th.down, 97), bottom_color = kE >= 50 ? color.new(th.up, 97) : color.new(th.down, 78), title = "Momentum fill")
bool popZoneL = kE > inUpper and trendUp
bool popZoneS = kE < inLower and trendDn
bgcolor(inShade and popZoneL ? color.new(th.up, 93) : inShade and popZoneS ? color.new(th.down, 93) : na, title = "Pop / drop state")

// ═══════════════════════════════════════════════════════════════════ CHART · EMA, band, signals, trade levels (force_overlay)
color emaCol = emaRising ? th.up : emaFalling ? th.down : th.flat
plot(ema200, "EMA 200", color = color.new(emaCol, 35), linewidth = 1, offset = inEmaOff, force_overlay = true, display = display.pane)
color trailBase = trailDir == 1 ? th.up : th.down
color trailCol  = sideways ? color.from_gradient(0.75, 0.0, 1.0, trailBase, th.flat) : trailStrong ? trailBase : color.from_gradient(0.45, 0.0, 1.0, th.flat, trailBase)
float trailUp = inTrendTrail and trailDir == 1 ? trail : na
float trailDn = inTrendTrail and trailDir == -1 ? trail : na
plot(trailUp, "Trend trail · up glow", color = color.new(trailCol, 84), linewidth = 6, style = plot.style_linebr, force_overlay = true, display = display.pane)
plot(trailDn, "Trend trail · down glow", color = color.new(trailCol, 84), linewidth = 6, style = plot.style_linebr, force_overlay = true, display = display.pane)
pTrailUp = plot(trailUp, "Trend trail · up", color = color.new(trailCol, 0), linewidth = 2, style = plot.style_linebr, force_overlay = true, display = display.pane)
pTrailDn = plot(trailDn, "Trend trail · down", color = color.new(trailCol, 0), linewidth = 2, style = plot.style_linebr, force_overlay = true, display = display.pane)
pPx      = plot(inTrendTrail ? close : na, "Trend fill anchor", color = color.new(th.bg, 100), force_overlay = true, display = display.none)
// gradient: dense at the trail line, fading toward price; lighter while sideways
int fillDense = sideways ? 90 : trailStrong ? 72 : 84
fill(pTrailUp, pPx, top_value = not na(trailUp) ? math.max(trail, close) : na, bottom_value = not na(trailUp) ? math.min(trail, close) : na,
     top_color = color.new(trailCol, 97), bottom_color = color.new(trailCol, fillDense), title = "Trend fill · up")
fill(pTrailDn, pPx, top_value = not na(trailDn) ? math.max(trail, close) : na, bottom_value = not na(trailDn) ? math.min(trail, close) : na,
     top_color = color.new(trailCol, fillDense), bottom_color = color.new(trailCol, 97), title = "Trend fill · down")
plot(inShowSm and inSmType != "None" ? emaSm : na, "EMA 200 · smoothing", color = color.new(th.accent, 40), linewidth = 1, style = plot.style_circles, offset = inEmaOff, force_overlay = true, display = display.pane)
plot(inShowBand ? ema200 + inMaxDist * atrU : na, "Stretch band · top", color = color.new(emaCol, 84), linewidth = 1, force_overlay = true, display = display.pane)
plot(inShowBand ? ema200 - inMaxDist * atrU : na, "Stretch band · bottom", color = color.new(emaCol, 84), linewidth = 1, force_overlay = true, display = display.pane)
plotshape(ev.sigL, "POP long", shape.triangleup, location.belowbar, color = th.up, size = size.small, force_overlay = true, display = display.pane)
plotshape(ev.sigS, "DROP short", shape.triangledown, location.abovebar, color = th.down, size = size.small, force_overlay = true, display = display.pane)
bool live = t.dir != 0 or ev.exit
plot(inPlan and live ? t.entry : na, "Trade · entry", color = color.new(th.txtHi, 15), linewidth = 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
plot(inPlan and live ? t.sl : na, "Trade · stop", color = color.new(th.down, 15), linewidth = 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
plot(inPlan and live ? t.tp1 : na, "Trade · TP1", color = color.new(th.up, 55), linewidth = 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
plot(inPlan and live ? t.tp2 : na, "Trade · TP2", color = color.new(th.up, 15), linewidth = 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
color regCol = trendUp ? th.up : trendDn ? th.down : th.flat
barcolor(inTint and t.dir != 0 ? color.new(th.accentHi, 15) : inCandleTrend ? (trendUp or trendDn ? color.new(regCol, 30) : color.new(th.flat, 35)) : na, title = "Trend candles / trade tint")

// gradient risk / reward zones: four bands each, densest at the entry, extended while the trade is open
var array<box> zones = array.new<box>()
var array<box> liveZ = array.new<box>()
gradBand(float top, float bot, color c, int x1, int x2, bool denseTop) =>
    float h = (top - bot) / 4
    for k = 0 to 3
        float bTop = top - h * k
        float bBot = bTop - h
        int alpha = denseTop ? 78 + k * 5 : 93 - k * 5
        box b = box.new(x1, bTop, x2, bBot, border_color = color.new(c, 100), bgcolor = color.new(c, alpha), force_overlay = true)
        array.push(zones, b)
        array.push(liveZ, b)
if barstate.isconfirmed
    if (ev.sigL or ev.sigS) and inZones and inPlan
        array.clear(liveZ)
        int d = t.dir
        // risk: entry → stop (dense at the entry) · reward: entry → TP2 (dense at the entry)
        gradBand(math.max(t.entry, t.sl), math.min(t.entry, t.sl), th.down, bar_index, bar_index + 1, d == 1)
        gradBand(math.max(t.entry, t.tp2), math.min(t.entry, t.tp2), th.up, bar_index, bar_index + 1, d == -1)
        while array.size(zones) > 96
            box.delete(array.shift(zones))
    else if (t.dir != 0 or ev.exit) and array.size(liveZ) > 0
        for b in liveZ
            box.set_right(b, bar_index)

// event labels on the chart
if ev.sigL
    label.new(bar_index, low, "POP ▲  " + fmt(t.entry) + "\nstop " + fmt(t.sl) + " · TP2 " + fmt(t.tp2), style = label.style_label_up, color = color.new(th.up, 12), textcolor = th.bg, size = size.small, force_overlay = true,
         tooltip = "Long pop: the stochastic EMA crossed above " + str.tostring(inUpper, "0") + " with price above a rising EMA 200 and within " + str.tostring(inMaxDist, "0.0") + " ATR of it.\nentry " + fmt(t.entry) + " · stop " + fmt(t.sl) + " (" + fmt(t.r) + " = 1R)\nTP1 " + fmt(t.tp1) + " → breakeven · TP2 " + fmt(t.tp2))
if ev.sigS
    label.new(bar_index, high, "DROP ▼  " + fmt(t.entry) + "\nstop " + fmt(t.sl) + " · TP2 " + fmt(t.tp2), style = label.style_label_down, color = color.new(th.down, 12), textcolor = th.bg, size = size.small, force_overlay = true,
         tooltip = "Short drop: the stochastic EMA crossed below " + str.tostring(inLower, "0") + " with price below a falling EMA 200 and within " + str.tostring(inMaxDist, "0.0") + " ATR of it.\nentry " + fmt(t.entry) + " · stop " + fmt(t.sl) + " (" + fmt(t.r) + " = 1R)\nTP1 " + fmt(t.tp1) + " → breakeven · TP2 " + fmt(t.tp2))
if ev.exit
    string rTxt = (ev.r >= 0 ? "+" : "") + str.tostring(ev.r, "0.0") + "R"
    string whyTxt = ev.why == "BAND" ? "momentum exit" : ev.why == "TP2" ? "target" : ev.why == "BE" ? "breakeven" : "stop"
    label.new(bar_index, ev.dir == 1 ? high : low, (ev.dir == 1 ? "exit long · " : "exit short · ") + whyTxt + " " + rTxt, style = ev.dir == 1 ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 15), textcolor = ev.r >= 0 ? th.up : th.down, size = size.tiny, force_overlay = true)

// ═══════════════════════════════════════════════════════════════════ WORDS (one source of truth)
string stateWord = t.dir == 1 ? "LONG OPEN" : t.dir == -1 ? "SHORT OPEN" : (side == 1 ? "FLAT · long side" : "FLAT · short side") + (sideways ? " · sideways" : "")
color  stateCol  = t.dir == 1 ? th.up : t.dir == -1 ? th.down : s1 ? (side == 1 ? th.up : th.down) : th.flat
float  openR     = t.dir != 0 ? (close - t.entry) * t.dir / t.r : na
string chipTxt   = t.dir != 0 ? (openR >= 0 ? "+" : "") + str.tostring(openR, "0.0") + "R now" + (t.tp1Hit ? " · stop at breakeven" : "") : allPre ? (s4 ? "set · on the pop side" : "set · waiting for the cross") : str.tostring(ready) + "/" + str.tostring(readyMax) + " steps true"

// ═══════════════════════════════════════════════════════════════════ DASHBOARD (force_overlay)
posOf(string p) =>
    p == "Top Left" ? position.top_left : p == "Bottom Right" ? position.bottom_right : p == "Bottom Left" ? position.bottom_left : p == "Middle Right" ? position.middle_right : p == "Middle Left" ? position.middle_left : position.top_right
szLead() => inSize == "Small" ? size.small : inSize == "Large" ? size.large  : size.normal
szBody() => inSize == "Small" ? size.tiny  : inSize == "Large" ? size.normal : size.small
szSub()  => inSize == "Small" ? size.tiny  : inSize == "Large" ? size.small  : size.tiny
row3(table tb, int r, string k, bool ok, string why, bool showMark) =>
    color stripe = r % 2 == 1 ? color.new(th.accent, 94) : th.bg
    table.cell(tb, 0, r, k, text_color = th.flat, text_size = szBody(), text_halign = text.align_left, bgcolor = stripe)
    table.cell(tb, 1, r, showMark ? (ok ? "✓" : "✗") : "", text_color = ok ? th.up : th.down, text_size = szBody(), text_halign = text.align_center, bgcolor = stripe)
    table.cell(tb, 2, r, why, text_color = ok ? th.txt : th.txtHi, text_size = szBody(), text_halign = text.align_left, text_font_family = font.family_monospace, bgcolor = stripe)
section(table tb, int r, string title) =>
    table.cell(tb, 0, r, title, text_color = th.accent, text_size = szSub(), text_halign = text.align_left, bgcolor = color.new(th.accent, 90))
    table.cell(tb, 1, r, "", bgcolor = color.new(th.accent, 90))
    table.cell(tb, 2, r, "", bgcolor = color.new(th.accent, 90))
    table.merge_cells(tb, 0, r, 2, r)

var table dash = table.new(posOf(inDashPos), 3, 20, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_width = 0, force_overlay = true)
if barstate.islast and inDash
    table.clear(dash, 0, 0, 2, 19)
    int r = 0
    // header
    table.cell(dash, 0, r, "STOCHASTIC POP DESK", text_color = th.accentHi, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "EMA 200 trend · Stochastic POP (Method 2, EMA 4 on the line) · the four steps of the rule, in order. Everything commits on the closed bar.")
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    table.cell(dash, 2, r, syminfo.ticker + " · " + timeframe.period, text_color = th.accent, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg)
    r += 1
    // state + chip
    table.cell(dash, 0, r, (t.dir == 1 ? "▲ " : t.dir == -1 ? "▼ " : "● ") + stateWord, text_color = stateCol, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "The open paper trade, or the side the EMA 200 allows right now.")
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    color chipBg = t.dir != 0 ? color.new(openR >= 0 ? th.up : th.down, 75) : color.new(color.from_gradient(ready, 0, readyMax, th.down, th.up), 75)
    table.cell(dash, 2, r, chipTxt, text_color = th.txtHi, text_size = szBody(), text_halign = text.align_right, bgcolor = chipBg, tooltip = t.dir != 0 ? "Open R of the paper trade at this close." : "How many of the listed steps are true right now. 'Set' = everything but the cross itself; the cross is the trigger and must be fresh.")
    r += 1
    if t.dir == 0
        // readiness — the rule in order
        section(dash, r, "READINESS FOR A " + str.upper(sideWord) + " · " + str.tostring(ready) + " OF " + str.tostring(readyMax) + " STEPS")
        r += 1
        row3(dash, r, "1 · Trend", s1, s1Txt, true)
        r += 1
        row3(dash, r, "2 · Not stretched", s2, s2Txt, true)
        r += 1
        row3(dash, r, "3 · Fresh pop", s3, s3Txt, true)
        r += 1
        row3(dash, r, "4 · Cross " + str.tostring(side == 1 ? inUpper : inLower, "0"), s4, s4Txt, true)
        r += 1
        if gateOn
            row3(dash, r, "5 · Agreements", s5, s5Txt, true)
            r += 1
        if inHtfOn
            row3(dash, r, "HTF " + inHtf, s6, htfOk ? (htfClose > htfEma ? "price above the HTF EMA" : "price below the HTF EMA") : "HTF must be higher than the chart", true)
            r += 1
        if inSessOn
            row3(dash, r, "Session", s7, s7 ? "inside " + inSess1 + " / " + inSess2 : "outside the sessions (" + inSessTz + ")", true)
            r += 1
        row3(dash, r, "Missing", allPre, missing, false)
        r += 1
    else
        section(dash, r, "TRADE · " + (t.dir == 1 ? "LONG" : "SHORT") + " · " + str.tostring(bar_index - t.bar) + " BARS")
        r += 1
        row3(dash, r, "Entry", true, fmt(t.entry) + " · 1R = " + fmt(t.r), false)
        r += 1
        row3(dash, r, "Stop", true, fmt(t.sl) + (inTrailStop != "Off" and t.sl != t.sl0 and t.sl != t.entry ? " · following the trail" : t.tp1Hit ? " · breakeven" : inStopMode == "ATR" ? " · ATR stop" : " · swing"), false)
        r += 1
        row3(dash, r, "Targets", t.tp1Hit, "TP1 " + fmt(t.tp1) + (t.tp1Hit ? " ✓" : "") + " · TP2 " + fmt(t.tp2), false)
        r += 1
        row3(dash, r, "Exit rule", true, (inExitMode == "Stop / targets only" ? "stop or TP2" : (t.dir == 1 ? "line back below " + str.tostring(inUpper, "0") : "line back above " + str.tostring(inLower, "0")) + (inExitMode == "Band cross back only" ? "" : " · or TP2 / stop")) + (inTrail ? " · trail " + str.tostring(inTrailAtr, "0.0") + " ATR" : "") + (inTrailStop != "Off" ? " · stop = trend trail" + (inTrailStop == "After TP1" ? " after TP1" : "") : "") + (inOppExit ? " · flip" : "") + (inTimeStop > 0 ? " · time " + str.tostring(inTimeStop) : ""), false)
        r += 1
        row3(dash, r, "Stochastic", s4, str.tostring(kE, "0.0") + (s4 ? " · momentum intact" : " · momentum fading"), true)
        r += 1
    if inShowCtx
        section(dash, r, "CONTEXT · " + str.tostring(side == 1 ? agCountL : agCountS) + " OF " + str.tostring(agTotal) + " AGREE" + (inGate == "Trend + POP only" ? "" : " · GATE"))
        r += 1
        if inUseStack
            row3(dash, r, "MA stack", side == 1 ? agStackL : agStackS, str.tostring(stackUp) + "/5 EMAs below price (5·10·20·100·200)", true)
            r += 1
        if inUse821
            row3(dash, r, "EMA 8 / 21", side == 1 ? ag821L : ag821S, ema8 > ema21 ? "8 above 21" : "8 below 21", true)
            r += 1
        if inUseRsi
            row3(dash, r, "RSI " + str.tostring(inRsiLen), side == 1 ? agRsiL : agRsiS, str.tostring(rsi, "0.0") + (rsi > 50 ? " · above 50" : " · below 50"), true)
            r += 1
        if inUseVol
            row3(dash, r, "Volume", agVol, volMa > 0 ? str.tostring(nz(volume, 0.0) / volMa, "0.0") + "× its " + str.tostring(inVolLen) + "-bar average" : "n/a", true)
            r += 1
    section(dash, r, "RECORD · PAPER, NO COSTS")
    r += 1
    row3(dash, r, "Closed", st.n >= 5, st.n < 5 ? str.tostring(st.n) + " trades · too few to read" : str.tostring(st.n) + " · " + str.tostring(math.round(st.wins / st.n * 100)) + "% win" + (st.flat > 0 ? " · " + str.tostring(st.flat) + " flat" : "") + " · avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R", false)
    r += 1
    row3(dash, r, "How they ended", true, "band " + str.tostring(st.nBand) + " · stop " + str.tostring(st.nStop) + " · TP2 " + str.tostring(st.nTp) + (na(t.lastR) ? "" : " · last " + (t.lastR >= 0 ? "+" : "") + str.tostring(t.lastR, "0.0") + "R"), false)
    r += 1
    table.cell(dash, 0, r, "EMA " + str.tostring(inEmaLen) + " · stoch " + str.tostring(inStLen) + "/" + str.tostring(inStSmooth) + " · EMA " + str.tostring(inStEma) + " · " + str.tostring(inUpper, "0") + "/" + str.tostring(inLower, "0"), text_color = th.flat, text_size = szSub(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Your settings, as the thread listed them.")
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    string sw = (inRangeBlock ? "range-skip · " : "") + (inTrail ? "trail · " : "") + (inTrailStop != "Off" ? "trail-stop · " : "") + (inSessOn ? "session · " : "") + (inTimeStop > 0 ? "time · " : "") + (inOppExit ? "flip · " : "") + (inHtfOn ? "HTF " + inHtf + " · " : "")
    table.cell(dash, 2, r, (sw == "" ? "no switches · " : sw) + (inGate == "Trend + POP only" ? "classic rule" : inGate == "Trend + POP + all agreements" ? "strict gate" : "majority gate"), text_color = th.flat, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg, tooltip = "Active switches and the signal gate mode. ATR " + fmt(atr) + " is the unit for stops and the stretch band.")

// ═══════════════════════════════════════════════════════════════════ NARRATIVE · Co-Pilot (translates state; never a new signal)
wrap(string sIn, int width) =>
    array<string> words = str.split(sIn, " ")
    string out  = ""
    string line = ""
    for wd in words
        if line != "" and str.length(line) + str.length(wd) + 1 > width
            out  := out + (out == "" ? "" : "\n") + line
            line := wd
        else
            line := line == "" ? wd : line + " " + wd
    out == "" ? line : out + "\n" + line

narrNow() =>
    string o = ""
    if t.dir != 0
        o := "in a " + (t.dir == 1 ? "long" : "short") + " from " + fmt(t.entry) + " for " + str.tostring(bar_index - t.bar) + " bars, " + (openR >= 0 ? "+" : "") + str.tostring(openR, "0.0") + "R at this close — stop " + fmt(t.sl) + (t.tp1Hit ? " (moved to breakeven after TP1)" : "") + ", the stochastic line is at " + str.tostring(kE, "0.0") + (s4 ? " so momentum is still on the trade's side" : " — momentum is fading toward the band")
    else
        o := "flat — " + (s1 ? "the EMA 200 allows the " + sideWord + " side (" + s1Txt + ")" : "no clean side yet (" + s1Txt + ")") + (sideways ? "; the last " + str.tostring(inRangeLen) + " bars are a range (" + erTxt + ")" + (rangeBlock ? ", so signals are skipped" : " — pops inside a range are the weakest kind") : "") + "; the stochastic line is at " + str.tostring(kE, "0.0") + (s4 ? ", already on the pop side, " : ", ") + (allPre ? (s4 ? "every condition is true — the next fresh cross fires" : "every condition is true — the cross is the only thing left") : "and what is still false: " + missing)
    o
narrWhy() =>
    "Bernstein's Stochastic Pop, made mechanical: the side comes from a rising / falling EMA " + str.tostring(inEmaLen) + ", the entry from the EMA-" + str.tostring(inStEma) + " stochastic crossing " + str.tostring(inUpper, "0") + " / " + str.tostring(inLower, "0") + ", no pops that start more than " + str.tostring(inMaxDist, "0.0") + " ATR from the EMA, fresh pops only. The four dashboard steps are that rule in order."
narrPlan() =>
    string o = ""
    if t.dir != 0
        o := "Stop first on every closed bar. " + (inExitMode == "Stop / targets only" ? "" : "If the line crosses back " + (t.dir == 1 ? "below " + str.tostring(inUpper, "0") : "above " + str.tostring(inLower, "0")) + ", the momentum exit closes the trade at that close. ") + (inExitMode == "Band cross back only" ? "" : "TP1 " + fmt(t.tp1) + (inTrail ? " starts a " + str.tostring(inTrailAtr, "0.0") + " ATR trailing stop" : " moves the stop to breakeven") + "; TP2 " + fmt(t.tp2) + " is the final target. ") + (inOppExit ? "An opposite cross closes it. " : "") + (inTimeStop > 0 ? "If TP1 is not reached within " + str.tostring(inTimeStop) + " bars, it closes on time. " : "") + "The idea is wrong beyond " + fmt(t.sl0) + "."
    else
        o := "A " + sideWord + " fires when " + (s1 ? "" : "price gets back on the right side of a " + (side == 1 ? "rising" : "falling") + " EMA 200, ") + (s2 ? "" : "price comes back within " + str.tostring(inMaxDist, "0.0") + " ATR of the EMA, ") + (s3 ? "" : "the line dips " + (side == 1 ? "below " + str.tostring(inLower, "0") : "above " + str.tostring(inUpper, "0")) + " to re-arm, ") + (inHtfOn and not s6 ? "the " + inHtf + " chart gets on the same side of its EMA, " : "") + (inSessOn and not s7 ? "a session opens, " : "") + "the line crosses " + (side == 1 ? "above " + str.tostring(inUpper, "0") : "below " + str.tostring(inLower, "0")) + " on a closed bar. Then: entry at that close, stop at the " + (inStopMode == "ATR" ? str.tostring(inStopAtr, "0.0") + " ATR distance" : str.tostring(inSwingN) + "-bar swing " + (side == 1 ? "low" : "high") + " minus a buffer") + ", TP1 " + str.tostring(inTp1, "0.0") + "R to breakeven, TP2 " + str.tostring(inTp2, "0.0") + "R" + (inExitMode == "Stop / targets only" ? "." : ", exit early if the line crosses back through its band.")
    o
narrTrack() =>
    st.n < 5 ? "Too few closed trades on this chart to say anything yet." : str.tostring(st.n) + " paper trades closed here: " + str.tostring(math.round(st.wins / st.n * 100)) + "% won, avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R; " + str.tostring(st.nBand) + " ended on the momentum exit, " + str.tostring(st.nStop) + " on the stop / breakeven, " + str.tostring(st.nTp) + " at TP2. No costs, small sample — a description of the past on THIS chart, not an edge."
narrPosOf(string p) =>
    p == "Top Left" ? position.top_left : p == "Top Right" ? position.top_right : p == "Bottom Right" ? position.bottom_right : p == "Middle Left" ? position.middle_left : p == "Middle Right" ? position.middle_right : position.bottom_left
narrBodySz() => inNarrSize == "Small" ? size.small : inNarrSize == "Medium" ? size.normal : inNarrSize == "Large" ? size.large : szBody()
narrHeadSz() => inNarrSize == "Small" ? size.normal : inNarrSize == "Medium" ? size.large : inNarrSize == "Large" ? size.large : szLead()

var table narrT = na
if barstate.islast
    if not na(narrT)
        table.delete(narrT)
        narrT := na
    if inNarr != "Off"
        array<string> labs = array.new<string>()
        array<string> vals = array.new<string>()
        array.push(labs, "NOW")
        array.push(vals, wrap(narrNow() + " (decision support, not a trade instruction)", inNarrWrap))
        bool deep = inNarr == "Standard" or inNarr == "Detailed"
        if deep
            array.push(labs, "WHY")
            array.push(vals, wrap(narrWhy(), inNarrWrap))
            array.push(labs, "PLAN")
            array.push(vals, wrap(narrPlan(), inNarrWrap))
        if inNarr == "Detailed"
            array.push(labs, "TRACK REC")
            array.push(vals, wrap(narrTrack(), inNarrWrap))
            array.push(labs, "GLOSSARY")
            array.push(vals, wrap("Pop = the stochastic line breaking above " + str.tostring(inUpper, "0") + " — momentum arriving. Drop = breaking below " + str.tostring(inLower, "0") + ". Fresh pop = the line has visited the other band since the last signal. Stretched = price too far from the EMA 200 to be a fair entry. Momentum exit = the line crossing back through its band.", inNarrWrap))
        int nr = array.size(labs)
        bool atBottom = inNarrPos == "Bottom Left" or inNarrPos == "Bottom Right"
        int rows = nr + 2 + (atBottom ? 1 : 0)
        narrT := table.new(narrPosOf(inNarrPos), 2, rows, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_color = color.new(th.frame, 70), border_width = 1, force_overlay = true)
        table.cell(narrT, 0, 0, "◆ CO-PILOT", text_color = th.accentHi, bgcolor = color.new(th.accent, 88), text_size = narrHeadSz(), text_halign = text.align_left)
        table.cell(narrT, 1, 0, stateWord + " · " + str.upper(inNarr), text_color = th.accent, bgcolor = color.new(th.accent, 88), text_size = narrBodySz(), text_halign = text.align_right)
        for i = 0 to nr - 1
            table.cell(narrT, 0, i + 1, array.get(labs, i), text_color = th.accent, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left, text_valign = text.align_top)
            table.cell(narrT, 1, i + 1, array.get(vals, i), text_color = th.txt, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left, text_valign = text.align_top)
        table.cell(narrT, 0, nr + 1, "", bgcolor = th.bg)
        table.cell(narrT, 1, nr + 1, "Honest: levels are geometry, not promises. The record has no costs — measure before you trust it.", text_color = th.flat, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left)
        if atBottom
            table.cell(narrT, 0, nr + 2, " ", bgcolor = th.bg)
            table.cell(narrT, 1, nr + 2, " ", bgcolor = th.bg)

// ═══════════════════════════════════════════════════════════════════ ALERTS (confirmed bars only by construction)
payload(string evName, string why) =>
    string js = '{"src":"StochasticPopDesk","symbol":"' + syminfo.ticker + '","tf":"' + timeframe.period + '","event":"' + evName + '","side":"' + (t.dir == 1 or ev.dir == 1 ? "LONG" : "SHORT") + '","entry":' + fmt(nz(t.entry, close)) + ',"sl":' + fmt(nz(t.sl, 0)) + ',"tp1":' + fmt(nz(t.tp1, 0)) + ',"tp2":' + fmt(nz(t.tp2, 0)) + ',"stoch":' + str.tostring(kE, "0.0") + ',"ema200":' + fmt(ema200) + ',"reason":"' + why + '"' + (na(ev.r) ? "" : ',"r":' + str.tostring(ev.r, "0.00")) + '}'
    string tx = "Stochastic Pop Desk · " + syminfo.ticker + " " + timeframe.period + " · " + evName + " · " + (t.dir == 1 or ev.dir == 1 ? "LONG" : "SHORT") + " · entry " + fmt(nz(t.entry, close)) + " · SL " + fmt(nz(t.sl, 0)) + " · TP " + fmt(nz(t.tp1, 0)) + " / " + fmt(nz(t.tp2, 0)) + (why != "" ? " · " + why : "")
    inAlertJson ? js : tx
alertcondition(ev.sigL, "POP long", "Stochastic Pop Desk: long pop — stochastic crossed above the pop band with price above a rising EMA 200")
alertcondition(ev.sigS, "DROP short", "Stochastic Pop Desk: short drop — stochastic crossed below the drop band with price below a falling EMA 200")
alertcondition(ev.tp1, "TP1 reached", "Stochastic Pop Desk: TP1 reached — stop to breakeven")
alertcondition(ev.exit and ev.why == "BAND", "Momentum exit", "Stochastic Pop Desk: the stochastic crossed back through its band — exit")
alertcondition(ev.exit and ev.why == "TP2", "TP2 reached", "Stochastic Pop Desk: final target reached")
alertcondition(ev.exit and (ev.why == "SL" or ev.why == "BE"), "Stopped", "Stochastic Pop Desk: stop hit")
alertcondition(t.dir == 0 and allPre and not s4, "Ready — waiting for the cross", "Stochastic Pop Desk: every condition but the cross is true — the next stochastic cross fires a signal")
alertcondition(trailDir != trailDir[1] and trailDir[1] != 0, "Trend flipped", "Stochastic Pop Desk: the trend trail flipped to the other side of price")
if ev.sigL
    alert(payload("POP", ""), alert.freq_once_per_bar_close)
if ev.sigS
    alert(payload("DROP", ""), alert.freq_once_per_bar_close)
if ev.tp1
    alert(payload("TP1", ""), alert.freq_once_per_bar_close)
if ev.exit
    alert(payload("EXIT", ev.why), alert.freq_once_per_bar_close)
Download the indicator .pine file

The strategy twin · optional

Measure the rules with costs on a second chart.

Strategy code

Copy the code as it is. No coding needed.

View the full code
// ═══════════════════════════════════════════════════════════════════════════════
//  STOCHASTIC POP DESK — STRATEGY TWIN  ·  Pine Script v6  ·  v1.5.0   (generated from pop_desk.pine by tools/gen-twin.mjs)
//  One script that replaces three: the EMA 200 trend filter, the Stochastic POP (Jake Bernstein's method, the
//  ChrisMoody "Method 2" bands 55 / 45, with an EMA 4 on the stochastic line) and an analysis panel that walks the
//  four steps of the rule in order and tells you — in words — what is still missing before a signal can fire.
//
//  HOW IT READS THE MARKET (every decision commits on the CLOSED bar — nothing repaints):
//    1 TREND      price above a RISING EMA 200 = long side only · below a FALLING EMA 200 = short side only.
//    2 STRETCH    the close must sit within N ATR of the EMA — a pop that starts far away is a chase.
//    3 FRESH      the stochastic line must have visited the other band since the last signal (no re-entries).
//    4 CROSS      the EMA-4 of the smoothed stochastic (14, 5) crosses ABOVE 55 → long pop · BELOW 45 → short drop.
//    EXIT         the line crosses back through its band (momentum gone) · or a stop / target ladder · or both.
//    CONTEXT      MA stack 5·10·20·100·200, EMA 8 / 21, RSI, volume — shown as agreements, optionally used as gates.
//    SWITCHES     (all off by default, each measured — numbers in the tooltips) ATR trailing stop after TP1 · session
//                 filter · time stop · opposite-signal exit · higher-timeframe EMA agreement.
//
//  The stochastic lives in this pane; the EMA, the signals, the trade levels, the panel and the Co-Pilot are drawn
//  on the price chart with force_overlay — one script, one settings dialog, two views.
//  Paper ledger has NO costs — a description of the past on your chart, not a promise.
//
//  License: MIT — share freely, keep this header.  © 2026 ProEA Lab · built for r/pinescript
// ═══════════════════════════════════════════════════════════════════════════════
//@version=6
strategy("Stochastic Pop Desk — Strategy Twin", "StochPopS", overlay = false, initial_capital = 100000, default_qty_type = strategy.fixed, default_qty_value = 1, pyramiding = 0,
     commission_type = strategy.commission.percent, commission_value = 0.02, slippage = 5, process_orders_on_close = true, calc_on_every_tick = false,
     max_labels_count = 300, max_boxes_count = 160, max_lines_count = 100)
// COSTS ARE ON: 0.02% commission per order + 5 ticks slippage per fill (edit the strategy() header for your broker). Quantity = 1 unit.
// Entries fill at the signal bar's close (process_orders_on_close) — the same price the paper ledger uses.

// ─────────────────────────────────────────────────────────────────── INPUTS · trend (the EMA 200 exactly as the built-in)
gT = "Trend · EMA 200"
inEmaLen   = input.int(200, "EMA length", minval = 1, group = gT, display = display.none)
inEmaSrc   = input.source(close, "Source", group = gT, display = display.none)
inEmaOff   = input.int(0, "Offset", minval = -500, maxval = 500, group = gT, display = display.none)
inSmType   = input.string("SMA", "Smoothing line", options = ["None", "SMA", "EMA", "SMMA (RMA)", "WMA", "VWMA"], group = gT, tooltip = "The built-in EMA's second line: a moving average OF the EMA. Drawn dotted; it does not affect signals.", display = display.none)
inSmLen    = input.int(5, "Smoothing length", minval = 1, group = gT, display = display.none)
inSlopeLen = input.int(10, "EMA must be rising / falling over N bars", minval = 1, maxval = 100, group = gT, tooltip = "Long side only while the EMA 200 is higher than N bars ago; short side only while it is lower.", display = display.none)
inMaxDist  = input.float(3.0, "Not stretched: price within N ATR of the EMA", minval = 0.5, maxval = 20, step = 0.5, group = gT, tooltip = "A pop that fires far above a rising EMA is a chase. Signals are skipped when the close is farther than this from the EMA.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · stochastic POP (CM_Stochastic POP Method 2 + EMA 4)
gS = "Stochastic POP (Method 2)"
inStLen    = input.int(14, "Stochastic length", minval = 1, group = gS, display = display.none)
inStSmooth = input.int(5, "Smoothing (%K)", minval = 1, group = gS, display = display.none)
inStEma    = input.int(4, "EMA on the stochastic", minval = 1, group = gS, tooltip = "The 'plus EMA 4' — the signal line is an EMA of the smoothed stochastic. It cuts the whipsaws; it adds a little lag.", display = display.none)
inUpper    = input.float(55, "Pop band (long above)", minval = 50, maxval = 95, group = gS, display = display.none)
inLower    = input.float(45, "Drop band (short below)", minval = 5, maxval = 50, group = gS, display = display.none)
inMustReset = input.bool(true, "Fresh pop only (the line must visit the other band between signals)", group = gS, tooltip = "A long pop counts only if the line has been below the drop band since the last long pop — no re-entries on every wiggle across 55.", display = display.none)
inCooldown = input.int(5, "Cooldown between signals (bars)", minval = 0, maxval = 200, group = gS, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · context components
gA = "Context (agreements)"
inUseStack = input.bool(true, "MA stack 5 · 10 · 20 · 100 · 200", group = gA, display = display.none)
inUse821   = input.bool(true, "EMA 8 / 21 cross", group = gA, display = display.none)
inUseRsi   = input.bool(true, "RSI above / below 50", group = gA, display = display.none)
inRsiLen   = input.int(9, "RSI length", minval = 2, group = gA, display = display.none)
inUseVol   = input.bool(true, "Volume above its average", group = gA, display = display.none)
inVolLen   = input.int(20, "Volume average length", minval = 2, group = gA, display = display.none)
inAtrLen   = input.int(10, "ATR length (stops, stretch, panel)", minval = 1, group = gA, display = display.none)
inGate     = input.string("Trend + POP only", "Signal needs", options = ["Trend + POP only", "Trend + POP + majority of agreements", "Trend + POP + all agreements"], group = gA,
     tooltip = "What must be true for a signal. 'Trend + POP only' is the classic Bernstein rule; the agreements are then context. The stricter modes use them as gates.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · trade plan
gE = "Trade plan (paper — no costs)"
inPlan     = input.bool(true, "Show entry / stop / targets", group = gE, display = display.none)
inStopMode = input.string("Swing low / high", "Stop", options = ["Swing low / high", "ATR"], group = gE, display = display.none)
inSwingN   = input.int(10, "Swing lookback (bars)", minval = 2, maxval = 100, group = gE, display = display.none)
inStopAtr  = input.float(2.0, "ATR stop (× ATR)", minval = 0.3, maxval = 10, step = 0.1, group = gE, display = display.none)
inStopBuf  = input.float(0.2, "Stop buffer (× ATR)", minval = 0, maxval = 3, step = 0.1, group = gE, display = display.none)
inTp1      = input.float(1.0, "TP1 (R) → stop to breakeven", minval = 0.3, maxval = 10, step = 0.1, group = gE, display = display.none)
inTp2      = input.float(2.0, "TP2 (R) final", minval = 0.5, maxval = 20, step = 0.1, group = gE, display = display.none)
inExitMode = input.string("Stop / targets only", "Exit", options = ["Stop / targets only", "Band cross back or TP2", "Band cross back only"], group = gE,
     tooltip = "Stop / targets only = the stop (swing or ATR), TP1 to breakeven, TP2 final — measured best on 2 of 3 test charts, tied on the third. Band cross back = the stochastic line crossing back through its band closes the trade early (the classic exit) — it cut winners short in the measurement. Both = the ladder plus the band exit.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · switches (all off — measured, see tooltips)
gX = "Switches (off by default)"
inTrail    = input.bool(false, "Trailing stop after TP1 (ATR)", group = gX, tooltip = "Once TP1 is reached the stop trails N ATR behind price instead of parking at breakeven. Measured with costs: BTCUSD 1H PF 1.51 → 1.58 (more winners, smaller), XAUUSD 1H 1.31 → 1.10 — market-dependent.", display = display.none)
inTrailAtr = input.float(2.0, "  Trail distance (× ATR)", minval = 0.5, maxval = 10, step = 0.5, group = gX, display = display.none)
inTrailStop = input.string("Off", "Stop follows the trend trail", options = ["Off", "After TP1", "From entry"], group = gX,
     tooltip = "The chandelier trend trail drawn on the chart becomes the stop: it ratchets with the trend and the trade closes when the trail flips to the other side of price. 'After TP1' keeps the swing / ATR stop until TP1 is paid, then hands the trade to the trail; 'From entry' uses the trail from the first bar. Uses the trail settings in Style. Measured with costs (profit factor, ladder default → switch): After TP1 — BTC 1H 1.51 → 1.48 (win rate 30 → 44 %), gold 1H 1.31 → 1.18, gold 15m 0.81 → 0.88. From entry — BTC 1H 1.51 → 1.81 (drawdown −27 %), gold 1H 1.31 → 0.96, gold 15m 0.81 → 0.55. It pays where trends run (bitcoin) and costs where price chops around the EMA 200 (gold) — read your chart first.", display = display.none)
inSessOn   = input.bool(false, "Session filter (signals only inside the sessions)", group = gX, tooltip = "Two windows, exchange time as set below. Off = any time. Measured: XAUUSD 1H PF 1.31 → 1.23 with a third of the trades; meaningless on 24/7 markets.", display = display.none)
inSessTz   = input.string("America/New_York", "  Session timezone (IANA)", group = gX, display = display.none)
inSess1    = input.session("0200-0500", "  Session 1", group = gX, display = display.none)
inSess2    = input.session("0830-1100", "  Session 2", group = gX, display = display.none)
inTimeStop = input.int(0, "Time stop: close after N bars if TP1 not reached (0 = off)", minval = 0, maxval = 500, group = gX, tooltip = "Measured at 30 bars: BTCUSD 1H PF 1.51 → 1.33, XAUUSD 1H 1.31 → 1.23 — it hurt on both.", display = display.none)
inOppExit  = input.bool(false, "Opposite-signal exit (a drop cross closes a long, a pop cross closes a short)", group = gX, tooltip = "Measured: BTCUSD 1H PF 1.51 → 1.68, XAUUSD 1H 1.31 → 1.02 — market-dependent.", display = display.none)
inHtfOn    = input.bool(false, "Higher-timeframe agreement (price on the same side of the EMA on the HTF)", group = gX, tooltip = "Read from the last CLOSED higher-timeframe bar — no lookahead. Adds a row to the readiness list. The one switch that helped on every chart tried (240 on 1H / 15m): BTCUSD 1H PF 1.51 → 1.89, XAUUSD 1H 1.31 → 1.41, XAUUSD 15m 0.81 → 0.98 — fewer trades each time.", display = display.none)
inHtf      = input.timeframe("240", "  Higher timeframe", group = gX, display = display.none)
inRangeBlock = input.bool(false, "Skip signals in a sideways regime", group = gX,
     tooltip = "The sideways detector is always shown (state line, step 1 text, the trail turns grey). With this on, step 1 also fails while the market is sideways, so no pop / drop fires inside a range. Sideways = Kaufman efficiency (net move ÷ path over N bars) in the choppiest N % of the last 200 bars, released 15 points higher so it does not flicker. Measured with costs (profit factor, ladder default → switch): BTC 1H 1.51 → 1.90 (27 → 13 trades), gold 1H 1.31 → 2.55 (83 → 42 trades, drawdown −60 %), gold 15m 0.81 → 0.50 (44 → 19 trades). Strong on the hourly charts, wrong on the 15-minute one — read your chart first.", display = display.none)
inRangeLen   = input.int(20, "  Sideways detector: bars", minval = 5, maxval = 200, group = gX, display = display.none)
inRangePct   = input.int(25, "  Sideways = the choppiest N % of the last 200 bars", minval = 5, maxval = 60, group = gX, tooltip = "Efficiency = |close − close[N]| ÷ Σ|close − close[1]| over N bars (100 % = a straight line, 0 % = pure chop). Instead of a fixed threshold — which is never portable between charts — the market is 'sideways' while its efficiency sits in the bottom N % of the last 200 bars, and stays flagged until it climbs above the (N + 15) % mark. Self-calibrating on any symbol and timeframe.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · style / panel / co-pilot / alerts
gV = "Style"
inTheme    = input.string("Aurora Neon", "Theme", options = ["Aurora Neon", "Royal Gold", "Colorblind-safe"], group = gV, display = display.none)
inShowSm   = input.bool(true, "Draw the EMA smoothing line", group = gV, display = display.none)
inShowBand = input.bool(false, "Draw the stretch band (EMA ± N ATR)", group = gV, display = display.none)
inTrendTrail = input.bool(true, "Trend trail line + fill (green under price in an uptrend, red over price in a downtrend)", group = gV,
     tooltip = "A chandelier-style trail: highest high − N ATR while the EMA 200 regime is up, lowest low + N ATR while it is down; it only ratchets with the trend and resets when the regime flips. The soft fill between the trail and price is the regime at a glance. Display only — signals still come from the four steps.", display = display.none)
inTrailMult2 = input.float(3.0, "  Trail distance (× ATR)", minval = 0.5, maxval = 10, step = 0.5, group = gV, display = display.none)
inZones    = input.bool(false, "Gradient risk / reward zones", group = gV, display = display.none)
inTint     = input.bool(true, "Tint candles while a trade is open", group = gV, display = display.none)
inShade    = input.bool(false, "Shade the pane while in a pop / drop", group = gV, display = display.none)
inCandleTrend = input.bool(false, "Colour candles by trend (green · red · grey)", group = gV, tooltip = "Soft green = price above a rising EMA 200 (long side) · soft red = below a falling EMA 200 (short side) · grey = no clean side. While a trade is open the candles turn accent-coloured instead, so the trade period is visible at a glance.", display = display.none)
gD = "Dashboard"
inDash     = input.bool(false, "Show the dashboard", group = gD, display = display.none)
inDashPos  = input.string("Top Right", "Position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right", "Middle Left"], group = gD, display = display.none)
inSize     = input.string("Medium", "Size", options = ["Small", "Medium", "Large"], group = gD, display = display.none)
inShowCtx  = input.bool(false, "Show the context section (MA stack · EMA 8/21 · RSI · volume)", group = gD, display = display.none)
gN = "Narrative · Co-Pilot"
inNarr     = input.string("Off", "Narrative depth", options = ["Off", "Brief", "Standard", "Detailed"], group = gN, tooltip = "Brief = one sentence (NOW). Standard adds WHY and PLAN. Detailed adds the track record and a glossary.", display = display.none)
inNarrPos  = input.string("Bottom Left", "Position", options = ["Bottom Left", "Bottom Right", "Top Left", "Top Right", "Middle Left", "Middle Right"], group = gN, display = display.none)
inNarrSize = input.string("Auto", "Size", options = ["Auto", "Small", "Medium", "Large"], group = gN, display = display.none)
inNarrWrap = input.int(64, "Line width (characters)", minval = 30, maxval = 120, group = gN, display = display.none)
gL = "Alerts"
inAlertJson = input.bool(true, "alert() payload as JSON (webhooks)", group = gL, tooltip = "Choose 'Any alert() function call' when creating the alert.", display = display.none)

// ─────────────────────────────────────────────────────────────────── THEME
type Theme
    color bg       = #0B0E14
    color frame    = #B8923A
    color accent   = #D8B14A
    color accentHi = #E8C25A
    color up       = #2BE7C7
    color down     = #FF3B5C
    color flat     = #6B7280
    color txt      = #D7DCE6
    color txtHi    = #ECEFF5
themeOf(string name) =>
    Theme t = Theme.new()
    if name == "Aurora Neon"
        t.bg       := #0E1118
        t.frame    := #2C3A5A
        t.accent   := #7C9CFF
        t.accentHi := #A9B8FF
        t.up       := #2AF0C8
        t.down     := #FF4D6D
        t.flat     := #5B6472
        t.txt      := #E6E9EF
        t.txtHi    := #F2F4F9
    else if name == "Colorblind-safe"
        t.bg       := #0E1014
        t.frame    := #3A4150
        t.accent   := #56B4E9
        t.accentHi := #8ED0F5
        t.up       := #E69F00
        t.down     := #0072B2
        t.flat     := #8C92A0
        t.txt      := #E8EAED
        t.txtHi    := #FFFFFF
    t
Theme th = themeOf(inTheme)
fmt(float x) => str.tostring(x, format.mintick)

// ═══════════════════════════════════════════════════════════════════ ENGINE INPUTS (ta.* at global scope)
float ema200 = ta.ema(inEmaSrc, inEmaLen)
float smSma  = ta.sma(ema200, inSmLen)
float smEma  = ta.ema(ema200, inSmLen)
float smRma  = ta.rma(ema200, inSmLen)
float smWma  = ta.wma(ema200, inSmLen)
float smVwma = ta.vwma(ema200, inSmLen)
float emaSm  = inSmType == "SMA" ? smSma : inSmType == "EMA" ? smEma : inSmType == "SMMA (RMA)" ? smRma : inSmType == "WMA" ? smWma : inSmType == "VWMA" ? smVwma : ema200
float atr    = ta.atr(inAtrLen)
float atrU   = math.max(atr, syminfo.mintick)
float kRaw   = ta.stoch(close, high, low, inStLen)
float kS     = ta.sma(kRaw, inStSmooth)
float kE     = ta.ema(kS, inStEma)
float ma5    = ta.ema(close, 5)
float ma10   = ta.ema(close, 10)
float ma20   = ta.ema(close, 20)
float ma100  = ta.ema(close, 100)
float ma200  = ta.ema(close, 200)
float ema8   = ta.ema(close, 8)
float ema21  = ta.ema(close, 21)
float rsi    = ta.rsi(close, inRsiLen)
float volMa  = ta.sma(nz(volume, 0.0), inVolLen)
float swLo   = ta.lowest(low, inSwingN)
float swHi   = ta.highest(high, inSwingN)
bool  inSess  = not inSessOn or not na(time(timeframe.period, inSess1, inSessTz)) or not na(time(timeframe.period, inSess2, inSessTz))
htfEmaFn() => ta.ema(close, inEmaLen)
float htfEma   = request.security(syminfo.tickerid, inHtf, htfEmaFn()[1], lookahead = barmerge.lookahead_on)
float htfClose = request.security(syminfo.tickerid, inHtf, close[1], lookahead = barmerge.lookahead_on)
bool  htfOk    = timeframe.in_seconds(inHtf) > timeframe.in_seconds()
bool  htfUp    = not inHtfOn or not htfOk or htfClose > htfEma
bool  htfDn    = not inHtfOn or not htfOk or htfClose < htfEma
bool  crossUp = ta.crossover(kE, inUpper)
bool  crossDn = ta.crossunder(kE, inLower)
bool  backDn  = ta.crossunder(kE, inUpper)     // long momentum gone
bool  backUp  = ta.crossover(kE, inLower)      // short momentum gone

// trend
bool emaRising  = ema200 > ema200[inSlopeLen]
bool emaFalling = ema200 < ema200[inSlopeLen]
bool trendUpRaw = close > ema200 and emaRising
bool trendDnRaw = close < ema200 and emaFalling
// sideways detector: Kaufman efficiency (net move ÷ path) with hysteresis; always shown, blocks signals only when the switch is on
float erNet   = math.abs(close - close[inRangeLen])
float erPath  = math.sum(math.abs(close - close[1]), inRangeLen)
float er      = erPath > 0 ? erNet / erPath : 0.0
float erLo    = ta.percentile_nearest_rank(er, 200, inRangePct)
float erHi    = ta.percentile_nearest_rank(er, 200, math.min(inRangePct + 15, 95))
var bool sideways = false
sideways := sideways ? er < erHi : er < erLo
bool rangeBlock = inRangeBlock and sideways
bool trendUp    = trendUpRaw and not rangeBlock
bool trendDn    = trendDnRaw and not rangeBlock
float distAtr   = (close - ema200) / atrU
bool stretchedUp = distAtr > inMaxDist
bool stretchedDn = -distAtr > inMaxDist

// context (agreements)
int stackUp = (close > ma5 ? 1 : 0) + (close > ma10 ? 1 : 0) + (close > ma20 ? 1 : 0) + (close > ma100 ? 1 : 0) + (close > ma200 ? 1 : 0)
int stackDn = 5 - stackUp
bool agStackL = stackUp >= 4
bool agStackS = stackDn >= 4
bool ag821L   = ema8 > ema21
bool ag821S   = ema8 < ema21
bool agRsiL   = rsi > 50
bool agRsiS   = rsi < 50
bool agVol    = volMa > 0 and nz(volume, 0.0) > volMa
int  agCountL = (inUseStack and agStackL ? 1 : 0) + (inUse821 and ag821L ? 1 : 0) + (inUseRsi and agRsiL ? 1 : 0) + (inUseVol and agVol ? 1 : 0)
int  agCountS = (inUseStack and agStackS ? 1 : 0) + (inUse821 and ag821S ? 1 : 0) + (inUseRsi and agRsiS ? 1 : 0) + (inUseVol and agVol ? 1 : 0)
int  agTotal  = (inUseStack ? 1 : 0) + (inUse821 ? 1 : 0) + (inUseRsi ? 1 : 0) + (inUseVol ? 1 : 0)
bool gateL = inGate == "Trend + POP only" ? true : inGate == "Trend + POP + all agreements" ? agCountL == agTotal : agCountL * 2 >= agTotal
bool gateS = inGate == "Trend + POP only" ? true : inGate == "Trend + POP + all agreements" ? agCountS == agTotal : agCountS * 2 >= agTotal

// trail state (filled in the display section; the state machine reads the PREVIOUS bar's values — no repaint)
var float trail = na
var int   trailDir = 0

// ═══════════════════════════════════════════════════════════════════ STATE (confirmed bars only)
type Trade
    int   dir      = 0        // 0 flat · 1 long · −1 short
    float entry    = na
    float sl       = na
    float sl0      = na
    float r        = na
    float tp1      = na
    float tp2      = na
    int   bar      = na
    bool  tp1Hit   = false
    int   lastSig  = -1000
    bool  resetL   = true     // a long pop is allowed (the line has visited the drop band since the last long)
    bool  resetS   = true
    float lastR    = na       // last closed trade's R (for the panel)
    string lastWhy = ""
type Stat
    int   n = 0
    int   wins = 0
    int   flat = 0
    float sumR = 0.0
    int   nBand = 0
    int   nStop = 0
    int   nTp = 0
type Ev
    bool   sigL = false
    bool   sigS = false
    bool   exit = false
    bool   tp1  = false
    string why  = ""
    float  r    = na
    int    dir  = 0
var Trade t  = Trade.new()
var Stat  st = Stat.new()
Ev ev = Ev.new()

closeTrade(float px, string why) =>
    float rr = (px - t.entry) * t.dir / t.r
    ev.exit := true
    ev.why := why
    ev.r := rr
    ev.dir := t.dir
    st.n := st.n + 1
    st.wins := st.wins + (rr >= 0.05 ? 1 : 0)
    st.flat := st.flat + (math.abs(rr) < 0.05 ? 1 : 0)
    st.sumR := st.sumR + rr
    if why == "BAND" or why == "FLIP" or why == "TIME"
        st.nBand := st.nBand + 1
    else if why == "TP2"
        st.nTp := st.nTp + 1
    else
        st.nStop := st.nStop + 1
    t.lastR := rr
    t.lastWhy := why
    t.dir := 0

openTrade(int d) =>
    float anchor = inStopMode == "ATR" ? (d == 1 ? close - inStopAtr * atrU : close + inStopAtr * atrU) : (d == 1 ? swLo - inStopBuf * atrU : swHi + inStopBuf * atrU)
    float slPx = d == 1 ? math.min(anchor, close - syminfo.mintick) : math.max(anchor, close + syminfo.mintick)
    t.dir := d
    t.entry := close
    t.sl := slPx
    t.sl0 := slPx
    t.r := math.abs(close - slPx)
    t.tp1 := close + d * inTp1 * t.r
    t.tp2 := close + d * inTp2 * t.r
    t.bar := bar_index
    t.tp1Hit := false
    t.lastSig := bar_index

if barstate.isconfirmed and not na(atr) and not na(kE) and bar_index > inEmaLen
    if kE < inLower
        t.resetL := true
    if kE > inUpper
        t.resetS := true
    // ── manage an open trade (stop first)
    if t.dir != 0
        int d = t.dir
        bool stopHit = d == 1 ? low <= t.sl : high >= t.sl
        bool useLadder = inExitMode != "Band cross back only"
        bool useBand   = inExitMode != "Stop / targets only"
        if useLadder and stopHit
            closeTrade(t.sl, (inTrailStop != "Off" or (inTrail and t.tp1Hit)) and t.sl != t.entry and t.sl != t.sl0 ? "TRAIL" : t.tp1Hit ? "BE" : "SL")
        else if useLadder and (d == 1 ? high >= t.tp2 : low <= t.tp2)
            closeTrade(t.tp2, "TP2")
        else
            if useLadder and not t.tp1Hit and (d == 1 ? high >= t.tp1 : low <= t.tp1)
                t.tp1Hit := true
                ev.tp1 := true
                t.sl := t.entry
            if inTrail and t.tp1Hit
                float trailPx = d == 1 ? close - inTrailAtr * atrU : close + inTrailAtr * atrU
                t.sl := d == 1 ? math.max(t.sl, trailPx) : math.min(t.sl, trailPx)
            if inTrailStop != "Off" and not na(trail) and (inTrailStop == "From entry" or t.tp1Hit)
                if trailDir == d
                    t.sl := d == 1 ? math.max(t.sl, trail) : math.min(t.sl, trail)
                else if bar_index - t.bar >= 1
                    closeTrade(close, "TRAIL")
            if useBand and (d == 1 ? backDn : backUp)
                closeTrade(close, "BAND")
            else if inOppExit and (d == 1 ? crossDn : crossUp)
                closeTrade(close, "FLIP")
            else if inTimeStop > 0 and not t.tp1Hit and bar_index - t.bar >= inTimeStop
                closeTrade(close, "TIME")
    // ── signals (only when flat)
    if t.dir == 0
        bool cool = bar_index - t.lastSig >= inCooldown
        bool okL = crossUp and trendUp and not stretchedUp and gateL and cool and (not inMustReset or t.resetL) and inSess and htfUp
        bool okS = crossDn and trendDn and not stretchedDn and gateS and cool and (not inMustReset or t.resetS) and inSess and htfDn
        if okL
            openTrade(1)
            t.resetL := false
            ev.sigL := true
        else if okS
            openTrade(-1)
            t.resetS := false
            ev.sigS := true

// ═══════════════════════════════════════════════════════════════════ READINESS (display only — the four steps of the rule, in order)
int  side  = t.dir != 0 ? t.dir : trendUp ? 1 : trendDn ? -1 : (close > ema200 ? 1 : -1)
bool s1    = side == 1 ? trendUp : trendDn
bool s2    = side == 1 ? not stretchedUp : not stretchedDn
bool s3    = not inMustReset or (side == 1 ? t.resetL : t.resetS)
bool s4    = side == 1 ? kE > inUpper : kE < inLower           // the line is on the pop side (the cross is the event)
bool s5    = side == 1 ? gateL : gateS
bool gateOn = inGate != "Trend + POP only"
bool s6    = side == 1 ? htfUp : htfDn
bool s7    = inSess
int  ready = (s1 ? 1 : 0) + (s2 ? 1 : 0) + (s3 ? 1 : 0) + (s4 ? 1 : 0) + (gateOn and s5 ? 1 : 0) + (inHtfOn and s6 ? 1 : 0) + (inSessOn and s7 ? 1 : 0)
int  readyMax = 4 + (gateOn ? 1 : 0) + (inHtfOn ? 1 : 0) + (inSessOn ? 1 : 0)
bool allPre = s1 and s2 and s3 and (not gateOn or s5) and (not inHtfOn or s6) and (not inSessOn or s7)
string sideWord = side == 1 ? "long" : "short"
string erTxt = "efficiency " + str.tostring(er * 100, "0") + " % over " + str.tostring(inRangeLen) + " bars" + (sideways ? ", the choppiest " + str.tostring(inRangePct) + " % of recent bars" : "")
string s1Base = side == 1 ? (close > ema200 ? (emaRising ? "above a rising EMA 200" : "above the EMA, but it is not rising") : "price is below the EMA 200") : (close < ema200 ? (emaFalling ? "below a falling EMA 200" : "below the EMA, but it is not falling") : "price is above the EMA 200")
string s1Txt = rangeBlock and (side == 1 ? trendUpRaw : trendDnRaw) ? "sideways — " + erTxt + " · signals skipped" : s1Base + (sideways ? " · sideways (" + erTxt + ")" : "")
string s2Txt = str.tostring(math.abs(distAtr), "0.0") + " ATR from the EMA" + (s2 ? " (≤ " + str.tostring(inMaxDist, "0.0") + ")" : " — too far, > " + str.tostring(inMaxDist, "0.0"))
string s3Txt = not inMustReset ? "not required" : (s3 ? "armed — the line visited " + str.tostring(side == 1 ? inLower : inUpper, "0") : "spent — needs a dip " + (side == 1 ? "below " + str.tostring(inLower, "0") : "above " + str.tostring(inUpper, "0")))
string s4Txt = "line " + str.tostring(kE, "0.0") + (s4 ? " · on the pop side" : (side == 1 ? " · waiting for a cross above " + str.tostring(inUpper, "0") : " · waiting for a cross below " + str.tostring(inLower, "0")))
string s5Txt = str.tostring(side == 1 ? agCountL : agCountS) + " / " + str.tostring(agTotal) + (inGate == "Trend + POP only" ? " agree · context only" : s5 ? " agree · gate passed" : " agree · gate blocks")
string missing = t.dir != 0 ? "in a trade" : allPre ? (s4 ? "nothing — a fresh cross is the trigger" : "only the cross itself") : (not s1 ? (rangeBlock ? "step 1 (sideways)" : "step 1 (trend)") : "") + (not s2 ? (not s1 ? " · " : "") + "step 2 (stretched)" : "") + (not s3 ? ((not s1 or not s2) ? " · " : "") + "step 3 (fresh pop)" : "") + (gateOn and not s5 ? ((not s1 or not s2 or not s3) ? " · " : "") + "agreements" : "") + (inHtfOn and not s6 ? " · HTF disagrees" : "") + (inSessOn and not s7 ? " · outside the sessions" : "")

// ── trend trail (display + optional stop): a chandelier-style trailing line that flips ONLY when price closes through
//    it — a pullback that hugs the EMA 200 does not flip it, so a range does not zig-zag the line. It seeds from the flip
//    bar's own extreme (always on the right side of price) and ratchets from the extreme reached since the flip.
//    Colour: full = the EMA 200 slope confirms the side (the rule's regime); muted = the slope does not confirm yet;
//    grey = the sideways detector is on.
var float regExt = na
if trailDir == 0
    if not na(atrU) and not na(ema200)
        trailDir := close > ema200 ? 1 : -1
        regExt   := trailDir == 1 ? high : low
        trail    := trailDir == 1 ? regExt - inTrailMult2 * atrU : regExt + inTrailMult2 * atrU
else if trailDir == 1
    if close < trail
        trailDir := -1
        regExt   := low
        trail    := low + inTrailMult2 * atrU
    else
        regExt := math.max(nz(regExt, high), high)
        float candU = regExt - inTrailMult2 * atrU
        trail  := math.max(nz(trail, candU), candU)
else
    if close > trail
        trailDir := 1
        regExt   := high
        trail    := high - inTrailMult2 * atrU
    else
        regExt := math.min(nz(regExt, low), low)
        float candD = regExt + inTrailMult2 * atrU
        trail  := math.min(nz(trail, candD), candD)
bool trailStrong = trailDir == 1 ? emaRising : emaFalling

// ═══════════════════════════════════════════════════════════════════ PANE · the stochastic
color kCol = color.from_gradient(kE, inLower, inUpper, th.down, th.up)
pU = plot(inUpper, "Pop band", color = color.new(th.up, 55), linewidth = 1, style = plot.style_circles, display = display.pane)
pL = plot(inLower, "Drop band", color = color.new(th.down, 55), linewidth = 1, style = plot.style_circles, display = display.pane)
plot(50, "Midline", color = color.new(th.flat, 70), linewidth = 1, style = plot.style_circles, display = display.pane)
fill(pU, pL, color = color.new(th.flat, 92), title = "Neutral band")
pKE  = plot(kE, "Stochastic · EMA", color = kCol, linewidth = 2)
pMid = plot(50, "Midline anchor", color = na, display = display.none)
fill(pKE, pMid, top_value = math.max(kE, 50), bottom_value = math.min(kE, 50),
     top_color = kE >= 50 ? color.new(th.up, 78) : color.new(th.down, 97), bottom_color = kE >= 50 ? color.new(th.up, 97) : color.new(th.down, 78), title = "Momentum fill")
bool popZoneL = kE > inUpper and trendUp
bool popZoneS = kE < inLower and trendDn
bgcolor(inShade and popZoneL ? color.new(th.up, 93) : inShade and popZoneS ? color.new(th.down, 93) : na, title = "Pop / drop state")

// ═══════════════════════════════════════════════════════════════════ CHART · EMA, band, signals, trade levels (force_overlay)
color emaCol = emaRising ? th.up : emaFalling ? th.down : th.flat
plot(ema200, "EMA 200", color = color.new(emaCol, 35), linewidth = 1, offset = inEmaOff, force_overlay = true, display = display.pane)
color trailBase = trailDir == 1 ? th.up : th.down
color trailCol  = sideways ? color.from_gradient(0.75, 0.0, 1.0, trailBase, th.flat) : trailStrong ? trailBase : color.from_gradient(0.45, 0.0, 1.0, th.flat, trailBase)
float trailUp = inTrendTrail and trailDir == 1 ? trail : na
float trailDn = inTrendTrail and trailDir == -1 ? trail : na
plot(trailUp, "Trend trail · up glow", color = color.new(trailCol, 84), linewidth = 6, style = plot.style_linebr, force_overlay = true, display = display.pane)
plot(trailDn, "Trend trail · down glow", color = color.new(trailCol, 84), linewidth = 6, style = plot.style_linebr, force_overlay = true, display = display.pane)
pTrailUp = plot(trailUp, "Trend trail · up", color = color.new(trailCol, 0), linewidth = 2, style = plot.style_linebr, force_overlay = true, display = display.pane)
pTrailDn = plot(trailDn, "Trend trail · down", color = color.new(trailCol, 0), linewidth = 2, style = plot.style_linebr, force_overlay = true, display = display.pane)
pPx      = plot(inTrendTrail ? close : na, "Trend fill anchor", color = color.new(th.bg, 100), force_overlay = true, display = display.none)
// gradient: dense at the trail line, fading toward price; lighter while sideways
int fillDense = sideways ? 90 : trailStrong ? 72 : 84
fill(pTrailUp, pPx, top_value = not na(trailUp) ? math.max(trail, close) : na, bottom_value = not na(trailUp) ? math.min(trail, close) : na,
     top_color = color.new(trailCol, 97), bottom_color = color.new(trailCol, fillDense), title = "Trend fill · up")
fill(pTrailDn, pPx, top_value = not na(trailDn) ? math.max(trail, close) : na, bottom_value = not na(trailDn) ? math.min(trail, close) : na,
     top_color = color.new(trailCol, fillDense), bottom_color = color.new(trailCol, 97), title = "Trend fill · down")
plot(inShowSm and inSmType != "None" ? emaSm : na, "EMA 200 · smoothing", color = color.new(th.accent, 40), linewidth = 1, style = plot.style_circles, offset = inEmaOff, force_overlay = true, display = display.pane)
plot(inShowBand ? ema200 + inMaxDist * atrU : na, "Stretch band · top", color = color.new(emaCol, 84), linewidth = 1, force_overlay = true, display = display.pane)
plot(inShowBand ? ema200 - inMaxDist * atrU : na, "Stretch band · bottom", color = color.new(emaCol, 84), linewidth = 1, force_overlay = true, display = display.pane)
plotshape(ev.sigL, "POP long", shape.triangleup, location.belowbar, color = th.up, size = size.small, force_overlay = true, display = display.pane)
plotshape(ev.sigS, "DROP short", shape.triangledown, location.abovebar, color = th.down, size = size.small, force_overlay = true, display = display.pane)
bool live = t.dir != 0 or ev.exit
plot(inPlan and live ? t.entry : na, "Trade · entry", color = color.new(th.txtHi, 15), linewidth = 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
plot(inPlan and live ? t.sl : na, "Trade · stop", color = color.new(th.down, 15), linewidth = 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
plot(inPlan and live ? t.tp1 : na, "Trade · TP1", color = color.new(th.up, 55), linewidth = 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
plot(inPlan and live ? t.tp2 : na, "Trade · TP2", color = color.new(th.up, 15), linewidth = 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
color regCol = trendUp ? th.up : trendDn ? th.down : th.flat
barcolor(inTint and t.dir != 0 ? color.new(th.accentHi, 15) : inCandleTrend ? (trendUp or trendDn ? color.new(regCol, 30) : color.new(th.flat, 35)) : na, title = "Trend candles / trade tint")

// gradient risk / reward zones: four bands each, densest at the entry, extended while the trade is open
var array<box> zones = array.new<box>()
var array<box> liveZ = array.new<box>()
gradBand(float top, float bot, color c, int x1, int x2, bool denseTop) =>
    float h = (top - bot) / 4
    for k = 0 to 3
        float bTop = top - h * k
        float bBot = bTop - h
        int alpha = denseTop ? 78 + k * 5 : 93 - k * 5
        box b = box.new(x1, bTop, x2, bBot, border_color = color.new(c, 100), bgcolor = color.new(c, alpha), force_overlay = true)
        array.push(zones, b)
        array.push(liveZ, b)
if barstate.isconfirmed
    if (ev.sigL or ev.sigS) and inZones and inPlan
        array.clear(liveZ)
        int d = t.dir
        // risk: entry → stop (dense at the entry) · reward: entry → TP2 (dense at the entry)
        gradBand(math.max(t.entry, t.sl), math.min(t.entry, t.sl), th.down, bar_index, bar_index + 1, d == 1)
        gradBand(math.max(t.entry, t.tp2), math.min(t.entry, t.tp2), th.up, bar_index, bar_index + 1, d == -1)
        while array.size(zones) > 96
            box.delete(array.shift(zones))
    else if (t.dir != 0 or ev.exit) and array.size(liveZ) > 0
        for b in liveZ
            box.set_right(b, bar_index)

// event labels on the chart
if ev.sigL
    label.new(bar_index, low, "POP ▲  " + fmt(t.entry) + "\nstop " + fmt(t.sl) + " · TP2 " + fmt(t.tp2), style = label.style_label_up, color = color.new(th.up, 12), textcolor = th.bg, size = size.small, force_overlay = true,
         tooltip = "Long pop: the stochastic EMA crossed above " + str.tostring(inUpper, "0") + " with price above a rising EMA 200 and within " + str.tostring(inMaxDist, "0.0") + " ATR of it.\nentry " + fmt(t.entry) + " · stop " + fmt(t.sl) + " (" + fmt(t.r) + " = 1R)\nTP1 " + fmt(t.tp1) + " → breakeven · TP2 " + fmt(t.tp2))
if ev.sigS
    label.new(bar_index, high, "DROP ▼  " + fmt(t.entry) + "\nstop " + fmt(t.sl) + " · TP2 " + fmt(t.tp2), style = label.style_label_down, color = color.new(th.down, 12), textcolor = th.bg, size = size.small, force_overlay = true,
         tooltip = "Short drop: the stochastic EMA crossed below " + str.tostring(inLower, "0") + " with price below a falling EMA 200 and within " + str.tostring(inMaxDist, "0.0") + " ATR of it.\nentry " + fmt(t.entry) + " · stop " + fmt(t.sl) + " (" + fmt(t.r) + " = 1R)\nTP1 " + fmt(t.tp1) + " → breakeven · TP2 " + fmt(t.tp2))
if ev.exit
    string rTxt = (ev.r >= 0 ? "+" : "") + str.tostring(ev.r, "0.0") + "R"
    string whyTxt = ev.why == "BAND" ? "momentum exit" : ev.why == "TP2" ? "target" : ev.why == "BE" ? "breakeven" : "stop"
    label.new(bar_index, ev.dir == 1 ? high : low, (ev.dir == 1 ? "exit long · " : "exit short · ") + whyTxt + " " + rTxt, style = ev.dir == 1 ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 15), textcolor = ev.r >= 0 ? th.up : th.down, size = size.tiny, force_overlay = true)

// ═══════════════════════════════════════════════════════════════════ WORDS (one source of truth)
string stateWord = t.dir == 1 ? "LONG OPEN" : t.dir == -1 ? "SHORT OPEN" : (side == 1 ? "FLAT · long side" : "FLAT · short side") + (sideways ? " · sideways" : "")
color  stateCol  = t.dir == 1 ? th.up : t.dir == -1 ? th.down : s1 ? (side == 1 ? th.up : th.down) : th.flat
float  openR     = t.dir != 0 ? (close - t.entry) * t.dir / t.r : na
string chipTxt   = t.dir != 0 ? (openR >= 0 ? "+" : "") + str.tostring(openR, "0.0") + "R now" + (t.tp1Hit ? " · stop at breakeven" : "") : allPre ? (s4 ? "set · on the pop side" : "set · waiting for the cross") : str.tostring(ready) + "/" + str.tostring(readyMax) + " steps true"

// ═══════════════════════════════════════════════════════════════════ DASHBOARD (force_overlay)
posOf(string p) =>
    p == "Top Left" ? position.top_left : p == "Bottom Right" ? position.bottom_right : p == "Bottom Left" ? position.bottom_left : p == "Middle Right" ? position.middle_right : p == "Middle Left" ? position.middle_left : position.top_right
szLead() => inSize == "Small" ? size.small : inSize == "Large" ? size.large  : size.normal
szBody() => inSize == "Small" ? size.tiny  : inSize == "Large" ? size.normal : size.small
szSub()  => inSize == "Small" ? size.tiny  : inSize == "Large" ? size.small  : size.tiny
row3(table tb, int r, string k, bool ok, string why, bool showMark) =>
    color stripe = r % 2 == 1 ? color.new(th.accent, 94) : th.bg
    table.cell(tb, 0, r, k, text_color = th.flat, text_size = szBody(), text_halign = text.align_left, bgcolor = stripe)
    table.cell(tb, 1, r, showMark ? (ok ? "✓" : "✗") : "", text_color = ok ? th.up : th.down, text_size = szBody(), text_halign = text.align_center, bgcolor = stripe)
    table.cell(tb, 2, r, why, text_color = ok ? th.txt : th.txtHi, text_size = szBody(), text_halign = text.align_left, text_font_family = font.family_monospace, bgcolor = stripe)
section(table tb, int r, string title) =>
    table.cell(tb, 0, r, title, text_color = th.accent, text_size = szSub(), text_halign = text.align_left, bgcolor = color.new(th.accent, 90))
    table.cell(tb, 1, r, "", bgcolor = color.new(th.accent, 90))
    table.cell(tb, 2, r, "", bgcolor = color.new(th.accent, 90))
    table.merge_cells(tb, 0, r, 2, r)

var table dash = table.new(posOf(inDashPos), 3, 20, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_width = 0, force_overlay = true)
if barstate.islast and inDash
    table.clear(dash, 0, 0, 2, 19)
    int r = 0
    // header
    table.cell(dash, 0, r, "STOCHASTIC POP DESK", text_color = th.accentHi, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "EMA 200 trend · Stochastic POP (Method 2, EMA 4 on the line) · the four steps of the rule, in order. Everything commits on the closed bar.")
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    table.cell(dash, 2, r, syminfo.ticker + " · " + timeframe.period, text_color = th.accent, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg)
    r += 1
    // state + chip
    table.cell(dash, 0, r, (t.dir == 1 ? "▲ " : t.dir == -1 ? "▼ " : "● ") + stateWord, text_color = stateCol, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "The open paper trade, or the side the EMA 200 allows right now.")
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    color chipBg = t.dir != 0 ? color.new(openR >= 0 ? th.up : th.down, 75) : color.new(color.from_gradient(ready, 0, readyMax, th.down, th.up), 75)
    table.cell(dash, 2, r, chipTxt, text_color = th.txtHi, text_size = szBody(), text_halign = text.align_right, bgcolor = chipBg, tooltip = t.dir != 0 ? "Open R of the paper trade at this close." : "How many of the listed steps are true right now. 'Set' = everything but the cross itself; the cross is the trigger and must be fresh.")
    r += 1
    if t.dir == 0
        // readiness — the rule in order
        section(dash, r, "READINESS FOR A " + str.upper(sideWord) + " · " + str.tostring(ready) + " OF " + str.tostring(readyMax) + " STEPS")
        r += 1
        row3(dash, r, "1 · Trend", s1, s1Txt, true)
        r += 1
        row3(dash, r, "2 · Not stretched", s2, s2Txt, true)
        r += 1
        row3(dash, r, "3 · Fresh pop", s3, s3Txt, true)
        r += 1
        row3(dash, r, "4 · Cross " + str.tostring(side == 1 ? inUpper : inLower, "0"), s4, s4Txt, true)
        r += 1
        if gateOn
            row3(dash, r, "5 · Agreements", s5, s5Txt, true)
            r += 1
        if inHtfOn
            row3(dash, r, "HTF " + inHtf, s6, htfOk ? (htfClose > htfEma ? "price above the HTF EMA" : "price below the HTF EMA") : "HTF must be higher than the chart", true)
            r += 1
        if inSessOn
            row3(dash, r, "Session", s7, s7 ? "inside " + inSess1 + " / " + inSess2 : "outside the sessions (" + inSessTz + ")", true)
            r += 1
        row3(dash, r, "Missing", allPre, missing, false)
        r += 1
    else
        section(dash, r, "TRADE · " + (t.dir == 1 ? "LONG" : "SHORT") + " · " + str.tostring(bar_index - t.bar) + " BARS")
        r += 1
        row3(dash, r, "Entry", true, fmt(t.entry) + " · 1R = " + fmt(t.r), false)
        r += 1
        row3(dash, r, "Stop", true, fmt(t.sl) + (inTrailStop != "Off" and t.sl != t.sl0 and t.sl != t.entry ? " · following the trail" : t.tp1Hit ? " · breakeven" : inStopMode == "ATR" ? " · ATR stop" : " · swing"), false)
        r += 1
        row3(dash, r, "Targets", t.tp1Hit, "TP1 " + fmt(t.tp1) + (t.tp1Hit ? " ✓" : "") + " · TP2 " + fmt(t.tp2), false)
        r += 1
        row3(dash, r, "Exit rule", true, (inExitMode == "Stop / targets only" ? "stop or TP2" : (t.dir == 1 ? "line back below " + str.tostring(inUpper, "0") : "line back above " + str.tostring(inLower, "0")) + (inExitMode == "Band cross back only" ? "" : " · or TP2 / stop")) + (inTrail ? " · trail " + str.tostring(inTrailAtr, "0.0") + " ATR" : "") + (inTrailStop != "Off" ? " · stop = trend trail" + (inTrailStop == "After TP1" ? " after TP1" : "") : "") + (inOppExit ? " · flip" : "") + (inTimeStop > 0 ? " · time " + str.tostring(inTimeStop) : ""), false)
        r += 1
        row3(dash, r, "Stochastic", s4, str.tostring(kE, "0.0") + (s4 ? " · momentum intact" : " · momentum fading"), true)
        r += 1
    if inShowCtx
        section(dash, r, "CONTEXT · " + str.tostring(side == 1 ? agCountL : agCountS) + " OF " + str.tostring(agTotal) + " AGREE" + (inGate == "Trend + POP only" ? "" : " · GATE"))
        r += 1
        if inUseStack
            row3(dash, r, "MA stack", side == 1 ? agStackL : agStackS, str.tostring(stackUp) + "/5 EMAs below price (5·10·20·100·200)", true)
            r += 1
        if inUse821
            row3(dash, r, "EMA 8 / 21", side == 1 ? ag821L : ag821S, ema8 > ema21 ? "8 above 21" : "8 below 21", true)
            r += 1
        if inUseRsi
            row3(dash, r, "RSI " + str.tostring(inRsiLen), side == 1 ? agRsiL : agRsiS, str.tostring(rsi, "0.0") + (rsi > 50 ? " · above 50" : " · below 50"), true)
            r += 1
        if inUseVol
            row3(dash, r, "Volume", agVol, volMa > 0 ? str.tostring(nz(volume, 0.0) / volMa, "0.0") + "× its " + str.tostring(inVolLen) + "-bar average" : "n/a", true)
            r += 1
    section(dash, r, "RECORD · PAPER, NO COSTS")
    r += 1
    row3(dash, r, "Closed", st.n >= 5, st.n < 5 ? str.tostring(st.n) + " trades · too few to read" : str.tostring(st.n) + " · " + str.tostring(math.round(st.wins / st.n * 100)) + "% win" + (st.flat > 0 ? " · " + str.tostring(st.flat) + " flat" : "") + " · avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R", false)
    r += 1
    row3(dash, r, "How they ended", true, "band " + str.tostring(st.nBand) + " · stop " + str.tostring(st.nStop) + " · TP2 " + str.tostring(st.nTp) + (na(t.lastR) ? "" : " · last " + (t.lastR >= 0 ? "+" : "") + str.tostring(t.lastR, "0.0") + "R"), false)
    r += 1
    table.cell(dash, 0, r, "EMA " + str.tostring(inEmaLen) + " · stoch " + str.tostring(inStLen) + "/" + str.tostring(inStSmooth) + " · EMA " + str.tostring(inStEma) + " · " + str.tostring(inUpper, "0") + "/" + str.tostring(inLower, "0"), text_color = th.flat, text_size = szSub(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Your settings, as the thread listed them.")
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    string sw = (inRangeBlock ? "range-skip · " : "") + (inTrail ? "trail · " : "") + (inTrailStop != "Off" ? "trail-stop · " : "") + (inSessOn ? "session · " : "") + (inTimeStop > 0 ? "time · " : "") + (inOppExit ? "flip · " : "") + (inHtfOn ? "HTF " + inHtf + " · " : "")
    table.cell(dash, 2, r, (sw == "" ? "no switches · " : sw) + (inGate == "Trend + POP only" ? "classic rule" : inGate == "Trend + POP + all agreements" ? "strict gate" : "majority gate"), text_color = th.flat, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg, tooltip = "Active switches and the signal gate mode. ATR " + fmt(atr) + " is the unit for stops and the stretch band.")

// ═══════════════════════════════════════════════════════════════════ NARRATIVE · Co-Pilot (translates state; never a new signal)
wrap(string sIn, int width) =>
    array<string> words = str.split(sIn, " ")
    string out  = ""
    string line = ""
    for wd in words
        if line != "" and str.length(line) + str.length(wd) + 1 > width
            out  := out + (out == "" ? "" : "\n") + line
            line := wd
        else
            line := line == "" ? wd : line + " " + wd
    out == "" ? line : out + "\n" + line

narrNow() =>
    string o = ""
    if t.dir != 0
        o := "in a " + (t.dir == 1 ? "long" : "short") + " from " + fmt(t.entry) + " for " + str.tostring(bar_index - t.bar) + " bars, " + (openR >= 0 ? "+" : "") + str.tostring(openR, "0.0") + "R at this close — stop " + fmt(t.sl) + (t.tp1Hit ? " (moved to breakeven after TP1)" : "") + ", the stochastic line is at " + str.tostring(kE, "0.0") + (s4 ? " so momentum is still on the trade's side" : " — momentum is fading toward the band")
    else
        o := "flat — " + (s1 ? "the EMA 200 allows the " + sideWord + " side (" + s1Txt + ")" : "no clean side yet (" + s1Txt + ")") + (sideways ? "; the last " + str.tostring(inRangeLen) + " bars are a range (" + erTxt + ")" + (rangeBlock ? ", so signals are skipped" : " — pops inside a range are the weakest kind") : "") + "; the stochastic line is at " + str.tostring(kE, "0.0") + (s4 ? ", already on the pop side, " : ", ") + (allPre ? (s4 ? "every condition is true — the next fresh cross fires" : "every condition is true — the cross is the only thing left") : "and what is still false: " + missing)
    o
narrWhy() =>
    "Bernstein's Stochastic Pop, made mechanical: the side comes from a rising / falling EMA " + str.tostring(inEmaLen) + ", the entry from the EMA-" + str.tostring(inStEma) + " stochastic crossing " + str.tostring(inUpper, "0") + " / " + str.tostring(inLower, "0") + ", no pops that start more than " + str.tostring(inMaxDist, "0.0") + " ATR from the EMA, fresh pops only. The four dashboard steps are that rule in order."
narrPlan() =>
    string o = ""
    if t.dir != 0
        o := "Stop first on every closed bar. " + (inExitMode == "Stop / targets only" ? "" : "If the line crosses back " + (t.dir == 1 ? "below " + str.tostring(inUpper, "0") : "above " + str.tostring(inLower, "0")) + ", the momentum exit closes the trade at that close. ") + (inExitMode == "Band cross back only" ? "" : "TP1 " + fmt(t.tp1) + (inTrail ? " starts a " + str.tostring(inTrailAtr, "0.0") + " ATR trailing stop" : " moves the stop to breakeven") + "; TP2 " + fmt(t.tp2) + " is the final target. ") + (inOppExit ? "An opposite cross closes it. " : "") + (inTimeStop > 0 ? "If TP1 is not reached within " + str.tostring(inTimeStop) + " bars, it closes on time. " : "") + "The idea is wrong beyond " + fmt(t.sl0) + "."
    else
        o := "A " + sideWord + " fires when " + (s1 ? "" : "price gets back on the right side of a " + (side == 1 ? "rising" : "falling") + " EMA 200, ") + (s2 ? "" : "price comes back within " + str.tostring(inMaxDist, "0.0") + " ATR of the EMA, ") + (s3 ? "" : "the line dips " + (side == 1 ? "below " + str.tostring(inLower, "0") : "above " + str.tostring(inUpper, "0")) + " to re-arm, ") + (inHtfOn and not s6 ? "the " + inHtf + " chart gets on the same side of its EMA, " : "") + (inSessOn and not s7 ? "a session opens, " : "") + "the line crosses " + (side == 1 ? "above " + str.tostring(inUpper, "0") : "below " + str.tostring(inLower, "0")) + " on a closed bar. Then: entry at that close, stop at the " + (inStopMode == "ATR" ? str.tostring(inStopAtr, "0.0") + " ATR distance" : str.tostring(inSwingN) + "-bar swing " + (side == 1 ? "low" : "high") + " minus a buffer") + ", TP1 " + str.tostring(inTp1, "0.0") + "R to breakeven, TP2 " + str.tostring(inTp2, "0.0") + "R" + (inExitMode == "Stop / targets only" ? "." : ", exit early if the line crosses back through its band.")
    o
narrTrack() =>
    st.n < 5 ? "Too few closed trades on this chart to say anything yet." : str.tostring(st.n) + " paper trades closed here: " + str.tostring(math.round(st.wins / st.n * 100)) + "% won, avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R; " + str.tostring(st.nBand) + " ended on the momentum exit, " + str.tostring(st.nStop) + " on the stop / breakeven, " + str.tostring(st.nTp) + " at TP2. No costs, small sample — a description of the past on THIS chart, not an edge."
narrPosOf(string p) =>
    p == "Top Left" ? position.top_left : p == "Top Right" ? position.top_right : p == "Bottom Right" ? position.bottom_right : p == "Middle Left" ? position.middle_left : p == "Middle Right" ? position.middle_right : position.bottom_left
narrBodySz() => inNarrSize == "Small" ? size.small : inNarrSize == "Medium" ? size.normal : inNarrSize == "Large" ? size.large : szBody()
narrHeadSz() => inNarrSize == "Small" ? size.normal : inNarrSize == "Medium" ? size.large : inNarrSize == "Large" ? size.large : szLead()

var table narrT = na
if barstate.islast
    if not na(narrT)
        table.delete(narrT)
        narrT := na
    if inNarr != "Off"
        array<string> labs = array.new<string>()
        array<string> vals = array.new<string>()
        array.push(labs, "NOW")
        array.push(vals, wrap(narrNow() + " (decision support, not a trade instruction)", inNarrWrap))
        bool deep = inNarr == "Standard" or inNarr == "Detailed"
        if deep
            array.push(labs, "WHY")
            array.push(vals, wrap(narrWhy(), inNarrWrap))
            array.push(labs, "PLAN")
            array.push(vals, wrap(narrPlan(), inNarrWrap))
        if inNarr == "Detailed"
            array.push(labs, "TRACK REC")
            array.push(vals, wrap(narrTrack(), inNarrWrap))
            array.push(labs, "GLOSSARY")
            array.push(vals, wrap("Pop = the stochastic line breaking above " + str.tostring(inUpper, "0") + " — momentum arriving. Drop = breaking below " + str.tostring(inLower, "0") + ". Fresh pop = the line has visited the other band since the last signal. Stretched = price too far from the EMA 200 to be a fair entry. Momentum exit = the line crossing back through its band.", inNarrWrap))
        int nr = array.size(labs)
        bool atBottom = inNarrPos == "Bottom Left" or inNarrPos == "Bottom Right"
        int rows = nr + 2 + (atBottom ? 1 : 0)
        narrT := table.new(narrPosOf(inNarrPos), 2, rows, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_color = color.new(th.frame, 70), border_width = 1, force_overlay = true)
        table.cell(narrT, 0, 0, "◆ CO-PILOT", text_color = th.accentHi, bgcolor = color.new(th.accent, 88), text_size = narrHeadSz(), text_halign = text.align_left)
        table.cell(narrT, 1, 0, stateWord + " · " + str.upper(inNarr), text_color = th.accent, bgcolor = color.new(th.accent, 88), text_size = narrBodySz(), text_halign = text.align_right)
        for i = 0 to nr - 1
            table.cell(narrT, 0, i + 1, array.get(labs, i), text_color = th.accent, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left, text_valign = text.align_top)
            table.cell(narrT, 1, i + 1, array.get(vals, i), text_color = th.txt, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left, text_valign = text.align_top)
        table.cell(narrT, 0, nr + 1, "", bgcolor = th.bg)
        table.cell(narrT, 1, nr + 1, "Honest: levels are geometry, not promises. The record has no costs — measure before you trust it.", text_color = th.flat, bgcolor = th.bg, text_size = narrBodySz(), text_halign = text.align_left)
        if atBottom
            table.cell(narrT, 0, nr + 2, " ", bgcolor = th.bg)
            table.cell(narrT, 1, nr + 2, " ", bgcolor = th.bg)

// ═══════════════════════════════════════════════════════════════════ STRATEGY BRIDGE (real orders mirror the paper ledger; the engine above is byte-identical to the indicator)
// ORDER MATTERS: cancel_all() cancels every pending order placed this bar — a close_all() issued before it would be cancelled too.
if ev.exit
    strategy.cancel_all()
    if strategy.position_size != 0
        strategy.close_all(comment = ev.why)
if ev.sigL
    strategy.entry("L", strategy.long, comment = "POP")
if ev.sigS
    strategy.entry("S", strategy.short, comment = "DROP")
if t.dir != 0 and strategy.position_size != 0 and inExitMode != "Band cross back only"
    strategy.exit("X", stop = t.sl, limit = t.tp2, comment_profit = "TP2", comment_loss = t.tp1Hit ? "BE" : "SL")
// SYNC guard: if the emulator holds a position the ledger does not know about, flatten at once and tag it.
if strategy.position_size != 0 and t.dir == 0 and not ev.exit and not ev.sigL and not ev.sigS
    strategy.cancel_all()
    strategy.close_all(comment = "SYNC")

// ═══════════════════════════════════════════════════════════════════ ALERTS (confirmed bars only by construction)
payload(string evName, string why) =>
    string js = '{"src":"StochasticPopDesk","symbol":"' + syminfo.ticker + '","tf":"' + timeframe.period + '","event":"' + evName + '","side":"' + (t.dir == 1 or ev.dir == 1 ? "LONG" : "SHORT") + '","entry":' + fmt(nz(t.entry, close)) + ',"sl":' + fmt(nz(t.sl, 0)) + ',"tp1":' + fmt(nz(t.tp1, 0)) + ',"tp2":' + fmt(nz(t.tp2, 0)) + ',"stoch":' + str.tostring(kE, "0.0") + ',"ema200":' + fmt(ema200) + ',"reason":"' + why + '"' + (na(ev.r) ? "" : ',"r":' + str.tostring(ev.r, "0.00")) + '}'
    string tx = "Stochastic Pop Desk · " + syminfo.ticker + " " + timeframe.period + " · " + evName + " · " + (t.dir == 1 or ev.dir == 1 ? "LONG" : "SHORT") + " · entry " + fmt(nz(t.entry, close)) + " · SL " + fmt(nz(t.sl, 0)) + " · TP " + fmt(nz(t.tp1, 0)) + " / " + fmt(nz(t.tp2, 0)) + (why != "" ? " · " + why : "")
    inAlertJson ? js : tx
alertcondition(ev.sigL, "POP long", "Stochastic Pop Desk: long pop — stochastic crossed above the pop band with price above a rising EMA 200")
alertcondition(ev.sigS, "DROP short", "Stochastic Pop Desk: short drop — stochastic crossed below the drop band with price below a falling EMA 200")
alertcondition(ev.tp1, "TP1 reached", "Stochastic Pop Desk: TP1 reached — stop to breakeven")
alertcondition(ev.exit and ev.why == "BAND", "Momentum exit", "Stochastic Pop Desk: the stochastic crossed back through its band — exit")
alertcondition(ev.exit and ev.why == "TP2", "TP2 reached", "Stochastic Pop Desk: final target reached")
alertcondition(ev.exit and (ev.why == "SL" or ev.why == "BE"), "Stopped", "Stochastic Pop Desk: stop hit")
alertcondition(t.dir == 0 and allPre and not s4, "Ready — waiting for the cross", "Stochastic Pop Desk: every condition but the cross is true — the next stochastic cross fires a signal")
alertcondition(trailDir != trailDir[1] and trailDir[1] != 0, "Trend flipped", "Stochastic Pop Desk: the trend trail flipped to the other side of price")
if ev.sigL
    alert(payload("POP", ""), alert.freq_once_per_bar_close)
if ev.sigS
    alert(payload("DROP", ""), alert.freq_once_per_bar_close)
if ev.tp1
    alert(payload("TP1", ""), alert.freq_once_per_bar_close)
if ev.exit
    alert(payload("EXIT", ev.why), alert.freq_once_per_bar_close)
Download the strategy .pine file
No signal yet?

Allow enough loaded history. Check Missing, the fresh reset and cooldown. A line staying beyond its band is not a new cross.

Go deeper, one question at a time.

Full rules and settings, when you need them.

What qualifies as POP or DROP?

Long: close above EMA 200; EMA above its value ten candles ago; price within 3 ATR of EMA; a new smoothed cross from 55 or below to strictly above 55. ATR measures recent price movement, including gaps.

Fresh pop: after an accepted long, a closed reading strictly below 45 re-arms that side. Short signals mirror the rules: falling EMA, cross below 45, reset above 55. Both reset flags start ready when the script begins.

Wait at least five candles after either side’s previous signal. Entry also needs a closed candle, sufficient history and no open paper trade. It manages an older trade before checking entry; an exit and new entry can share a candle.

Why two chart panels?

Price chart: EMA, trail, tickets, levels and panels. Lower pane: stochastic 14 → SMA 5 → EMA 4. This smoothed line crosses the bands.

The extra SMA 5 drawn beside EMA 200 is a separate, display-only smoothing line. Its type and the EMA drawing offset do not change entry calculations.

How do the stop and targets work?

1R is initial entry-to-stop distance. Default long stop: the lowest low of the last ten candles, including entry, minus 0.2 ATR. It is a rolling extreme, not a confirmed pivot. ATR stop mode instead uses 2 ATR with no extra swing buffer.

  1. Stop firstA touch ends the paper trade, even if a target was touched too.
  2. TP2 nextThe final 2R target closes the trade.
  3. TP1 nextAt 1R, stop moves to entry. No partial profit.

The raised stop is not checked against an earlier wick on that same candle. New entries are created after management, so their entry candle’s earlier wick does not stop them immediately.

Stop / targets only is default. Band cross back only exits a long on a cross back below 55, a short above 45, and disables the stop/target ladder. Band cross back or TP2 keeps the ladder plus that band exit.

What does the colored trail do?

A chandelier-style line tracks price extremes with an ATR offset. It changes side after a qualifying close through the previous trail. Color shows whether EMA slope agrees; sideways can turn it grey. Drawing the trail is on by default; using it as a stop is off.

Two separate options: ATR trailing after TP1 follows the close at a set ATR distance. Stop follows the trend trail uses the chart trail after TP1 or from the first management candle after entry. It reads the previous candle’s trail state to tighten the stop. An opposing prior trail state can also close the trade at the current close, with reason TRAIL, after at least one held candle.

What does sideways mean?

Efficiency compares net progress with total close-to-close travel over twenty candles. The tool ranks that reading against the last two hundred values. Default: flag below the lowest-quarter threshold; release at or above the fortieth-percentile threshold.

Different entry and release thresholds reduce flicker. This is a relative price measurement, not proof of market structure. Blocking new entries while flagged is a separate switch, off by default.

What do the optional filters add?

Signal needs: Trend + POP only is default. Optional agreements can require at least half or all enabled checks. “MA stack” means price above or below four of five EMAs, not their ordering. “EMA 8/21 cross” checks their current ordering, not a new cross. RSI checks its side of 50; volume compares with its average.

Higher timeframe: compares the last closed higher candle’s close with its EMA. Choosing an equal or lower timeframe bypasses that gate. Sessions: restrict new entries to the configured timezone and windows.

Opposite exit: needs an opposite oscillator cross, not a full opposing setup. Time stop: exits after the selected wait only if TP1 has not been reached. These switches all start off.

How do I read the panel and Co-Pilot?

State: trend, sideways or an open trade. Trend / Not stretched / Fresh pop / Cross: the checklist. Missing: its unmet checks. Trade card: entry, current stop, targets, exit rule and momentum. Record: closed paper outcomes without costs.

The Cross tick means the line is currently beyond its band, not that it just crossed. READY excludes cooldown, so the next cross may still be blocked.

Brief Co-Pilot shows NOW. Standard adds WHY and PLAN. Detailed adds the record and glossary. Context rows are optional; showing them does not enable an entry gate.

Stochastic Pop Desk on a historical bitcoin hourly chart with Standard Co-Pilot, optional agreement rows, recent tickets and exit labels.
Historical bitcoin example · Standard Co-Pilot and contextOpen full-size chart ↗
What does each setting change?

All sixty-two inputs, in settings order. Indicator and twin share the rules; the twin initially hides risk zones, dashboard and Co-Pilot.

Trend and distance

The main EMA selects the side. Its extra smoothing line and drawing offset do not change signals.

Main price-average length
200
Price used by the main EMA
Close
Shift the EMA drawings left or right
0 candles
Type of the extra EMA smoothing line
SMA
Length of the extra smoothing line
5
Compare EMA with this many candles ago
10
Maximum entry distance from EMA
ATR × 3

Momentum and a fresh trigger

Stochastic → SMA → EMA. After a signal, that side needs a reset across the opposite band before another fresh cross can qualify.

Price-range lookback for stochastic
14 candles
First smoothing of the stochastic line
SMA 5
Final smoothing before the band check
EMA 4
Long trigger: cross above this band
55
Short trigger: cross below this band
45
Require a reset between same-side signals
On
Minimum candles between accepted signals
5

Optional agreement checks

Context by default. Signal needs can require at least half or all enabled checks; hiding context rows does not change their calculations.

Compare price with five moving averages
On
Compare EMA 8 with EMA 21
On
Check which side of 50 RSI occupies
On
Candles used by RSI
9
Compare volume with its average
On
Candles used by the volume average
20
Candles used to measure typical movement
10
Whether agreements must pass for entry
Trend + POP only

Entry, stop and targets

R is initial entry-to-stop distance. TP1 moves stop to entry without partial profit. Band cross back only turns off the stop/target ladder.

Draw the paper entry, stop and targets
On
Choose the initial stop anchor
Swing low / high
Lookback for the rolling high or low
10 candles
Stop distance when ATR mode is selected
ATR × 2
Extra space beyond the rolling high or low
ATR × 0.2
Milestone that moves stop to entry
1R
Final target that closes the paper trade
2R
Choose ladder, band exit or both
Stop / targets only

Optional filters and exits

All seven switches start off. ATR trail and chart-trail stop are different options. Sessions filter new entries, not session-end exits.

After TP1, let stop follow the close
Off
Distance behind the close after TP1
ATR × 2
Use the chart trail to manage stop
Off
Allow entries only during session windows
Off
Timezone used by session windows
America/New_York
First allowed session window
02:00–05:00
Second allowed session window
08:30–11:00
Maximum waiting time for TP1
0 candles · off
Exit on an opposite oscillator cross
Off
Require higher-timeframe price agreement
Off
Timeframe used for that agreement
240 minutes
Skip entries while sideways is flagged
Off
Candles used by the sideways detector
20
Sideways threshold in the recent ranking
25 · lowest quarter

Chart appearance

Aurora Neon, Royal Gold or Colorblind-safe. The chart trail is visible by default; using it as a stop is separate. Its distance affects that optional stop too.

Choose the color theme
Aurora Neon
Show the extra EMA smoothing line
On
Show allowed distance around EMA
Off
Show the chart trail and fill
On
Distance used by the chart trail
ATR × 3
Shade initial risk and reward areas
On · twin: Off
Highlight candles during an open paper trade
On
Shade the oscillator’s pop or drop phase
Off
Color candles by allowed trend side
Off

Dashboard

The checklist explains readiness; the trade card explains an open plan. The record describes paper outcomes without costs.

Show the dashboard
On · twin: Off
Choose dashboard position
Top Right
Choose dashboard text size
Medium
Show optional agreement rows
Off

Co-Pilot

Brief shows NOW. Standard adds WHY and PLAN. Detailed adds the record and glossary. Off hides the explanation.

Choose the amount of explanation
Brief · twin: Off
Choose explanation position
Bottom Left
Choose explanation text size
Auto
Wrap text after this line width
64 characters

Alerts

Dynamic messages cover POP, DROP, TP1 and EXIT. Choose JSON or readable text; separate Ready and Trend flipped conditions are not included.

Send dynamic messages as JSON
On
Which events can send an alert?

The indicator has eight named conditions: POP long, DROP short, TP1, Momentum exit, TP2, Stopped, Ready and Trend flipped. Stopped covers initial/breakeven stops; a TRAIL exit is separate in the dynamic exit reason.

Any alert() function call sends POP, DROP, TP1 and EXIT as JSON or readable text. It does not send Ready or Trend flipped. The payload includes entry, sl, tp1, tp2, stoch, ema200, reason and optional r.

Committed entries and trade events use closed candles. Ready and Trend flipped conditions are not explicitly close-gated; choose Once Per Bar Close if you want their closed readings. Recreate alerts after changing settings.

How do I test it on my own chart?
  1. Keep defaults and follow the checklist across waiting and entry examples.
  2. Include stopped trades. Read the exit reason, not just the entry label.
  3. Load the twin on a second chart; set realistic size, costs and slippage.
  4. Change one setting, then check a separate period.

Twin code defaults: capital 100,000, quantity 1, commission 0.02 percent per order and five ticks of slippage. The fill emulator can differ from the indicator’s paper ledger.

Stochastic Pop Desk strategy twin's Strategy Tester panel on a historical bitcoin hourly chart, with commission and slippage enabled.
Strategy Tester · historical example with costsOpen full-size chart ↗
What did our measurements show?

We compared exits and switches on three charts with costs on. The band exit cut trades short; band-only and time-stop variants hurt that sample. The stop and target ladder stayed default. Higher-timeframe agreement helped each chart tried, but remains optional.

One window per chart, small in-sample sets. Fast-chart results were around break-even. The lab notes keep all numbers beside their limits; they do not establish future outcomes.

Read the lab notes
What can change the reading?

A live candle can change before close. Different history, feeds, timeframes or inputs can recalculate results. EMA and smoothing respond with a delay; higher-timeframe agreement uses the last closed higher candle.

The record has no costs. The tool does not predict prices or account for news, fills or trade size. Trading risk is yours; this is research tooling, not financial advice.

  • Signal Desk

    Compare momentum-triggered entries with a checklist of trend, structure, location and candle checks.

  • Opening Range Desk

    Study a session-based breakout trigger alongside the stochastic reset-and-cross approach.

Built by ProEA Lab. Compare all free indicators.

05 · THE LOG

Every change, dated

What shipped and what changed in Stochastic Pop Desk, newest first. Ask for the next change below; what gets built lands here with a date, and on the lab queue on the front page.

  1. Stochastic Pop Desk v1.5.0 Shipped

    Checks momentum, trend and timing before an entry.

  2. Three scripts, one checklist Request Stochastic Pop Desk

    The ask it was built from: an EMA 200, the Stochastic Pop with an EMA on the line, and an analysis table, combined.

    1. Received
    2. Building
    3. Done

    An EMA 200 with its smoothing line, the Stochastic Pop with an EMA 4 on the line, and an analysis table. Can these be one script that says what is missing?

    a commenter on the r/pinescript thread, paraphrased · Asked 3 Sep 2026 · Done 4 Sep 2026
  3. Measured on three charts before it shipped Lab note Stochastic Pop Desk

    Run with costs on across three charts. The textbook exit closed trades early; the plain ladder became the default. The fast chart stayed marginal.

BUILT WITH TRADERS

Something to change in Stochastic Pop Desk?

A small friction or a big idea. Tell us what would make this tool work better for you.

What would help?

Describe your rules and what you want the tool to help you see.

Add chart details Optional

For a question about your idea or an update if it ships.

Guides & new tools Optional

Add an email above to choose this.

Show us on your chart Optional

Crop out any account details you don't want to share.

No account needed. Sent privately to the lab.
  1. We read it
  2. We choose what to build
  3. Shipped changes get a date

Requests for a new tool go on the same queue: see what is asked for and what is being built.

© 2026 ProEA · All rights reservedBuilt for MT5 · Not affiliated with MetaQuotes