Opening Range Desk · Free & open-source · MIT

Give the market’s opening move a frame.

Box the opening high and low. Then watch where later candles close.

Draws on the price chart. The indicator does not place trades.

Pastel clay illustration of an opening range taking shape, a price move beyond its edge and a drawn entry, stop and target plan.
Workflow illustration
  1. Box the opening

    Capture the opening high and low.

  2. Wait for confirmation

    Check a breakout or retest; flag a return inside.

  3. Draw the plan

    Entry, stop and target levels.

See the real chart

What is Opening Range Desk?

Opening Range Desk is a free TradingView indicator for opening range breakouts. It draws the opening high and low, checks later candle closes against that range, and displays entry, stop and target rules. A separate strategy script lets you test the configured rules with TradingView's Strategy Tester.

Why is the opening range box missing?

Check that your chart includes the selected opening window and timezone. Five-minute candles fit the default fifteen-minute window; larger candles cannot reconstruct that smaller range. Allow the opening window to finish before expecting a locked box. Read chart setup and installation

Does a wick outside the range count as a breakout?

A wick alone does not meet the default rule. Opening Range Desk requires two consecutive candle closes strictly beyond the range edge and buffer. A close on or inside that boundary resets the count. Read the breakout checks

How does the retest entry differ from a breakout entry?

The retest option first arms after the required breakout closes. A later candle must revisit the edge within the configured tolerance and close beyond the raw edge. The arming candle cannot also complete that retest. Read the retest entry rules

Where does the box come from?

Can price move beyond the opening high or low? First, build those two edges.

Building the opening rangeHigh 104 · Low 101
Keep the first candle’s high and low.The first five-minute candle traded between 101 and 104. These prices start our box. High 104, low 101. Green candles close higher than they open; red candles close lower. The white dot marks the latest close at 103.Opening window → later candle
White dot = close · Thin line = wick
09:35 close · first candle recorded

Keep the first candle’s high and low.

The first five-minute candle traded between 101 and 104. These prices start our box.

Fictional five-minute candles · New York close times. Candles start at 09:30, 09:35, 09:40 and 09:45.

How could this help a trade?

The idea: follow a move beyond the box. Test its entry, stop and targets. Price can reverse after entry; profit depends on what follows, trade size and costs.

Why did we build this tool?

A reader asked for an “ORB strategy.” ORB means opening-range breakout. There are many versions, so we made the window, entry and exit rules editable, with a checklist that explains what is still missing.

Did it close outside—or only visit?

Play or step through. The default needs two consecutive closes beyond an edge.

Building the opening rangeHigh 104 · Low 101
Keep the first candle’s high and low.The first five-minute candle traded between 101 and 104. These prices start our box. High 104, low 101. Green candles close higher than they open; red candles close lower. The white dot marks the latest close at 103.Earlier candles → newest close
White dot = close · Thin line = wick
Default mode · two closes through

Keep the first candle’s high and low.

The first five-minute candle traded between 101 and 104. These prices start our box.

Closes above the box in a row0 / 2

Fictional candles · ATR fixed at 8. Default entries; other checks held satisfied, optional filters and exits off. Shorts mirror the rule below the box.

What is a candle’s close?

The final price when that candle’s time ends. Its thin wick shows the highest and lowest prices visited. A visit beyond the box alone does not meet the default entry rule.

Find the same three things on your chart.

Box first. Candle closes second. Read the checklist when something is missing.

Real TradingView SPY five-minute chart with Opening Range Desk: opening boxes, entry and exit labels, stop and target zones, and the checklist dashboard.
TradingView snapshot · SPY · five-minute candles · historical exampleOpen full-size chart ↗
  1. 1 The opening box

    Top = opening high. Bottom = opening low. Locked edges stay fixed.

  2. 2 Closes and plan labels

    Entry marks a completed rule. Stop and target lines are a plan, with no promised outcome.

  3. 3 The Missing row

    Start here in the dashboard: it names what the tool is waiting for.

Try changing the price, wick and confirmation count

This extra simulator uses fictional candles and the same entry logic. Change one control at a time; an earlier entry keeps its original plan.

CHANGE THE CANDLE. SEE WHY.

Does this close complete the rule?

The range is fixed. Move the final candle to recalculate the checklist.

Earlier candles → test candleFinal close 109
Range high106Plan stop99.2138125.5113100.588
Opening windowEntry 109Drag the blue close ↕
Watch the same candle change
109
4
How the calculation works & more controls
2

Retest needs an earlier armed breakout. Try two earlier closes, then move this candle's wick toward the edge. A close exactly on the edge waits. The plan uses the opposite edge plus its stop buffer; the first target moves the stop to entry.

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

Paste it into TradingView.

Use an intraday chart, such as five minutes. Both complete files are free · Pine v6 · MIT.

  1. 1

    Open Pine Editor

    On your chart, create a new indicator.

  2. 2

    Paste the full code

    Copy below and replace the sample code.

  3. 3

    Add to chart

    Save it. Start with the default settings.

The indicator

Draws the range, checklist, plan and alerts.

Indicator code

Copy the code as it is. No coding needed.

View the full code
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════
//  OPENING RANGE DESK — ORB · close-through or retest · VWAP / EMA / volume filters · checklist · Co-Pilot  ·  Pine Script v6  ·  v1.2.0
//  Built for a r/pinescript thread reply ("Orb strategy") — every variation the thread asked about is an input:
//  the range window (5 / 15 / 30 min or any session), direct breakout closes OR retests, VWAP and EMA trend
//  filters, plus the things the textbook leaves out: a range-size gate, a volume gate, a trade-window and
//  flat-at-the-close rule, one trade per day (or more), and a panel that says which step is still missing.
//
//  HOW IT READS THE MARKET (every decision commits on the CLOSED bar — nothing repaints):
//     1 RANGE      the high / low of every bar inside the opening-range window; final on the first bar after it
//     2 WINDOW     entries only inside the trade window; flat at its end (switch)
//     3 FILTERS    VWAP side · EMA side · breakout-bar volume · range size vs the daily ATR — all off by default
//     4 BREAKOUT   close-through: a bar CLOSES beyond the range edge (+ buffer) → entry at that close
//                  retest: after the close-through, price comes back to the edge and a bar closes back beyond it
//     EXIT         stop at the opposite side of the range (or the midpoint / ATR), TP1 → breakeven, TP2 final,
//                  flat at the end of the trade window
//
//  Paper ledger has NO costs — a description of the past on your chart. The strategy twin measures it with costs.
//  License: MIT — share freely, keep this header.  © 2026 ProEA Lab · built for r/pinescript
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════
//@version=6
indicator("Opening Range Desk — ORB · close-through or retest · filters · Checklist", "ORB Desk", overlay = true, max_labels_count = 400, max_boxes_count = 200, max_lines_count = 300)

// ─────────────────────────────────────────────────────────────────── INPUTS
gR = "Opening range"
inRangeSess = input.session("0930-0945", "Opening-range window", group = gR, tooltip = "The bars whose highs and lows form the range. 0930-0935 = a 5-minute range, 0930-0945 = 15 minutes, 0930-1000 = 30 minutes. The range is final on the first bar after the window — that bar can already be the breakout bar. Measured on SPY 5m (profit factor, costs on): 5-minute range 0.61 · 15-minute 0.69 · 30-minute 0.60 — none of them paid this summer.", display = display.none)
inTz        = input.string("America/New_York", "Timezone (IANA)", group = gR, tooltip = "Both windows are read in this timezone. New York for US stocks, indices and the NY gold open.", display = display.none)
inTradeSess = input.session("0945-1555", "Trade window (entries allowed)", group = gR, tooltip = "Entries only inside this window. With 'flat at the end' on, an open trade is closed on the first bar after it.", display = display.none)
inEodFlat   = input.bool(true, "Flat at the end of the trade window", group = gR, display = display.none)
inLastEntry = input.string("", "Latest entry time — HH:MM in the timezone (blank = the whole window)", group = gR, tooltip = "Breakouts late in the day have the least room to run. With a time here, no new trade opens after it; open trades are still managed to the window's end. Measured (11:00, profit factor): SPY 5m 0.69 → 0.71 · QQQ 5m unchanged (every breakout came before 11:00 anyway) · gold 15m 0.88 → 1.01.", display = display.none)
inMinRange  = input.float(0.0, "Skip the day if the range is smaller than N × daily ATR (0 = off)", minval = 0, maxval = 3, step = 0.05, group = gR, tooltip = "A tiny range breaks on noise. The range height is compared with the previous day's 14-day ATR. Measured (0.15): SPY 5m 0.69 → 0.59.", display = display.none)
inMaxRange  = input.float(0.0, "Skip the day if the range is larger than N × daily ATR (0 = off)", minval = 0, maxval = 5, step = 0.05, group = gR, tooltip = "A huge range means the move already happened and the stop (the other side of the range) is far away. Measured (0.5): SPY 5m 0.69 → 0.75 · QQQ 5m 0.44 → 0.49.", display = display.none)

gE = "Entry"
inEntryMode = input.string("Close through", "Entry", options = ["Close through", "Retest"], group = gE, tooltip = "Close through: a bar closes beyond the range edge (+ buffer) → entry at that close. Retest: after that close-through, wait for price to come back to the edge (within the tolerance) and enter when a bar closes back beyond it; the setup expires if the retest does not come in time, and fails if a bar closes back inside the range. Measured (profit factor, close-through → retest): SPY 5m 0.69 → 0.58 · QQQ 5m 0.44 → 0.67 · gold 15m 0.88 → 0.55.", display = display.none)
inBreakBuf  = input.float(0.0, "Breakout buffer (× ATR beyond the edge)", minval = 0, maxval = 2, step = 0.05, group = gE, tooltip = "The close must be this far beyond the edge, in ATR. Measured (0.25, on the classic 1-close rule): SPY 5m 0.69 → 0.75 · QQQ 5m 0.44 → 0.39 · gold 15m 0.88 → 0.85.", display = display.none)
inRetestTol = input.float(0.25, "Retest tolerance (× ATR around the edge)", minval = 0, maxval = 2, step = 0.05, group = gE, display = display.none)
inRetestWait = input.int(12, "Retest must come within N bars", minval = 1, maxval = 200, group = gE, display = display.none)
inMaxTrades = input.int(1, "Trades per day", minval = 1, maxval = 3, group = gE, tooltip = "1 = the first breakout only (classic). 2 = after a stop-out the next breakout — either side — can fire again. Measured (2): SPY 5m 0.69 → 0.65 with 107 trades instead of 65.", display = display.none)

gB = "False-breakout defence (all off by default — each one measured)"
inConfirmBars = input.int(2, "Closes beyond the edge before entry", minval = 1, maxval = 5, group = gB, tooltip = "1 = the first close beyond the edge (the classic rule). 2 = the breakout must hold for a second consecutive close; a close back inside resets the count. Measured (1 → 2, profit factor): SPY 5m 0.69 → 0.80 · QQQ 5m 0.44 → 0.58 · gold 15m 0.88 → 1.08 — the one false-breakout defence that helped on all three charts, so 2 is the shipped default.", display = display.none)
inStrongClose = input.bool(false, "Strong close: the breakout bar must close in the top / bottom part of its range", group = gB, tooltip = "A breakout bar that closes near its own extreme shows conviction; one that closes mid-bar after poking through is the anatomy of a fake-out. Measured (30 %, on the classic 1-close rule): SPY 5m 0.69 → 0.67 · QQQ 5m 0.44 → 0.44 · gold 15m 0.88 → 0.93 — cosmetic.", display = display.none)
inStrongPct   = input.int(30, "  Top / bottom part of the bar (%)", minval = 10, maxval = 50, group = gB, display = display.none)
inFailExit    = input.bool(false, "Exit when a bar closes back inside the range (failed breakout)", group = gB, tooltip = "The breakout is the idea; a close back inside the range is the idea failing. This closes the trade at that close instead of waiting for the far-side stop. Measured (on the classic 1-close rule): SPY 5m 0.69 → 0.51 (win rate 34 → 17 % — the ETFs chop around the edge and the exit throws away trades that would have worked) · QQQ 5m 0.44 → 0.40 with a third less net loss · gold 15m 0.88 → 1.10 with the drawdown halved. Where breakouts either work or fail cleanly (the New York gold open) it is the right defence; where they chop, use the confirmation closes instead.", display = display.none)
inFadeFail    = input.bool(false, "Trade the failure: after a close back inside, enter the opposite way", group = gB, tooltip = "The failed-breakout fade: when a close beyond an edge is followed by a close back inside the range, enter against the failed side at that close, stop at the failed extreme plus the buffer, targets in R. Counts against the trades-per-day budget (set it to 2 to fade after your own stop-out). Measured (with the failed-breakout exit and 2 trades per day): SPY 5m 0.69 → 0.59 (123 trades) · QQQ 5m 0.44 → 0.52 · gold 15m 0.88 → 1.15 (128 trades).", display = display.none)

gX = "Exit"
inStopMode  = input.string("Opposite side of the range", "Stop", options = ["Opposite side of the range", "Range midpoint", "ATR from entry"], group = gX, tooltip = "Measured (midpoint): SPY 5m 0.69 → 0.64, drawdown −22 %. The classic opposite-side stop is the default.", display = display.none)
inStopAtr   = input.float(1.5, "  ATR stop distance (× ATR)", minval = 0.3, maxval = 10, step = 0.1, group = gX, display = display.none)
inStopBuf   = input.float(0.1, "  Stop buffer (× ATR beyond the level)", minval = 0, maxval = 2, step = 0.05, group = gX, display = display.none)
inTpUnit    = input.string("R (risk multiples)", "Targets measured in", options = ["R (risk multiples)", "Range heights"], group = gX, tooltip = "Measured (range heights): SPY 5m 0.69 → 0.81 · QQQ 5m 0.44 → 0.56 · gold 15m 0.88 → 0.74. With the volume gate too: QQQ 1.01.", display = display.none)
inTp1       = input.float(1.0, "TP1 → stop to breakeven", minval = 0.2, maxval = 10, step = 0.1, group = gX, display = display.none)
inTp2       = input.float(2.0, "TP2 final", minval = 0.3, maxval = 20, step = 0.1, group = gX, display = display.none)
inBeAtTp1   = input.bool(true, "Move the stop to breakeven at TP1", group = gX, tooltip = "Measured (off): SPY 5m 0.69 → 0.68, win rate 34 → 42 % — more small wins, same result.", display = display.none)
inTrailOn   = input.bool(false, "Trailing stop after TP1 (ATR)", group = gX, tooltip = "After TP1 the stop trails N ATR behind price instead of parking at breakeven — the classic 'let the winner run into the close'. Measured (profit factor, base → switch): SPY 5m 0.69 → 0.82 (win rate 34 → 51 %) · QQQ 5m 0.44 → 0.54 · gold 15m 0.88 → 0.89 — the only switch that helped on all three charts, still not enough on the ETFs.", display = display.none)
inTrailAtr  = input.float(1.5, "  Trail distance (× ATR)", minval = 0.3, maxval = 10, step = 0.1, group = gX, display = display.none)
inTimeStop  = input.string("", "Time stop — HH:MM in the timezone (blank = off): exit if TP1 not reached", group = gX, tooltip = "Classic ORB discipline: if the breakout has not paid its first target by this time, it is not the day — close it and stop waiting for the bell. Measured (12:00, profit factor): SPY 5m 0.69 → 0.55 · QQQ 5m 0.44 → 0.34 · gold 15m 0.88 → 1.09 with the drawdown cut by two thirds; with the VWAP + EMA gates too, gold 1.55. Right for the New York gold open, wrong for the index ETFs.", display = display.none)

