Signal Desk · Free & open-source

A signal needs more than a strong candle.

Count the checks. Apply the limits. Then see the entry and exit plan.

Draws on your price chart. The indicator does not place orders.

Pastel clay illustration of five setup checks and a chase filter: agreement earns a grade, stretched triggers are held back and qualified setups receive a plan.
Workflow illustration
  1. Count agreement

    Five setup factors give the grade.

  2. Skip the chase

    Stretched triggers are refused by rule.

  3. Show the plan

    Qualified entries and exits with reasons.

See the real chart

What is Signal Desk?

Signal Desk is a free TradingView indicator that evaluates a pullback or breakout trigger against an entry checklist. It grades five setup checks, applies chase and daily limits, and displays a reference entry and exit plan. The guide also explains its optional index-to-options labels and separate strategy script.

Why can a high-grade setup still have no entry?

The grade counts setup checks; it does not override the entry gates. The selected trigger, chase limits, session window, daily allowance, cooldown and existing trade state must also permit an entry on the confirmed candle. Read the entry vetoes

Is learned strength a win probability?

No. Learned strength is a relative score, not a measured win probability. With Grade from set to Setup count, it is informational. Selecting Learned strength uses that score to determine the grade used by entry filtering. Read the strength score limits

Do BUY CE and BUY PE labels use live option prices?

No. Options mapping formats a strike and expiry label from the index or futures chart. It does not read an option chain, premiums or Greeks. The displayed entry and risk levels remain prices of the charted instrument. Read the options label limits

Why can a good-looking candle be skipped?

Built for an options buyer who wanted fewer signals after oversized candles. Follow the checks below.

Illustrative price chartSame final close · 110
A big green candle catches your eye.It closed higher than it opened. That alone does not tell you whether it meets the tool’s rules. Green candles close higher than they open; red candles close lower. A candle’s wick shows its high and low. The last candle spans 28 units and closes at 110.Earlier candles → latest candle
Green = closed higher · Red = closed lower
1 / 4 · The temptation

A big green candle catches your eye.

It closed higher than it opened. That alone does not tell you whether it meets the tool’s rules.

Fictional examples. The last step compares a different candle, not a candle shrinking over time. ATR, averages and momentum are supplied context.

How could this help a trade?

Test visible entry conditions and exit rules consistently. Profit still depends on price, trade size and costs. A passing grade cannot remove losses.

Where did the idea come from?

An options buyer wanted fewer signals, especially after oversized candles. We built a checklist, a limit on daily entries and clear exit rules. The default allows at most two entries a day; some days have none.

Same close. What changes the decision?

Switch cases at the same step.

Illustrative price chartBefore the new candle
Price returns toward the average.The last two candles pull back. A touch alone is not an entry. Watch the next closed candle. Green candles close higher than they open; red candles close lower. A candle’s wick shows its high and low. The latest shown close is 104.Earlier candles → latest candle
Blue = fast average · Dashed = slow average
The pullback

Price returns toward the average.

The last two candles pull back. A touch alone is not an entry. Watch the next closed candle.

Same close: 110. Compare cases without losing your place. “Window closed” changes only the clock: 10:00 to 14:45 in Asia/Kolkata, using the candle’s opening time.

What is held fixed in this example?

ATR 8; fast/slow EMA 106/101; rising momentum, bullish Supertrend and confirmed structure, pivot low 99. One earlier trade, eight candles since its exit, no open position. The current bar touches the EMA area. These snapshots use the default setup-count rules; the short chart excerpt does not calculate that longer context. The full simulator below does more of the calculation.

Does grade A mean this trade will win?

No. In the default mode it means all five setup checks agree. It is a count, not a probability. The other entry limits still apply.

Find the checklist on a real chart.

Read label → plan → panel.

Real TradingView NIFTY five-minute chart with Signal Desk: graded entries, risk and reward zones, EMA trend cloud, dashboard and Co-Pilot.
TradingView snapshot · NIFTY · five-minute candles · Clean viewOpen full-size chart ↗
  1. 1 The entry label

    The letter records setup agreement when the entry was marked.

  2. 2 The colored zones

    Entry-to-stop is the planned risk distance. Targets show the plan in the other direction.

  3. 3 The dashboard

    Setup and Grade explain agreement. Session and Today explain timing and daily capacity.

Explore the full price simulator

Change the close, wick or clock. This simulator recalculates averages, momentum and ATR using a longer fictional history; some earlier market state is supplied.

CHANGE THE CANDLE. SEE WHY.

Can this candle pass the chase check?

Change the close or wicks. ATR, momentum and entry checks recalculate.

Earlier candles → test candleTest close 110
Paper entry110ATR fallback stop106.044138122.510791.576
EMA 20EMA 50Drag the blue close ↕
Watch the same candle change
110
0
How the calculation works & more controls

Factors: trend yes, momentum yes, structure yes, location yes, candle yes. RSI 63.60, average 43.99; ATR 14 is 2.64.

Supplied Supertrend: up, bands 100/118; pivots 99/113. Current bands and strict flips recalculate. Strength/context gates off.

Entry 110.00; stop 106.04 uses the 1.5-ATR fallback because the swing is too far. Targets: 113.96 / 117.91 / 121.87.

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

Paste it into TradingView.

Start on an intraday chart. Both complete Pine v6 source files are free.

  1. 1

    Open Pine Editor

    Create a new indicator on your chart.

  2. 2

    Paste the code

    Copy below and replace the sample code.

  3. 3

    Add to chart

    Save it. Keep the defaults initially.

The indicator

Signals, zones, checklist, Co-Pilot and alerts.

Indicator code

Copy the code as it is. No coding needed.

View the full code
// ═══════════════════════════════════════════════════════════════════════════════
//  SIGNAL DESK — Options Buyer  ·  Pine Script v6  ·  v1.2.0
//  Long / short entries and exits for option-premium and index charts — FEWER, GRADED, and LEARNING.
//
//  WHY IT EXISTS  A Nifty options buyer rents two signal tools that fire many times a day and hit the
//                 stop as often as the target — the worst ones right after a big candle. He asked for
//                 "one or two strong calls a day". This is that: every trigger is scored by eight factors
//                 (5 setup + 3 context) into a STRENGTH 0–100, the factor weights are LEARNED from how
//                 trades actually closed on THIS chart (online logistic regression, causal), a chase-veto
//                 skips entries on stretched candles, and a per-day cap + cooldown keep it quiet.
//  RUNS ON        any chart; built for NIFTY option contracts (5-minute) and the index / futures chart
//                 with Options mapping (long = BUY CE, short = BUY PE, ATM strike + expiry in the alert).
//  STATUS         testable draft — compiles clean · confirmed-bar signals (no repaint) · paper P&L has
//                 NO costs · nothing here is a validated edge (see docs/build-report.md). The strategy
//                 twin (signal_desk_strategy.pine) measures the win-% honestly with costs.
//
//  © 2026 ProEA Lab · decision support, never a prediction · built for a Reddit reviewer's feedback
// ═══════════════════════════════════════════════════════════════════════════════
//@version=6
indicator("Signal Desk — Options Buyer", "SigDesk", overlay = true, max_labels_count = 500, max_lines_count = 100, max_boxes_count = 100)

// ─────────────────────────────────────────────────────────────────── INPUTS · trade
gT = "Trade"
inDir      = input.string("Both", "Direction", options = ["Both", "Long only", "Short only"], group = gT,
     tooltip = "On an OPTION premium chart use Long only (you buy the option whose chart signals a long). On the index / futures chart use Both — with Options mapping on, a long = BUY CE and a short = BUY PE.", display = display.none)
inMode     = input.string("Intraday", "Mode", options = ["Intraday", "Positional"], group = gT,
     tooltip = "Intraday = any open position exits in the square-off window. Positional = no forced square-off (entries still respect the entry window).", display = display.none)
inTrigger  = input.string("Pullback", "Entry trigger", options = ["Pullback", "Breakout", "Both"], group = gT,
     tooltip = "Pullback = trend up, price came back to the fast EMA, then a strong close back above it with momentum turning up (calm: fewer, later). Breakout = a confirmed BOS / CHoCH in the trend direction (earlier, more). Both = either.", display = display.none)
inMinGrade = input.string("B", "Signal only when grade ≥", options = ["A", "B", "C"], group = gT,
     tooltip = "Grade comes from STRENGTH (0–100), the learned model's read of the eight factors: 50 = a typical trigger on this chart, higher = a better-than-typical mix of what has paid here. A ≥ the A threshold · B ≥ the B threshold · C ≥ 50. Lower never fires; in the Pro view they show as faint ghost marks so you can see what was filtered.", display = display.none)
inGateMode = input.string("Setup count", "Grade from", options = ["Setup count", "Learned strength"], group = gT,
     tooltip = "Setup count (default) = the five setup factors: A = 5/5 · B = 4/5 · C = 3/5 — the plain, measured-best rule; the context factors and the learned strength are shown and sent in alerts as information. Learned strength = the grade comes from the learning model's STRENGTH instead (A / B thresholds below). Measured on NIFTY 5m, May–Sep 2026: setup count → 93 trades, PF 1.28; learned strength → 51 trades, negative expectancy. Test both on YOUR charts before trusting either.", display = display.none)
inGradeA   = input.int(80, "A = strength ≥ (learned mode)", minval = 50, maxval = 95, group = gT, display = display.none)
inGradeB   = input.int(65, "B = strength ≥ (learned mode)", minval = 40, maxval = 90, group = gT, display = display.none)
inMaxDay   = input.int(2, "Max entries per day", minval = 1, maxval = 20, group = gT,
     tooltip = "The 'one or two calls a day' rule — counted per exchange day, both directions together.", display = display.none)
inCooldown = input.int(6, "Cooldown after an exit (bars)", minval = 0, maxval = 200, group = gT, display = display.none)
inQty      = input.int(75, "Quantity (lot size)", minval = 1, group = gT,
     tooltip = "Used only for the paper P&L on the dashboard and the alert payload. NIFTY lot = 75.", display = display.none)

gL = "Strength model (learns on this chart)"
inLearn = input.bool(true, "Learn factor weights on this chart", group = gL,
     tooltip = "EVERY trigger — taken or filtered — is followed as a shadow 1R bracket (stop-first, never peeks). When it resolves, one online logistic-regression step nudges the eight factor weights toward what actually paid on THIS chart. Learning from all triggers, not just the taken trades, is what lets the model see which factors matter. Off = fixed equal weights (a plain factor count).", display = display.none)
inLr    = input.float(0.02, "Learning rate", minval = 0.0, maxval = 0.2, step = 0.005, group = gL,
     tooltip = "How hard each resolved trigger pulls the weights. 0.02 = a weight needs ~50 resolutions to move by one full step. Higher learns faster and forgets faster.", display = display.none)
inShadowBars = input.int(30, "Shadow bracket timeout (bars)", minval = 5, maxval = 300, group = gL,
     tooltip = "A shadow trigger that reaches neither its 1R target nor its stop within this many bars counts as a half-win (0.5).", display = display.none)

gO = "Options mapping (index / futures chart)"
inOptMap     = input.bool(false, "Map long → BUY CE · short → BUY PE", group = gO,
     tooltip = "Turn on when the indicator sits on the INDEX or futures chart. Labels and alerts then say BUY CE / BUY PE with the strike below.", display = display.none)
inStrikeStep = input.int(50, "Strike step", minval = 1, group = gO, tooltip = "NIFTY = 50 · BANKNIFTY = 100.", display = display.none)
inStrikeOff  = input.int(0, "Strikes OTM (+) / ITM (−)", minval = -10, maxval = 10, group = gO,
     tooltip = "0 = at the money. +1 = one strike out of the money, −1 = one strike in the money.", display = display.none)
inExpiry     = input.string("", "Expiry label for the alert (e.g. 09SEP26)", group = gO,
     tooltip = "Free text added to the alert payload as the instrument, e.g. NIFTY 09SEP26 24450 CE — so your webhook knows exactly what to buy. Leave empty to omit.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · session
gSn = "Session (exchange time)"
inTZ       = input.string("Asia/Kolkata", "Exchange timezone", group = gSn,
     tooltip = "IANA name. NSE = Asia/Kolkata · NYSE = America/New_York · Bangkok = Asia/Bangkok.", display = display.none)
inEntryWin = input.session("0930-1445", "Entry window", group = gSn,
     tooltip = "New entries only inside this window. The default skips the first 15 minutes — the opening spike is where most chase entries come from.", display = display.none)
inSqOff    = input.session("1500-1515", "Square-off window (Intraday)", group = gSn,
     tooltip = "Intraday mode: an open position exits on the first closed bar inside this window.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · risk levels
gR = "Risk levels"
inSlMode    = input.string("Structure", "Stop-loss", options = ["Structure", "ATR"], group = gR,
     tooltip = "Structure = beyond the recent swing low / high plus a small buffer (falls back to the ATR stop when the swing is too far). ATR = entry ∓ ATR × multiple.", display = display.none)
inSlAtr     = input.float(1.5, "ATR multiple (ATR stop · trail · far-swing cap)", minval = 0.3, maxval = 6, step = 0.1, group = gR, display = display.none)
inTp1       = input.float(1.0, "TP1 (R)", minval = 0.2, maxval = 10, step = 0.1, group = gR, display = display.none)
inTp2       = input.float(2.0, "TP2 (R)", minval = 0.3, maxval = 20, step = 0.1, group = gR, display = display.none)
inTp3       = input.float(3.0, "TP3 (R) — final target", minval = 0.5, maxval = 30, step = 0.1, group = gR, display = display.none)
inBE        = input.bool(true, "TP1 → stop to breakeven", group = gR, display = display.none)
inTrail     = input.bool(true, "TP2 → stop to TP1, then ATR trail", group = gR, display = display.none)
inExitTrend = input.bool(true, "Exit on trend flip (Supertrend)", group = gR, display = display.none)
inMomExit   = input.string("RSI vs average", "Momentum-loss exit", options = ["RSI vs average", "RSI through 50", "Off"], group = gR,
     tooltip = "The explicit intraday exit. RSI vs average = a long closes when RSI crosses back under its own average (cuts losers early, takes small wins — theta-friendly). RSI through 50 = only a real momentum turn (fewer, later exits). Off = ride until stop / target / trend flip / square-off.", display = display.none)
inTimeStop  = input.int(0, "Time stop (bars, 0 = off)", minval = 0, maxval = 500, group = gR,
     tooltip = "Exit a trade that has not reached TP1 after N bars — dead trades cost theta. 0 = off.", display = display.none)
inExitOpp   = input.bool(true, "Exit (and reverse) on an opposite signal", group = gR, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · filters
gF = "Filters (fewer, stronger)"
inBigBar    = input.float(2.5, "Veto: trigger bar range > ATR ×", minval = 1, maxval = 8, step = 0.1, group = gF,
     tooltip = "The chase filter — a signal on a candle this big is skipped. That is the 'big candle closes, I enter, it reverses' trade.", display = display.none)
inStretch   = input.float(2.0, "Veto: distance from fast EMA > ATR ×", minval = 0.5, maxval = 8, step = 0.1, group = gF, display = display.none)
inRunAtr    = input.float(3.0, "Veto: 3-bar run > ATR ×", minval = 1, maxval = 12, step = 0.5, group = gF, display = display.none)
inTouch     = input.float(0.3, "Pullback: touch tolerance (ATR ×)", minval = 0, maxval = 2, step = 0.1, group = gF, display = display.none)
inTouchBars = input.int(3, "Pullback: touch within N bars", minval = 1, maxval = 20, group = gF, display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · engine
gE = "Engine"
inEmaF   = input.int(20, "Fast EMA", minval = 2, group = gE, display = display.none)
inEmaS   = input.int(50, "Slow EMA", minval = 3, group = gE, display = display.none)
inRsiLen = input.int(14, "RSI length", minval = 2, group = gE, display = display.none)
inRsiMa  = input.int(14, "RSI average length", minval = 2, group = gE, display = display.none)
inStAtr  = input.int(10, "Supertrend ATR length", minval = 1, group = gE, tooltip = "The rented tool uses 72 / 1.5 (very slow). 10 / 3 reacts within the session.", display = display.none)
inStFac  = input.float(3.0, "Supertrend factor", minval = 0.5, maxval = 10, step = 0.1, group = gE, display = display.none)
inPiv    = input.int(5, "Swing pivot length", minval = 2, maxval = 30, group = gE,
     tooltip = "Bars on each side that confirm a swing. BOS / CHoCH and the structure stop come from these swings; a swing is known only N bars after it forms — no repainting, some lag.", display = display.none)
inAtrLen = input.int(14, "ATR length", minval = 1, group = gE, display = display.none)
inHtf    = input.timeframe("15", "Higher timeframe (context factor)", group = gE,
     tooltip = "The H factor: EMA 20 vs 50 on this timeframe, read from its LAST CLOSED bar (no repainting). Must be higher than the chart timeframe or the factor is neutral.", display = display.none)

// ─────────────────────────────────────────────────────────────────── INPUTS · style / panels / alerts
gV = "Style"
inView   = input.string("Clean", "View preset", options = ["Minimal", "Clean", "Pro", "Full"], group = gV,
     tooltip = "Minimal = signals + trade zones only. Clean = + EMA trend cloud. Pro = + Supertrend, VWAP, BOS / CHoCH tags, filtered-signal ghosts. Full = + strength-coloured candles + session shading.", display = display.none)
inTheme  = input.string("Aurora Neon", "Theme", options = ["Aurora Neon", "Royal Gold", "Colorblind-safe"], group = gV, display = display.none)
inCloud  = input.bool(true, "EMA trend cloud (gradient)", group = gV, tooltip = "A gradient between the fast and slow EMA — dense at the fast line, fading to the slow one; colour = trend, opacity = strength.", display = display.none)
inZones  = input.bool(true, "Trade zones (gradient risk / reward)", group = gV, tooltip = "Every trade paints its risk (entry → stop) and reward (entry → TP3) as gradients that fade away from the entry. The risk zone shrinks as the stop moves to breakeven / trails.", display = display.none)
inBarCol = input.bool(false, "Colour candles by strength", group = gV, tooltip = "Grey = weak, full trend colour = strong. Also on in the Full preset.", display = display.none)
gD = "Dashboard"
inDash    = input.bool(true, "Show 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)
inZebra   = input.bool(true, "Row stripes", group = gD, display = display.none)
gN = "Narrative · Co-Pilot"
inNarr     = input.string("Standard", "Narrative depth", options = ["Off", "Brief", "Standard", "Detailed"], group = gN,
     tooltip = "Plain-language read of what the desk is doing now. Brief = headline · Standard = + why + plan · Detailed = + confidence, blind spots, track record. Display only.", display = display.none)
inNarrPos  = input.string("Bottom Left", "Position", options = ["Bottom Left", "Bottom Right", "Top Left", "Top Right", "Middle Left", "Middle Right"], group = gN, display = display.none)
inNarrSize = input.string("Auto", "Size", options = ["Auto", "Small", "Medium", "Large"], group = gN, display = display.none)
inNarrWrap = input.int(64, "Line width (characters)", minval = 30, maxval = 120, group = gN, display = display.none)
gA = "Alerts"
inAlertJson = input.bool(true, "alert() payload as JSON (webhooks)", group = gA,
     tooltip = "Off = a plain sentence. Either way, choose 'Any alert() function call' when you create the alert. The alertcondition entries below it are the classic per-event alerts.", display = display.none)
inAlertMile = input.bool(true, "alert() on TP1 / TP2 milestones", group = gA, display = display.none)

// ─────────────────────────────────────────────────────────────────── THEME (colour by role)
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)

bool vClean = inView != "Minimal"
bool vPro   = inView == "Pro" or inView == "Full"
bool vFull  = inView == "Full"
bool showCloud  = vClean and inCloud
bool showZones  = inZones
bool showSt     = vPro
bool showStruct = vPro
bool showGhost  = vPro
bool showSess   = vFull
bool showBars   = inBarCol or vFull

fmt(float x) => str.tostring(x, format.mintick)
clamp01(float x) => math.max(0.0, math.min(1.0, x))

// ═══════════════════════════════════════════════════════════════════ ENGINE (all ta.* / request.* at global scope)
float atr   = ta.atr(inAtrLen)
float atrU  = math.max(atr, syminfo.mintick)
float emaF  = ta.ema(close, inEmaF)
float emaS  = ta.ema(close, inEmaS)
float rsi   = ta.rsi(close, inRsiLen)
float rsiMa = ta.sma(rsi, inRsiMa)
bool  rsiXup = ta.crossover(rsi, rsiMa)
bool  rsiXdn = ta.crossunder(rsi, rsiMa)
bool  rsi50Dn = ta.crossunder(rsi, 50)
bool  rsi50Up = ta.crossover(rsi, 50)
[stLine, stDir] = ta.supertrend(inStFac, inStAtr)
bool  stUp     = stDir < 0                      // ta.supertrend: −1 = uptrend, +1 = downtrend
bool  stFlipDn = stDir > 0 and stDir[1] < 0
bool  stFlipUp = stDir < 0 and stDir[1] > 0
float ph = ta.pivothigh(high, inPiv, inPiv)
float pl = ta.pivotlow(low, inPiv, inPiv)
int   sinceTouchF = ta.barssince(low <= emaF + inTouch * atrU)
int   sinceTouchS = ta.barssince(high >= emaF - inTouch * atrU)
float run3 = ta.highest(close, 3) - ta.lowest(close, 3)
float lo5  = ta.lowest(low, 5)
float hi5  = ta.highest(high, 5)
bool  winOK  = not na(time(timeframe.period, inEntryWin, inTZ))
bool  sqOff  = not na(time(timeframe.period, inSqOff, inTZ))
int   dayNum = dayofmonth(time, inTZ)
bool  newDay = ta.change(dayNum) != 0

// ── context: VWAP side · higher-timeframe bias (last CLOSED HTF bar → no repaint) · room in the day's range
float vwapPx = ta.vwap(hlc3)
bool  vwapOK = not na(vwapPx) and nz(volume, 0) > 0
htfBiasFn() =>
    ta.ema(close, 20) > ta.ema(close, 50) ? 1 : -1
bool  htfValid   = timeframe.in_seconds(inHtf) > timeframe.in_seconds()
// confirmed-HTF pattern: the [1] offset + lookahead_on together = the last CLOSED higher-timeframe bar (never the forming one)
int   htfBiasRaw = request.security(syminfo.tickerid, inHtf, htfBiasFn()[1], lookahead = barmerge.lookahead_on)
int   htfBias    = htfValid ? nz(htfBiasRaw, 0) : 0
var float dayHi   = na
var float dayLo   = na
var int   dayBars = 0
if newDay or na(dayHi)
    dayHi   := high
    dayLo   := low
    dayBars := 1
else
    dayHi   := math.max(dayHi, high)
    dayLo   := math.min(dayLo, low)
    dayBars += 1

// ── structure: confirmed swings → BOS / CHoCH (commits on closed bars only)
var float swHigh    = na
var float swLow     = na
var float pivHigh   = na
var float pivLow    = na
var int   structDir = 0
var int   structBar = na
bool bosUp   = false
bool bosDn   = false
bool chochUp = false
bool chochDn = false
if not na(ph)
    swHigh  := ph
    pivHigh := ph
if not na(pl)
    swLow  := pl
    pivLow := pl
if barstate.isconfirmed
    if not na(swHigh) and close > swHigh
        if structDir == -1
            chochUp := true
        else
            bosUp := true
        structDir := 1
        structBar := bar_index
        swHigh := na
    if not na(swLow) and close < swLow
        if structDir == 1
            chochDn := true
        else
            bosDn := true
        structDir := -1
        structBar := bar_index
        swLow := na

// ── the eight factors, per side (1 = agrees, 0 = not, 0.5 = not available on this chart)
int   fTrend  = stUp and emaF > emaS ? 1 : (not stUp and emaF < emaS) ? -1 : 0
int   fMom    = rsi > rsiMa and rsi > 50 ? 1 : rsi < rsiMa and rsi < 50 ? -1 : 0
int   fStruct = structDir
float distF   = (close - emaF) / atrU
bool  locL    = distF >= -0.5 and distF <= 1.0
bool  locS    = distF <= 0.5 and distF >= -1.0
float rng     = high - low
float body    = math.abs(close - open)
bool  bigBar  = rng > inBigBar * atrU
bool  bullBar = close > open and rng > 0 and (high - close) <= 0.35 * rng and body >= 0.4 * rng
bool  bearBar = close < open and rng > 0 and (close - low) <= 0.35 * rng and body >= 0.4 * rng
bool  runUp   = run3 > inRunAtr * atrU and close > close[3]
bool  runDn   = run3 > inRunAtr * atrU and close < close[3]
bool  vetoL   = bigBar or distF > inStretch or runUp
bool  vetoS   = bigBar or distF < -inStretch or runDn
bool  candleL = bullBar and not bigBar
bool  candleS = bearBar and not bigBar
float toHi    = dayHi - close
float toLo    = close - dayLo
bool  roomL   = dayBars < 3 or not (toHi > 0.1 * atrU and toHi < 1.0 * atrU)     // not buying straight into the day's high
bool  roomS   = dayBars < 3 or not (toLo > 0.1 * atrU and toLo < 1.0 * atrU)
// feature vectors (index: 0 T · 1 M · 2 S · 3 L · 4 C · 5 V · 6 H · 7 R)
featL() =>
    array<float> f = array.new<float>(8, 0.0)
    array.set(f, 0, fTrend == 1 ? 1.0 : 0.0)
    array.set(f, 1, fMom == 1 ? 1.0 : 0.0)
    array.set(f, 2, fStruct == 1 ? 1.0 : 0.0)
    array.set(f, 3, locL ? 1.0 : 0.0)
    array.set(f, 4, candleL ? 1.0 : 0.0)
    array.set(f, 5, vwapOK ? (close > vwapPx ? 1.0 : 0.0) : 0.5)
    array.set(f, 6, htfValid ? (htfBias == 1 ? 1.0 : 0.0) : 0.5)
    array.set(f, 7, roomL ? 1.0 : 0.0)
    f
featS() =>
    array<float> f = array.new<float>(8, 0.0)
    array.set(f, 0, fTrend == -1 ? 1.0 : 0.0)
    array.set(f, 1, fMom == -1 ? 1.0 : 0.0)
    array.set(f, 2, fStruct == -1 ? 1.0 : 0.0)
    array.set(f, 3, locS ? 1.0 : 0.0)
    array.set(f, 4, candleS ? 1.0 : 0.0)
    array.set(f, 5, vwapOK ? (close < vwapPx ? 1.0 : 0.0) : 0.5)
    array.set(f, 6, htfValid ? (htfBias == -1 ? 1.0 : 0.0) : 0.5)
    array.set(f, 7, roomS ? 1.0 : 0.0)
    f

// ── STRENGTH model — online logistic regression over the eight factors (seeded as an equal-weight count)
// Features are CENTRED on their running mean over the triggers seen here (mu, starts at 0.5). A factor that
// is always present on triggers (the trigger requires it) therefore contributes nothing and cannot soak up
// the base rate — the bias learns the base rate, the weights learn the RELATIVE value of the factors that vary.
// STRENGTH 50 = a typical trigger on this chart; above = better-than-typical mix of what has paid here.
type Model
    array<float> w
    array<float> mu
    float w0     = 0.0
    int   trained = 0
var Model mdl = Model.new(w = array.new<float>(8, 0.75), mu = array.new<float>(8, 0.5))
squash(float z) => 1.0 / (1.0 + math.exp(-z))
mdlLogit(Model m, array<float> f) =>
    float z = 0.0
    for i = 0 to 7
        z += array.get(m.w, i) * (array.get(f, i) - array.get(m.mu, i))
    z
// one causal gradient step on the FROZEN features of a trigger that has already resolved
mdlTrain(Model m, array<float> f, float y) =>
    float p   = squash(m.w0 + mdlLogit(m, f))
    float err = p - y
    m.w0 := m.w0 - inLr * err
    for i = 0 to 7
        float xc = array.get(f, i) - array.get(m.mu, i)
        float wi = array.get(m.w, i) - inLr * err * xc
        array.set(m.w, i, math.max(-1.0, math.min(2.5, wi)))
        array.set(m.mu, i, array.get(m.mu, i) + 0.02 * (array.get(f, i) - array.get(m.mu, i)))
    m.trained := m.trained + 1
mdlStrength(Model m, array<float> f) => squash(mdlLogit(m, f))
array<float> fxL = featL()
array<float> fxS = featS()
float strengthL = 100.0 * mdlStrength(mdl, fxL)
float strengthS = 100.0 * mdlStrength(mdl, fxS)
int   setupL  = (fTrend == 1 ? 1 : 0) + (fMom == 1 ? 1 : 0) + (fStruct == 1 ? 1 : 0) + (locL ? 1 : 0) + (candleL ? 1 : 0)
int   setupS  = (fTrend == -1 ? 1 : 0) + (fMom == -1 ? 1 : 0) + (fStruct == -1 ? 1 : 0) + (locS ? 1 : 0) + (candleS ? 1 : 0)
int   ctxMax  = (vwapOK ? 1 : 0) + (htfValid ? 1 : 0) + 1
int   ctxL    = (vwapOK and close > vwapPx ? 1 : 0) + (htfValid and htfBias == 1 ? 1 : 0) + (roomL ? 1 : 0)
int   ctxS    = (vwapOK and close < vwapPx ? 1 : 0) + (htfValid and htfBias == -1 ? 1 : 0) + (roomS ? 1 : 0)
// grade — default from the five setup factors (measured best); optionally from the learned strength
gradeOf(float s)    => s >= inGradeA ? "A" : s >= inGradeB ? "B" : s >= 50 ? "C" : ""
gradeCount(int s)   => s >= 5 ? "A" : s == 4 ? "B" : s == 3 ? "C" : ""
gradeRank(string g) => g == "A" ? 3 : g == "B" ? 2 : g == "C" ? 1 : 0
string gradeL = inGateMode == "Learned strength" ? gradeOf(strengthL) : gradeCount(setupL)
string gradeS = inGateMode == "Learned strength" ? gradeOf(strengthS) : gradeCount(setupS)

// ── triggers
bool momUpTurn = rsiXup or (rsi > rsiMa and rsi > rsi[1])
bool momDnTurn = rsiXdn or (rsi < rsiMa and rsi < rsi[1])
bool pullL = fTrend == 1  and sinceTouchF <= inTouchBars and bullBar and close > emaF and momUpTurn
bool pullS = fTrend == -1 and sinceTouchS <= inTouchBars and bearBar and close < emaF and momDnTurn
bool brkL  = fTrend == 1  and (bosUp or chochUp)
bool brkS  = fTrend == -1 and (bosDn or chochDn)
bool trigL = inTrigger == "Pullback" ? pullL : inTrigger == "Breakout" ? brkL : (pullL or brkL)
bool trigS = inTrigger == "Pullback" ? pullS : inTrigger == "Breakout" ? brkS : (pullS or brkS)
bool dirL  = inDir != "Short only"
bool dirS  = inDir != "Long only"

// ═══════════════════════════════════════════════════════════════════ TRADE STATE (confirmed bars only)
type Trade
    int    dir    = 0
    float  entry  = na
    float  sl     = na
    float  sl0    = na
    float  tp1    = na
    float  tp2    = na
    float  tp3    = na
    float  r      = na
    string grade  = ""
    float  strength = na
    int    bar    = na
    bool   tp1Hit = false
    bool   tp2Hit = false
    array<float> fx
type Stat
    int   n        = 0
    int   wins     = 0
    int   scratch  = 0
    float sumR     = 0.0
    float pnlTotal = 0.0
    float pnlToday = 0.0
    int   today    = 0
    int   lastExit = -100000
    int   nA = 0
    int   wA = 0
    int   nB = 0
    int   wB = 0
    int   nC = 0
    int   wC = 0
var Trade t  = Trade.new(fx = array.new<float>(8, 0.5))
var Stat  st = Stat.new()

if newDay
    st.today    := 0
    st.pnlToday := 0.0

bool   gateCommon = barstate.isconfirmed and not na(atr) and winOK and st.today < inMaxDay and (bar_index - st.lastExit) >= inCooldown
bool   candL = trigL and not vetoL and gradeRank(gradeL) >= gradeRank(inMinGrade) and dirL and gateCommon
bool   candS = trigS and not vetoS and gradeRank(gradeS) >= gradeRank(inMinGrade) and dirS and gateCommon

bool   entryL  = false
bool   entryS  = false
bool   exitNow = false
int    exitDir = 0
float  exitPx  = na
string exitWhy = ""
float  exitR   = na
bool   tp1Now  = false
bool   tp2Now  = false

// 1 · manage the open trade — the stop is checked FIRST (a bar that straddles stop and target books the stop)
if barstate.isconfirmed and t.dir != 0
    int d = t.dir
    bool stopHit = d == 1 ? low <= t.sl : high >= t.sl
    if stopHit
        exitNow := true
        exitPx  := t.sl
        exitWhy := t.tp2Hit ? "TRAIL" : (t.tp1Hit and inBE) ? "BE" : "SL"
    else
        bool tp3Hit = d == 1 ? high >= t.tp3 : low <= t.tp3
        if tp3Hit
            exitNow := true
            exitPx  := t.tp3
            exitWhy := "TP3"
        else
            bool h1 = d == 1 ? high >= t.tp1 : low <= t.tp1
            bool h2 = d == 1 ? high >= t.tp2 : low <= t.tp2
            if h1 and not t.tp1Hit
                t.tp1Hit := true
                tp1Now   := true
                if inBE
                    t.sl := t.entry
            if h2 and not t.tp2Hit
                t.tp2Hit := true
                tp2Now   := true
                if inTrail
                    t.sl := t.tp1
            if inTrail and t.tp2Hit
                float trailPx = d == 1 ? close - inSlAtr * atrU : close + inSlAtr * atrU
                t.sl := d == 1 ? math.max(t.sl, trailPx) : math.min(t.sl, trailPx)
            bool flip    = inExitTrend and (d == 1 ? stFlipDn : stFlipUp)
            bool momLoss = (bar_index - t.bar) >= 2 and (inMomExit == "RSI vs average" ? (d == 1 ? rsiXdn : rsiXup) : inMomExit == "RSI through 50" ? (d == 1 ? rsi50Dn : rsi50Up) : false)
            bool opp     = inExitOpp and (d == 1 ? candS : candL)
            bool sq      = inMode == "Intraday" and sqOff
            bool timeUp  = inTimeStop > 0 and not t.tp1Hit and (bar_index - t.bar) >= inTimeStop
            if flip or momLoss or opp or sq or timeUp
                exitNow := true
                exitPx  := close
                exitWhy := sq ? "SQ-OFF" : flip ? "TREND" : opp ? "OPP" : momLoss ? "MOM" : "TIME"
    if exitNow
        float pts = (exitPx - t.entry) * d
        exitR   := pts / t.r
        exitDir := d
        bool isWin     = exitR >= 0.05
        bool isScratch = math.abs(exitR) < 0.05
        st.n        := st.n + 1
        st.wins     := st.wins + (isWin ? 1 : 0)
        st.scratch  := st.scratch + (isScratch ? 1 : 0)
        if t.grade == "A"
            st.nA := st.nA + 1
            st.wA := st.wA + (isWin ? 1 : 0)
        else if t.grade == "B"
            st.nB := st.nB + 1
            st.wB := st.wB + (isWin ? 1 : 0)
        else
            st.nC := st.nC + 1
            st.wC := st.wC + (isWin ? 1 : 0)
        st.sumR     := st.sumR + exitR
        st.pnlTotal := st.pnlTotal + pts * inQty
        st.pnlToday := st.pnlToday + pts * inQty
        st.lastExit := bar_index
        t.dir := 0

// 2 · open a trade (flat; a same-bar re-entry only when the exit was a reversal)
bool canOpen = t.dir == 0 and (not exitNow or exitWhy == "OPP")
if candL and canOpen
    float slStruct = math.min(nz(pivLow, lo5), lo5) - 0.25 * atrU
    float slAtr    = close - inSlAtr * atrU
    float slPx     = inSlMode == "ATR" ? slAtr : ((close - slStruct) > inSlAtr * 1.5 * atrU ? slAtr : slStruct)
    slPx := math.min(slPx, close - syminfo.mintick)
    float r = close - slPx
    t.dir := 1
    t.entry := close
    t.sl := slPx
    t.sl0 := slPx
    t.r := r
    t.tp1 := close + inTp1 * r
    t.tp2 := close + inTp2 * r
    t.tp3 := close + inTp3 * r
    t.grade := gradeL
    t.strength := strengthL
    t.bar := bar_index
    t.tp1Hit := false
    t.tp2Hit := false
    t.fx := array.copy(fxL)
    st.today := st.today + 1
    entryL := true
else if candS and canOpen
    float slStruct = math.max(nz(pivHigh, hi5), hi5) + 0.25 * atrU
    float slAtr    = close + inSlAtr * atrU
    float slPx     = inSlMode == "ATR" ? slAtr : ((slStruct - close) > inSlAtr * 1.5 * atrU ? slAtr : slStruct)
    slPx := math.max(slPx, close + syminfo.mintick)
    float r = slPx - close
    t.dir := -1
    t.entry := close
    t.sl := slPx
    t.sl0 := slPx
    t.r := r
    t.tp1 := close - inTp1 * r
    t.tp2 := close - inTp2 * r
    t.tp3 := close - inTp3 * r
    t.grade := gradeS
    t.strength := strengthS
    t.bar := bar_index
    t.tp1Hit := false
    t.tp2Hit := false
    t.fx := array.copy(fxS)
    st.today := st.today + 1
    entryS := true

// ═══════════════════════════════════════════════════════════════════ SHADOW LEARNING
// Every trigger — taken or filtered — is followed as a shadow 1R bracket (stop first, resolved only on
// LATER bars). Learning from the whole population of triggers, not just the gated trades, is what lets
// the model see which factors separate winners from losers. Taken trades are a subset; no double count.
type Shadow
    bool  on  = false
    int   dir = 0
    float sl  = na
    float tp  = na
    int   bar = na
    array<float> fx
var Shadow sh = Shadow.new(fx = array.new<float>(8, 0.5))
stopFor(int d) =>
    float s = d == 1 ? math.min(nz(pivLow, lo5), lo5) - 0.25 * atrU : math.max(nz(pivHigh, hi5), hi5) + 0.25 * atrU
    float a = d == 1 ? close - inSlAtr * atrU : close + inSlAtr * atrU
    float p = inSlMode == "ATR" ? a : (math.abs(close - s) > inSlAtr * 1.5 * atrU ? a : s)
    d == 1 ? math.min(p, close - syminfo.mintick) : math.max(p, close + syminfo.mintick)
if barstate.isconfirmed and sh.on and bar_index > sh.bar
    bool shStop = sh.dir == 1 ? low <= sh.sl : high >= sh.sl
    bool shTgt  = sh.dir == 1 ? high >= sh.tp : low <= sh.tp
    bool shOut  = bar_index - sh.bar >= inShadowBars
    if shStop or shTgt or shOut
        if inLearn
            mdlTrain(mdl, sh.fx, shStop ? 0.0 : shTgt ? 1.0 : 0.5)
        sh.on := false
if barstate.isconfirmed and not sh.on and not na(atr) and (trigL or trigS)
    int   sd  = trigL ? 1 : -1
    float ssl = stopFor(sd)
    float sr  = math.abs(close - ssl)
    sh.on  := true
    sh.dir := sd
    sh.sl  := ssl
    sh.tp  := sd == 1 ? close + inTp1 * sr : close - inTp1 * sr
    sh.bar := bar_index
    sh.fx  := array.copy(sd == 1 ? fxL : fxS)

// ═══════════════════════════════════════════════════════════════════ OPTIONS MAPPING + ALERT PAYLOADS
optAction(int d) => inOptMap ? (d == 1 ? "BUY CE" : "BUY PE") : (d == 1 ? "BUY" : "SELL")
strikeOf(int d) =>
    float base = math.round(close / inStrikeStep) * inStrikeStep
    d == 1 ? base + inStrikeOff * inStrikeStep : base - inStrikeOff * inStrikeStep
strikeTxt(int d) => inOptMap ? str.tostring(strikeOf(d), "0") : ""
instrumentTxt(int d) => inOptMap ? syminfo.ticker + (inExpiry != "" ? " " + inExpiry : "") + " " + strikeTxt(d) + (d == 1 ? " CE" : " PE") : syminfo.ticker

payloadEntry(int d, string g) =>
    string act = optAction(d)
    string sk  = strikeTxt(d)
    int    sc  = math.round(d == 1 ? strengthL : strengthS)
    string js  = '{"src":"SignalDesk","symbol":"' + syminfo.ticker + '","tf":"' + timeframe.period + '","event":"ENTRY","side":"' + (d == 1 ? "LONG" : "SHORT") + '","action":"' + act + '","instrument":"' + instrumentTxt(d) + '"' + (inOptMap ? ',"strike":' + sk : '') + ',"grade":"' + g + '","strength":' + str.tostring(sc) + ',"setup":' + str.tostring(d == 1 ? setupL : setupS) + ',"context":' + str.tostring(d == 1 ? ctxL : ctxS) + ',"entry":' + fmt(t.entry) + ',"sl":' + fmt(t.sl) + ',"tp1":' + fmt(t.tp1) + ',"tp2":' + fmt(t.tp2) + ',"tp3":' + fmt(t.tp3) + ',"qty":' + str.tostring(inQty) + '}'
    string tx  = "Signal Desk · " + instrumentTxt(d) + " " + timeframe.period + " · " + act + (inOptMap ? " " + sk : "") + " · grade " + g + " · strength " + str.tostring(sc) + " · entry " + fmt(t.entry) + " · SL " + fmt(t.sl) + " · TP " + fmt(t.tp1) + " / " + fmt(t.tp2) + " / " + fmt(t.tp3) + " · qty " + str.tostring(inQty)
    inAlertJson ? js : tx
payloadExit(int d, string why, float px, float rr) =>
    string js = '{"src":"SignalDesk","symbol":"' + syminfo.ticker + '","tf":"' + timeframe.period + '","event":"EXIT","side":"' + (d == 1 ? "LONG" : "SHORT") + '","instrument":"' + instrumentTxt(d) + '","reason":"' + why + '","price":' + fmt(px) + ',"r":' + str.tostring(rr, "0.00") + ',"qty":' + str.tostring(inQty) + '}'
    string tx = "Signal Desk · " + instrumentTxt(d) + " " + timeframe.period + " · EXIT " + (d == 1 ? "LONG" : "SHORT") + " · " + why + " at " + fmt(px) + " · " + (rr >= 0 ? "+" : "") + str.tostring(rr, "0.0") + "R"
    inAlertJson ? js : tx
payloadMile(string which) =>
    string js = '{"src":"SignalDesk","symbol":"' + syminfo.ticker + '","tf":"' + timeframe.period + '","event":"' + which + '","side":"' + (t.dir == 1 ? "LONG" : "SHORT") + '","price":' + fmt(close) + ',"sl_now":' + fmt(t.sl) + '}'
    string tx = "Signal Desk · " + syminfo.ticker + " " + timeframe.period + " · " + which + " reached · stop now " + fmt(t.sl)
    inAlertJson ? js : tx

// ═══════════════════════════════════════════════════════════════════ CHART
int    viewSide  = t.dir != 0 ? t.dir : fTrend != 0 ? fTrend : 1
float  viewStr   = viewSide == 1 ? strengthL : strengthS
string viewGrade = viewSide == 1 ? gradeL : gradeS
color  sideCol   = viewSide == 1 ? th.up : th.down
strCol(float s, int d) => color.from_gradient(s, 40, 85, color.new(d == 1 ? th.up : th.down, 60), d == 1 ? th.up : th.down)
float  trendStr  = fTrend == 1 ? strengthL : fTrend == -1 ? strengthS : 50.0
color  cloudCol  = fTrend == 1 ? th.up : fTrend == -1 ? th.down : th.flat
int    cloudTop  = 96 - int(22 * clamp01((trendStr - 40) / 45))          // stronger → denser (74..96 transparency)

// EMA trend cloud: dense at the fast EMA, fading to the slow one (the only native gradient Pine has)
plEmaF = plot(emaF, "EMA fast", color = vClean ? color.new(th.accent, 15) : na, linewidth = 1)
plEmaS = plot(emaS, "EMA slow", color = vClean ? color.new(th.flat, 35) : na, linewidth = 1)
fill(plEmaF, plEmaS, top_value = math.max(emaF, emaS), bottom_value = math.min(emaF, emaS),
     top_color = showCloud ? color.new(cloudCol, emaF >= emaS ? cloudTop : 99) : color.new(th.bg, 100),
     bottom_color = showCloud ? color.new(cloudCol, emaF >= emaS ? 99 : cloudTop) : color.new(th.bg, 100), title = "Trend cloud")

// trade zones — every trade paints its risk (entry → stop) and reward (entry → TP3), fading away from the entry
bool  zoneOn  = showZones and (t.dir != 0 or exitNow)
float zEntry  = zoneOn ? t.entry : na
float zStop   = zoneOn ? t.sl : na
float zTp1    = zoneOn ? t.tp1 : na
float zTp2    = zoneOn ? t.tp2 : na
float zTp3    = zoneOn ? t.tp3 : na
int   zDir    = t.dir != 0 ? t.dir : exitDir
plEntry = plot(zEntry, "Trade · entry", color = color.new(th.txtHi, 15), linewidth = 1, style = plot.style_linebr, display = display.pane)
plStop  = plot(zStop,  "Trade · stop",  color = color.new(th.down, 15),  linewidth = 1, style = plot.style_linebr, display = display.pane)
plTp1   = plot(zTp1,   "Trade · TP1",   color = color.new(th.up, 60),    linewidth = 1, style = plot.style_linebr, display = display.pane)
plTp2   = plot(zTp2,   "Trade · TP2",   color = color.new(th.up, 45),    linewidth = 1, style = plot.style_linebr, display = display.pane)
plTp3   = plot(zTp3,   "Trade · TP3",   color = color.new(th.up, 15),    linewidth = 1, style = plot.style_linebr, display = display.pane)
fill(plEntry, plStop, top_value = math.max(zEntry, zStop), bottom_value = math.min(zEntry, zStop),
     top_color = zDir == 1 ? color.new(th.down, 70) : color.new(th.down, 94), bottom_color = zDir == 1 ? color.new(th.down, 94) : color.new(th.down, 70), title = "Risk zone", fillgaps = false)
fill(plEntry, plTp3, top_value = math.max(zEntry, zTp3), bottom_value = math.min(zEntry, zTp3),
     top_color = zDir == 1 ? color.new(th.up, 94) : color.new(th.up, 72), bottom_color = zDir == 1 ? color.new(th.up, 72) : color.new(th.up, 94), title = "Reward zone", fillgaps = false)

plot(stLine, "Supertrend", color = showSt ? (stUp ? color.new(th.up, 55) : color.new(th.down, 55)) : na, linewidth = 1, style = plot.style_linebr, display = display.pane)
plot(vwapOK ? vwapPx : na, "VWAP", color = showSt ? color.new(th.accentHi, 45) : na, linewidth = 1, style = plot.style_circles, display = display.pane)
bgcolor(showSess and winOK ? color.new(th.accent, 96) : showSess and sqOff ? color.new(th.down, 93) : na, title = "Session shading")
barcolor(showBars ? color.from_gradient(viewStr, 35, 80, color.new(th.flat, 20), sideCol) : na, title = "Candles by strength")

// ghosts: a trigger fired but was filtered (grade / veto / window / cap / cooldown) — Pro view
plotshape(showGhost and trigL and not candL and barstate.isconfirmed, "Filtered long", shape.circle, location.belowbar, color = color.new(th.up, 72), size = size.tiny, display = display.pane)
plotshape(showGhost and trigS and not candS and barstate.isconfirmed, "Filtered short", shape.circle, location.abovebar, color = color.new(th.down, 72), size = size.tiny, display = display.pane)

// entry / exit labels (dynamic text → labels)
if entryL
    label.new(bar_index, low, optAction(1) + (inOptMap ? " " + strikeTxt(1) : "") + " · " + t.grade + " " + str.tostring(t.strength, "0"), style = label.style_label_up, color = strCol(t.strength, 1), textcolor = th.bg, size = size.small,
         tooltip = "LONG · grade " + t.grade + " · strength " + str.tostring(t.strength, "0") + "\nsetup " + str.tostring(setupL) + "/5 · context " + str.tostring(ctxL) + "/" + str.tostring(ctxMax) + "\nentry " + fmt(t.entry) + "\nstop " + fmt(t.sl) + "  (" + fmt(t.r) + " = 1R)\nTP1 " + fmt(t.tp1) + " · TP2 " + fmt(t.tp2) + " · TP3 " + fmt(t.tp3))
if entryS
    label.new(bar_index, high, optAction(-1) + (inOptMap ? " " + strikeTxt(-1) : "") + " · " + t.grade + " " + str.tostring(t.strength, "0"), style = label.style_label_down, color = strCol(t.strength, -1), textcolor = th.bg, size = size.small,
         tooltip = "SHORT · grade " + t.grade + " · strength " + str.tostring(t.strength, "0") + "\nsetup " + str.tostring(setupS) + "/5 · context " + str.tostring(ctxS) + "/" + str.tostring(ctxMax) + "\nentry " + fmt(t.entry) + "\nstop " + fmt(t.sl) + "  (" + fmt(t.r) + " = 1R)\nTP1 " + fmt(t.tp1) + " · TP2 " + fmt(t.tp2) + " · TP3 " + fmt(t.tp3))
if exitNow
    string rTxt = (exitR >= 0 ? "+" : "") + str.tostring(exitR, "0.0") + "R"
    label.new(bar_index, exitDir == 1 ? high : low, (exitDir == 1 ? "XL" : "XS") + " · " + exitWhy + " " + rTxt, style = exitDir == 1 ? label.style_label_down : label.style_label_up, color = color.new(th.bg, 15), textcolor = exitR >= 0 ? th.up : th.down, size = size.tiny,
         tooltip = "exit " + fmt(exitPx) + " · " + exitWhy + " · " + rTxt + " · paper " + (exitR >= 0 ? "+" : "") + str.tostring(exitR * t.r * inQty, "0") + " (no costs)")
if showStruct and (bosUp or chochUp)
    label.new(bar_index, high, chochUp ? "CHoCH" : "BOS", style = label.style_label_down, color = color.new(th.up, 82), textcolor = th.up, size = size.tiny)
if showStruct and (bosDn or chochDn)
    label.new(bar_index, low, chochDn ? "CHoCH" : "BOS", style = label.style_label_up, color = color.new(th.down, 82), textcolor = th.down, size = size.tiny)

// ═══════════════════════════════════════════════════════════════════ WORDS (one source of truth)
int    viewSetup = viewSide == 1 ? setupL : setupS
int    viewCtx   = viewSide == 1 ? ctxL : ctxS
string okG       = viewSide == 1 ? "▲" : "▼"
fx(string letter, bool ok) => letter + (ok ? okG : "·") + " "
string setupGlyphs = fx("T", viewSide == 1 ? fTrend == 1 : fTrend == -1) + fx("M", viewSide == 1 ? fMom == 1 : fMom == -1) + fx("S", viewSide == 1 ? fStruct == 1 : fStruct == -1) + fx("L", viewSide == 1 ? locL : locS) + fx("C", viewSide == 1 ? candleL : candleS)
string ctxGlyphs   = (vwapOK ? fx("V", viewSide == 1 ? close > vwapPx : close < vwapPx) : "V– ") + (htfValid ? fx("H", viewSide == 1 ? htfBias == 1 : htfBias == -1) : "H– ") + fx("R", viewSide == 1 ? roomL : roomS)
wGlyph(int i) =>
    float w = array.get(mdl.w, i)
    w >= 0.95 ? "+" : w <= 0.55 ? "−" : "·"
string learnedGlyphs = "T" + wGlyph(0) + " M" + wGlyph(1) + " S" + wGlyph(2) + " L" + wGlyph(3) + " C" + wGlyph(4) + " V" + wGlyph(5) + " H" + wGlyph(6) + " R" + wGlyph(7)
string learnedTip = "Learned weights on this chart (start 0.75 each; + ≥ 0.95 = this factor paid here, − ≤ 0.55 = it hurt, · = about neutral). T " + str.tostring(array.get(mdl.w, 0), "0.00") + " · M " + str.tostring(array.get(mdl.w, 1), "0.00") + " · S " + str.tostring(array.get(mdl.w, 2), "0.00") + " · L " + str.tostring(array.get(mdl.w, 3), "0.00") + " · C " + str.tostring(array.get(mdl.w, 4), "0.00") + " · V " + str.tostring(array.get(mdl.w, 5), "0.00") + " · H " + str.tostring(array.get(mdl.w, 6), "0.00") + " · R " + str.tostring(array.get(mdl.w, 7), "0.00") + " · bias " + str.tostring(mdl.w0, "0.00") + " · learned from " + str.tostring(mdl.trained) + " resolved triggers (taken or not). Small samples move slowly on purpose."
string posWord   = t.dir == 1 ? "▲ LONG · " + t.grade + " " + str.tostring(t.strength, "0") : t.dir == -1 ? "▼ SHORT · " + t.grade + " " + str.tostring(t.strength, "0") : "● FLAT"
color  posColor  = t.dir == 1 ? th.up : t.dir == -1 ? th.down : th.txt
bool   coolActive = (bar_index - st.lastExit) < inCooldown
string stateWord = t.dir != 0 ? "IN TRADE" : sqOff ? "SQUARE-OFF" : not winOK ? "NO ENTRIES" : st.today >= inMaxDay ? "CAP " + str.tostring(st.today) + "/" + str.tostring(inMaxDay) : coolActive ? "COOLDOWN" : "WAITING"
color  stateColor = t.dir != 0 ? posColor : winOK and st.today < inMaxDay and not coolActive ? th.accentHi : th.flat
string clockTxt  = str.format_time(time, "HH:mm", inTZ)
string trendWord = fTrend == 1 ? "up" : fTrend == -1 ? "down" : "mixed"

// ═══════════════════════════════════════════════════════════════════ DASHBOARD
posOf(string s) =>
    s == "Top Left" ? position.top_left : s == "Bottom Right" ? position.bottom_right : s == "Bottom Left" ? position.bottom_left : s == "Middle Right" ? position.middle_right : s == "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
gauge(float p01, int segs) =>
    string out = ""
    int filled = na(p01) ? 0 : int(math.round(clamp01(p01) * segs))
    for i = 0 to segs - 1
        out := out + (i < filled ? "▰" : "▱")
    out
kv(table tb, int row, string k, string v, color vc, string tip) =>
    color stripe = inZebra and row % 2 == 1 ? color.new(th.accent, 93) : th.bg
    table.cell(tb, 0, row, k, text_color = th.flat, text_size = szBody(), text_halign = text.align_left, bgcolor = stripe, tooltip = tip)
    table.cell(tb, 1, row, v, text_color = vc, text_size = szBody(), text_halign = text.align_right, text_font_family = font.family_monospace, bgcolor = stripe, tooltip = tip)
kvBg(table tb, int row, string k, string v, color vc, color bgc, string tip) =>
    table.cell(tb, 0, row, k, text_color = th.flat, text_size = szBody(), text_halign = text.align_left, bgcolor = th.bg, tooltip = tip)
    table.cell(tb, 1, row, v, text_color = vc, text_size = szBody(), text_halign = text.align_right, text_font_family = font.family_monospace, bgcolor = bgc, tooltip = tip)

var table dash = table.new(posOf(inDashPos), 2, 18, bgcolor = th.bg, frame_color = th.frame, frame_width = 1, border_width = 0)   // pre-sized with headroom (rows written: 16)
if barstate.islast and inDash
    table.clear(dash, 0, 0, 1, 17)
    int r = 0
    table.cell(dash, 0, r, "SIGNAL DESK", text_color = th.accentHi, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Graded, learning long / short signals for option buyers. Confirmed bars only.")
    table.cell(dash, 1, 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, posWord, text_color = posColor, text_size = szLead(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Position, its grade and strength at entry.")
    table.cell(dash, 1, r, stateWord, text_color = stateColor, text_size = szBody(), text_halign = text.align_right, bgcolor = th.bg, tooltip = "WAITING = flat, inside the entry window, cap not reached. CAP = today's entry limit used. COOLDOWN = bars since the last exit < cooldown. NO ENTRIES = outside the entry window. SQUARE-OFF = the intraday exit window.")
    r += 1
    color strBg = color.from_gradient(viewStr, 30, 85, color.new(th.down, 75), color.new(th.up, 55))
    kvBg(dash, r, (viewSide == 1 ? "Long" : "Short") + " strength", gauge(viewStr / 100.0, 10) + " " + str.tostring(viewStr, "0") + (viewGrade != "" ? " · " + viewGrade : ""), th.txtHi, strBg, "STRENGTH 0–100 for the side shown = the learned model's read of the eight factors on this bar. Cell colour runs red (weak) → green (strong). A ≥ " + str.tostring(inGradeA) + " · B ≥ " + str.tostring(inGradeB) + " · C ≥ 50.")
    r += 1
    kv(dash, r, "Setup", setupGlyphs + " " + str.tostring(viewSetup) + "/5", viewSetup >= 4 ? sideCol : th.txt, "SETUP factors — T Trend (Supertrend + EMA 20/50) · M Momentum (RSI vs its average, above/below 50) · S Structure (last BOS / CHoCH) · L Location (within 1 ATR of the fast EMA) · C Candle (a clean close in the signal direction, not oversized). ▲/▼ = present · = missing.")
    r += 1
    kv(dash, r, "Context", ctxGlyphs + " " + str.tostring(viewCtx) + "/" + str.tostring(ctxMax), viewCtx == ctxMax ? sideCol : th.txt, "CONTEXT factors — V VWAP side (long above / short below) · H Higher-timeframe bias (EMA 20/50 on " + inHtf + ", last closed bar) · R Room (not buying straight into the day's high / selling into its low). – = not available here (fed to the model as neutral).")
    r += 1
    kv(dash, r, "Learned", learnedGlyphs, inLearn ? th.txt : th.flat, learnedTip + (inLearn ? "" : " Learning is OFF — fixed equal weights."))
    r += 1
    kv(dash, r, "Grade", (viewGrade != "" ? viewGrade : "—") + " · " + (gradeRank(viewGrade) >= gradeRank(inMinGrade) and viewGrade != "" ? "fires" : "below ≥" + inMinGrade) + (inGateMode == "Learned strength" ? " · learned" : " · setup count"), viewGrade == "A" ? sideCol : viewGrade == "B" ? th.accentHi : th.txt, "Grade of the current bar for the side shown. Setup count mode (default): A = 5/5 setup factors · B = 4/5 · C = 3/5. Learned-strength mode: A ≥ " + str.tostring(inGradeA) + " · B ≥ " + str.tostring(inGradeB) + " · C ≥ 50. 'fires' = a trigger on this bar would be allowed to signal (grade ≥ your minimum).")
    r += 1
    kv(dash, r, "Entry", t.dir != 0 ? fmt(t.entry) : "—", th.txtHi, "Reference entry = the close of the signal bar.")
    r += 1
    kv(dash, r, "Stop", t.dir != 0 ? fmt(t.sl) + (t.tp2Hit ? " · TRAIL" : t.tp1Hit and inBE ? " · BE" : "") : "—", th.down, "Current stop. BE = moved to breakeven after TP1 · TRAIL = ATR trail after TP2.")
    r += 1
    kv(dash, r, "Targets", t.dir != 0 ? fmt(t.tp1) + " · " + fmt(t.tp2) + " · " + fmt(t.tp3) : "—", th.up, "TP1 / TP2 / TP3 in R-multiples of the initial stop distance.")
    r += 1
    string htfWord  = htfValid ? (htfBias == 1 ? "up" : htfBias == -1 ? "down" : "—") : "n/a"
    string vwapWord = vwapOK ? (close > vwapPx ? "above" : "below") : "n/a"
    kv(dash, r, "Trend", trendWord + " · " + inHtf + "m " + htfWord + " · VWAP " + vwapWord + " · RSI " + str.tostring(rsi, "0"), fTrend == 1 ? th.up : fTrend == -1 ? th.down : th.txt, "Chart trend (Supertrend + EMA alignment) · higher-timeframe bias (" + inHtf + ", last closed bar) · price vs session VWAP · RSI.")
    r += 1
    kv(dash, r, "Session", clockTxt + " · " + (winOK ? "OPEN" : sqOff ? "SQ-OFF" : "CLOSED"), winOK ? th.txt : th.flat, "Exchange time (" + inTZ + "). Entry window " + inEntryWin + " · square-off " + inSqOff + ".")
    r += 1
    kv(dash, r, "Today", str.tostring(st.today) + "/" + str.tostring(inMaxDay) + " · " + (st.pnlToday >= 0 ? "+" : "") + str.tostring(st.pnlToday, "0"), st.pnlToday > 0 ? th.up : st.pnlToday < 0 ? th.down : th.txt, "Entries used today / cap · paper P&L today = points × qty, NO costs.")
    r += 1
    int    decided = st.n - st.scratch
    string recTxt  = st.n < 5 ? str.tostring(st.n) + " closed · too few" : str.tostring(st.n) + " closed · " + (decided > 0 ? str.tostring(math.round(st.wins / decided * 100)) + "% win" : "—") + " · avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R"
    kv(dash, r, "Record", recTxt, th.txt, "Closed signals on this chart: count · win% of decided trades (breakeven scratches excluded: " + str.tostring(st.scratch) + ") · average R over all. Paper, no costs, small sample — a description of the past on THIS chart, not an edge.")
    r += 1
    string txA = st.nA > 0 ? "A " + str.tostring(math.round(st.wA / st.nA * 100)) + "% (" + str.tostring(st.nA) + ")" : "A —"
    string txB = st.nB > 0 ? "B " + str.tostring(math.round(st.wB / st.nB * 100)) + "% (" + str.tostring(st.nB) + ")" : "B —"
    string txC = st.nC > 0 ? " · C " + str.tostring(math.round(st.wC / st.nC * 100)) + "% (" + str.tostring(st.nC) + ")" : ""
    kv(dash, r, "By grade", txA + " · " + txB + txC, th.txt, "Win% per entry grade (wins ÷ all trades of that grade, scratches count as non-wins). If A is not clearly better than B on your chart, the grade is not earning its keep here — tell us.")
    r += 1
    table.cell(dash, 0, r, inMode + " · " + inTrigger + " · ≥" + inMinGrade, text_color = th.flat, text_size = szSub(), text_halign = text.align_left, bgcolor = th.bg, tooltip = "Mode · trigger · minimum grade.")
    table.cell(dash, 1, r, (st.pnlTotal >= 0 ? "+" : "") + str.tostring(st.pnlTotal, "0") + " paper · no costs", text_color = th.flat, text_size = szSub(), text_halign = text.align_right, bgcolor = th.bg, tooltip = "Total paper P&L over the loaded history. Use the strategy twin for costs.")

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

narrNow() =>
    string s = ""
    if t.dir != 0
        string side = t.dir == 1 ? "long" : "short"
        string nextT = not t.tp1Hit ? "TP1 " + fmt(t.tp1) : not t.tp2Hit ? "TP2 " + fmt(t.tp2) : "TP3 " + fmt(t.tp3)
        s := "in a " + side + " from " + fmt(t.entry) + " (grade " + t.grade + ", strength " + str.tostring(t.strength, "0") + ") — stop " + fmt(t.sl) + (t.tp2Hit ? " (trailing)" : t.tp1Hit and inBE ? " (breakeven)" : "") + ", next target " + nextT
    else if sqOff
        s := "square-off window — no new entries, open positions are closed here in Intraday mode"
    else if not winOK
        s := "outside the entry window (" + inEntryWin + " " + inTZ + ") — the desk does not enter here"
    else if st.today >= inMaxDay
        s := "flat — today's cap of " + str.tostring(inMaxDay) + " entries is used; the next call is tomorrow"
    else if coolActive
        s := "flat — cooling down for " + str.tostring(inCooldown - (bar_index - st.lastExit)) + " more bars after the last exit"
    else if fTrend == 0
        s := "flat — trend is mixed (Supertrend and the EMAs disagree); no side to trade until they line up"
    else
        string side = fTrend == 1 ? "long" : "short"
        string need = inTrigger == "Breakout" ? "a confirmed structure break in the trend direction" : "a pullback to the fast EMA and a strong close back in the trend direction with momentum turning"
        s := "flat — trend " + trendWord + ", " + side + " strength " + str.tostring(viewStr, "0") + " (grade " + (viewGrade != "" ? viewGrade : "—") + "); waiting for " + need
    s

narrWhy() =>
    string t1 = fTrend == 1 ? "trend up (Supertrend + EMA 20 > 50)" : fTrend == -1 ? "trend down (Supertrend + EMA 20 < 50)" : "trend mixed"
    string m1 = "RSI " + str.tostring(rsi, "0") + (rsi > rsiMa ? " above" : " below") + " its average"
    string s1 = structDir == 1 ? "structure bullish (last break up" + (na(structBar) ? ")" : ", " + str.tostring(bar_index - structBar) + " bars ago)") : structDir == -1 ? "structure bearish (last break down" + (na(structBar) ? ")" : ", " + str.tostring(bar_index - structBar) + " bars ago)") : "no structure break yet"
    string l1 = "price " + str.tostring(math.abs(distF), "0.0") + " ATR " + (distF >= 0 ? "above" : "below") + " the fast EMA" + (math.abs(distF) > inStretch ? " (stretched — chase veto)" : "")
    string c1 = bigBar ? "this candle is oversized (" + str.tostring(rng / atrU, "0.0") + " ATR — veto)" : (viewSide == 1 ? bullBar : bearBar) ? "a clean candle in the signal direction" : "no clean signal candle"
    string v1 = vwapOK ? "price " + (close > vwapPx ? "above" : "below") + " VWAP" : "no VWAP (no volume on this feed)"
    string h1 = htfValid ? inHtf + "m bias " + (htfBias == 1 ? "up" : htfBias == -1 ? "down" : "unclear") : "HTF factor neutral (" + inHtf + " is not above the chart timeframe)"
    string r1 = dayBars < 3 ? "day range too young for the room check" : (viewSide == 1 ? (roomL ? "room to the day's high " + str.tostring(toHi / atrU, "0.0") + " ATR" : "only " + str.tostring(toHi / atrU, "0.0") + " ATR to the day's high — no room") : (roomS ? "room to the day's low " + str.tostring(toLo / atrU, "0.0") + " ATR" : "only " + str.tostring(toLo / atrU, "0.0") + " ATR to the day's low — no room"))
    string w1 = inLearn and mdl.trained > 0 ? " The model has learned from " + str.tostring(mdl.trained) + " resolved triggers here (weights: " + learnedGlyphs + ")." : ""
    "Setup: " + t1 + " · " + m1 + " · " + s1 + " · " + l1 + " · " + c1 + ". Context: " + v1 + " · " + h1 + " · " + r1 + "." + w1

narrPlan() =>
    string s = ""
    if t.dir != 0
        s := "stop is checked first on every closed bar; TP1 " + fmt(t.tp1) + " moves the stop to breakeven, TP2 " + fmt(t.tp2) + " to TP1 (then an ATR trail), TP3 " + fmt(t.tp3) + " is the final target. Also out on a Supertrend flip" + (inMomExit == "RSI vs average" ? ", on RSI losing its average" : inMomExit == "RSI through 50" ? ", on RSI back through 50" : "") + (inTimeStop > 0 ? ", after " + str.tostring(inTimeStop) + " bars without TP1" : "") + (inMode == "Intraday" ? ", and at square-off " + inSqOff : "") + "."
    else if fTrend != 0
        float refSl = fTrend == 1 ? math.max(math.min(nz(pivLow, lo5), lo5) - 0.25 * atrU, close - inSlAtr * 1.5 * atrU) : math.min(math.max(nz(pivHigh, hi5), hi5) + 0.25 * atrU, close + inSlAtr * 1.5 * atrU)
        float refR  = math.abs(close - refSl)
        s := "if a " + (fTrend == 1 ? "long" : "short") + " fires near " + fmt(close) + ": stop ≈ " + fmt(refSl) + " (" + fmt(refR) + " = 1R), TP1 / TP2 / TP3 ≈ " + fmt(close + (fTrend == 1 ? 1 : -1) * inTp1 * refR) + " / " + fmt(close + (fTrend == 1 ? 1 : -1) * inTp2 * refR) + " / " + fmt(close + (fTrend == 1 ? 1 : -1) * inTp3 * refR) + ". Only grade " + inMinGrade + " or better fires; max " + str.tostring(inMaxDay) + " a day."
    s

narrConf() =>
    string g = t.dir != 0 ? t.grade : viewGrade
    float  sv = t.dir != 0 ? t.strength : viewStr
    string word = g == "A" ? "high" : g == "B" ? "moderate" : g == "C" ? "low" : "no setup"
    "Confidence: " + word + " (strength " + str.tostring(sv, "0") + ")  ·  can't see: news, the option's theta / IV, order flow, the next tick, and whether your fill matches the signal close."

narrTrack() =>
    int decided = st.n - st.scratch
    st.n < 5 ? "Track record here: " + str.tostring(st.n) + " closed — too few to mean anything yet." : "Track record here: " + str.tostring(st.n) + " closed, " + (decided > 0 ? str.tostring(math.round(st.wins / decided * 100)) + "% won of " + str.tostring(decided) + " decided (" + str.tostring(st.scratch) + " breakeven)" : "all breakeven") + ", avg " + (st.sumR / st.n >= 0 ? "+" : "") + str.tostring(st.sumR / st.n, "0.00") + "R (paper, no costs, small sample — NOT an edge)."

narrPosOf(string s) =>
    s == "Top Left" ? position.top_left : s == "Top Right" ? position.top_right : s == "Bottom Right" ? position.bottom_right : s == "Middle Left" ? position.middle_left : s == "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))
            string p = narrPlan()
            if p != ""
                array.push(labs, "PLAN")
                array.push(vals, wrap(p, inNarrWrap))
        if inNarr == "Detailed"
            array.push(labs, "CONFIDENCE")
            array.push(vals, wrap(narrConf(), inNarrWrap))
            array.push(labs, "TRACK REC")
            array.push(vals, wrap(narrTrack(), 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: graded signals are context for your decision. Paper P&L has no costs; the strategy twin measures it with costs.", 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)
alertcondition(entryL, "Long entry (grade ≥ minimum)", "Signal Desk: LONG entry")
alertcondition(entryS, "Short entry (grade ≥ minimum)", "Signal Desk: SHORT entry")
alertcondition(entryL and t.grade == "A", "Long entry · A grade only", "Signal Desk: LONG entry · grade A")
alertcondition(entryS and t.grade == "A", "Short entry · A grade only", "Signal Desk: SHORT entry · grade A")
alertcondition(exitNow and exitDir == 1, "Exit long", "Signal Desk: EXIT LONG")
alertcondition(exitNow and exitDir == -1, "Exit short", "Signal Desk: EXIT SHORT")
alertcondition(tp1Now, "TP1 reached", "Signal Desk: TP1 reached — stop moves per settings")
alertcondition(tp2Now, "TP2 reached", "Signal Desk: TP2 reached — stop moves per settings")
if entryL
    alert(payloadEntry(1, t.grade), alert.freq_once_per_bar_close)
if entryS
    alert(payloadEntry(-1, t.grade), alert.freq_once_per_bar_close)
if exitNow
    alert(payloadExit(exitDir, exitWhy, exitPx, exitR), alert.freq_once_per_bar_close)
if inAlertMile and tp1Now
    alert(payloadMile("TP1"), alert.freq_once_per_bar_close)
if inAlertMile and tp2Now
    alert(payloadMile("TP2"), alert.freq_once_per_bar_close)
Download the indicator .pine file

The strategy twin · optional

Measure the trading rules with costs on a second chart. Read the workflow chapter first.

Strategy code

Copy the code as it is. No coding needed.

View the full code
// ═══════════════════════════════════════════════════════════════════════════════
//  SIGNAL DESK — strategy twin  ·  Pine Script v6  ·  v1.2.0
//  The SAME engine as signal_desk.pine (indicator) — eight factors, the learning strength model, the
//  same triggers / vetoes / gates / trade management — wired to strategy.* so the Strategy Tester can
//  measure it HONESTLY: ₹20 per order + 1 tick slippage, one lot, fills at the signal bar's close.
//  Use it to answer the only question that matters — "is the winning percentage better?" — on YOUR
//  chart, instead of taking anyone's word for it. Whatever it says is in-sample until you test it on
//  data the settings never saw.
//
//  Costs are ON from bar one. Turn them off and the results will flatter you.
//  © 2026 ProEA Lab · testable draft · not a validated edge
// ═══════════════════════════════════════════════════════════════════════════════
//@version=6
// initial_capital is set high and margin is 0 on purpose: on the INDEX chart one lot (75) is a
// ~₹18 lakh notional, and TradingView silently skips orders that exceed equity × margin. This test
// is about the signals, not position sizing — size your real option trade by premium × lot.
strategy("Signal Desk — strategy twin", "SigDesk ST", overlay = true, initial_capital = 5000000, currency = currency.NONE,
     default_qty_type = strategy.fixed, default_qty_value = 75, pyramiding = 0, margin_long = 0, margin_short = 0,
     commission_type = strategy.commission.cash_per_order, commission_value = 20, slippage = 1,
     process_orders_on_close = true, calc_on_every_tick = false, max_labels_count = 200)

// ─────────────────────────────────────────────────────────────────── INPUTS (mirror of the indicator)
gT = "Trade"
inDir      = input.string("Both", "Direction", options = ["Both", "Long only", "Short only"], group = gT, display = display.none)
inMode     = input.string("Intraday", "Mode", options = ["Intraday", "Positional"], group = gT, display = display.none)
inTrigger  = input.string("Pullback", "Entry trigger", options = ["Pullback", "Breakout", "Both"], group = gT, display = display.none)
inMinGrade = input.string("B", "Trade only when grade ≥", options = ["A", "B", "C"], group = gT, display = display.none)
inGateMode = input.string("Setup count", "Grade from", options = ["Setup count", "Learned strength"], group = gT, display = display.none)
inGradeA   = input.int(80, "A = strength ≥ (learned mode)", minval = 50, maxval = 95, group = gT, display = display.none)
inGradeB   = input.int(65, "B = strength ≥ (learned mode)", minval = 40, maxval = 90, group = gT, display = display.none)
inMaxDay   = input.int(2, "Max entries per day", minval = 1, maxval = 20, group = gT, display = display.none)
inCooldown = input.int(6, "Cooldown after an exit (bars)", minval = 0, maxval = 200, group = gT, display = display.none)
gL = "Strength model (learns on this chart)"
inLearn = input.bool(true, "Learn factor weights on this chart", group = gL, display = display.none)
inLr    = input.float(0.02, "Learning rate", minval = 0.0, maxval = 0.2, step = 0.005, group = gL, display = display.none)
inShadowBars = input.int(30, "Shadow bracket timeout (bars)", minval = 5, maxval = 300, group = gL, display = display.none)
gSn = "Session (exchange time)"
inTZ       = input.string("Asia/Kolkata", "Exchange timezone", group = gSn, display = display.none)
inEntryWin = input.session("0930-1445", "Entry window", group = gSn, display = display.none)
inSqOff    = input.session("1500-1515", "Square-off window (Intraday)", group = gSn, display = display.none)
gR = "Risk levels"
inSlMode    = input.string("Structure", "Stop-loss", options = ["Structure", "ATR"], group = gR, display = display.none)
inSlAtr     = input.float(1.5, "ATR multiple (ATR stop · trail · far-swing cap)", minval = 0.3, maxval = 6, step = 0.1, group = gR, display = display.none)
inTp1       = input.float(1.0, "TP1 (R)", minval = 0.2, maxval = 10, step = 0.1, group = gR, display = display.none)
inTp2       = input.float(2.0, "TP2 (R)", minval = 0.3, maxval = 20, step = 0.1, group = gR, display = display.none)
inTp3       = input.float(3.0, "TP3 (R) — final target", minval = 0.5, maxval = 30, step = 0.1, group = gR, display = display.none)
inBE        = input.bool(true, "TP1 → stop to breakeven", group = gR, display = display.none)
inTrail     = input.bool(true, "TP2 → stop to TP1, then ATR trail", group = gR, display = display.none)
inExitTrend = input.bool(true, "Exit on trend flip (Supertrend)", group = gR, display = display.none)
inMomExit   = input.string("RSI vs average", "Momentum-loss exit", options = ["RSI vs average", "RSI through 50", "Off"], group = gR, display = display.none)
inTimeStop  = input.int(0, "Time stop (bars, 0 = off)", minval = 0, maxval = 500, group = gR, display = display.none)
inExitOpp   = input.bool(true, "Exit (and reverse) on an opposite signal", group = gR, display = display.none)
gF = "Filters (fewer, stronger)"
inBigBar    = input.float(2.5, "Veto: trigger bar range > ATR ×", minval = 1, maxval = 8, step = 0.1, group = gF, display = display.none)
inStretch   = input.float(2.0, "Veto: distance from fast EMA > ATR ×", minval = 0.5, maxval = 8, step = 0.1, group = gF, display = display.none)
inRunAtr    = input.float(3.0, "Veto: 3-bar run > ATR ×", minval = 1, maxval = 12, step = 0.5, group = gF, display = display.none)
inTouch     = input.float(0.3, "Pullback: touch tolerance (ATR ×)", minval = 0, maxval = 2, step = 0.1, group = gF, display = display.none)
inTouchBars = input.int(3, "Pullback: touch within N bars", minval = 1, maxval = 20, group = gF, display = display.none)
gE = "Engine"
inEmaF   = input.int(20, "Fast EMA", minval = 2, group = gE, display = display.none)
inEmaS   = input.int(50, "Slow EMA", minval = 3, group = gE, display = display.none)
inRsiLen = input.int(14, "RSI length", minval = 2, group = gE, display = display.none)
inRsiMa  = input.int(14, "RSI average length", minval = 2, group = gE, display = display.none)
inStAtr  = input.int(10, "Supertrend ATR length", minval = 1, group = gE, display = display.none)
inStFac  = input.float(3.0, "Supertrend factor", minval = 0.5, maxval = 10, step = 0.1, group = gE, display = display.none)
inPiv    = input.int(5, "Swing pivot length", minval = 2, maxval = 30, group = gE, display = display.none)
inAtrLen = input.int(14, "ATR length", minval = 1, group = gE, display = display.none)
inHtf    = input.timeframe("15", "Higher timeframe (context factor)", group = gE, display = display.none)
gV = "Style"
inShowLvls = input.bool(true, "Show EMAs + active stop / target", group = gV, display = display.none)

// ═══════════════════════════════════════════════════════════════════ ENGINE (identical to the indicator)
float atr   = ta.atr(inAtrLen)
float atrU  = math.max(atr, syminfo.mintick)
float emaF  = ta.ema(close, inEmaF)
float emaS  = ta.ema(close, inEmaS)
float rsi   = ta.rsi(close, inRsiLen)
float rsiMa = ta.sma(rsi, inRsiMa)
bool  rsiXup = ta.crossover(rsi, rsiMa)
bool  rsiXdn = ta.crossunder(rsi, rsiMa)
bool  rsi50Dn = ta.crossunder(rsi, 50)
bool  rsi50Up = ta.crossover(rsi, 50)
[stLine, stDir] = ta.supertrend(inStFac, inStAtr)
bool  stUp     = stDir < 0
bool  stFlipDn = stDir > 0 and stDir[1] < 0
bool  stFlipUp = stDir < 0 and stDir[1] > 0
float ph = ta.pivothigh(high, inPiv, inPiv)
float pl = ta.pivotlow(low, inPiv, inPiv)
int   sinceTouchF = ta.barssince(low <= emaF + inTouch * atrU)
int   sinceTouchS = ta.barssince(high >= emaF - inTouch * atrU)
float run3 = ta.highest(close, 3) - ta.lowest(close, 3)
float lo5  = ta.lowest(low, 5)
float hi5  = ta.highest(high, 5)
bool  winOK  = not na(time(timeframe.period, inEntryWin, inTZ))
bool  sqOff  = not na(time(timeframe.period, inSqOff, inTZ))
int   dayNum = dayofmonth(time, inTZ)
bool  newDay = ta.change(dayNum) != 0

float vwapPx = ta.vwap(hlc3)
bool  vwapOK = not na(vwapPx) and nz(volume, 0) > 0
htfBiasFn() =>
    ta.ema(close, 20) > ta.ema(close, 50) ? 1 : -1
bool  htfValid   = timeframe.in_seconds(inHtf) > timeframe.in_seconds()
// confirmed-HTF pattern: the [1] offset + lookahead_on together = the last CLOSED higher-timeframe bar (never the forming one)
int   htfBiasRaw = request.security(syminfo.tickerid, inHtf, htfBiasFn()[1], lookahead = barmerge.lookahead_on)
int   htfBias    = htfValid ? nz(htfBiasRaw, 0) : 0
var float dayHi   = na
var float dayLo   = na
var int   dayBars = 0
if newDay or na(dayHi)
    dayHi   := high
    dayLo   := low
    dayBars := 1
else
    dayHi   := math.max(dayHi, high)
    dayLo   := math.min(dayLo, low)
    dayBars += 1

var float swHigh    = na
var float swLow     = na
var float pivHigh   = na
var float pivLow    = na
var int   structDir = 0
bool bosUp   = false
bool bosDn   = false
bool chochUp = false
bool chochDn = false
if not na(ph)
    swHigh  := ph
    pivHigh := ph
if not na(pl)
    swLow  := pl
    pivLow := pl
if barstate.isconfirmed
    if not na(swHigh) and close > swHigh
        if structDir == -1
            chochUp := true
        else
            bosUp := true
        structDir := 1
        swHigh := na
    if not na(swLow) and close < swLow
        if structDir == 1
            chochDn := true
        else
            bosDn := true
        structDir := -1
        swLow := na

int   fTrend  = stUp and emaF > emaS ? 1 : (not stUp and emaF < emaS) ? -1 : 0
int   fMom    = rsi > rsiMa and rsi > 50 ? 1 : rsi < rsiMa and rsi < 50 ? -1 : 0
int   fStruct = structDir
float distF   = (close - emaF) / atrU
bool  locL    = distF >= -0.5 and distF <= 1.0
bool  locS    = distF <= 0.5 and distF >= -1.0
float rng     = high - low
float body    = math.abs(close - open)
bool  bigBar  = rng > inBigBar * atrU
bool  bullBar = close > open and rng > 0 and (high - close) <= 0.35 * rng and body >= 0.4 * rng
bool  bearBar = close < open and rng > 0 and (close - low) <= 0.35 * rng and body >= 0.4 * rng
bool  runUp   = run3 > inRunAtr * atrU and close > close[3]
bool  runDn   = run3 > inRunAtr * atrU and close < close[3]
bool  vetoL   = bigBar or distF > inStretch or runUp
bool  vetoS   = bigBar or distF < -inStretch or runDn
bool  candleL = bullBar and not bigBar
bool  candleS = bearBar and not bigBar
float toHi    = dayHi - close
float toLo    = close - dayLo
bool  roomL   = dayBars < 3 or not (toHi > 0.1 * atrU and toHi < 1.0 * atrU)
bool  roomS   = dayBars < 3 or not (toLo > 0.1 * atrU and toLo < 1.0 * atrU)
featL() =>
    array<float> f = array.new<float>(8, 0.0)
    array.set(f, 0, fTrend == 1 ? 1.0 : 0.0)
    array.set(f, 1, fMom == 1 ? 1.0 : 0.0)
    array.set(f, 2, fStruct == 1 ? 1.0 : 0.0)
    array.set(f, 3, locL ? 1.0 : 0.0)
    array.set(f, 4, candleL ? 1.0 : 0.0)
    array.set(f, 5, vwapOK ? (close > vwapPx ? 1.0 : 0.0) : 0.5)
    array.set(f, 6, htfValid ? (htfBias == 1 ? 1.0 : 0.0) : 0.5)
    array.set(f, 7, roomL ? 1.0 : 0.0)
    f
featS() =>
    array<float> f = array.new<float>(8, 0.0)
    array.set(f, 0, fTrend == -1 ? 1.0 : 0.0)
    array.set(f, 1, fMom == -1 ? 1.0 : 0.0)
    array.set(f, 2, fStruct == -1 ? 1.0 : 0.0)
    array.set(f, 3, locS ? 1.0 : 0.0)
    array.set(f, 4, candleS ? 1.0 : 0.0)
    array.set(f, 5, vwapOK ? (close < vwapPx ? 1.0 : 0.0) : 0.5)
    array.set(f, 6, htfValid ? (htfBias == -1 ? 1.0 : 0.0) : 0.5)
    array.set(f, 7, roomS ? 1.0 : 0.0)
    f

type Model
    array<float> w
    array<float> mu
    float w0     = 0.0
    int   trained = 0
var Model mdl = Model.new(w = array.new<float>(8, 0.75), mu = array.new<float>(8, 0.5))
squash(float z) => 1.0 / (1.0 + math.exp(-z))
mdlLogit(Model m, array<float> f) =>
    float z = 0.0
    for i = 0 to 7
        z += array.get(m.w, i) * (array.get(f, i) - array.get(m.mu, i))
    z
mdlTrain(Model m, array<float> f, float y) =>
    float p   = squash(m.w0 + mdlLogit(m, f))
    float err = p - y
    m.w0 := m.w0 - inLr * err
    for i = 0 to 7
        float xc = array.get(f, i) - array.get(m.mu, i)
        float wi = array.get(m.w, i) - inLr * err * xc
        array.set(m.w, i, math.max(-1.0, math.min(2.5, wi)))
        array.set(m.mu, i, array.get(m.mu, i) + 0.02 * (array.get(f, i) - array.get(m.mu, i)))
    m.trained := m.trained + 1
mdlStrength(Model m, array<float> f) => squash(mdlLogit(m, f))
array<float> fxL = featL()
array<float> fxS = featS()
float strengthL = 100.0 * mdlStrength(mdl, fxL)
float strengthS = 100.0 * mdlStrength(mdl, fxS)
int   setupL  = (fTrend == 1 ? 1 : 0) + (fMom == 1 ? 1 : 0) + (fStruct == 1 ? 1 : 0) + (locL ? 1 : 0) + (candleL ? 1 : 0)
int   setupS  = (fTrend == -1 ? 1 : 0) + (fMom == -1 ? 1 : 0) + (fStruct == -1 ? 1 : 0) + (locS ? 1 : 0) + (candleS ? 1 : 0)
gradeOf(float s)    => s >= inGradeA ? "A" : s >= inGradeB ? "B" : s >= 50 ? "C" : ""
gradeCount(int s)   => s >= 5 ? "A" : s == 4 ? "B" : s == 3 ? "C" : ""
gradeRank(string g) => g == "A" ? 3 : g == "B" ? 2 : g == "C" ? 1 : 0
string gradeL = inGateMode == "Learned strength" ? gradeOf(strengthL) : gradeCount(setupL)
string gradeS = inGateMode == "Learned strength" ? gradeOf(strengthS) : gradeCount(setupS)

bool momUpTurn = rsiXup or (rsi > rsiMa and rsi > rsi[1])
bool momDnTurn = rsiXdn or (rsi < rsiMa and rsi < rsi[1])
bool pullL = fTrend == 1  and sinceTouchF <= inTouchBars and bullBar and close > emaF and momUpTurn
bool pullS = fTrend == -1 and sinceTouchS <= inTouchBars and bearBar and close < emaF and momDnTurn
bool brkL  = fTrend == 1  and (bosUp or chochUp)
bool brkS  = fTrend == -1 and (bosDn or chochDn)
bool trigL = inTrigger == "Pullback" ? pullL : inTrigger == "Breakout" ? brkL : (pullL or brkL)
bool trigS = inTrigger == "Pullback" ? pullS : inTrigger == "Breakout" ? brkS : (pullS or brkS)
bool dirL  = inDir != "Short only"
bool dirS  = inDir != "Long only"

// ═══════════════════════════════════════════════════════════════════ ORDERS
var int    today    = 0
var int    lastExit = -100000
var float  tEntry = na
var float  tSl  = na
var float  tR   = na
var float  tTp1 = na
var float  tTp2 = na
var float  tTp3 = na
var int    tDir = 0
var bool   tp1Hit = false
var bool   tp2Hit = false
var string tGrade = ""
var array<float> tFx = array.new<float>(8, 0.5)
if newDay
    today := 0

int posDir = strategy.position_size > 0 ? 1 : strategy.position_size < 0 ? -1 : 0
bool closedNow = strategy.closedtrades > strategy.closedtrades[1]
if closedNow
    lastExit := bar_index

// shadow learning — identical to the indicator: every trigger is followed as a 1R bracket, stop first, later bars only
type Shadow
    bool  on  = false
    int   dir = 0
    float sl  = na
    float tp  = na
    int   bar = na
    array<float> fx
var Shadow sh = Shadow.new(fx = array.new<float>(8, 0.5))
stopFor(int d) =>
    float s = d == 1 ? math.min(nz(pivLow, lo5), lo5) - 0.25 * atrU : math.max(nz(pivHigh, hi5), hi5) + 0.25 * atrU
    float a = d == 1 ? close - inSlAtr * atrU : close + inSlAtr * atrU
    float p = inSlMode == "ATR" ? a : (math.abs(close - s) > inSlAtr * 1.5 * atrU ? a : s)
    d == 1 ? math.min(p, close - syminfo.mintick) : math.max(p, close + syminfo.mintick)
if barstate.isconfirmed and sh.on and bar_index > sh.bar
    bool shStop = sh.dir == 1 ? low <= sh.sl : high >= sh.sl
    bool shTgt  = sh.dir == 1 ? high >= sh.tp : low <= sh.tp
    bool shOut  = bar_index - sh.bar >= inShadowBars
    if shStop or shTgt or shOut
        if inLearn
            mdlTrain(mdl, sh.fx, shStop ? 0.0 : shTgt ? 1.0 : 0.5)
        sh.on := false
if barstate.isconfirmed and not sh.on and not na(atr) and (trigL or trigS)
    int   sd  = trigL ? 1 : -1
    float ssl = stopFor(sd)
    float sr  = math.abs(close - ssl)
    sh.on  := true
    sh.dir := sd
    sh.sl  := ssl
    sh.tp  := sd == 1 ? close + inTp1 * sr : close - inTp1 * sr
    sh.bar := bar_index
    sh.fx  := array.copy(sd == 1 ? fxL : fxS)

bool gateCommon = barstate.isconfirmed and not na(atr) and winOK and today < inMaxDay and (bar_index - lastExit) >= inCooldown
bool candL = trigL and not vetoL and gradeRank(gradeL) >= gradeRank(inMinGrade) and dirL and gateCommon
bool candS = trigS and not vetoS and gradeRank(gradeS) >= gradeRank(inMinGrade) and dirS and gateCommon

// manage the open position: milestones move the stop, discretionary exits close at the bar's close
if barstate.isconfirmed and posDir != 0
    float entry = strategy.position_avg_price
    bool h1 = posDir == 1 ? high >= tTp1 : low <= tTp1
    bool h2 = posDir == 1 ? high >= tTp2 : low <= tTp2
    if h1 and not tp1Hit
        tp1Hit := true
        if inBE
            tSl := entry
    if h2 and not tp2Hit
        tp2Hit := true
        if inTrail
            tSl := tTp1
    if inTrail and tp2Hit
        float trailPx = posDir == 1 ? close - inSlAtr * atrU : close + inSlAtr * atrU
        tSl := posDir == 1 ? math.max(tSl, trailPx) : math.min(tSl, trailPx)
    int  barsIn  = bar_index - strategy.opentrades.entry_bar_index(strategy.opentrades - 1)
    bool flip    = inExitTrend and (posDir == 1 ? stFlipDn : stFlipUp)
    bool momLoss = barsIn >= 2 and (inMomExit == "RSI vs average" ? (posDir == 1 ? rsiXdn : rsiXup) : inMomExit == "RSI through 50" ? (posDir == 1 ? rsi50Dn : rsi50Up) : false)
    bool opp     = inExitOpp and (posDir == 1 ? candS : candL)
    bool sq      = inMode == "Intraday" and sqOff
    bool timeUp  = inTimeStop > 0 and not tp1Hit and barsIn >= inTimeStop
    if flip or momLoss or opp or sq or timeUp
        strategy.close_all(comment = sq ? "SQ-OFF" : flip ? "TREND" : opp ? "OPP" : momLoss ? "MOM" : "TIME")
    else
        strategy.exit(posDir == 1 ? "XL" : "XS", posDir == 1 ? "L" : "S", stop = tSl, limit = tTp3, comment_loss = "SL", comment_profit = "TP3", comment_trailing = "TRAIL")

// entries (flat, or reversing on an opposite signal)
bool canOpen = posDir == 0 or (inExitOpp and (posDir == 1 ? candS : candL))
if candL and canOpen and (posDir != 1)
    float slStruct = math.min(nz(pivLow, lo5), lo5) - 0.25 * atrU
    float slAtr    = close - inSlAtr * atrU
    float slPx     = inSlMode == "ATR" ? slAtr : ((close - slStruct) > inSlAtr * 1.5 * atrU ? slAtr : slStruct)
    slPx := math.min(slPx, close - syminfo.mintick)
    tEntry := close
    tDir := 1
    tR   := close - slPx
    tSl  := slPx
    tTp1 := close + inTp1 * tR
    tTp2 := close + inTp2 * tR
    tTp3 := close + inTp3 * tR
    tp1Hit := false
    tp2Hit := false
    tGrade := gradeL
    tFx := array.copy(fxL)
    today := today + 1
    strategy.entry("L", strategy.long, comment = "L·" + gradeL + " " + str.tostring(strengthL, "0"))
    strategy.exit("XL", "L", stop = tSl, limit = tTp3, comment_loss = "SL", comment_profit = "TP3")
else if candS and canOpen and (posDir != -1)
    float slStruct = math.max(nz(pivHigh, hi5), hi5) + 0.25 * atrU
    float slAtr    = close + inSlAtr * atrU
    float slPx     = inSlMode == "ATR" ? slAtr : ((slStruct - close) > inSlAtr * 1.5 * atrU ? slAtr : slStruct)
    slPx := math.max(slPx, close + syminfo.mintick)
    tEntry := close
    tDir := -1
    tR   := slPx - close
    tSl  := slPx
    tTp1 := close - inTp1 * tR
    tTp2 := close - inTp2 * tR
    tTp3 := close - inTp3 * tR
    tp1Hit := false
    tp2Hit := false
    tGrade := gradeS
    tFx := array.copy(fxS)
    today := today + 1
    strategy.entry("S", strategy.short, comment = "S·" + gradeS + " " + str.tostring(strengthS, "0"))
    strategy.exit("XS", "S", stop = tSl, limit = tTp3, comment_loss = "SL", comment_profit = "TP3")

// ═══════════════════════════════════════════════════════════════════ CHART (minimal — the tester is the report)
plot(emaF, "EMA fast", color = inShowLvls ? color.new(#7C9CFF, 15) : na)
plot(emaS, "EMA slow", color = inShowLvls ? color.new(#5B6472, 30) : na)
plot(inShowLvls and posDir != 0 ? tSl : na,  "Active stop",   color = #FF4D6D, style = plot.style_linebr, display = display.pane)
plot(inShowLvls and posDir != 0 ? tTp3 : na, "Active target", color = #2AF0C8, style = plot.style_linebr, display = display.pane)

// POS label on the last CONFIRMED bar (strategies do not run on the forming bar)
var label pos = na
if barstate.islastconfirmedhistory
    string txt = posDir == 1 ? "LONG · " + tGrade + " · stop " + str.tostring(tSl, format.mintick) : posDir == -1 ? "SHORT · " + tGrade + " · stop " + str.tostring(tSl, format.mintick) : "FLAT · " + str.tostring(today) + "/" + str.tostring(inMaxDay) + " today · learned " + str.tostring(mdl.trained)
    if na(pos)
        pos := label.new(bar_index, high, txt, style = label.style_label_left, color = color.new(#0E1118, 10), textcolor = #E6E9EF, size = size.small)
    label.set_xy(pos, bar_index, high)
    label.set_text(pos, txt)
Download the strategy .pine file
No signal yet?

Check the session timezone and entry window, available history, minimum grade, vetoes, daily allowance and cooldown. The tool can correctly show no entry.

Go deeper, one question at a time.

The full rules and settings are here when you need them.

What starts a possible entry?

Pullback, the default: price visits the fast average, then gives a strong close beyond it as momentum turns. Trend also needs Supertrend and EMA agreement. A touch alone is insufficient.

Breakout: a close breaks a confirmed swing in the trend direction. BOS marks continuation; CHoCH marks a change from the prior structure direction. Both mode accepts either trigger.

Then come the grade, chase limits, entry window, available daily allowance and position checks. There is one position at a time and normally a six-candle cooldown after exit. A qualifying opposite-signal reversal can reopen on the exit candle, subject to that bar’s gates.

What do the five checks and the grade mean?
  • Trend: Supertrend and fast/slow EMA direction agree.
  • Momentum: RSI is on the trade’s side of its average and 50.
  • Structure: the last confirmed swing break agrees.
  • Location: close is near the fast EMA; long range is half an ATR below to one ATR above. Shorts mirror it.
  • Candle: a strong directional body, close near its extreme, and no oversized range.

Default grades: A = five, B = four, C = three. Minimum B. Raising the minimum reduces qualifying setups; it does not establish better outcomes.

VWAP side, higher-timeframe direction and proximity to the day’s high/low are three context factors. They and learned strength are information by default, not extra entry gates.

Exactly what blocks a chase entry?

Any one blocks it: candle high–low range above 2.5 ATR; directional stretch from fast EMA above 2 ATR; or range of the last three closes above 3 ATR, with the close beyond the close three candles ago in the entry direction. Exact equality passes.

ATR measures recent price movement, including gaps. For a default pullback, the current candle or one of the previous three must touch within 0.3 ATR of the fast EMA. A strong body occupies at least forty percent of its candle; the wick beyond the close is at most thirty-five percent.

What does the learning score actually learn?

Strength 0–100 is a relative model score, not a chance of winning. Fifty represents the model’s typical factor mix. It affects the grade only if Grade from is changed to Learned strength.

  1. Open one practice bracketA trigger is sampled when no shadow bracket is active.
  2. Wait for its outcomeLater candles reach its stop, configured TP1 distance, or timeout.
  3. Adjust the factor weightsThe model updates using the sampled trigger’s saved readings.

Only one shadow runs at a time; intervening triggers are not separately sampled. Stop wins a same-candle collision. TP1 is 1R by default; thirty candles without resolution trains an outcome of 0.5. This differs from the full managed trade.

The Learned row’s T/M/S/L/C/V/H/R weights cover the five setup factors and three context factors. Plus/minus marks describe learned weights, not proof of a lasting advantage.

How do the stop and three targets work?

1R is the initial entry-to-stop distance. Default targets are 1R, 2R and 3R. TP1 moves the stop to entry; TP2 moves it to TP1 and starts an ATR trail; TP3 ends the plan. TP1 and TP2 do not take partial profits.

The default structure stop uses the farther recent swing or five-candle extreme, plus 0.25 ATR. If its distance exceeds 2.25 ATR, it falls back to 1.5 ATR. The ATR multiple setting changes that fallback, cap and trail.

Each later candle checks the existing stop before targets. A newly moved stop does not apply retroactively to that candle’s earlier wick.

Other exits include Supertrend reversal, RSI crossing its average after at least two candles held, a qualifying opposite signal, Intraday square-off, or the optional time stop before TP1. Exit labels record the reason.

How do I read the dashboard and Co-Pilot?

Setup / Grade show agreement; Context / Strength / Learned show supporting readings. Entry, Stop and Targets describe the plan; Trend, Session and Today show direction, time and daily use. Record / By grade summarize closed paper outcomes without costs.

Co-Pilot’s NOW describes the state, WHY gives the readings and PLAN names what follows under the rules. It explains the same engine, not an independent prediction.

Clean shows the trend cloud. Pro adds Supertrend, VWAP, swing labels and faint filtered-trigger marks. Full adds strength coloring and session shading. Minimal reduces the drawings.

Signal Desk Pro view on a NIFTY chart with mapped CE and PE labels, structure marks, dashboard and learned weights.
Historical NIFTY example · Pro view · options labels enabledOpen full-size chart ↗
What changes on an options chart?

For studying purchases on a call or put premium chart, the tool’s intended configuration is Long only, mapping off. On an index/futures chart, Both with mapping on labels longs BUY CE and shorts BUY PE.

Mapping formats a strike and expiry label. It does not read an option chain, premiums, Greeks or available expiries. Index-derived levels remain index prices. Quantity is an input, not automatic risk sizing.

An EXIT message recalculates its strike from the current price rather than preserving the entry strike. Treat it as descriptive data; it cannot by itself identify the original option contract for execution.

Which alerts can I create?

Eight named conditions: long/short entries, their A-only variants, long/short exits, TP1 and TP2. Choose Once Per Bar Close.

Any alert() function call sends ENTRY and EXIT, plus TP1/TP2 milestones by default. Choose JSON or readable text. Payloads include symbol, timeframe and event; entry messages include grade, strength, plan and supplied quantity.

These are indicator events, not broker fill confirmations. The strategy twin has no matching alert configuration.

What does each setting change?

Indicator defaults below. Change one input at a time.

Trade

Choose direction, trigger and grading method. Daily entries are a maximum. Quantity scales paper totals and alert data; it does not size broker orders or recommend current exchange lots.

Direction
Both
Mode
Intraday
Trigger
Pullback
Minimum grade
B
Grade from
Setup count
Learned A threshold
80
Learned B threshold
65
Maximum entries per day
2
Cooldown after exit
6 candles
Paper P&L / alert quantity
75

Strength model

Turn learning on, control its adjustment speed and limit how long a shadow example can remain unresolved.

Learn on this chart
On
Learning rate
0.02
Shadow timeout
30 candles

Options mapping

Format call/put labels on an index or futures chart. These controls do not read an option chain or place orders.

Options mapping
Off
Strike spacing
50
Strike offset
0
Expiry label
Blank

Session

Both windows use the chosen timezone and candle opening times. Intraday mode uses square-off; Positional still respects the entry window.

Timezone
Asia/Kolkata
Entry window
09:30–14:45
Square-off window
15:00–15:15

Risk levels

Structure or ATR stop; targets in risk multiples. Milestones can move the stop. Optional exits can finish a trade before its final target.

Stop-loss
Structure
ATR multiple: stop / trail / cap
1.5
TP1
1R
TP2
2R
TP3 · final
3R
TP1 → stop to entry
On
TP2 → stop to TP1, then trail
On
Exit on Supertrend flip
On
Momentum-loss exit
RSI vs average
Time stop before TP1
0 · off
Exit / reverse on opposite signal
On

Filters

Chase limits block values strictly above the threshold. Touch controls define how near and how recent the return to the average must be.

Maximum candle range
ATR × 2.5
Maximum stretch from fast EMA
ATR × 2
Maximum three-close range
ATR × 3
Touch tolerance
ATR × 0.3
Maximum candles since touch
3

Engine

EMA averages emphasize recent prices. RSI measures momentum. Supertrend tracks direction using ATR, a measure of recent price movement including gaps.

Fast EMA length
20
Slow EMA length
50
RSI length
14
RSI average length
14
Supertrend ATR length
10
Supertrend factor
3
Swing pivot length
5
ATR length
14
Higher timeframe: context only
15 minutes

Style

Minimal, Clean, Pro or Full changes the drawings. Themes: Aurora Neon, Royal Gold or Colorblind-safe.

View preset
Clean
Theme
Aurora Neon
EMA trend cloud
On
Risk / reward zones
On
Color candles by strength
Off

Dashboard

Show, place and resize the readings panel.

Show dashboard
On
Position
Top Right
Size
Medium
Row stripes
On

Co-Pilot

Brief gives a headline. Standard adds reasons and a plan. Detailed adds context and limitations. Off hides it.

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

Alerts

Choose JSON or readable text, and whether the dynamic alert also sends first and second target milestones.

JSON payload
On
TP1 / TP2 milestone messages
On

The twin shares Trade except quantity, Strength, Session, Risk, Filters and Engine inputs. It has no options, panel or alert settings.

Show EMAs + active stop / target
On
How do I measure it on my own chart?
  1. Keep defaults and watch a full session, including skipped candidates.
  2. Load the twin on a second chart. Set realistic quantity, fees and slippage in its properties.
  3. Compare one change at a time, then check a separate period. Include losing examples.

Twin header defaults: capital 5,000,000, quantity 75, cash fee 20 per order and one tick of slippage. They are code defaults, not current broker terms. Its fill emulator can differ from the indicator’s stop-first paper record.

Signal Desk strategy twin in TradingView Strategy Tester on NIFTY five-minute, with costs enabled.
Strategy Tester · historical example with costsOpen full-size chart ↗
What did our measurements show?

We measured one index chart with real costs on. Counting all eight factors, using learned strength as the gate and a stricter momentum exit made that sample worse. The five-factor setup count remained the default.

One symbol, one window, settings chosen while viewing that chart: in-sample evidence. The lab notes include failures and context. They do not establish the next trade’s outcome.

Read the lab notes
What can change the reading?

Entries commit on confirmed candles. Live readings can move before the close. Changing history, feed, timeframe or inputs can recalculate results. Pivots need five later candles by default; higher-timeframe context uses its last closed candle.

Default entry times are 09:30 inclusive to 14:45 exclusive, Asia/Kolkata, based on candle opening times. Intraday square-off happens on the first closed candle whose opening time falls inside 15:00–15:15.

The tool sees chart bars, not news, option Greeks, order flow or actual fills. Its paper record omits costs. It does not predict prices. This is research tooling, not financial advice.

  • Stochastic Pop Desk

    Compare the graded checklist with a momentum cross that requires a fresh reset.

  • Drift Desk

    Compare a graded entry checklist with candle pressure and a confirmed ATR-trail flip.

Built by ProEA Lab. Compare all free indicators.

05 · THE LOG

Every change, dated

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

    Checks entry quality, daily limits and whether you're chasing price.

  2. Fewer, stronger calls, no chase entry Request Signal Desk

    The ask Signal Desk was built from. Shipped as v1.2.0 with the grade, the chase veto and the strength score.

    1. Received
    2. Building
    3. Done

    Fewer signals, only the strong ones — one or two alerts a day would be fine. And stop the trade where a big candle closes, I enter, and it reverses.

    a Nifty options buyer, by message · Asked 2 Sep 2026 · Done 3 Sep 2026
  3. Measured before it shipped Lab note Signal Desk

    Run with real costs on. The three smarter versions of the gate lost to the plain count, so the plain count is the default.

BUILT WITH TRADERS

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