gF = "Filters (all off by default — each one measured, numbers in its tooltip)"
inVwapOn    = input.bool(false, "VWAP side: longs above VWAP, shorts below", group = gF, tooltip = "The breakout bar must close on the VWAP's side of the trade. Measured (profit factor): SPY 5m and QQQ 5m — changed nothing (a close beyond the opening range at the open is on the VWAP's side anyway); gold 15m 0.88 → 1.11, and 1.36 together with the EMA filter. On a 24-hour market the VWAP has a whole session behind it — that is where this gate bites.", display = display.none)
inEmaOn     = input.bool(false, "EMA trend: longs above the EMA, shorts below", group = gF, tooltip = "Measured (profit factor): SPY 5m 0.69 → 0.53 · QQQ 5m 0.44 → 0.41 · gold 15m 0.88 → 1.27 (1.36 with the VWAP gate too). Helps where the open trends with the day, hurts where the open reverses it.", display = display.none)
inEmaLen    = input.int(200, "  EMA length", minval = 5, maxval = 500, group = gF, display = display.none)
inEmaTf     = input.timeframe("", "  EMA timeframe (blank = chart)", group = gF, tooltip = "A higher timeframe is read from its last CLOSED bar — no lookahead.", display = display.none)
inVolOn     = input.bool(false, "Breakout bar volume above its average", group = gF, tooltip = "Measured (profit factor): SPY 5m 0.69 → 0.78 · QQQ 5m 0.44 → 0.93 (1.01 with range-height targets) · gold 15m 0.88 → 0.90. The most useful gate on the index ETFs, still not enough to make them pay this summer.", display = display.none)
inVolMult   = input.float(1.2, "  Volume must be ≥ N × its average", minval = 0.5, maxval = 5, step = 0.1, group = gF, display = display.none)
inVolLen    = input.int(20, "  Volume average length", minval = 5, maxval = 200, group = gF, display = display.none)
inGapOn     = input.bool(false, "Gap direction: longs only after a gap up, shorts only after a gap down", group = gF, tooltip = "The day's open (the first bar of the range window) against the previous daily close. 'Gap and go' — trade the breakout only when it continues the gap. Measured (profit factor): SPY 5m 0.69 → 0.91 with the drawdown more than halved · QQQ 5m 0.44 → 0.65 · gold 15m 0.88 → 0.79 (a 24-hour market barely gaps — the gate has nothing to read there).", display = display.none)

gP = "Position size (display only)"
inRiskUsd   = input.float(0, "Risk per trade in account currency (0 = off)", minval = 0, step = 10, group = gP, tooltip = "Shows the size that risks this amount between entry and stop — on the ticket, the trade card and the Co-Pilot plan. Display only; the strategy twin trades one unit.", display = display.none)

gV = "Style"
inTheme     = input.string("Aurora Neon", "Theme", options = ["Aurora Neon", "Royal Gold", "Colorblind-safe"], group = gV, display = display.none)
inShowOr    = input.string("All days", "Opening-range boxes", options = ["All days", "Today only", "Off"], group = gV, display = display.none)
inShowLvls  = input.bool(true, "Breakout levels (dotted) while the day is live", 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)
inDrawVwap  = input.bool(false, "Draw VWAP (auto when its filter is on)", group = gV, display = display.none)
inDrawEma   = input.bool(false, "Draw the EMA (auto when its filter is on)", group = gV, display = display.none)
inRangeTag  = input.bool(true, "Range tag (height vs daily ATR) on each box", group = gV, display = display.none)
inZoneKeep  = input.string("Last trade only", "Keep risk / reward zones for", options = ["Last trade only", "All trades"], group = gV, tooltip = "Zones of older trades are removed when a new trade opens, so the chart stays readable; the tickets and exit tags keep the history.", display = display.none)
inBoxResult = input.bool(true, "Colour finished days by their result (green · red · grey)", group = gV, display = display.none)
inMidline   = input.bool(true, "Range midline (dotted)", group = gV, display = display.none)
inShowFake  = input.bool(true, "Mark fake-outs (a wick beyond the edge that closed back inside)", group = gV, 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)

gN = "Co-Pilot (narrative)"
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 = 140, 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 (all ta.* at global scope)
float atr    = ta.atr(14)
float atrU   = math.max(atr, syminfo.mintick)
float vwap   = ta.vwap(hlc3)
float emaC   = ta.ema(close, inEmaLen)
emaFn() => ta.ema(close, inEmaLen)
float emaH   = request.security(syminfo.tickerid, inEmaTf == "" ? timeframe.period : inEmaTf, emaFn()[1], lookahead = barmerge.lookahead_on)
float emaF   = inEmaTf == "" ? emaC : emaH
float volMa  = ta.sma(nz(volume, 0.0), inVolLen)
dAtrFn() => ta.atr(14)
float dAtr   = request.security(syminfo.tickerid, "D", dAtrFn()[1], lookahead = barmerge.lookahead_on)
float dAtrU  = math.max(nz(dAtr, atrU * 10), syminfo.mintick)
float pClose = request.security(syminfo.tickerid, "D", close[1], lookahead = barmerge.lookahead_on)
// clock helpers (all in the input timezone)
int    nowMin = hour(time, inTz) * 60 + minute(time, inTz)
string hhmm   = str.tostring(hour(time, inTz), "00") + ":" + str.tostring(minute(time, inTz), "00")
sessEndMin(string sess) =>
    string e = str.length(sess) >= 9 ? str.substring(sess, 5, 9) : "0000"
    int hh = int(nz(str.tonumber(str.substring(e, 0, 2)), 0))
    int mm = int(nz(str.tonumber(str.substring(e, 2, 4)), 0))
    hh * 60 + mm
clockMin(string hm) =>
    array<string> parts = str.split(hm, ":")
    array.size(parts) == 2 ? int(nz(str.tonumber(array.get(parts, 0)), -1)) * 60 + int(nz(str.tonumber(array.get(parts, 1)), 0)) : -1
int    winEndMin   = sessEndMin(inTradeSess)
int    minsLeft    = winEndMin - nowMin
string leftTxt     = minsLeft <= 0 ? "closing" : (minsLeft >= 60 ? str.tostring(math.floor(minsLeft / 60)) + " h " : "") + str.tostring(minsLeft % 60) + " m left"
int    timeStopMin = clockMin(inTimeStop)
int    lastEntryMin = clockMin(inLastEntry)
bool   timeStopNow = timeStopMin >= 0 and nowMin >= timeStopMin
bool  inRange  = not na(time(timeframe.period, inRangeSess, inTz))
bool  inTrade  = not na(time(timeframe.period, inTradeSess, inTz))
bool  rangeStart = inRange and not inRange[1]
bool  rangeEnd   = not inRange and inRange[1]
bool  windowEnd  = not inTrade and inTrade[1]
bool  entryOpen  = inTrade and (lastEntryMin < 0 or nowMin <= lastEntryMin)
bool  volOk    = not inVolOn or (not na(volume) and volume >= inVolMult * volMa)
bool  vwapUp   = not inVwapOn or close > vwap
bool  vwapDn   = not inVwapOn or close < vwap
bool  emaUp    = not inEmaOn or close > emaF
bool  emaDn    = not inEmaOn or close < emaF

// ─────────────────────────────────────────────────────────────────── STATE
type Day
    float  orH = na
    float  orL = na
    bool   set = false
    int    setBar = na
    int    startBar = na
    int    trades = 0
    int    brk = 0          // retest mode: side of the close-through we are waiting on
    int    brkBar = na
    bool   touched = false
    string skip = ""        // "" = tradable day; otherwise the reason
    float  todayR = 0.0
    int    dayNo = 0
    bool   done = false
    float  openPx = na
    string log = ""
    int    cntUp = 0
    int    cntDn = 0
    int    lastBrk = 0
    float  brkExt = na
    bool   fakeUpLogged = false
    bool   fakeDnLogged = false
type Trade
    int   dir = 0
    float entry = na
    float sl = na
    float sl0 = na
    float r = na
    float tp1 = na
    float tp2 = na
    int   bar = na
    bool  tp1Hit = false
    bool  retest = false
    bool  fade = false
    float lastR = na
    string lastWhy = ""
type Stat
    int   n = 0
    int   wins = 0
    int   flat = 0
    float sumR = 0.0
    int   nTp = 0
    int   nStop = 0
    int   nBe = 0
    int   nEod = 0
    int   days = 0
    int   daysTraded = 0
    int   daysSkipped = 0
    int   nTime = 0
    int   nFail = 0
    int   nFake = 0
type Ev
    bool   sigL = false
    bool   sigS = false
    bool   exit = false
    bool   tp1  = false
    bool   rangeSet = false
    bool   brkL = false
    bool   brkS = false
    bool   retestFail = false
    bool   fake = false
    string why  = ""
    float  r    = na
    int    dir  = 0
var Day   dy = Day.new()
var Trade t  = Trade.new()
var Stat  st = Stat.new()
Ev ev = Ev.new()
bool  gapUp  = not na(dy.openPx) and dy.openPx > pClose
bool  gapDn  = not na(dy.openPx) and dy.openPx < pClose
bool  gapOkL = not inGapOn or gapUp
bool  gapOkS = not inGapOn or gapDn

logEv(string what) =>
    dy.log := dy.log + (dy.log == "" ? "" : " · ") + hhmm + " " + what

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 == "TP2"
        st.nTp := st.nTp + 1
    else if why == "EOD"
        st.nEod := st.nEod + 1
    else if why == "BE"
        st.nBe := st.nBe + 1
    else if why == "TIME"
        st.nTime := st.nTime + 1
    else if why == "FAIL"
        st.nFail := st.nFail + 1
    else
        st.nStop := st.nStop + 1
    dy.todayR := dy.todayR + rr
    logEv((why == "TP2" ? "TP2 " : why == "EOD" ? "flat " : why == "TIME" ? "time stop " : why == "BE" ? "breakeven " : why == "TRAIL" ? "trail " : why == "FAIL" ? "failed " : "stop ") + (rr >= 0 ? "+" : "") + str.tostring(rr, "0.0") + "R")
    t.lastR := rr
    t.lastWhy := why
    t.dir := 0

openTrade(int d, bool viaRetest, float stopOverride) =>
    float rangeH = dy.orH - dy.orL
    float base = inStopMode == "Range midpoint" ? (dy.orH + dy.orL) / 2 : inStopMode == "ATR from entry" ? (d == 1 ? close - inStopAtr * atrU : close + inStopAtr * atrU) : (d == 1 ? dy.orL : dy.orH)
    float anchor = na(stopOverride) ? base : stopOverride
    float slPx = d == 1 ? math.min(anchor - inStopBuf * atrU, close - syminfo.mintick) : math.max(anchor + inStopBuf * atrU, close + syminfo.mintick)
    float unit = inTpUnit == "Range heights" ? math.max(rangeH, syminfo.mintick) : math.abs(close - slPx)
    t.dir := d
    t.entry := close
    t.sl := slPx
    t.sl0 := slPx
    t.r := math.abs(close - slPx)
    t.tp1 := close + d * inTp1 * unit
    t.tp2 := close + d * inTp2 * unit
    t.bar := bar_index
    t.tp1Hit := false
    t.retest := viaRetest
    t.fade := not na(stopOverride)
    dy.trades := dy.trades + 1
    logEv((d == 1 ? "▲ long " : "▼ short ") + fmt(close) + (viaRetest ? " (retest)" : t.fade ? " (fade)" : ""))
    if dy.trades == 1
        st.daysTraded := st.daysTraded + 1
    if d == 1
        ev.sigL := true
    else
        ev.sigS := true

if barstate.isconfirmed and not na(atr)
    // ── the day
    if rangeStart
        dy.orH := high
        dy.orL := low
        dy.set := false
        dy.setBar := na
        dy.startBar := bar_index
        dy.trades := 0
        dy.brk := 0
        dy.brkBar := na
        dy.touched := false
        dy.skip := ""
        dy.todayR := 0.0
        dy.dayNo := dy.dayNo + 1
        dy.done := false
        dy.openPx := open
        dy.log := ""
        dy.cntUp := 0
        dy.cntDn := 0
        dy.lastBrk := 0
        dy.brkExt := na
        dy.fakeUpLogged := false
        dy.fakeDnLogged := false
        st.days := st.days + 1
    else if inRange
        dy.orH := math.max(nz(dy.orH, high), high)
        dy.orL := math.min(nz(dy.orL, low), low)
    if rangeEnd and not dy.set and not na(dy.orH)
        dy.set := true
        dy.setBar := bar_index
        ev.rangeSet := true
        float rAtr = (dy.orH - dy.orL) / dAtrU
        if inMinRange > 0 and rAtr < inMinRange
            dy.skip := "range " + str.tostring(rAtr, "0.00") + " × daily ATR — too small (< " + str.tostring(inMinRange, "0.00") + ")"
        else if inMaxRange > 0 and rAtr > inMaxRange
            dy.skip := "range " + str.tostring(rAtr, "0.00") + " × daily ATR — too large (> " + str.tostring(inMaxRange, "0.00") + ")"
        if dy.skip != ""
            st.daysSkipped := st.daysSkipped + 1
        logEv("range set" + (dy.skip != "" ? " · skipped" : ""))
    // ── manage an open trade (stop first, then TP2, TP1, end of window)
    if t.dir != 0
        int d = t.dir
        bool stopHit = d == 1 ? low <= t.sl : high >= t.sl
        if stopHit
            closeTrade(t.sl, t.tp1Hit and t.sl == t.entry ? "BE" : t.tp1Hit and inTrailOn and t.sl != t.sl0 ? "TRAIL" : "SL")
        else if d == 1 ? high >= t.tp2 : low <= t.tp2
            closeTrade(t.tp2, "TP2")
        else
            if not t.tp1Hit and (d == 1 ? high >= t.tp1 : low <= t.tp1)
                t.tp1Hit := true
                ev.tp1 := true
                logEv("TP1")
                if inBeAtTp1
                    t.sl := t.entry
            if inFailExit and not t.fade and t.dir != 0 and (d == 1 ? close < dy.orH : close > dy.orL)
                closeTrade(close, "FAIL")
            if inTrailOn and t.tp1Hit and t.dir != 0
                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 timeStopNow and not t.tp1Hit and t.dir != 0
                closeTrade(close, "TIME")
            if inEodFlat and windowEnd and t.dir != 0
                closeTrade(close, "EOD")
    // ── breakout bookkeeping (every confirmed bar once the range is set): consecutive closes beyond an edge,
    //    the extreme reached since the last break, fake-outs (a wick beyond the edge that closed back inside)
    bool failUp = false
    bool failDn = false
    if dy.set
        float upLvlB = dy.orH + inBreakBuf * atrU
        float dnLvlB = dy.orL - inBreakBuf * atrU
        bool upRaw = close > upLvlB
        bool dnRaw = close < dnLvlB
        dy.cntUp := upRaw ? dy.cntUp + 1 : 0
        dy.cntDn := dnRaw ? dy.cntDn + 1 : 0
        failUp := dy.lastBrk == 1 and not upRaw and close < dy.orH
        failDn := dy.lastBrk == -1 and not dnRaw and close > dy.orL
        if upRaw
            dy.brkExt := dy.lastBrk == 1 ? math.max(nz(dy.brkExt, high), high) : high
            dy.lastBrk := 1
        else if dnRaw
            dy.brkExt := dy.lastBrk == -1 ? math.min(nz(dy.brkExt, low), low) : low
            dy.lastBrk := -1
        if t.dir == 0 and not upRaw and not dnRaw and dy.lastBrk == 0 and (high > dy.orH or low < dy.orL)
            ev.fake := true
            st.nFake := st.nFake + 1
            bool fakeAbv = high > dy.orH
            if fakeAbv and not dy.fakeUpLogged
                dy.fakeUpLogged := true
                logEv("fake-out ▲")
            else if not fakeAbv and not dy.fakeDnLogged
                dy.fakeDnLogged := true
                logEv("fake-out ▼")
    // ── signals (only when flat, the range is set, inside the trade window, on a tradable day)
    if t.dir == 0 and dy.set and entryOpen and dy.skip == "" and dy.trades < inMaxTrades
        float upLvl = dy.orH + inBreakBuf * atrU
        float dnLvl = dy.orL - inBreakBuf * atrU
        float barRange = math.max(high - low, syminfo.mintick)
        bool strongUp = not inStrongClose or (close - low) / barRange >= 1 - inStrongPct / 100.0
        bool strongDn = not inStrongClose or (high - close) / barRange >= 1 - inStrongPct / 100.0
        bool brkUp = close > upLvl and dy.cntUp >= inConfirmBars and strongUp
        bool brkDn = close < dnLvl and dy.cntDn >= inConfirmBars and strongDn
        bool longOk  = vwapUp and emaUp and volOk and gapOkL
        bool shortOk = vwapDn and emaDn and volOk and gapOkS
        if inFadeFail and ((failUp and shortOk) or (failDn and longOk))
            openTrade(failUp ? -1 : 1, false, dy.brkExt)
            dy.lastBrk := 0
        else if inEntryMode == "Close through"
            if brkUp and longOk
                openTrade(1, false, na)
            else if brkDn and shortOk
                openTrade(-1, false, na)
        else
            if dy.brk == 0
                if brkUp and longOk
                    dy.brk := 1
                    dy.brkBar := bar_index
                    dy.touched := false
                    ev.brkL := true
                    logEv("broke out ▲")
                else if brkDn and shortOk
                    dy.brk := -1
                    dy.brkBar := bar_index
                    dy.touched := false
                    ev.brkS := true
                    logEv("broke out ▼")
            else
                int bd = dy.brk
                float edge = bd == 1 ? dy.orH : dy.orL
                float tol = inRetestTol * atrU
                if bar_index - dy.brkBar > inRetestWait
                    dy.brk := 0
                    ev.retestFail := true
                    ev.why := "retest expired"
                    logEv("no retest")
                else if bd == 1 ? close < edge - tol : close > edge + tol
                    dy.brk := 0
                    ev.retestFail := true
                    ev.why := "closed back inside the range"
                    logEv("back inside")
                else if bar_index > dy.brkBar
                    if bd == 1 ? low <= edge + tol : high >= edge - tol
                        dy.touched := true
                    if dy.touched and (bd == 1 ? close > edge : close < edge)
                        openTrade(bd, true, na)
                        dy.brk := 0
    if failUp or failDn
        dy.lastBrk := 0
    if dy.set and t.dir == 0 and (dy.trades >= inMaxTrades or (not inTrade and dy.trades > 0))
        dy.done := true

// ─────────────────────────────────────────────────────────────────── READINESS (display only)
bool  flatNow  = t.dir == 0
float rangeH   = dy.set ? dy.orH - dy.orL : na
float rangeAtr = dy.set ? rangeH / dAtrU : na
float upLvlD   = dy.set ? dy.orH + inBreakBuf * atrU : na
float dnLvlD   = dy.set ? dy.orL - inBreakBuf * atrU : na
bool  s1 = dy.set
bool  s2 = entryOpen
bool  s3 = dy.skip == ""
bool  fVwapL = vwapUp
bool  fVwapS = vwapDn
bool  fEmaL  = emaUp
bool  fEmaS  = emaDn
bool  filtersOn = inVwapOn or inEmaOn or inVolOn or inGapOn
bool  s4L = fVwapL and fEmaL and gapOkL
bool  s4S = fVwapS and fEmaS and gapOkS
bool  s5  = dy.trades < inMaxTrades
int   side = filtersOn and (s4L != s4S) ? (s4L ? 1 : -1) : close >= (dy.set ? (dy.orH + dy.orL) / 2 : close) ? 1 : -1     // the side the filters allow, else the nearer edge
string sideWord = side == 1 ? "long" : "short"
string s1Txt = dy.set ? fmt(dy.orL) + " – " + fmt(dy.orH) + " · " + str.tostring(rangeAtr, "0.00") + " × daily ATR" : inRange ? "forming — " + (na(dy.orH) ? "first bar" : fmt(dy.orL) + " – " + fmt(dy.orH) + " so far") : "waiting for " + inRangeSess + " (" + inTz + ")"
string s2Txt = entryOpen ? "inside " + inTradeSess + (lastEntryMin >= 0 ? " · entries until " + inLastEntry : "") + " · " + leftTxt : inTrade ? "past the latest entry time " + inLastEntry + " · " + leftTxt : "outside " + inTradeSess + (dy.set ? " · no new entries" : "")
string s3Txt = dy.skip == "" ? ((inMinRange > 0 or inMaxRange > 0) ? "range size passes" : "no size gate") : dy.skip
string fTxt  = (inVwapOn ? "VWAP " + (close > vwap ? "below price" : "above price") : "") + (inEmaOn ? (inVwapOn ? " · " : "") + "EMA " + (close > emaF ? "below price" : "above price") : "") + (inVolOn ? ((inVwapOn or inEmaOn) ? " · " : "") + "volume " + str.tostring(nz(volume) / math.max(volMa, 1e-9), "0.0") + "× avg" : "") + (inGapOn ? ((inVwapOn or inEmaOn or inVolOn) ? " · " : "") + "gap " + (gapUp ? "up" : gapDn ? "down" : "flat") : "")
string s4Txt = filtersOn ? fTxt + " → " + (s4L and s4S ? "either side" : s4L ? "longs only" : s4S ? "shorts only" : "no side passes") : "no filters on — either side"
string brkTxt = not dy.set ? "needs the range first" : dy.brk != 0 ? (dy.brk == 1 ? "broke out ▲ · retest of " + fmt(dy.orH) : "broke out ▼ · retest of " + fmt(dy.orL)) + (dy.touched ? " · touched" : "") + " · " + str.tostring(math.max(0, inRetestWait - (bar_index - nz(dy.brkBar, bar_index)))) + " bars left" : "long > " + fmt(upLvlD) + " · short < " + fmt(dnLvlD) + (close > dy.orH ? " · price above" + (inConfirmBars > 1 and not dy.done ? " · " + str.tostring(math.min(dy.cntUp, inConfirmBars)) + "/" + str.tostring(inConfirmBars) + " closes" : "") : close < dy.orL ? " · price below" + (inConfirmBars > 1 and not dy.done ? " · " + str.tostring(math.min(dy.cntDn, inConfirmBars)) + "/" + str.tostring(inConfirmBars) + " closes" : "") : " · price inside")
bool  allPre = s1 and s2 and s3 and s5 and (s4L or s4S)
string missing = not flatNow ? "in a trade" : dy.done ? "done for today (" + str.tostring(dy.trades) + " of " + str.tostring(inMaxTrades) + " trades)" : not s1 ? "the range" : not s2 ? "the trade window" : not s3 ? "a tradable range size" : not (s4L or s4S) ? "a filter on either side" : inEntryMode == "Retest" and dy.brk != 0 ? "the retest" : "the breakout close"
string stateWord = t.dir == 1 ? "LONG OPEN" : t.dir == -1 ? "SHORT OPEN" : not dy.set ? (inRange ? "RANGE FORMING" : "WAITING FOR THE RANGE") : dy.skip != "" ? "DAY SKIPPED" : dy.done ? "DONE FOR TODAY" : not inTrade ? "OUTSIDE THE WINDOW" : not entryOpen ? "PAST THE ENTRY CUT-OFF" : dy.brk != 0 ? (dy.brk == 1 ? "BROKE OUT ▲ · RETEST?" : "BROKE OUT ▼ · RETEST?") : "RANGE SET · WATCHING"
color  stateCol  = t.dir == 1 ? th.up : t.dir == -1 ? th.down : dy.brk == 1 ? th.up : dy.brk == -1 ? th.down : dy.set and entryOpen and dy.skip == "" and not dy.done ? th.accentHi : th.flat
float  openR     = t.dir != 0 ? (close - t.entry) * t.dir / t.r : na
string sizeTxt   = inRiskUsd > 0 and t.dir != 0 and t.r > 0 ? str.tostring(math.floor(inRiskUsd / t.r)) + (syminfo.type == "stock" or syminfo.type == "fund" ? " shares" : " units") + " for " + str.tostring(inRiskUsd, "#") + " risk" : ""
string chipTxt   = t.dir != 0 ? (openR >= 0 ? "+" : "") + str.tostring(openR, "0.0") + "R now" + (t.tp1Hit ? " · stop at breakeven" : "") : dy.trades > 0 ? "today " + (dy.todayR >= 0 ? "+" : "") + str.tostring(dy.todayR, "0.0") + "R" : dy.set ? str.tostring(bar_index - nz(dy.setBar, bar_index)) + " bars since the range" : "day " + str.tostring(dy.dayNo)

// ─────────────────────────────────────────────────────────────────── CHART · range boxes, levels, trade levels
var array<box>   orBoxes = array.new<box>()
var array<line>  orLines = array.new<line>()
var array<label> orTags  = array.new<label>()
var box   curBox = na
var box   curExt = na
var line  curHi = na
var line  curLo = na
var line  curMid = na
var label curTag = na
color rangeCol = dy.skip != "" ? th.flat : th.accent
if barstate.isconfirmed
    if rangeEnd and dy.set and inShowOr != "Off"
        if inShowOr == "Today only"
            for b in orBoxes
                box.delete(b)
            array.clear(orBoxes)
            for l in orLines
                line.delete(l)
            array.clear(orLines)
            for g in orTags
                label.delete(g)
            array.clear(orTags)
        int x1 = math.max(nz(dy.startBar, bar_index - 1), bar_index - 250)
        float midPx = (dy.orH + dy.orL) / 2
        curBox := box.new(x1, dy.orH, bar_index, dy.orL, border_color = color.new(rangeCol, 100), border_width = 1, bgcolor = color.new(rangeCol, 82))
        curExt := box.new(bar_index, dy.orH, bar_index, dy.orL, border_color = color.new(rangeCol, 100), border_width = 1, bgcolor = color.new(rangeCol, 93))
        curHi := line.new(x1, dy.orH, bar_index, dy.orH, color = color.new(rangeCol, 15), width = 2)
        curLo := line.new(x1, dy.orL, bar_index, dy.orL, color = color.new(rangeCol, 15), width = 2)
        curMid := line.new(x1, midPx, bar_index, midPx, color = color.new(rangeCol, inMidline ? 60 : 100), width = 1, style = line.style_dotted)
        array.push(orBoxes, curBox)
        array.push(orBoxes, curExt)
        array.push(orLines, curHi)
        array.push(orLines, curLo)
        array.push(orLines, curMid)
        if inRangeTag
            curTag := label.new(bar_index, dy.orH, "OR " + str.tostring(rangeAtr, "0.00") + "× dATR" + (dy.skip != "" ? " · skipped" : ""), style = label.style_label_lower_left, color = color.new(th.bg, 20), textcolor = dy.skip != "" ? th.flat : th.accent, size = size.tiny)
            array.push(orTags, curTag)
        while array.size(orBoxes) > 120
            box.delete(array.shift(orBoxes))
        while array.size(orLines) > 180
            line.delete(array.shift(orLines))
        while array.size(orTags) > 60
            label.delete(array.shift(orTags))
    else if dy.set and inTrade and not na(curExt)
        box.set_right(curExt, bar_index)
        line.set_x2(curHi, bar_index)
        line.set_x2(curLo, bar_index)
        line.set_x2(curMid, bar_index)
    if inBoxResult and not na(curBox) and dy.set and t.dir == 0 and (dy.done or (not inTrade and inTrade[1]))
        color resCol = dy.trades == 0 ? th.flat : dy.todayR > 0.05 ? th.up : dy.todayR < -0.05 ? th.down : th.flat
        box.set_bgcolor(curBox, color.new(resCol, 86))
        box.set_bgcolor(curExt, color.new(resCol, 94))
        line.set_color(curHi, color.new(resCol, 45))
        line.set_color(curLo, color.new(resCol, 45))
        line.set_width(curHi, 1)
        line.set_width(curLo, 1)
        line.set_color(curMid, color.new(resCol, inMidline ? 75 : 100))
// breakout levels (dotted) while the day is live and no trade is open
bool  lvlOn = inShowLvls and dy.set and inTrade and t.dir == 0 and dy.skip == "" and not dy.done
plot(lvlOn ? upLvlD : na, "Breakout level · long", color = color.new(th.up, 45), linewidth = 1, style = plot.style_circles, display = display.pane)
plot(lvlOn ? dnLvlD : na, "Breakout level · short", color = color.new(th.down, 45), linewidth = 1, style = plot.style_circles, display = display.pane)
// filters
plot(inVwapOn or inDrawVwap ? vwap : na, "VWAP", color = color.new(close > vwap ? th.up : th.down, 35), linewidth = 1, display = display.pane)
plot(inEmaOn or inDrawEma ? emaF : na, "EMA", color = color.new(th.accent, 40), linewidth = 1, display = display.pane)
// trade levels
bool inT = t.dir != 0
plot(inT ? t.entry : na, "Entry", color = color.new(th.accentHi, 20), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(inT ? t.sl : na, "Stop", color = color.new(th.down, 20), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(inT ? t.tp1 : na, "TP1", color = color.new(th.up, 55), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(inT ? t.tp2 : na, "TP2", color = color.new(th.up, 20), linewidth = 1, style = plot.style_linebr, display = display.pane)
barcolor(inTint and inT ? color.new(t.dir == 1 ? th.up : th.down, 15) : na, title = "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 ? 88 + k * 2 : 96 - k * 2
        box b = box.new(x1, bTop, x2, bBot, border_color = color.new(c, 100), bgcolor = color.new(c, alpha))
        array.push(zones, b)
        array.push(liveZ, b)
if barstate.isconfirmed
    if (ev.sigL or ev.sigS) and inZones
        if inZoneKeep == "Last trade only"
            for b in zones
                box.delete(b)
            array.clear(zones)
        array.clear(liveZ)
        int d = t.dir
        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
if ev.sigL
    label.new(bar_index, low, "ORB ▲  " + fmt(t.entry) + (t.retest ? "  · retest" : t.fade ? "  · fade" : "") + "\nstop " + fmt(t.sl) + " · TP2 " + fmt(t.tp2) + (sizeTxt != "" ? "\n" + sizeTxt : ""), style = label.style_label_up, color = color.new(th.up, 12), textcolor = th.bg, size = size.small,
         tooltip = "Long: a bar closed above the opening range" + (t.retest ? " and price came back to the edge and closed above it again" : "") + ". Stop " + fmt(t.sl) + " · TP1 " + fmt(t.tp1) + " · TP2 " + fmt(t.tp2))
if ev.sigS
    label.new(bar_index, high, "ORB ▼  " + fmt(t.entry) + (t.retest ? "  · retest" : t.fade ? "  · fade" : "") + "\nstop " + fmt(t.sl) + " · TP2 " + fmt(t.tp2) + (sizeTxt != "" ? "\n" + sizeTxt : ""), style = label.style_label_down, color = color.new(th.down, 12), textcolor = th.bg, size = size.small,
         tooltip = "Short: a bar closed below the opening range" + (t.retest ? " and price came back to the edge and closed below it again" : "") + ". Stop " + fmt(t.sl) + " · TP1 " + fmt(t.tp1) + " · TP2 " + fmt(t.tp2))
if ev.brkL
    label.new(bar_index, high, "broke out ▲ · waiting for the retest", style = label.style_label_down, color = color.new(th.bg, 15), textcolor = th.up, size = size.tiny)
if ev.brkS
    label.new(bar_index, low, "broke out ▼ · waiting for the retest", style = label.style_label_up, color = color.new(th.bg, 15), textcolor = th.down, size = size.tiny)
if ev.retestFail
    label.new(bar_index, high, "no retest · " + ev.why, style = label.style_label_down, color = color.new(th.bg, 15), textcolor = th.flat, size = size.tiny)
if ev.fake and inShowFake
    bool fakeAbove = high > dy.orH
    label.new(bar_index, fakeAbove ? high : low, "✕", style = fakeAbove ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 100), textcolor = color.new(th.flat, 25), size = size.tiny, tooltip = "Fake-out: the bar poked beyond the range and closed back inside")
if ev.exit
    string rTxt = (ev.r >= 0 ? "+" : "") + str.tostring(ev.r, "0.0") + "R"
    string whyTxt = ev.why == "TP2" ? "target" : ev.why == "EOD" ? "flat at the close" : ev.why == "BE" ? "breakeven" : ev.why == "TIME" ? "time stop" : ev.why == "TRAIL" ? "trail" : ev.why == "FAIL" ? "failed breakout" : "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)

// ─────────────────────────────────────────────────────────────────── DASHBOARD
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)

int readyN   = (s1 ? 1 : 0) + (s2 ? 1 : 0) + (s3 ? 1 : 0) + ((s4L or s4S) ? 1 : 0) + (s5 ? 1 : 0)
var table dash = table.new(posOf(inDashPos), 3, 20, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_width = 0, force_overlay = false)
if barstate.islast and inDash
    table.clear(dash, 0, 0, 2, 19)
    int r = 0
    table.cell(dash, 0, r, "OPENING RANGE DESK", text_color = th.accentHi, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Opening range breakout: the range window, the trade window, the filters, the breakout — as a checklist, 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
    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 = "Where today stands: range forming → range set → breakout (or retest) → trade → done.")
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    color chipBg = t.dir != 0 ? color.new(openR >= 0 ? th.up : th.down, 75) : dy.trades > 0 ? color.new(dy.todayR >= 0 ? th.up : th.down, 75) : color.new(color.from_gradient(readyN, 0, 5, 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)
    r += 1
    if t.dir == 0
        section(dash, r, "CHECKLIST · " + str.tostring(readyN) + " OF 5 STEPS")
        r += 1
        row3(dash, r, "1 · Range", s1, s1Txt, true)
        r += 1
        row3(dash, r, "2 · Window", s2, s2Txt, true)
        r += 1
        row3(dash, r, "3 · Range size", s3, s3Txt, true)
        r += 1
        row3(dash, r, "4 · Filters", s4L or s4S, s4Txt, true)
        r += 1
        row3(dash, r, "5 · Budget", s5, str.tostring(dy.trades) + " of " + str.tostring(inMaxTrades) + " trades today" + (dy.trades > 0 ? " · " + (dy.todayR >= 0 ? "+" : "") + str.tostring(dy.todayR, "0.0") + "R" : ""), true)
        r += 1
        row3(dash, r, "Breakout", dy.brk != 0 or (dy.set and (close > dy.orH or close < dy.orL)), brkTxt, false)
        r += 1
        row3(dash, r, "Missing", allPre and not dy.done, missing, false)
        r += 1
        if dy.log != ""
            row3(dash, r, "Today", true, dy.log, false)
            r += 1
    else
        section(dash, r, "TRADE · " + (t.dir == 1 ? "LONG" : "SHORT") + (t.retest ? " · RETEST" : " · CLOSE-THROUGH") + " · " + 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) + (t.tp1Hit and t.sl == t.entry ? " · breakeven" : inStopMode == "Range midpoint" ? " · range midpoint" : inStopMode == "ATR from entry" ? " · ATR stop" : " · other side of the range"), false)
        r += 1
        row3(dash, r, "Targets", t.tp1Hit, "TP1 " + fmt(t.tp1) + (t.tp1Hit ? " ✓" : "") + " · TP2 " + fmt(t.tp2) + (inTpUnit == "Range heights" ? " (range heights)" : ""), false)
        r += 1
        row3(dash, r, "Exit rule", true, "stop or TP2" + (inFailExit and not t.fade ? " · out if a bar closes back inside" : "") + (inTrailOn ? " · trail " + str.tostring(inTrailAtr, "0.0") + " ATR after TP1" : "") + (timeStopMin >= 0 ? " · time stop " + inTimeStop : "") + (inEodFlat ? " · flat after " + inTradeSess : " · may hold overnight"), false)
        r += 1
        if sizeTxt != ""
            row3(dash, r, "Size", true, sizeTxt + " · " + fmt(t.r) + " per unit", false)
            r += 1
        if dy.log != ""
            row3(dash, r, "Today", true, dy.log, false)
            r += 1
        row3(dash, r, "Range", true, fmt(dy.orL) + " – " + fmt(dy.orH) + " · " + str.tostring(rangeAtr, "0.00") + " × daily ATR", false)
        r += 1
    section(dash, r, "RECORD · PAPER, NO COSTS")
    r += 1
    row3(dash, r, "Days", true, str.tostring(st.days) + " seen · " + str.tostring(st.daysTraded) + " traded" + (st.daysSkipped > 0 ? " · " + str.tostring(st.daysSkipped) + " skipped" : "") + (st.nFake > 0 ? " · " + str.tostring(st.nFake) + " fake-outs" : ""), false)
    r += 1
    row3(dash, r, "Closed", st.n > 0 and st.sumR >= 0, st.n == 0 ? "no closed trades yet" : str.tostring(st.n) + " · " + str.tostring(math.round(st.wins / st.n * 100)) + "% win · " + 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, "TP2 " + str.tostring(st.nTp) + " · stop " + str.tostring(st.nStop) + " · BE " + str.tostring(st.nBe) + " · EOD " + str.tostring(st.nEod) + (st.nTime > 0 ? " · time " + str.tostring(st.nTime) : "") + (st.nFail > 0 ? " · failed " + str.tostring(st.nFail) : "") + (na(t.lastR) ? "" : " · last " + (t.lastR >= 0 ? "+" : "") + str.tostring(t.lastR, "0.0") + "R"), false)
    r += 1
    string fSum = (inVwapOn ? "VWAP · " : "") + (inEmaOn ? "EMA " + str.tostring(inEmaLen) + (inEmaTf != "" ? "@" + inEmaTf : "") + " · " : "") + (inVolOn ? "volume · " : "") + (inGapOn ? "gap · " : "") + (inMinRange > 0 or inMaxRange > 0 ? "range size · " : "") + (inTrailOn ? "trail · " : "") + (timeStopMin >= 0 ? "time " + inTimeStop + " · " : "") + (inConfirmBars > 1 ? str.tostring(inConfirmBars) + " closes · " : "") + (inStrongClose ? "strong close · " : "") + (inFailExit ? "fail-exit · " : "") + (inFadeFail ? "fade · " : "") + (lastEntryMin >= 0 ? "entries until " + inLastEntry + " · " : "")
    table.cell(dash, 0, r, "OR " + inRangeSess + " · " + (inEntryMode == "Retest" ? "retest" : "close-through") + " · stop " + (inStopMode == "Range midpoint" ? "midpoint" : inStopMode == "ATR from entry" ? "ATR" : "far side") + " · TP " + str.tostring(inTp1, "0.0") + "/" + str.tostring(inTp2, "0.0") + (inTpUnit == "Range heights" ? " ranges" : "R") + (inEodFlat ? " · flat at close" : ""), text_color = th.flat, text_size = szSub(), text_halign = text.align_left, bgcolor = th.bg)
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    table.cell(dash, 2, r, fSum == "" ? "no filters · classic rule" : "filters: " + str.substring(fSum, 0, str.length(fSum) - 3), text_color = th.flat, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg)

// ─────────────────────────────────────────────────────────────────── CO-PILOT
wrap(string s, int width) =>
    array<string> words = str.split(s, " ")
    string out = ""
    string line = ""
    for w in words
        if str.length(line) + str.length(w) + 1 > width and line != ""
            out := out + line + "\n"
            line := w
        else
            line := line == "" ? w : line + " " + w
    out + line
narrNow() =>
    string o = ""
    if t.dir != 0
        o := "in a " + (t.dir == 1 ? "long" : "short") + " from " + fmt(t.entry) + (t.retest ? " (retest entry)" : " (close-through 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 ? " (breakeven after TP1)" : " at the " + (inStopMode == "Range midpoint" ? "range midpoint" : inStopMode == "ATR from entry" ? "ATR distance" : "other side of the range")) + ", TP2 " + fmt(t.tp2) + (inEodFlat ? ", flat after " + inTradeSess : "")
    else if not dy.set
        o := inRange ? "the opening range is forming (" + inRangeSess + " " + inTz + ") — " + (na(dy.orH) ? "first bar" : fmt(dy.orL) + " to " + fmt(dy.orH) + " so far") + "; nothing fires until the window closes" : "waiting for the opening-range window (" + inRangeSess + " " + inTz + ")"
    else if dy.skip != ""
        o := "today is skipped — " + dy.skip
    else if dy.done
        o := "done for today: " + str.tostring(dy.trades) + " trade" + (dy.trades == 1 ? "" : "s") + ", " + (dy.todayR >= 0 ? "+" : "") + str.tostring(dy.todayR, "0.0") + "R" + (t.lastWhy != "" ? ", the last one ended on the " + (t.lastWhy == "TP2" ? "target" : t.lastWhy == "EOD" ? "close" : t.lastWhy == "BE" ? "breakeven stop" : "stop") : "")
    else if not entryOpen
        o := "the range is " + fmt(dy.orL) + " to " + fmt(dy.orH) + " but " + (inTrade ? "the latest entry time " + inLastEntry + " has passed" : "the trade window " + inTradeSess + " is closed") + " — no new entries"
    else
        o := "the range is set: " + fmt(dy.orL) + " to " + fmt(dy.orH) + " (" + str.tostring(rangeAtr, "0.00") + " × the daily ATR); " + (dy.brk != 0 ? "price broke " + (dy.brk == 1 ? "out above and a retest of " + fmt(dy.orH) : "out below and a retest of " + fmt(dy.orL)) + " would be the entry (" + str.tostring(math.max(0, inRetestWait - (bar_index - nz(dy.brkBar, bar_index)))) + " bars left)" : "a long fires on a close above " + fmt(upLvlD) + ", a short on a close below " + fmt(dnLvlD)) + (filtersOn ? "; filters allow " + (s4L and s4S ? "either side" : s4L ? "longs only" : s4S ? "shorts only" : "neither side right now") : "")
    o + (dy.log != "" ? "; today so far: " + dy.log : "")
narrWhy() =>
    "Opening range breakout: the first " + inRangeSess + " of the session sets the day's first balance; a close beyond it says one side has lost, so the trade goes with the winner and the other side of the range is where the idea is wrong. " + (inEntryMode == "Retest" ? "The retest variant waits for price to come back to the edge and hold, trading the confirmation instead of the first push." : "The close-through variant trades the first confirmed push.")
narrPlan() =>
    string o = ""
    if t.dir != 0
        o := "Stop first on every closed bar. TP1 " + fmt(t.tp1) + (inBeAtTp1 ? " moves the stop to breakeven" : " is noted") + "; TP2 " + fmt(t.tp2) + " is the final target" + (inEodFlat ? "; whatever is open closes on the first bar after " + inTradeSess : "") + ". The idea is wrong beyond " + fmt(t.sl0) + (inFailExit and not t.fade ? ", and a bar closing back inside the range ends it early" : "") + "." + (sizeTxt != "" ? " Size: " + sizeTxt + "." : "")
    else
        o := (dy.set ? "" : "Wait for the window " + inRangeSess + " to close; then ") + "a " + sideWord + " fires when a bar closes " + (side == 1 ? "above " + fmt(upLvlD) : "below " + fmt(dnLvlD)) + (inEntryMode == "Retest" ? ", price comes back to the edge within " + str.tostring(inRetestWait) + " bars and a bar closes back beyond it" : "") + (inConfirmBars > 1 ? " and holds for " + str.tostring(inConfirmBars) + " closes" : "") + (inStrongClose ? ", closing in the top part of its bar" : "") + (inFadeFail ? "; a break that closes back inside is traded the other way" : "") + (filtersOn ? ", with the filters on that side" : "") + ". Then: entry at that close, stop at the " + (inStopMode == "Range midpoint" ? "range midpoint" : inStopMode == "ATR from entry" ? str.tostring(inStopAtr, "0.0") + " ATR distance" : "other side of the range") + " plus a buffer, TP1 " + str.tostring(inTp1, "0.0") + " and TP2 " + str.tostring(inTp2, "0.0") + (inTpUnit == "Range heights" ? " range heights" : "R") + (inEodFlat ? ", flat after " + inTradeSess : "") + ". " + str.tostring(inMaxTrades) + " trade" + (inMaxTrades == 1 ? "" : "s") + " per day."
    o
narrTrack() =>
    st.n < 5 ? "Too few closed trades on this chart to say anything yet." : str.tostring(st.n) + " paper trades over " + str.tostring(st.daysTraded) + " traded days: " + 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.nTp) + " reached TP2, " + str.tostring(st.nStop) + " stopped, " + str.tostring(st.nBe) + " breakeven, " + str.tostring(st.nEod) + " closed at the end of the window. 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("Opening range = the high and low of the first minutes of the session. Close-through = a bar closing beyond the range edge. Retest = price returning to the broken edge and holding. Daily ATR = the previous day's 14-day average true range, the yardstick for the range size. Flat at the close = no overnight risk.", 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)
        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":"OpeningRangeDesk","symbol":"' + syminfo.ticker + '","tf":"' + timeframe.period + '","event":"' + evName + '","side":"' + (t.dir == 1 or ev.dir == 1 ? "LONG" : t.dir == -1 or ev.dir == -1 ? "SHORT" : "NONE") + '","entry":' + fmt(nz(t.entry, close)) + ',"sl":' + fmt(nz(t.sl, 0)) + ',"tp1":' + fmt(nz(t.tp1, 0)) + ',"tp2":' + fmt(nz(t.tp2, 0)) + ',"orHigh":' + fmt(nz(dy.orH, 0)) + ',"orLow":' + fmt(nz(dy.orL, 0)) + ',"reason":"' + why + '"' + (na(ev.r) ? "" : ',"r":' + str.tostring(ev.r, "0.00")) + '}'
    string tx = "Opening Range Desk · " + syminfo.ticker + " " + timeframe.period + " · " + evName + " · entry " + fmt(nz(t.entry, close)) + " · SL " + fmt(nz(t.sl, 0)) + " · TP " + fmt(nz(t.tp1, 0)) + " / " + fmt(nz(t.tp2, 0)) + " · OR " + fmt(nz(dy.orL, 0)) + "–" + fmt(nz(dy.orH, 0)) + (why != "" ? " · " + why : "")
    inAlertJson ? js : tx
alertcondition(ev.rangeSet, "Range set", "Opening Range Desk: the opening range is final — watching for the breakout")
alertcondition(ev.sigL, "ORB long", "Opening Range Desk: long — a bar closed above the opening range")
alertcondition(ev.sigS, "ORB short", "Opening Range Desk: short — a bar closed below the opening range")
alertcondition(ev.brkL or ev.brkS, "Broke out — waiting for the retest", "Opening Range Desk: price closed beyond the range; the retest is the entry")
alertcondition(ev.tp1, "TP1 reached", "Opening Range Desk: TP1 reached — stop to breakeven")
alertcondition(ev.exit and ev.why == "TP2", "TP2 reached", "Opening Range Desk: final target reached")
alertcondition(ev.exit and (ev.why == "SL" or ev.why == "BE"), "Stopped", "Opening Range Desk: stop hit")
alertcondition(ev.exit and ev.why == "EOD", "Flat at the close", "Opening Range Desk: the trade window ended — position closed")
alertcondition(ev.exit and ev.why == "TIME", "Time stop", "Opening Range Desk: TP1 not reached by the time stop — position closed")
alertcondition(ev.exit and ev.why == "FAIL", "Failed breakout", "Opening Range Desk: a bar closed back inside the range — position closed")
alertcondition(ev.fake, "Fake-out", "Opening Range Desk: a wick beyond the range closed back inside")
alertcondition(ev.rangeSet and dy.skip != "", "Day skipped", "Opening Range Desk: the opening range failed the size gate — no trade today")
if ev.rangeSet
    alert(payload("RANGE_SET", dy.skip), alert.freq_once_per_bar_close)
if ev.sigL
    alert(payload("ORB_LONG", t.retest ? "retest" : t.fade ? "fade" : "close-through"), alert.freq_once_per_bar_close)
if ev.sigS
    alert(payload("ORB_SHORT", t.retest ? "retest" : t.fade ? "fade" : "close-through"), 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 with fees and slippage on a second chart. Read the workflow chapter first.

Strategy code

Copy the code as it is. No coding needed.

View the full code
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════
//  OPENING RANGE DESK — STRATEGY TWIN  ·  Pine Script v6  ·  v1.2.0   (generated from orb_desk.pine by tools/gen-twin.mjs)
//  Built for a r/pinescript thread reply ("Orb strategy") — every variation the thread asked about is an input:
//  the range window (5 / 15 / 30 min or any session), direct breakout closes OR retests, VWAP and EMA trend
//  filters, plus the things the textbook leaves out: a range-size gate, a volume gate, a trade-window and
//  flat-at-the-close rule, one trade per day (or more), and a panel that says which step is still missing.
//
//  HOW IT READS THE MARKET (every decision commits on the CLOSED bar — nothing repaints):
//     1 RANGE      the high / low of every bar inside the opening-range window; final on the first bar after it
//     2 WINDOW     entries only inside the trade window; flat at its end (switch)
//     3 FILTERS    VWAP side · EMA side · breakout-bar volume · range size vs the daily ATR — all off by default
//     4 BREAKOUT   close-through: a bar CLOSES beyond the range edge (+ buffer) → entry at that close
//                  retest: after the close-through, price comes back to the edge and a bar closes back beyond it
//     EXIT         stop at the opposite side of the range (or the midpoint / ATR), TP1 → breakeven, TP2 final,
//                  flat at the end of the trade window
//
//  Paper ledger has NO costs — a description of the past on your chart. The strategy twin measures it with costs.
//  License: MIT — share freely, keep this header.  © 2026 ProEA Lab · built for r/pinescript
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════
//@version=6
strategy("Opening Range Desk — Strategy Twin", "ORBDeskS", overlay = true, 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 = 400, max_boxes_count = 200, max_lines_count = 300)
// 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
gR = "Opening range"
inRangeSess = input.session("0930-0945", "Opening-range window", group = gR, tooltip = "The bars whose highs and lows form the range. 0930-0935 = a 5-minute range, 0930-0945 = 15 minutes, 0930-1000 = 30 minutes. The range is final on the first bar after the window — that bar can already be the breakout bar. Measured on SPY 5m (profit factor, costs on): 5-minute range 0.61 · 15-minute 0.69 · 30-minute 0.60 — none of them paid this summer.", display = display.none)
inTz        = input.string("America/New_York", "Timezone (IANA)", group = gR, tooltip = "Both windows are read in this timezone. New York for US stocks, indices and the NY gold open.", display = display.none)
inTradeSess = input.session("0945-1555", "Trade window (entries allowed)", group = gR, tooltip = "Entries only inside this window. With 'flat at the end' on, an open trade is closed on the first bar after it.", display = display.none)
inEodFlat   = input.bool(true, "Flat at the end of the trade window", group = gR, display = display.none)
inLastEntry = input.string("", "Latest entry time — HH:MM in the timezone (blank = the whole window)", group = gR, tooltip = "Breakouts late in the day have the least room to run. With a time here, no new trade opens after it; open trades are still managed to the window's end. Measured (11:00, profit factor): SPY 5m 0.69 → 0.71 · QQQ 5m unchanged (every breakout came before 11:00 anyway) · gold 15m 0.88 → 1.01.", display = display.none)
inMinRange  = input.float(0.0, "Skip the day if the range is smaller than N × daily ATR (0 = off)", minval = 0, maxval = 3, step = 0.05, group = gR, tooltip = "A tiny range breaks on noise. The range height is compared with the previous day's 14-day ATR. Measured (0.15): SPY 5m 0.69 → 0.59.", display = display.none)
inMaxRange  = input.float(0.0, "Skip the day if the range is larger than N × daily ATR (0 = off)", minval = 0, maxval = 5, step = 0.05, group = gR, tooltip = "A huge range means the move already happened and the stop (the other side of the range) is far away. Measured (0.5): SPY 5m 0.69 → 0.75 · QQQ 5m 0.44 → 0.49.", display = display.none)

gE = "Entry"
inEntryMode = input.string("Close through", "Entry", options = ["Close through", "Retest"], group = gE, tooltip = "Close through: a bar closes beyond the range edge (+ buffer) → entry at that close. Retest: after that close-through, wait for price to come back to the edge (within the tolerance) and enter when a bar closes back beyond it; the setup expires if the retest does not come in time, and fails if a bar closes back inside the range. Measured (profit factor, close-through → retest): SPY 5m 0.69 → 0.58 · QQQ 5m 0.44 → 0.67 · gold 15m 0.88 → 0.55.", display = display.none)
inBreakBuf  = input.float(0.0, "Breakout buffer (× ATR beyond the edge)", minval = 0, maxval = 2, step = 0.05, group = gE, tooltip = "The close must be this far beyond the edge, in ATR. Measured (0.25, on the classic 1-close rule): SPY 5m 0.69 → 0.75 · QQQ 5m 0.44 → 0.39 · gold 15m 0.88 → 0.85.", display = display.none)
inRetestTol = input.float(0.25, "Retest tolerance (× ATR around the edge)", minval = 0, maxval = 2, step = 0.05, group = gE, display = display.none)
inRetestWait = input.int(12, "Retest must come within N bars", minval = 1, maxval = 200, group = gE, display = display.none)
inMaxTrades = input.int(1, "Trades per day", minval = 1, maxval = 3, group = gE, tooltip = "1 = the first breakout only (classic). 2 = after a stop-out the next breakout — either side — can fire again. Measured (2): SPY 5m 0.69 → 0.65 with 107 trades instead of 65.", display = display.none)

gB = "False-breakout defence (all off by default — each one measured)"
inConfirmBars = input.int(2, "Closes beyond the edge before entry", minval = 1, maxval = 5, group = gB, tooltip = "1 = the first close beyond the edge (the classic rule). 2 = the breakout must hold for a second consecutive close; a close back inside resets the count. Measured (1 → 2, profit factor): SPY 5m 0.69 → 0.80 · QQQ 5m 0.44 → 0.58 · gold 15m 0.88 → 1.08 — the one false-breakout defence that helped on all three charts, so 2 is the shipped default.", display = display.none)
inStrongClose = input.bool(false, "Strong close: the breakout bar must close in the top / bottom part of its range", group = gB, tooltip = "A breakout bar that closes near its own extreme shows conviction; one that closes mid-bar after poking through is the anatomy of a fake-out. Measured (30 %, on the classic 1-close rule): SPY 5m 0.69 → 0.67 · QQQ 5m 0.44 → 0.44 · gold 15m 0.88 → 0.93 — cosmetic.", display = display.none)
inStrongPct   = input.int(30, "  Top / bottom part of the bar (%)", minval = 10, maxval = 50, group = gB, display = display.none)
inFailExit    = input.bool(false, "Exit when a bar closes back inside the range (failed breakout)", group = gB, tooltip = "The breakout is the idea; a close back inside the range is the idea failing. This closes the trade at that close instead of waiting for the far-side stop. Measured (on the classic 1-close rule): SPY 5m 0.69 → 0.51 (win rate 34 → 17 % — the ETFs chop around the edge and the exit throws away trades that would have worked) · QQQ 5m 0.44 → 0.40 with a third less net loss · gold 15m 0.88 → 1.10 with the drawdown halved. Where breakouts either work or fail cleanly (the New York gold open) it is the right defence; where they chop, use the confirmation closes instead.", display = display.none)
inFadeFail    = input.bool(false, "Trade the failure: after a close back inside, enter the opposite way", group = gB, tooltip = "The failed-breakout fade: when a close beyond an edge is followed by a close back inside the range, enter against the failed side at that close, stop at the failed extreme plus the buffer, targets in R. Counts against the trades-per-day budget (set it to 2 to fade after your own stop-out). Measured (with the failed-breakout exit and 2 trades per day): SPY 5m 0.69 → 0.59 (123 trades) · QQQ 5m 0.44 → 0.52 · gold 15m 0.88 → 1.15 (128 trades).", display = display.none)

gX = "Exit"
inStopMode  = input.string("Opposite side of the range", "Stop", options = ["Opposite side of the range", "Range midpoint", "ATR from entry"], group = gX, tooltip = "Measured (midpoint): SPY 5m 0.69 → 0.64, drawdown −22 %. The classic opposite-side stop is the default.", display = display.none)
inStopAtr   = input.float(1.5, "  ATR stop distance (× ATR)", minval = 0.3, maxval = 10, step = 0.1, group = gX, display = display.none)
inStopBuf   = input.float(0.1, "  Stop buffer (× ATR beyond the level)", minval = 0, maxval = 2, step = 0.05, group = gX, display = display.none)
inTpUnit    = input.string("R (risk multiples)", "Targets measured in", options = ["R (risk multiples)", "Range heights"], group = gX, tooltip = "Measured (range heights): SPY 5m 0.69 → 0.81 · QQQ 5m 0.44 → 0.56 · gold 15m 0.88 → 0.74. With the volume gate too: QQQ 1.01.", display = display.none)
inTp1       = input.float(1.0, "TP1 → stop to breakeven", minval = 0.2, maxval = 10, step = 0.1, group = gX, display = display.none)
inTp2       = input.float(2.0, "TP2 final", minval = 0.3, maxval = 20, step = 0.1, group = gX, display = display.none)
inBeAtTp1   = input.bool(true, "Move the stop to breakeven at TP1", group = gX, tooltip = "Measured (off): SPY 5m 0.69 → 0.68, win rate 34 → 42 % — more small wins, same result.", display = display.none)
inTrailOn   = input.bool(false, "Trailing stop after TP1 (ATR)", group = gX, tooltip = "After TP1 the stop trails N ATR behind price instead of parking at breakeven — the classic 'let the winner run into the close'. Measured (profit factor, base → switch): SPY 5m 0.69 → 0.82 (win rate 34 → 51 %) · QQQ 5m 0.44 → 0.54 · gold 15m 0.88 → 0.89 — the only switch that helped on all three charts, still not enough on the ETFs.", display = display.none)
inTrailAtr  = input.float(1.5, "  Trail distance (× ATR)", minval = 0.3, maxval = 10, step = 0.1, group = gX, display = display.none)
inTimeStop  = input.string("", "Time stop — HH:MM in the timezone (blank = off): exit if TP1 not reached", group = gX, tooltip = "Classic ORB discipline: if the breakout has not paid its first target by this time, it is not the day — close it and stop waiting for the bell. Measured (12:00, profit factor): SPY 5m 0.69 → 0.55 · QQQ 5m 0.44 → 0.34 · gold 15m 0.88 → 1.09 with the drawdown cut by two thirds; with the VWAP + EMA gates too, gold 1.55. Right for the New York gold open, wrong for the index ETFs.", display = display.none)

gF = "Filters (all off by default — each one measured, numbers in its tooltip)"
inVwapOn    = input.bool(false, "VWAP side: longs above VWAP, shorts below", group = gF, tooltip = "The breakout bar must close on the VWAP's side of the trade. Measured (profit factor): SPY 5m and QQQ 5m — changed nothing (a close beyond the opening range at the open is on the VWAP's side anyway); gold 15m 0.88 → 1.11, and 1.36 together with the EMA filter. On a 24-hour market the VWAP has a whole session behind it — that is where this gate bites.", display = display.none)
inEmaOn     = input.bool(false, "EMA trend: longs above the EMA, shorts below", group = gF, tooltip = "Measured (profit factor): SPY 5m 0.69 → 0.53 · QQQ 5m 0.44 → 0.41 · gold 15m 0.88 → 1.27 (1.36 with the VWAP gate too). Helps where the open trends with the day, hurts where the open reverses it.", display = display.none)
inEmaLen    = input.int(200, "  EMA length", minval = 5, maxval = 500, group = gF, display = display.none)
inEmaTf     = input.timeframe("", "  EMA timeframe (blank = chart)", group = gF, tooltip = "A higher timeframe is read from its last CLOSED bar — no lookahead.", display = display.none)
inVolOn     = input.bool(false, "Breakout bar volume above its average", group = gF, tooltip = "Measured (profit factor): SPY 5m 0.69 → 0.78 · QQQ 5m 0.44 → 0.93 (1.01 with range-height targets) · gold 15m 0.88 → 0.90. The most useful gate on the index ETFs, still not enough to make them pay this summer.", display = display.none)
inVolMult   = input.float(1.2, "  Volume must be ≥ N × its average", minval = 0.5, maxval = 5, step = 0.1, group = gF, display = display.none)
inVolLen    = input.int(20, "  Volume average length", minval = 5, maxval = 200, group = gF, display = display.none)
inGapOn     = input.bool(false, "Gap direction: longs only after a gap up, shorts only after a gap down", group = gF, tooltip = "The day's open (the first bar of the range window) against the previous daily close. 'Gap and go' — trade the breakout only when it continues the gap. Measured (profit factor): SPY 5m 0.69 → 0.91 with the drawdown more than halved · QQQ 5m 0.44 → 0.65 · gold 15m 0.88 → 0.79 (a 24-hour market barely gaps — the gate has nothing to read there).", display = display.none)

gP = "Position size (display only)"
inRiskUsd   = input.float(0, "Risk per trade in account currency (0 = off)", minval = 0, step = 10, group = gP, tooltip = "Shows the size that risks this amount between entry and stop — on the ticket, the trade card and the Co-Pilot plan. Display only; the strategy twin trades one unit.", display = display.none)

gV = "Style"
inTheme     = input.string("Aurora Neon", "Theme", options = ["Aurora Neon", "Royal Gold", "Colorblind-safe"], group = gV, display = display.none)
inShowOr    = input.string("All days", "Opening-range boxes", options = ["All days", "Today only", "Off"], group = gV, display = display.none)
inShowLvls  = input.bool(true, "Breakout levels (dotted) while the day is live", 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)
inDrawVwap  = input.bool(false, "Draw VWAP (auto when its filter is on)", group = gV, display = display.none)
inDrawEma   = input.bool(false, "Draw the EMA (auto when its filter is on)", group = gV, display = display.none)
inRangeTag  = input.bool(true, "Range tag (height vs daily ATR) on each box", group = gV, display = display.none)
inZoneKeep  = input.string("Last trade only", "Keep risk / reward zones for", options = ["Last trade only", "All trades"], group = gV, tooltip = "Zones of older trades are removed when a new trade opens, so the chart stays readable; the tickets and exit tags keep the history.", display = display.none)
inBoxResult = input.bool(true, "Colour finished days by their result (green · red · grey)", group = gV, display = display.none)
inMidline   = input.bool(true, "Range midline (dotted)", group = gV, display = display.none)
inShowFake  = input.bool(true, "Mark fake-outs (a wick beyond the edge that closed back inside)", group = gV, 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)

gN = "Co-Pilot (narrative)"
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 = 140, 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 (all ta.* at global scope)
float atr    = ta.atr(14)
float atrU   = math.max(atr, syminfo.mintick)
float vwap   = ta.vwap(hlc3)
float emaC   = ta.ema(close, inEmaLen)
emaFn() => ta.ema(close, inEmaLen)
float emaH   = request.security(syminfo.tickerid, inEmaTf == "" ? timeframe.period : inEmaTf, emaFn()[1], lookahead = barmerge.lookahead_on)
float emaF   = inEmaTf == "" ? emaC : emaH
float volMa  = ta.sma(nz(volume, 0.0), inVolLen)
dAtrFn() => ta.atr(14)
float dAtr   = request.security(syminfo.tickerid, "D", dAtrFn()[1], lookahead = barmerge.lookahead_on)
float dAtrU  = math.max(nz(dAtr, atrU * 10), syminfo.mintick)
float pClose = request.security(syminfo.tickerid, "D", close[1], lookahead = barmerge.lookahead_on)
// clock helpers (all in the input timezone)
int    nowMin = hour(time, inTz) * 60 + minute(time, inTz)
string hhmm   = str.tostring(hour(time, inTz), "00") + ":" + str.tostring(minute(time, inTz), "00")
sessEndMin(string sess) =>
    string e = str.length(sess) >= 9 ? str.substring(sess, 5, 9) : "0000"
    int hh = int(nz(str.tonumber(str.substring(e, 0, 2)), 0))
    int mm = int(nz(str.tonumber(str.substring(e, 2, 4)), 0))
    hh * 60 + mm
clockMin(string hm) =>
    array<string> parts = str.split(hm, ":")
    array.size(parts) == 2 ? int(nz(str.tonumber(array.get(parts, 0)), -1)) * 60 + int(nz(str.tonumber(array.get(parts, 1)), 0)) : -1
int    winEndMin   = sessEndMin(inTradeSess)
int    minsLeft    = winEndMin - nowMin
string leftTxt     = minsLeft <= 0 ? "closing" : (minsLeft >= 60 ? str.tostring(math.floor(minsLeft / 60)) + " h " : "") + str.tostring(minsLeft % 60) + " m left"
int    timeStopMin = clockMin(inTimeStop)
int    lastEntryMin = clockMin(inLastEntry)
bool   timeStopNow = timeStopMin >= 0 and nowMin >= timeStopMin
bool  inRange  = not na(time(timeframe.period, inRangeSess, inTz))
bool  inTrade  = not na(time(timeframe.period, inTradeSess, inTz))
bool  rangeStart = inRange and not inRange[1]
bool  rangeEnd   = not inRange and inRange[1]
bool  windowEnd  = not inTrade and inTrade[1]
bool  entryOpen  = inTrade and (lastEntryMin < 0 or nowMin <= lastEntryMin)
bool  volOk    = not inVolOn or (not na(volume) and volume >= inVolMult * volMa)
bool  vwapUp   = not inVwapOn or close > vwap
bool  vwapDn   = not inVwapOn or close < vwap
bool  emaUp    = not inEmaOn or close > emaF
bool  emaDn    = not inEmaOn or close < emaF

// ─────────────────────────────────────────────────────────────────── STATE
type Day
    float  orH = na
    float  orL = na
    bool   set = false
    int    setBar = na
    int    startBar = na
    int    trades = 0
    int    brk = 0          // retest mode: side of the close-through we are waiting on
    int    brkBar = na
    bool   touched = false
    string skip = ""        // "" = tradable day; otherwise the reason
    float  todayR = 0.0
    int    dayNo = 0
    bool   done = false
    float  openPx = na
    string log = ""
    int    cntUp = 0
    int    cntDn = 0
    int    lastBrk = 0
    float  brkExt = na
    bool   fakeUpLogged = false
    bool   fakeDnLogged = false
type Trade
    int   dir = 0
    float entry = na
    float sl = na
    float sl0 = na
    float r = na
    float tp1 = na
    float tp2 = na
    int   bar = na
    bool  tp1Hit = false
    bool  retest = false
    bool  fade = false
    float lastR = na
    string lastWhy = ""
type Stat
    int   n = 0
    int   wins = 0
    int   flat = 0
    float sumR = 0.0
    int   nTp = 0
    int   nStop = 0
    int   nBe = 0
    int   nEod = 0
    int   days = 0
    int   daysTraded = 0
    int   daysSkipped = 0
    int   nTime = 0
    int   nFail = 0
    int   nFake = 0
type Ev
    bool   sigL = false
    bool   sigS = false
    bool   exit = false
    bool   tp1  = false
    bool   rangeSet = false
    bool   brkL = false
    bool   brkS = false
    bool   retestFail = false
    bool   fake = false
    string why  = ""
    float  r    = na
    int    dir  = 0
var Day   dy = Day.new()
var Trade t  = Trade.new()
var Stat  st = Stat.new()
Ev ev = Ev.new()
bool  gapUp  = not na(dy.openPx) and dy.openPx > pClose
bool  gapDn  = not na(dy.openPx) and dy.openPx < pClose
bool  gapOkL = not inGapOn or gapUp
bool  gapOkS = not inGapOn or gapDn

logEv(string what) =>
    dy.log := dy.log + (dy.log == "" ? "" : " · ") + hhmm + " " + what

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 == "TP2"
        st.nTp := st.nTp + 1
    else if why == "EOD"
        st.nEod := st.nEod + 1
    else if why == "BE"
        st.nBe := st.nBe + 1
    else if why == "TIME"
        st.nTime := st.nTime + 1
    else if why == "FAIL"
        st.nFail := st.nFail + 1
    else
        st.nStop := st.nStop + 1
    dy.todayR := dy.todayR + rr
    logEv((why == "TP2" ? "TP2 " : why == "EOD" ? "flat " : why == "TIME" ? "time stop " : why == "BE" ? "breakeven " : why == "TRAIL" ? "trail " : why == "FAIL" ? "failed " : "stop ") + (rr >= 0 ? "+" : "") + str.tostring(rr, "0.0") + "R")
    t.lastR := rr
    t.lastWhy := why
    t.dir := 0

openTrade(int d, bool viaRetest, float stopOverride) =>
    float rangeH = dy.orH - dy.orL
    float base = inStopMode == "Range midpoint" ? (dy.orH + dy.orL) / 2 : inStopMode == "ATR from entry" ? (d == 1 ? close - inStopAtr * atrU : close + inStopAtr * atrU) : (d == 1 ? dy.orL : dy.orH)
    float anchor = na(stopOverride) ? base : stopOverride
    float slPx = d == 1 ? math.min(anchor - inStopBuf * atrU, close - syminfo.mintick) : math.max(anchor + inStopBuf * atrU, close + syminfo.mintick)
    float unit = inTpUnit == "Range heights" ? math.max(rangeH, syminfo.mintick) : math.abs(close - slPx)
    t.dir := d
    t.entry := close
    t.sl := slPx
    t.sl0 := slPx
    t.r := math.abs(close - slPx)
    t.tp1 := close + d * inTp1 * unit
    t.tp2 := close + d * inTp2 * unit
    t.bar := bar_index
    t.tp1Hit := false
    t.retest := viaRetest
    t.fade := not na(stopOverride)
    dy.trades := dy.trades + 1
    logEv((d == 1 ? "▲ long " : "▼ short ") + fmt(close) + (viaRetest ? " (retest)" : t.fade ? " (fade)" : ""))
    if dy.trades == 1
        st.daysTraded := st.daysTraded + 1
    if d == 1
        ev.sigL := true
    else
        ev.sigS := true

if barstate.isconfirmed and not na(atr)
    // ── the day
    if rangeStart
        dy.orH := high
        dy.orL := low
        dy.set := false
        dy.setBar := na
        dy.startBar := bar_index
        dy.trades := 0
        dy.brk := 0
        dy.brkBar := na
        dy.touched := false
        dy.skip := ""
        dy.todayR := 0.0
        dy.dayNo := dy.dayNo + 1
        dy.done := false
        dy.openPx := open
        dy.log := ""
        dy.cntUp := 0
        dy.cntDn := 0
        dy.lastBrk := 0
        dy.brkExt := na
        dy.fakeUpLogged := false
        dy.fakeDnLogged := false
        st.days := st.days + 1
    else if inRange
        dy.orH := math.max(nz(dy.orH, high), high)
        dy.orL := math.min(nz(dy.orL, low), low)
    if rangeEnd and not dy.set and not na(dy.orH)
        dy.set := true
        dy.setBar := bar_index
        ev.rangeSet := true
        float rAtr = (dy.orH - dy.orL) / dAtrU
        if inMinRange > 0 and rAtr < inMinRange
            dy.skip := "range " + str.tostring(rAtr, "0.00") + " × daily ATR — too small (< " + str.tostring(inMinRange, "0.00") + ")"
        else if inMaxRange > 0 and rAtr > inMaxRange
            dy.skip := "range " + str.tostring(rAtr, "0.00") + " × daily ATR — too large (> " + str.tostring(inMaxRange, "0.00") + ")"
        if dy.skip != ""
            st.daysSkipped := st.daysSkipped + 1
        logEv("range set" + (dy.skip != "" ? " · skipped" : ""))
    // ── manage an open trade (stop first, then TP2, TP1, end of window)
    if t.dir != 0
        int d = t.dir
        bool stopHit = d == 1 ? low <= t.sl : high >= t.sl
        if stopHit
            closeTrade(t.sl, t.tp1Hit and t.sl == t.entry ? "BE" : t.tp1Hit and inTrailOn and t.sl != t.sl0 ? "TRAIL" : "SL")
        else if d == 1 ? high >= t.tp2 : low <= t.tp2
            closeTrade(t.tp2, "TP2")
        else
            if not t.tp1Hit and (d == 1 ? high >= t.tp1 : low <= t.tp1)
                t.tp1Hit := true
                ev.tp1 := true
                logEv("TP1")
                if inBeAtTp1
                    t.sl := t.entry
            if inFailExit and not t.fade and t.dir != 0 and (d == 1 ? close < dy.orH : close > dy.orL)
                closeTrade(close, "FAIL")
            if inTrailOn and t.tp1Hit and t.dir != 0
                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 timeStopNow and not t.tp1Hit and t.dir != 0
                closeTrade(close, "TIME")
            if inEodFlat and windowEnd and t.dir != 0
                closeTrade(close, "EOD")
    // ── breakout bookkeeping (every confirmed bar once the range is set): consecutive closes beyond an edge,
    //    the extreme reached since the last break, fake-outs (a wick beyond the edge that closed back inside)
    bool failUp = false
    bool failDn = false
    if dy.set
        float upLvlB = dy.orH + inBreakBuf * atrU
        float dnLvlB = dy.orL - inBreakBuf * atrU
        bool upRaw = close > upLvlB
        bool dnRaw = close < dnLvlB
        dy.cntUp := upRaw ? dy.cntUp + 1 : 0
        dy.cntDn := dnRaw ? dy.cntDn + 1 : 0
        failUp := dy.lastBrk == 1 and not upRaw and close < dy.orH
        failDn := dy.lastBrk == -1 and not dnRaw and close > dy.orL
        if upRaw
            dy.brkExt := dy.lastBrk == 1 ? math.max(nz(dy.brkExt, high), high) : high
            dy.lastBrk := 1
        else if dnRaw
            dy.brkExt := dy.lastBrk == -1 ? math.min(nz(dy.brkExt, low), low) : low
            dy.lastBrk := -1
        if t.dir == 0 and not upRaw and not dnRaw and dy.lastBrk == 0 and (high > dy.orH or low < dy.orL)
            ev.fake := true
            st.nFake := st.nFake + 1
            bool fakeAbv = high > dy.orH
            if fakeAbv and not dy.fakeUpLogged
                dy.fakeUpLogged := true
                logEv("fake-out ▲")
            else if not fakeAbv and not dy.fakeDnLogged
                dy.fakeDnLogged := true
                logEv("fake-out ▼")
    // ── signals (only when flat, the range is set, inside the trade window, on a tradable day)
    if t.dir == 0 and dy.set and entryOpen and dy.skip == "" and dy.trades < inMaxTrades
        float upLvl = dy.orH + inBreakBuf * atrU
        float dnLvl = dy.orL - inBreakBuf * atrU
        float barRange = math.max(high - low, syminfo.mintick)
        bool strongUp = not inStrongClose or (close - low) / barRange >= 1 - inStrongPct / 100.0
        bool strongDn = not inStrongClose or (high - close) / barRange >= 1 - inStrongPct / 100.0
        bool brkUp = close > upLvl and dy.cntUp >= inConfirmBars and strongUp
        bool brkDn = close < dnLvl and dy.cntDn >= inConfirmBars and strongDn
        bool longOk  = vwapUp and emaUp and volOk and gapOkL
        bool shortOk = vwapDn and emaDn and volOk and gapOkS
        if inFadeFail and ((failUp and shortOk) or (failDn and longOk))
            openTrade(failUp ? -1 : 1, false, dy.brkExt)
            dy.lastBrk := 0
        else if inEntryMode == "Close through"
            if brkUp and longOk
                openTrade(1, false, na)
            else if brkDn and shortOk
                openTrade(-1, false, na)
        else
            if dy.brk == 0
                if brkUp and longOk
                    dy.brk := 1
                    dy.brkBar := bar_index
                    dy.touched := false
                    ev.brkL := true
                    logEv("broke out ▲")
                else if brkDn and shortOk
                    dy.brk := -1
                    dy.brkBar := bar_index
                    dy.touched := false
                    ev.brkS := true
                    logEv("broke out ▼")
            else
                int bd = dy.brk
                float edge = bd == 1 ? dy.orH : dy.orL
                float tol = inRetestTol * atrU
                if bar_index - dy.brkBar > inRetestWait
                    dy.brk := 0
                    ev.retestFail := true
                    ev.why := "retest expired"
                    logEv("no retest")
                else if bd == 1 ? close < edge - tol : close > edge + tol
                    dy.brk := 0
                    ev.retestFail := true
                    ev.why := "closed back inside the range"
                    logEv("back inside")
                else if bar_index > dy.brkBar
                    if bd == 1 ? low <= edge + tol : high >= edge - tol
                        dy.touched := true
                    if dy.touched and (bd == 1 ? close > edge : close < edge)
                        openTrade(bd, true, na)
                        dy.brk := 0
    if failUp or failDn
        dy.lastBrk := 0
    if dy.set and t.dir == 0 and (dy.trades >= inMaxTrades or (not inTrade and dy.trades > 0))
        dy.done := true

// ─────────────────────────────────────────────────────────────────── READINESS (display only)
bool  flatNow  = t.dir == 0
float rangeH   = dy.set ? dy.orH - dy.orL : na
float rangeAtr = dy.set ? rangeH / dAtrU : na
float upLvlD   = dy.set ? dy.orH + inBreakBuf * atrU : na
float dnLvlD   = dy.set ? dy.orL - inBreakBuf * atrU : na
bool  s1 = dy.set
bool  s2 = entryOpen
bool  s3 = dy.skip == ""
bool  fVwapL = vwapUp
bool  fVwapS = vwapDn
bool  fEmaL  = emaUp
bool  fEmaS  = emaDn
bool  filtersOn = inVwapOn or inEmaOn or inVolOn or inGapOn
bool  s4L = fVwapL and fEmaL and gapOkL
bool  s4S = fVwapS and fEmaS and gapOkS
bool  s5  = dy.trades < inMaxTrades
int   side = filtersOn and (s4L != s4S) ? (s4L ? 1 : -1) : close >= (dy.set ? (dy.orH + dy.orL) / 2 : close) ? 1 : -1     // the side the filters allow, else the nearer edge
string sideWord = side == 1 ? "long" : "short"
string s1Txt = dy.set ? fmt(dy.orL) + " – " + fmt(dy.orH) + " · " + str.tostring(rangeAtr, "0.00") + " × daily ATR" : inRange ? "forming — " + (na(dy.orH) ? "first bar" : fmt(dy.orL) + " – " + fmt(dy.orH) + " so far") : "waiting for " + inRangeSess + " (" + inTz + ")"
string s2Txt = entryOpen ? "inside " + inTradeSess + (lastEntryMin >= 0 ? " · entries until " + inLastEntry : "") + " · " + leftTxt : inTrade ? "past the latest entry time " + inLastEntry + " · " + leftTxt : "outside " + inTradeSess + (dy.set ? " · no new entries" : "")
string s3Txt = dy.skip == "" ? ((inMinRange > 0 or inMaxRange > 0) ? "range size passes" : "no size gate") : dy.skip
string fTxt  = (inVwapOn ? "VWAP " + (close > vwap ? "below price" : "above price") : "") + (inEmaOn ? (inVwapOn ? " · " : "") + "EMA " + (close > emaF ? "below price" : "above price") : "") + (inVolOn ? ((inVwapOn or inEmaOn) ? " · " : "") + "volume " + str.tostring(nz(volume) / math.max(volMa, 1e-9), "0.0") + "× avg" : "") + (inGapOn ? ((inVwapOn or inEmaOn or inVolOn) ? " · " : "") + "gap " + (gapUp ? "up" : gapDn ? "down" : "flat") : "")
string s4Txt = filtersOn ? fTxt + " → " + (s4L and s4S ? "either side" : s4L ? "longs only" : s4S ? "shorts only" : "no side passes") : "no filters on — either side"
string brkTxt = not dy.set ? "needs the range first" : dy.brk != 0 ? (dy.brk == 1 ? "broke out ▲ · retest of " + fmt(dy.orH) : "broke out ▼ · retest of " + fmt(dy.orL)) + (dy.touched ? " · touched" : "") + " · " + str.tostring(math.max(0, inRetestWait - (bar_index - nz(dy.brkBar, bar_index)))) + " bars left" : "long > " + fmt(upLvlD) + " · short < " + fmt(dnLvlD) + (close > dy.orH ? " · price above" + (inConfirmBars > 1 and not dy.done ? " · " + str.tostring(math.min(dy.cntUp, inConfirmBars)) + "/" + str.tostring(inConfirmBars) + " closes" : "") : close < dy.orL ? " · price below" + (inConfirmBars > 1 and not dy.done ? " · " + str.tostring(math.min(dy.cntDn, inConfirmBars)) + "/" + str.tostring(inConfirmBars) + " closes" : "") : " · price inside")
bool  allPre = s1 and s2 and s3 and s5 and (s4L or s4S)
string missing = not flatNow ? "in a trade" : dy.done ? "done for today (" + str.tostring(dy.trades) + " of " + str.tostring(inMaxTrades) + " trades)" : not s1 ? "the range" : not s2 ? "the trade window" : not s3 ? "a tradable range size" : not (s4L or s4S) ? "a filter on either side" : inEntryMode == "Retest" and dy.brk != 0 ? "the retest" : "the breakout close"
string stateWord = t.dir == 1 ? "LONG OPEN" : t.dir == -1 ? "SHORT OPEN" : not dy.set ? (inRange ? "RANGE FORMING" : "WAITING FOR THE RANGE") : dy.skip != "" ? "DAY SKIPPED" : dy.done ? "DONE FOR TODAY" : not inTrade ? "OUTSIDE THE WINDOW" : not entryOpen ? "PAST THE ENTRY CUT-OFF" : dy.brk != 0 ? (dy.brk == 1 ? "BROKE OUT ▲ · RETEST?" : "BROKE OUT ▼ · RETEST?") : "RANGE SET · WATCHING"
color  stateCol  = t.dir == 1 ? th.up : t.dir == -1 ? th.down : dy.brk == 1 ? th.up : dy.brk == -1 ? th.down : dy.set and entryOpen and dy.skip == "" and not dy.done ? th.accentHi : th.flat
float  openR     = t.dir != 0 ? (close - t.entry) * t.dir / t.r : na
string sizeTxt   = inRiskUsd > 0 and t.dir != 0 and t.r > 0 ? str.tostring(math.floor(inRiskUsd / t.r)) + (syminfo.type == "stock" or syminfo.type == "fund" ? " shares" : " units") + " for " + str.tostring(inRiskUsd, "#") + " risk" : ""
string chipTxt   = t.dir != 0 ? (openR >= 0 ? "+" : "") + str.tostring(openR, "0.0") + "R now" + (t.tp1Hit ? " · stop at breakeven" : "") : dy.trades > 0 ? "today " + (dy.todayR >= 0 ? "+" : "") + str.tostring(dy.todayR, "0.0") + "R" : dy.set ? str.tostring(bar_index - nz(dy.setBar, bar_index)) + " bars since the range" : "day " + str.tostring(dy.dayNo)

// ─────────────────────────────────────────────────────────────────── CHART · range boxes, levels, trade levels
var array<box>   orBoxes = array.new<box>()
var array<line>  orLines = array.new<line>()
var array<label> orTags  = array.new<label>()
var box   curBox = na
var box   curExt = na
var line  curHi = na
var line  curLo = na
var line  curMid = na
var label curTag = na
color rangeCol = dy.skip != "" ? th.flat : th.accent
if barstate.isconfirmed
    if rangeEnd and dy.set and inShowOr != "Off"
        if inShowOr == "Today only"
            for b in orBoxes
                box.delete(b)
            array.clear(orBoxes)
            for l in orLines
                line.delete(l)
            array.clear(orLines)
            for g in orTags
                label.delete(g)
            array.clear(orTags)
        int x1 = math.max(nz(dy.startBar, bar_index - 1), bar_index - 250)
        float midPx = (dy.orH + dy.orL) / 2
        curBox := box.new(x1, dy.orH, bar_index, dy.orL, border_color = color.new(rangeCol, 100), border_width = 1, bgcolor = color.new(rangeCol, 82))
        curExt := box.new(bar_index, dy.orH, bar_index, dy.orL, border_color = color.new(rangeCol, 100), border_width = 1, bgcolor = color.new(rangeCol, 93))
        curHi := line.new(x1, dy.orH, bar_index, dy.orH, color = color.new(rangeCol, 15), width = 2)
        curLo := line.new(x1, dy.orL, bar_index, dy.orL, color = color.new(rangeCol, 15), width = 2)
        curMid := line.new(x1, midPx, bar_index, midPx, color = color.new(rangeCol, inMidline ? 60 : 100), width = 1, style = line.style_dotted)
        array.push(orBoxes, curBox)
        array.push(orBoxes, curExt)
        array.push(orLines, curHi)
        array.push(orLines, curLo)
        array.push(orLines, curMid)
        if inRangeTag
            curTag := label.new(bar_index, dy.orH, "OR " + str.tostring(rangeAtr, "0.00") + "× dATR" + (dy.skip != "" ? " · skipped" : ""), style = label.style_label_lower_left, color = color.new(th.bg, 20), textcolor = dy.skip != "" ? th.flat : th.accent, size = size.tiny)
            array.push(orTags, curTag)
        while array.size(orBoxes) > 120
            box.delete(array.shift(orBoxes))
        while array.size(orLines) > 180
            line.delete(array.shift(orLines))
        while array.size(orTags) > 60
            label.delete(array.shift(orTags))
    else if dy.set and inTrade and not na(curExt)
        box.set_right(curExt, bar_index)
        line.set_x2(curHi, bar_index)
        line.set_x2(curLo, bar_index)
        line.set_x2(curMid, bar_index)
    if inBoxResult and not na(curBox) and dy.set and t.dir == 0 and (dy.done or (not inTrade and inTrade[1]))
        color resCol = dy.trades == 0 ? th.flat : dy.todayR > 0.05 ? th.up : dy.todayR < -0.05 ? th.down : th.flat
        box.set_bgcolor(curBox, color.new(resCol, 86))
        box.set_bgcolor(curExt, color.new(resCol, 94))
        line.set_color(curHi, color.new(resCol, 45))
        line.set_color(curLo, color.new(resCol, 45))
        line.set_width(curHi, 1)
        line.set_width(curLo, 1)
        line.set_color(curMid, color.new(resCol, inMidline ? 75 : 100))
// breakout levels (dotted) while the day is live and no trade is open
bool  lvlOn = inShowLvls and dy.set and inTrade and t.dir == 0 and dy.skip == "" and not dy.done
plot(lvlOn ? upLvlD : na, "Breakout level · long", color = color.new(th.up, 45), linewidth = 1, style = plot.style_circles, display = display.pane)
plot(lvlOn ? dnLvlD : na, "Breakout level · short", color = color.new(th.down, 45), linewidth = 1, style = plot.style_circles, display = display.pane)
// filters
plot(inVwapOn or inDrawVwap ? vwap : na, "VWAP", color = color.new(close > vwap ? th.up : th.down, 35), linewidth = 1, display = display.pane)
plot(inEmaOn or inDrawEma ? emaF : na, "EMA", color = color.new(th.accent, 40), linewidth = 1, display = display.pane)
// trade levels
bool inT = t.dir != 0
plot(inT ? t.entry : na, "Entry", color = color.new(th.accentHi, 20), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(inT ? t.sl : na, "Stop", color = color.new(th.down, 20), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(inT ? t.tp1 : na, "TP1", color = color.new(th.up, 55), linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(inT ? t.tp2 : na, "TP2", color = color.new(th.up, 20), linewidth = 1, style = plot.style_linebr, display = display.pane)
barcolor(inTint and inT ? color.new(t.dir == 1 ? th.up : th.down, 15) : na, title = "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 ? 88 + k * 2 : 96 - k * 2
        box b = box.new(x1, bTop, x2, bBot, border_color = color.new(c, 100), bgcolor = color.new(c, alpha))
        array.push(zones, b)
        array.push(liveZ, b)
if barstate.isconfirmed
    if (ev.sigL or ev.sigS) and inZones
        if inZoneKeep == "Last trade only"
            for b in zones
                box.delete(b)
            array.clear(zones)
        array.clear(liveZ)
        int d = t.dir
        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
if ev.sigL
    label.new(bar_index, low, "ORB ▲  " + fmt(t.entry) + (t.retest ? "  · retest" : t.fade ? "  · fade" : "") + "\nstop " + fmt(t.sl) + " · TP2 " + fmt(t.tp2) + (sizeTxt != "" ? "\n" + sizeTxt : ""), style = label.style_label_up, color = color.new(th.up, 12), textcolor = th.bg, size = size.small,
         tooltip = "Long: a bar closed above the opening range" + (t.retest ? " and price came back to the edge and closed above it again" : "") + ". Stop " + fmt(t.sl) + " · TP1 " + fmt(t.tp1) + " · TP2 " + fmt(t.tp2))
if ev.sigS
    label.new(bar_index, high, "ORB ▼  " + fmt(t.entry) + (t.retest ? "  · retest" : t.fade ? "  · fade" : "") + "\nstop " + fmt(t.sl) + " · TP2 " + fmt(t.tp2) + (sizeTxt != "" ? "\n" + sizeTxt : ""), style = label.style_label_down, color = color.new(th.down, 12), textcolor = th.bg, size = size.small,
         tooltip = "Short: a bar closed below the opening range" + (t.retest ? " and price came back to the edge and closed below it again" : "") + ". Stop " + fmt(t.sl) + " · TP1 " + fmt(t.tp1) + " · TP2 " + fmt(t.tp2))
if ev.brkL
    label.new(bar_index, high, "broke out ▲ · waiting for the retest", style = label.style_label_down, color = color.new(th.bg, 15), textcolor = th.up, size = size.tiny)
if ev.brkS
    label.new(bar_index, low, "broke out ▼ · waiting for the retest", style = label.style_label_up, color = color.new(th.bg, 15), textcolor = th.down, size = size.tiny)
if ev.retestFail
    label.new(bar_index, high, "no retest · " + ev.why, style = label.style_label_down, color = color.new(th.bg, 15), textcolor = th.flat, size = size.tiny)
if ev.fake and inShowFake
    bool fakeAbove = high > dy.orH
    label.new(bar_index, fakeAbove ? high : low, "✕", style = fakeAbove ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 100), textcolor = color.new(th.flat, 25), size = size.tiny, tooltip = "Fake-out: the bar poked beyond the range and closed back inside")
if ev.exit
    string rTxt = (ev.r >= 0 ? "+" : "") + str.tostring(ev.r, "0.0") + "R"
    string whyTxt = ev.why == "TP2" ? "target" : ev.why == "EOD" ? "flat at the close" : ev.why == "BE" ? "breakeven" : ev.why == "TIME" ? "time stop" : ev.why == "TRAIL" ? "trail" : ev.why == "FAIL" ? "failed breakout" : "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)

// ─────────────────────────────────────────────────────────────────── DASHBOARD
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)

int readyN   = (s1 ? 1 : 0) + (s2 ? 1 : 0) + (s3 ? 1 : 0) + ((s4L or s4S) ? 1 : 0) + (s5 ? 1 : 0)
var table dash = table.new(posOf(inDashPos), 3, 20, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_width = 0, force_overlay = false)
if barstate.islast and inDash
    table.clear(dash, 0, 0, 2, 19)
    int r = 0
    table.cell(dash, 0, r, "OPENING RANGE DESK", text_color = th.accentHi, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Opening range breakout: the range window, the trade window, the filters, the breakout — as a checklist, 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
    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 = "Where today stands: range forming → range set → breakout (or retest) → trade → done.")
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    color chipBg = t.dir != 0 ? color.new(openR >= 0 ? th.up : th.down, 75) : dy.trades > 0 ? color.new(dy.todayR >= 0 ? th.up : th.down, 75) : color.new(color.from_gradient(readyN, 0, 5, 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)
    r += 1
    if t.dir == 0
        section(dash, r, "CHECKLIST · " + str.tostring(readyN) + " OF 5 STEPS")
        r += 1
        row3(dash, r, "1 · Range", s1, s1Txt, true)
        r += 1
        row3(dash, r, "2 · Window", s2, s2Txt, true)
        r += 1
        row3(dash, r, "3 · Range size", s3, s3Txt, true)
        r += 1
        row3(dash, r, "4 · Filters", s4L or s4S, s4Txt, true)
        r += 1
        row3(dash, r, "5 · Budget", s5, str.tostring(dy.trades) + " of " + str.tostring(inMaxTrades) + " trades today" + (dy.trades > 0 ? " · " + (dy.todayR >= 0 ? "+" : "") + str.tostring(dy.todayR, "0.0") + "R" : ""), true)
        r += 1
        row3(dash, r, "Breakout", dy.brk != 0 or (dy.set and (close > dy.orH or close < dy.orL)), brkTxt, false)
        r += 1
        row3(dash, r, "Missing", allPre and not dy.done, missing, false)
        r += 1
        if dy.log != ""
            row3(dash, r, "Today", true, dy.log, false)
            r += 1
    else
        section(dash, r, "TRADE · " + (t.dir == 1 ? "LONG" : "SHORT") + (t.retest ? " · RETEST" : " · CLOSE-THROUGH") + " · " + 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) + (t.tp1Hit and t.sl == t.entry ? " · breakeven" : inStopMode == "Range midpoint" ? " · range midpoint" : inStopMode == "ATR from entry" ? " · ATR stop" : " · other side of the range"), false)
        r += 1
        row3(dash, r, "Targets", t.tp1Hit, "TP1 " + fmt(t.tp1) + (t.tp1Hit ? " ✓" : "") + " · TP2 " + fmt(t.tp2) + (inTpUnit == "Range heights" ? " (range heights)" : ""), false)
        r += 1
        row3(dash, r, "Exit rule", true, "stop or TP2" + (inFailExit and not t.fade ? " · out if a bar closes back inside" : "") + (inTrailOn ? " · trail " + str.tostring(inTrailAtr, "0.0") + " ATR after TP1" : "") + (timeStopMin >= 0 ? " · time stop " + inTimeStop : "") + (inEodFlat ? " · flat after " + inTradeSess : " · may hold overnight"), false)
        r += 1
        if sizeTxt != ""
            row3(dash, r, "Size", true, sizeTxt + " · " + fmt(t.r) + " per unit", false)
            r += 1
        if dy.log != ""
            row3(dash, r, "Today", true, dy.log, false)
            r += 1
        row3(dash, r, "Range", true, fmt(dy.orL) + " – " + fmt(dy.orH) + " · " + str.tostring(rangeAtr, "0.00") + " × daily ATR", false)
        r += 1
    section(dash, r, "RECORD · PAPER, NO COSTS")
    r += 1
    row3(dash, r, "Days", true, str.tostring(st.days) + " seen · " + str.tostring(st.daysTraded) + " traded" + (st.daysSkipped > 0 ? " · " + str.tostring(st.daysSkipped) + " skipped" : "") + (st.nFake > 0 ? " · " + str.tostring(st.nFake) + " fake-outs" : ""), false)
    r += 1
    row3(dash, r, "Closed", st.n > 0 and st.sumR >= 0, st.n == 0 ? "no closed trades yet" : str.tostring(st.n) + " · " + str.tostring(math.round(st.wins / st.n * 100)) + "% win · " + 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, "TP2 " + str.tostring(st.nTp) + " · stop " + str.tostring(st.nStop) + " · BE " + str.tostring(st.nBe) + " · EOD " + str.tostring(st.nEod) + (st.nTime > 0 ? " · time " + str.tostring(st.nTime) : "") + (st.nFail > 0 ? " · failed " + str.tostring(st.nFail) : "") + (na(t.lastR) ? "" : " · last " + (t.lastR >= 0 ? "+" : "") + str.tostring(t.lastR, "0.0") + "R"), false)
    r += 1
    string fSum = (inVwapOn ? "VWAP · " : "") + (inEmaOn ? "EMA " + str.tostring(inEmaLen) + (inEmaTf != "" ? "@" + inEmaTf : "") + " · " : "") + (inVolOn ? "volume · " : "") + (inGapOn ? "gap · " : "") + (inMinRange > 0 or inMaxRange > 0 ? "range size · " : "") + (inTrailOn ? "trail · " : "") + (timeStopMin >= 0 ? "time " + inTimeStop + " · " : "") + (inConfirmBars > 1 ? str.tostring(inConfirmBars) + " closes · " : "") + (inStrongClose ? "strong close · " : "") + (inFailExit ? "fail-exit · " : "") + (inFadeFail ? "fade · " : "") + (lastEntryMin >= 0 ? "entries until " + inLastEntry + " · " : "")
    table.cell(dash, 0, r, "OR " + inRangeSess + " · " + (inEntryMode == "Retest" ? "retest" : "close-through") + " · stop " + (inStopMode == "Range midpoint" ? "midpoint" : inStopMode == "ATR from entry" ? "ATR" : "far side") + " · TP " + str.tostring(inTp1, "0.0") + "/" + str.tostring(inTp2, "0.0") + (inTpUnit == "Range heights" ? " ranges" : "R") + (inEodFlat ? " · flat at close" : ""), text_color = th.flat, text_size = szSub(), text_halign = text.align_left, bgcolor = th.bg)
    table.cell(dash, 1, r, "", bgcolor = th.bg)
    table.cell(dash, 2, r, fSum == "" ? "no filters · classic rule" : "filters: " + str.substring(fSum, 0, str.length(fSum) - 3), text_color = th.flat, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg)

// ─────────────────────────────────────────────────────────────────── CO-PILOT
wrap(string s, int width) =>
    array<string> words = str.split(s, " ")
    string out = ""
    string line = ""
    for w in words
        if str.length(line) + str.length(w) + 1 > width and line != ""
            out := out + line + "\n"
            line := w
        else
            line := line == "" ? w : line + " " + w
    out + line
narrNow() =>
    string o = ""
    if t.dir != 0
        o := "in a " + (t.dir == 1 ? "long" : "short") + " from " + fmt(t.entry) + (t.retest ? " (retest entry)" : " (close-through 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 ? " (breakeven after TP1)" : " at the " + (inStopMode == "Range midpoint" ? "range midpoint" : inStopMode == "ATR from entry" ? "ATR distance" : "other side of the range")) + ", TP2 " + fmt(t.tp2) + (inEodFlat ? ", flat after " + inTradeSess : "")
    else if not dy.set
        o := inRange ? "the opening range is forming (" + inRangeSess + " " + inTz + ") — " + (na(dy.orH) ? "first bar" : fmt(dy.orL) + " to " + fmt(dy.orH) + " so far") + "; nothing fires until the window closes" : "waiting for the opening-range window (" + inRangeSess + " " + inTz + ")"
    else if dy.skip != ""
        o := "today is skipped — " + dy.skip
    else if dy.done
        o := "done for today: " + str.tostring(dy.trades) + " trade" + (dy.trades == 1 ? "" : "s") + ", " + (dy.todayR >= 0 ? "+" : "") + str.tostring(dy.todayR, "0.0") + "R" + (t.lastWhy != "" ? ", the last one ended on the " + (t.lastWhy == "TP2" ? "target" : t.lastWhy == "EOD" ? "close" : t.lastWhy == "BE" ? "breakeven stop" : "stop") : "")
    else if not entryOpen
        o := "the range is " + fmt(dy.orL) + " to " + fmt(dy.orH) + " but " + (inTrade ? "the latest entry time " + inLastEntry + " has passed" : "the trade window " + inTradeSess + " is closed") + " — no new entries"
    else
        o := "the range is set: " + fmt(dy.orL) + " to " + fmt(dy.orH) + " (" + str.tostring(rangeAtr, "0.00") + " × the daily ATR); " + (dy.brk != 0 ? "price broke " + (dy.brk == 1 ? "out above and a retest of " + fmt(dy.orH) : "out below and a retest of " + fmt(dy.orL)) + " would be the entry (" + str.tostring(math.max(0, inRetestWait - (bar_index - nz(dy.brkBar, bar_index)))) + " bars left)" : "a long fires on a close above " + fmt(upLvlD) + ", a short on a close below " + fmt(dnLvlD)) + (filtersOn ? "; filters allow " + (s4L and s4S ? "either side" : s4L ? "longs only" : s4S ? "shorts only" : "neither side right now") : "")
    o + (dy.log != "" ? "; today so far: " + dy.log : "")
narrWhy() =>
    "Opening range breakout: the first " + inRangeSess + " of the session sets the day's first balance; a close beyond it says one side has lost, so the trade goes with the winner and the other side of the range is where the idea is wrong. " + (inEntryMode == "Retest" ? "The retest variant waits for price to come back to the edge and hold, trading the confirmation instead of the first push." : "The close-through variant trades the first confirmed push.")
narrPlan() =>
    string o = ""
    if t.dir != 0
        o := "Stop first on every closed bar. TP1 " + fmt(t.tp1) + (inBeAtTp1 ? " moves the stop to breakeven" : " is noted") + "; TP2 " + fmt(t.tp2) + " is the final target" + (inEodFlat ? "; whatever is open closes on the first bar after " + inTradeSess : "") + ". The idea is wrong beyond " + fmt(t.sl0) + (inFailExit and not t.fade ? ", and a bar closing back inside the range ends it early" : "") + "." + (sizeTxt != "" ? " Size: " + sizeTxt + "." : "")
    else
        o := (dy.set ? "" : "Wait for the window " + inRangeSess + " to close; then ") + "a " + sideWord + " fires when a bar closes " + (side == 1 ? "above " + fmt(upLvlD) : "below " + fmt(dnLvlD)) + (inEntryMode == "Retest" ? ", price comes back to the edge within " + str.tostring(inRetestWait) + " bars and a bar closes back beyond it" : "") + (inConfirmBars > 1 ? " and holds for " + str.tostring(inConfirmBars) + " closes" : "") + (inStrongClose ? ", closing in the top part of its bar" : "") + (inFadeFail ? "; a break that closes back inside is traded the other way" : "") + (filtersOn ? ", with the filters on that side" : "") + ". Then: entry at that close, stop at the " + (inStopMode == "Range midpoint" ? "range midpoint" : inStopMode == "ATR from entry" ? str.tostring(inStopAtr, "0.0") + " ATR distance" : "other side of the range") + " plus a buffer, TP1 " + str.tostring(inTp1, "0.0") + " and TP2 " + str.tostring(inTp2, "0.0") + (inTpUnit == "Range heights" ? " range heights" : "R") + (inEodFlat ? ", flat after " + inTradeSess : "") + ". " + str.tostring(inMaxTrades) + " trade" + (inMaxTrades == 1 ? "" : "s") + " per day."
    o
narrTrack() =>
    st.n < 5 ? "Too few closed trades on this chart to say anything yet." : str.tostring(st.n) + " paper trades over " + str.tostring(st.daysTraded) + " traded days: " + 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.nTp) + " reached TP2, " + str.tostring(st.nStop) + " stopped, " + str.tostring(st.nBe) + " breakeven, " + str.tostring(st.nEod) + " closed at the end of the window. 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("Opening range = the high and low of the first minutes of the session. Close-through = a bar closing beyond the range edge. Retest = price returning to the broken edge and holding. Daily ATR = the previous day's 14-day average true range, the yardstick for the range size. Flat at the close = no overnight risk.", 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)
        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 = t.retest ? "ORB retest" : t.fade ? "ORB fade" : "ORB")
if ev.sigS
    strategy.entry("S", strategy.short, comment = t.retest ? "ORB retest" : t.fade ? "ORB fade" : "ORB")
if t.dir != 0 and strategy.position_size != 0
    strategy.exit("X", stop = t.sl, limit = t.tp2, comment_profit = "TP2", comment_loss = t.tp1Hit and t.sl == t.entry ? "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":"OpeningRangeDesk","symbol":"' + syminfo.ticker + '","tf":"' + timeframe.period + '","event":"' + evName + '","side":"' + (t.dir == 1 or ev.dir == 1 ? "LONG" : t.dir == -1 or ev.dir == -1 ? "SHORT" : "NONE") + '","entry":' + fmt(nz(t.entry, close)) + ',"sl":' + fmt(nz(t.sl, 0)) + ',"tp1":' + fmt(nz(t.tp1, 0)) + ',"tp2":' + fmt(nz(t.tp2, 0)) + ',"orHigh":' + fmt(nz(dy.orH, 0)) + ',"orLow":' + fmt(nz(dy.orL, 0)) + ',"reason":"' + why + '"' + (na(ev.r) ? "" : ',"r":' + str.tostring(ev.r, "0.00")) + '}'
    string tx = "Opening Range Desk · " + syminfo.ticker + " " + timeframe.period + " · " + evName + " · entry " + fmt(nz(t.entry, close)) + " · SL " + fmt(nz(t.sl, 0)) + " · TP " + fmt(nz(t.tp1, 0)) + " / " + fmt(nz(t.tp2, 0)) + " · OR " + fmt(nz(dy.orL, 0)) + "–" + fmt(nz(dy.orH, 0)) + (why != "" ? " · " + why : "")
    inAlertJson ? js : tx
alertcondition(ev.rangeSet, "Range set", "Opening Range Desk: the opening range is final — watching for the breakout")
alertcondition(ev.sigL, "ORB long", "Opening Range Desk: long — a bar closed above the opening range")
alertcondition(ev.sigS, "ORB short", "Opening Range Desk: short — a bar closed below the opening range")
alertcondition(ev.brkL or ev.brkS, "Broke out — waiting for the retest", "Opening Range Desk: price closed beyond the range; the retest is the entry")
alertcondition(ev.tp1, "TP1 reached", "Opening Range Desk: TP1 reached — stop to breakeven")
alertcondition(ev.exit and ev.why == "TP2", "TP2 reached", "Opening Range Desk: final target reached")
alertcondition(ev.exit and (ev.why == "SL" or ev.why == "BE"), "Stopped", "Opening Range Desk: stop hit")
alertcondition(ev.exit and ev.why == "EOD", "Flat at the close", "Opening Range Desk: the trade window ended — position closed")
alertcondition(ev.exit and ev.why == "TIME", "Time stop", "Opening Range Desk: TP1 not reached by the time stop — position closed")
alertcondition(ev.exit and ev.why == "FAIL", "Failed breakout", "Opening Range Desk: a bar closed back inside the range — position closed")
alertcondition(ev.fake, "Fake-out", "Opening Range Desk: a wick beyond the range closed back inside")
alertcondition(ev.rangeSet and dy.skip != "", "Day skipped", "Opening Range Desk: the opening range failed the size gate — no trade today")
if ev.rangeSet
    alert(payload("RANGE_SET", dy.skip), alert.freq_once_per_bar_close)
if ev.sigL
    alert(payload("ORB_LONG", t.retest ? "retest" : t.fade ? "fade" : "close-through"), alert.freq_once_per_bar_close)
if ev.sigS
    alert(payload("ORB_SHORT", t.retest ? "retest" : t.fade ? "fade" : "close-through"), 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 box yet?

Check that your chart includes the opening window in the selected timezone. Use candles that fit the window: five-minute candles fit the default fifteen-minute opening range. Larger candles cannot reconstruct the smaller window exactly.

Go deeper, one question at a time.

Open the chapter you need. All settings are here.

When is a breakout allowed?

The range must be locked, the trade window open, the range-size and side filters passed, and the daily allowance available with no trade open. An optional latest-entry cutoff can stop new entries sooner.

Default windows use America/New_York: opening range 09:30–09:45; entries 09:45–15:55. Decisions commit on closed candles. On a five-minute chart, the 09:45 candle locks the range at its 09:50 close and can count toward entry.

With flat-at-end enabled, the tool closes at the close of the first candle outside the trade window—not necessarily at exactly 15:55.

How does waiting for a retest work?

Close through: the required consecutive closes must finish strictly beyond the edge plus any breakout buffer. A close on or inside that boundary resets the count.

Retest: those closes first arm the setup. A later candle must visit the allowed area near the edge and close beyond the raw edge. The arming candle cannot enter.

Example: upper edge 106, ATR 8, default tolerance 0.25. A later low at or below 108 records a touch. A close above 106 enters; exactly 106 waits. A close below 104 cancels; exactly 104 survives. A recorded touch can carry forward.

The default deadline is twelve later candles; the thirteenth expires first. Filters and strong-close checks qualify the arming candle; they are not checked again at retest entry. Window, allowance and flat-position gates still apply.

What if price comes back?

A wick beyond an edge that closes back inside can receive a fake-out mark. Two consecutive closes are required by default, but they cannot prevent every reversal.

The optional failed-breakout exit closes a long when a later close falls below the raw upper edge; it can pass through the whole box. The short rule mirrors this. The optional failure fade trades the opposite way and uses the daily allowance.

What do stop, targets and R mean?

Stop: the plan’s loss-exit level. By default it sits beyond the opposite edge by 0.1 ATR. ATR measures recent price movement, including gaps.

1R: the price distance from entry to initial stop. The first target is that distance in the trade’s favor; the final target is twice it. Reaching TP1 moves the stop to entry by default. It does not take partial profit.

The indicator checks the existing stop before targets on each later candle. If both are touched, its paper record counts the stop first. A newly moved stop is not applied retroactively to that candle’s earlier wick.

Size display: divides the risk input by the entry-to-stop distance and rounds down. It does not convert currencies or account for contract point values. Check your instrument’s actual sizing separately. The strategy twin defaults to one unit; this risk display does not control its order size.

How do I read the panel and Co-Pilot?

State names the stage. Range shows the box; Window shows entry time; Range size and Filters show checks; Budget shows today’s allowance. Breakout lists the levels, and Missing names what remains.

Today is the session timeline. Record summarizes paper results without costs. Co-Pilot’s default Brief mode gives NOW. Standard adds WHY and PLAN; Detailed adds the record and glossary.

Opening Range Desk on a gold fifteen-minute chart with filters, plan tickets and the Co-Pilot explanation.
Gold · fifteen-minute chart · optional filters enabledOpen full-size chart ↗
What does each setting change?

Defaults below refer to the indicator. Change one setting at a time.

Opening range

Choose which opening candles build the box and when entries are allowed. Size gates compare its height with the previous day’s fourteen-day ATR.

Opening-range window
09:30–09:45
Timezone
America/New_York
Trade window
09:45–15:55
Flat at window end
On
Latest entry time
Blank · whole window
Minimum range × daily ATR
0 · off
Maximum range × daily ATR
0 · off

Entry

Close through enters when the count completes. Retest waits for a later return. Buffers and tolerance use chart ATR: a measure of recent price movement, including gaps.

Entry mode
Close through
Breakout buffer
0 × ATR
Retest tolerance
ATR × 0.25
Retest deadline
12 candles
Trades per day
1

False-breakout controls

Two-close confirmation is on. The other controls are optional: a close near its candle’s extreme, an early failure exit, or an entry against a failed breakout.

Closes beyond the edge
2 in a row
Strong close required
Off
Top / bottom part
30 percent
Exit on a failed breakout
Off
Trade the failure
Off

Exit

Stops can use the opposite edge, midpoint or ATR distance. Targets can use risk multiples or box heights. The time stop exits only if the first target has not been reached.

Stop placement
Opposite edge
ATR stop distance
1.5 × ATR
Stop buffer
0.1 × ATR
Target units
R · risk multiples
First target · TP1
1R
Final target · TP2
2R
Move stop to entry at TP1
On
Trail after TP1
Off
Trail distance
1.5 × ATR
Time stop
Blank · off

Filters

VWAP is the volume-weighted average price; EMA is a moving average emphasizing recent prices. Filters can require the close to be on their side, enough volume, or a matching opening gap.

VWAP side
Off
EMA side
Off
EMA length
200 candles
EMA timeframe
Blank · chart
Volume gate
Off
Volume threshold
1.2 × average
Volume average length
20 candles
Gap direction
Off

Position size

Display only. Read the sizing limitations in the stop-and-target chapter before using the number.

Risk per trade in account currency
0 · off

Style

Control the drawings without changing the rules. Themes: Aurora Neon, Royal Gold or Colorblind-safe. Older tickets remain when older zones are removed.

Theme
Aurora Neon
Opening-range boxes
All days
Breakout levels
On
Risk / reward zones
On
Tint open-trade candles
On
Draw VWAP
Off · auto with filter
Draw EMA
Off · auto with filter
Range-height tag
On
Keep zones for
Last trade only
Color finished days by result
On
Range midline
On
Mark wick fake-outs
On

Dashboard

Show the checklist and move or resize it.

Show dashboard
On
Position
Top Right
Size
Medium

Co-Pilot

Brief gives NOW. Standard adds WHY and PLAN. Detailed adds the record and glossary. Off hides the narrative.

Narrative depth
Brief
Position
Bottom Left
Size
Auto
Line width
64 characters

Alerts

Use structured JSON for a webhook, or switch to readable text. See the alert chapter for which events are included.

JSON payload
On

The twin starts with zones and dashboard off, and narrative Off. The trading inputs match.

Which alerts can I create?

Twelve named conditions cover: range set, day skipped, long, short, breakout armed, fake-out, first target, final target, stop, time stop, failed breakout and flat at the close. Choose Once Per Bar Close.

Any alert() function call sends only RANGE_SET, ORB_LONG, ORB_SHORT, TP1 and EXIT. It does not separately send armed or fake-out messages. JSON includes symbol, timeframe, event, side, plan prices, range edges and reason.

These messages report the indicator’s events; they are not broker fill confirmations.

How do I study it on my own chart?
  1. Keep the defaults and read the Missing row through a session.
  2. Load the strategy twin on a second chart.
  3. Set realistic costs in its properties, then compare one change at a time.
  4. Record losing examples as well as successful ones; check a separate period too.

The twin starts with capital 100,000, one-unit orders, fees of 0.02 percent per order and five ticks of slippage. Its order emulator can fill differently from the indicator’s stop-first paper record.

Opening Range Desk strategy twin in TradingView Strategy Tester, with commission and slippage enabled.
Strategy Tester · historical example with costsOpen full-size chart ↗
What did our measurements show?

We compared twenty-six variations on three charts with costs. Results differed by market; a retest did not improve every chart. None of the variations paid on the two index ETFs in that summer sample.

It was one summer, small samples and in-sample testing. The numbers belong beside that context in the lab notes. They do not establish how your next trade will end.

The lab notes are in review and will be linked here.

What can change the reading?

The tool reads chart candles in your chosen timezone. Feed, session, timeframe or input changes can recalculate history. Use an intraday chart that fits the opening window.

Decisions use confirmed candles. A higher-timeframe EMA uses its last closed candle. VWAP follows the chart’s session, which can differ from your opening-range window.

The indicator draws a rule-based plan, not a prediction. Its record omits costs. Measure your own chart and actual execution. This is research tooling, not financial advice.

  • ICT Levels Desk

    Add previous highs, lows and session references around the opening range.

  • Signal Desk

    Compare an opening-window breakout with a separate graded entry checklist.

Built by ProEA Lab. Compare all free indicators.

05 · THE LOG

Every change, dated

What shipped and what changed in Opening Range 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. Opening Range Desk v1.2.0 Shipped

    Checks moves outside the session's first range.

  2. Two words: ORB strategy Request Opening Range Desk

    The ask it was built from: two words. We asked which range, entry and filters; no answer came, so every variation became a switch.

    1. Received
    2. Building
    3. Done

    Orb strategy

    a commenter on the r/pinescript thread · Asked 3 Sep 2026 · Done 4 Sep 2026
  3. Twenty-six variations, measured before shipping Lab note Opening Range Desk

    Run with costs on across three charts. The index ETFs did not pay this summer; gold's open did with filters. Second close = default.

BUILT WITH TRADERS

Something to change in Opening Range 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