1Copy the scriptPress Copy script above, or the copy button on the code below.
2Open the Pine EditorOpen a TradingView chart and choose Pine Editor at the bottom of the screen.
3Paste over the templateCreate a new indicator, select all of the template text and paste. Press Save and give it a name.
4Add it to the chartPress Add to chart, then adjust the settings with the gear icon next to its name.
Source Code
3,406 lines of Pine Script v6 from bist_mtf_signal.pine, published under MPL-2.0 (file header). Keep the author's credit and licence lines when you reuse it.
bist_mtf_signal.pine
// This source code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// BIST Multi-Timeframe Buy/Sell Signal System
// Conceptually inspired by SCALPTOOL R1.1 (JustUncleL) but written from scratch.
// No ticker-specific hardcoding: every calculation derives from bar data or user inputs.
//
// ============================================================================
// BUILD STATUS
// ============================================================================
// [x] Stage 1 Core layers 1-4 + simple total score
// [x] Stage 2 MTF architecture (1D lock + hard veto)
// [x] Stage 3 1h entry timing + 15m exhaustion gate
// [x] Stage 4 Mid-tier layers 6-8
// [x] Stage 5 Advanced layers 9-13
// [x] Stage 6 Cooldown + earnings blacklist
// [x] Stage 7 Visuals (plotshape, colour coding, score label)
// [x] Stage 8 Performance log panel
// [x] Stage 8.5 Exit logic (positions, exit rules, comparison)
// [x] Stage 9 Unified alertcondition()
// [x] Stage 10 End-to-end / repaint testing <-- CURRENT (closed)
// [x] Static audit - no defects found, recorded in the findings doc
// [x] Layer ablation harness (14 parallel streams, one pass)
// [x] Buy-and-hold benchmark row
// [x] README + calibration findings
// [x] Ablation cross-check vs manual - passed on BIMAS L1, see findings
// [ ] Bar-replay repaint runs - NOT PERFORMED. Repaint behaviour
// was reasoned about in code review (lookahead_off + gaps_off on
// every request.security() call, na-guarded warm-up, pivot levels
// locked srRight bars after printing) but never watched on a live
// or replayed chart. Protocol is recorded in calibration_findings.md
// §1b for whoever picks this back up.
//
// ============================================================================
// request.security() CALL INVENTORY (Stage 5)
// ============================================================================
// COUNT: 5 (final - stages 6-10 add no further references, so this is the
// script's permanent footprint and it sits inside Pine's limit)
//
// #1 Directional bias lock [Stage 2]
// symbol : syminfo.tickerid (never hardcoded)
// timeframe : biasTF input, default "1D"
// expression: f_dailyBiasExpr(off) -> [bias, adx, smaSide, emaSide]
// lookahead : barmerge.lookahead_off (mandatory, no future data)
// gaps : barmerge.gaps_off (hold last known bias between
// daily closes; gaps_on would blank the bias on every
// intermediate chart bar and drop the veto to neutral)
// CONSOLIDATION: the ADX / SMA / triple-EMA regime test is evaluated
// INSIDE the daily context and only the finished verdict plus three
// diagnostics come back, so all six daily series cost one call, not six.
//
// #2 Entry timing [Stage 3]
// symbol : syminfo.tickerid
// timeframe : entryTF input, default "60"
// expression: f_entryExpr(off, pacLen, minBody)
// -> [touchLong, touchShort, confLong, confShort] as ints
// lookahead : barmerge.lookahead_off
// gaps : barmerge.gaps_off (the last known touch/confirmation
// state must persist across chart bars; gaps_on would
// blank it and the entry could never arm)
// CONSOLIDATION: PAC channel, touch test and confirmation candle are
// all evaluated inside the entry timeframe; four booleans come back
// instead of six price series.
//
// #3 Exhaustion gate [Stage 3]
// symbol : syminfo.tickerid
// timeframe : exhaustTF input, default "15"
// expression: f_exhaustExpr(off, rsiLen, flat, swingLb, swingBars,
// maxBody, minWick)
// -> [openLong, openShort, reasonLong, reasonShort] as ints
// lookahead : barmerge.lookahead_off
// gaps : barmerge.gaps_off (gate state must persist across chart
// bars rather than blanking between exhaustion-TF closes)
// CONSOLIDATION: RSI slope, pin/engulfing and the swing-extreme test
// are all resolved inside the exhaustion timeframe; four ints return.
//
// #4 Market / index correlation [Stage 4]
// symbol : idxSymbol input, default "BIST:XU100" (input.symbol, so
// the index is user-selectable and never hardcoded; a user
// on a delayed feed can point it at their own data source)
// timeframe : idxTF input, default "" = the chart timeframe, which is
// what the spec means by "same timeframe"
// expression: f_indexExpr(off, lb) -> index % change over lb bars
// lookahead : barmerge.lookahead_off
// gaps : barmerge.gaps_off (the index and the stock do not always
// print the same bars - half-days, halts, a name that did
// not trade. gaps_on would blank the correlation on those
// bars and drop the layer to neutral; gaps_off holds the
// last known index move instead)
// CONSOLIDATION: the percentage change is computed inside the index
// context, so one float comes back rather than two price series.
// NOTE: this is a CROSS-SYMBOL call at the chart timeframe, not a
// higher-timeframe one. It carries no HTF repaint risk - on the
// realtime bar it moves exactly as the chart's own close does, and it
// settles when the bar closes. idxConfirmedOnly (default OFF) exists
// only for users who point idxTF at a HIGHER timeframe, where the
// developing-bar problem described above would otherwise apply.
//
// #5 USDTRY / FX correlation [Stage 5]
// symbol : fxSymbol input, default "FX_IDC:USDTRY" (input.symbol -
// the spec forbids hardcoding this one in particular)
// timeframe : fxTF input, default "" = the chart timeframe
// expression: f_indexExpr(off, lb) -> FX % change over lb bars
// REUSED from call #4: the two layers ask the same question
// of different symbols, so they share one expression.
// lookahead : barmerge.lookahead_off
// gaps : barmerge.gaps_off (FX trades when BIST does not and vice
// versa; holding the last known move beats blanking it)
// ALWAYS EVALUATED: layer 12 ships DISABLED, but the call itself is made
// unconditionally at global scope. Pine would still count a call placed
// inside a conditional, so hiding it there would buy nothing and cost
// clarity. With the layer off the returned value is simply never read.
// A user with no FX data entitlement gets na, which the layer degrades
// to neutral 0.5 rather than an error.
//
// Chart-timeframe work needs no security call: Heikin Ashi is derived
// arithmetically from the chart's own real OHLC (see the HA block below)
// rather than via request.security(ticker.heikinashi(...)). That saves a
// call, is inherently repaint-safe, and keeps real OHLC available on the
// same bar for layers 9 & 11 later.
//
// Recommended chart timeframe: 4h (the spec's primary signal timeframe).
// Nothing breaks on another TF; the bias reference is timeframe-agnostic.
//
// ============================================================================
// REPAINT NOTE ON THE 1D REFERENCE
// ============================================================================
// lookahead_off alone is NOT sufficient for realtime/historical parity. With
// lookahead_off the historical bars of a forming daily bar show the LAST
// CLOSED daily value, but the realtime bar shows the DEVELOPING daily value,
// which mutates tick by tick. Those two behaviours disagree, which is exactly
// the discrepancy bar-replay testing exposes.
//
// Fix: biasConfirmedOnly (default ON) offsets the expression by one bar
// INSIDE the daily context, so the value returned is always a fully closed
// daily bar and is therefore identical historically and in realtime.
// The offset must be applied inside the request (expr[1]); applying [1] to
// the result outside would step back one CHART bar, not one DAILY bar.
// Cost: the bias reflects the daily bar before last, i.e. up to one extra
// day of lag. Turning the input OFF removes that lag and reintroduces
// intrabar repainting of the developing daily bar - debugging only.
//
// ============================================================================
// ENTRY GATE TIMING - READ BEFORE INTERPRETING BACKTEST RESULTS
// ============================================================================
// The 1D bias (call #1) is a HIGHER timeframe than the chart and is fully
// repaint-safe. Calls #2 and #3 are different: on the recommended 4h chart
// the 1h and 15m references are LOWER timeframes, and that changes what
// request.security can honestly give us.
//
// Two consequences, neither of which is a bug to be fixed:
//
// 1. Only the LAST lower-TF value inside each chart bar is visible. Pine
// does not hand back the full intrabar sequence here, so on historical
// bars the gate reflects the final 1h/15m state of that 4h bar, not the
// moment the gate actually opened during it.
// 2. The gate is BY DESIGN a realtime, intrabar construct. Its whole purpose
// is to confirm entry timing while the 4h bar is still forming. A gate
// that only resolved at 4h close would be useless for the job the spec
// gives it.
//
// So: the SCORE and the 1D VETO are repaint-safe and may be read at any
// time. The ENTRY and EXHAUSTION gates will move within a forming chart bar.
// For backtest fidelity, judge signals on chart-bar CLOSE only.
// entryConfirmedOnly / exhaustConfirmedOnly reduce the movement by reading
// one bar back inside each lower timeframe; they cannot eliminate it,
// because the underlying limitation is consequence 1, not the offset.
//
// Running the indicator ON the 15m or 1h chart makes these references equal
// to or higher than the chart timeframe, which removes the issue entirely at
// the cost of a noisier signal timeframe.
//
// ============================================================================
// MIXED-CANDLE POLICY (enforced from Stage 1 onward)
// ============================================================================
// Heikin Ashi (optional, default ON) : PAC channel, regime EMA ordering
// Real OHLC (always) : ADX, SMA50, RSI, MACD, volume, ATR,
// pivot S/R levels, index close
// Real OHLC (mandatory, now shipped) : layer 9 liquidity sweep, layer 10
// divergence pivots, layer 11 candles
// ============================================================================
//@version=6
indicator("BIST MTF Signal System", shorttitle = "BIST MTF", overlay = false, precision = 1,
max_bars_back = 1000, max_labels_count = 500)
// max_labels_count = 500: one label per exit accumulates across the chart,
// plus the single live score label. Pine drops the oldest beyond the cap, so
// on a long history the earliest exits lose their labels while their markers
// and the panel record remain.
// max_bars_back = 1000 covers the deepest Stage 1 lookback (slow EMA, default
// 200, user-raisable toward the 600 discussed in the spec) plus PAC/pivot
// warm-up headroom added in later stages.
// ============================================================================
// INPUT GROUPS
// ============================================================================
const string G_GEN = "General"
const string G_SRC = "Candle Source (Heikin Ashi / PAC)"
const string G_L1 = "Layer 1 - Regime / Trend Filter"
const string G_L2 = "Layer 2 - PAC Crossover"
const string G_L3 = "Layer 3 - Momentum (RSI + MACD)"
const string G_L4 = "Layer 4 - Volume Confirmation"
const string G_L5 = "Layer 5 - 1D Directional Lock (hard veto)"
const string G_L6 = "Layer 6 - Structural Level Proximity (S/R)"
const string G_L7 = "Layer 7 - Volatility Regime (ATR)"
const string G_L8 = "Layer 8 - Market / Index Correlation"
const string G_L9 = "Layer 9 - Liquidity Sweep"
const string G_L10 = "Layer 10 - RSI / Price Divergence"
const string G_L11 = "Layer 11 - Candle Pattern Confirmation"
const string G_L12 = "Layer 12 - USDTRY Correlation (optional)"
const string G_L13 = "Layer 13 - Session / Time Filter"
const string G_ENT = "Entry Timing (1h)"
const string G_EXH = "Exhaustion Gate (15m)"
const string G_CD = "Cooldown (Anti-Whipsaw)"
const string G_KAP = "Earnings / KAP Blacklist"
const string G_EXIT = "Exit Logic"
const string G_LOG = "Performance Log"
const string G_DISP = "Display"
// ---------------------------------------------------------------- General ---
scoreThreshold = input.int(60, "Signal score threshold", minval = 1, maxval = 100, group = G_GEN,
tooltip = "The score a candidate must reach before the entry timeframe is consulted at all. " +
"It drives the scoreArmed gate, and Stage 9 attaches the unified alert to the same crossing.\n\n" +
"DEFAULT LOWERED 70 -> 60. Layers 9, 10 and 11 return a true zero on most bars by design, so " +
"roughly 15 of the 95 live weight points are structurally unavailable on an ordinary bar and " +
"70 was unreachable rather than merely strict - measured at 0.0% of bars on two large-cap " +
"names. Check the panel's SCORE REACH section on YOUR symbol before trusting any threshold: " +
"the peak row turns red when the setting is above anything the score has ever reached.")
normalizeWeights = input.bool(true, "Renormalise score across ENABLED layers only", group = G_GEN,
tooltip = "WEIGHT REDISTRIBUTION: total design weight is 100 across all 13 layers. " +
"With this ON the score is divided by the sum of enabled weights, so it always spans 0-100. " +
"With it OFF the raw weighted sum is shown, so disabling a layer removes its contribution " +
"WITHOUT redistributing it and the maximum drops below 100. All 13 layers now ship; with " +
"default settings the live weights total 95 of 100, because layer 12 (USDTRY) is off.")
atrLen = input.int(14, "ATR length (shared by layers 6 and 7)", minval = 1, group = G_GEN,
tooltip = "One ATR series feeds both the S/R distance normalisation (layer 6) and the " +
"volatility regime test (layer 7), so the two layers cannot disagree about what one ATR means. " +
"Always computed from REAL OHLC - true range on Heikin Ashi bars understates real risk.")
// ----------------------------------------------------------- Candle source ---
useHA = input.bool(true, "Use Heikin Ashi for PAC / EMA layers", group = G_SRC,
tooltip = "Affects ONLY the PAC channel and the regime EMA ordering. ADX, SMA50, RSI, MACD " +
"and volume always use real OHLC. Layers 9 and 11 will always use real OHLC.")
pacLen = input.int(34, "PAC length (EMA of HA high/low/close)", minval = 1, group = G_SRC)
// ---------------------------------------------------------------- Layer 1 ---
useL1 = input.bool(true, "Enable Layer 1", group = G_L1)
wL1 = input.float(20.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L1)
adxLen = input.int(14, "ADX / DI length", minval = 1, group = G_L1)
adxSmooth = input.int(14, "ADX smoothing", minval = 1, group = G_L1)
adxThr = input.float(25.0, "ADX trend threshold", minval = 0.0, step = 0.5, group = G_L1)
smaLen = input.int(50, "SMA length (price vs SMA)", minval = 1, group = G_L1)
emaFastLen = input.int(34, "EMA fast", minval = 1, group = G_L1)
emaMedLen = input.int(89, "EMA medium", minval = 1, group = G_L1)
emaSlowLen = input.int(200, "EMA slow", minval = 1, group = G_L1)
// ---------------------------------------------------------------- Layer 2 ---
useL2 = input.bool(true, "Enable Layer 2", group = G_L2)
wL2 = input.float(10.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L2)
pacDipBars = input.int(10, "PAC-mid pullback lookback (N bars)", minval = 1, maxval = 200, group = G_L2,
tooltip = "Long: price must have closed BELOW PAC-mid within the last N bars. Short: mirrored.")
// ---------------------------------------------------------------- Layer 3 ---
useL3 = input.bool(true, "Enable Layer 3", group = G_L3)
wL3 = input.float(15.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L3)
rsiLen = input.int(14, "RSI length", minval = 1, group = G_L3)
rsiSmoothLen = input.int(9, "RSI smoothing (SMA)", minval = 1, group = G_L3)
macdFastLen = input.int(12, "MACD fast", minval = 1, group = G_L3)
macdSlowLen = input.int(26, "MACD slow", minval = 1, group = G_L3)
macdSigLen = input.int(9, "MACD signal", minval = 1, group = G_L3)
// ---------------------------------------------------------------- Layer 4 ---
useL4 = input.bool(true, "Enable Layer 4", group = G_L4)
wL4 = input.float(10.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L4)
volLen = input.int(20, "Volume SMA length", minval = 1, group = G_L4)
volRatioLo = input.float(0.5, "Low anchor: vol / avg", minval = 0.0, step = 0.1, group = G_L4)
volConfLo = input.float(0.2, "Low anchor: confidence", minval = 0.0, maxval = 1.0, step = 0.05, group = G_L4)
volRatioHi = input.float(2.0, "High anchor: vol / avg", minval = 0.1, step = 0.1, group = G_L4)
volConfHi = input.float(1.0, "High anchor: confidence", minval = 0.0, maxval = 1.0, step = 0.05, group = G_L4)
// ------------------------------------- Layer 5 (hard veto, NOT weighted) ---
useVeto = input.bool(true, "Enable 1D hard veto", group = G_L5,
tooltip = "Layer 5 carries no weight. It is a gate: if the 4h candidate direction disagrees " +
"with the locked 1D bias - or the 1D bias is neutral - the ENTIRE score is zeroed before " +
"display. Turn OFF to inspect the raw pre-veto score while tuning layers 1-4.")
biasTF = input.timeframe("1D", "Bias timeframe", group = G_L5)
biasConfirmedOnly = input.bool(true, "Use last CLOSED bias bar only (no repaint)", group = G_L5,
tooltip = "ON = repaint-safe. The bias is read one bar back inside the bias timeframe, so " +
"realtime and historical values are identical under bar replay. Costs up to one extra day of lag.\n" +
"OFF = uses the developing daily bar. Less lag, but the value mutates intrabar and historical " +
"bars will NOT match what you saw live. Debugging only.")
dAdxLen = input.int(14, "1D ADX / DI length", minval = 1, group = G_L5)
dAdxSmooth = input.int(14, "1D ADX smoothing", minval = 1, group = G_L5)
dAdxThr = input.float(25.0, "1D ADX threshold", minval = 0.0, step = 0.5, group = G_L5)
dSmaLen = input.int(50, "1D SMA length", minval = 1, group = G_L5)
dEmaFastLen = input.int(21, "1D EMA fast", minval = 1, group = G_L5)
dEmaMedLen = input.int(50, "1D EMA medium", minval = 1, group = G_L5)
dEmaSlowLen = input.int(200, "1D EMA slow", minval = 1, group = G_L5)
// ---------------------------------------------------------------- Layer 6 ---
useL6 = input.bool(true, "Enable Layer 6", group = G_L6)
wL6 = input.float(10.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L6)
srLeft = input.int(5, "Pivot leftbars", minval = 1, maxval = 50, group = G_L6,
tooltip = "LOOKBACK JUSTIFICATION - leftbars = 5: a swing point must dominate the five bars " +
"before it. On the 4h signal timeframe that is roughly a trading week of context - enough to " +
"reject intrabar noise without demanding a major monthly extreme.")
srRight = input.int(2, "Pivot rightbars", minval = 1, maxval = 50, group = G_L6,
tooltip = "LOOKBACK JUSTIFICATION - rightbars = 2: the pivot is published two bars AFTER it " +
"printed, so the level is confirmed and can never shift on the realtime bar. Larger values are " +
"even safer but delay every level by that many bars; 2 is the smallest value that still gives " +
"genuine two-sided confirmation.")
srKeep = input.int(5, "Confirmed pivots kept per side", minval = 1, maxval = 20, group = G_L6,
tooltip = "Rolling window of recent confirmed pivot highs and pivot lows. Older levels are " +
"dropped: structure from 200 bars ago rarely still holds.")
srNearAtr = input.float(0.5, "Near anchor: distance in ATR -> confidence 1.0", minval = 0.0,
step = 0.1, group = G_L6)
srFarAtr = input.float(3.0, "Far anchor: distance in ATR -> confidence 0.0", minval = 0.1,
step = 0.1, group = G_L6)
srUseHeadroom = input.bool(true, "Penalise when the opposing level is close", group = G_L6,
tooltip = "Sitting right on support is only attractive if there is room to run. With this ON, " +
"a long whose nearest overhead resistance is closer than the threshold below is multiplied " +
"down. Turn OFF for the plain spec behaviour (distance to the nearest level only).")
srHeadroomAtr = input.float(0.5, "Headroom threshold (ATR units)", minval = 0.0, step = 0.1,
group = G_L6)
srHeadroomMult = input.float(0.5, "Headroom penalty multiplier", minval = 0.0, maxval = 1.0,
step = 0.05, group = G_L6)
// ---------------------------------------------------------------- Layer 7 ---
useL7 = input.bool(true, "Enable Layer 7", group = G_L7)
wL7 = input.float(5.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L7)
atrAvgLen = input.int(50, "ATR baseline SMA length", minval = 2, group = G_L7,
tooltip = "The recent average ATR is compared with the current ATR. Long enough to describe " +
"the ticker's normal volatility, short enough to still track a regime change.")
atrRatioLo = input.float(0.8, "Low anchor: ATR / baseline", minval = 0.0, step = 0.05, group = G_L7)
atrConfLo = input.float(0.2, "Low anchor: confidence", minval = 0.0, maxval = 1.0, step = 0.05,
group = G_L7)
atrRatioHi = input.float(1.5, "High anchor: ATR / baseline", minval = 0.1, step = 0.05, group = G_L7)
atrConfHi = input.float(1.0, "High anchor: confidence", minval = 0.0, maxval = 1.0, step = 0.05,
group = G_L7)
atrSlopeLb = input.int(5, "Slope lookback (bars)", minval = 1, maxval = 100, group = G_L7,
tooltip = "Current ATR is also compared with the ATR this many bars ago, so a range that is " +
"already wide but no longer widening scores below one that is actively expanding.")
atrSlopeBand = input.float(0.05, "Slope deadband (fraction)", minval = 0.0, maxval = 1.0,
step = 0.01, group = G_L7,
tooltip = "ATR within this fraction of its value `Slope lookback` bars ago counts as flat " +
"(0.5). Below the band -> contracting (0.0), above it -> expanding (1.0), linear between.")
atrSlopeWeight = input.float(0.3, "Slope sub-weight", minval = 0.0, maxval = 1.0, step = 0.05,
group = G_L7,
tooltip = "Split of the layer between the two tests: this much on the slope, the remainder on " +
"the level. 0.0 grades the level only, 1.0 the slope only.")
// ---------------------------------------------------------------- Layer 8 ---
useL8 = input.bool(true, "Enable Layer 8", group = G_L8)
wL8 = input.float(5.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L8)
idxSymbol = input.symbol("BIST:XU100", "Index symbol", group = G_L8,
tooltip = "The broad-market reference, NOT hardcoded: change it to any index or to a sector " +
"proxy. Users on a delayed feed should point this at the symbol their subscription provides, " +
"otherwise the layer stays neutral for want of data.")
idxTF = input.timeframe("", "Index timeframe (blank = chart timeframe)", group = G_L8,
tooltip = "Blank means the chart's own timeframe, which is what the spec's 'same timeframe' " +
"comparison calls for. Setting a HIGHER timeframe here also turns on the repaint question " +
"described in the header - use the confirmed-bar toggle below if you do.")
idxLookback = input.int(1, "Index return lookback (bars)", minval = 1, maxval = 100, group = G_L8,
tooltip = "How far back the index move is measured. 1 = this bar's own change. Raise it to " +
"judge the index swing rather than a single bar's noise.")
idxNeutralPct = input.float(0.10, "Neutral band (percent move)", minval = 0.0, step = 0.05,
group = G_L8,
tooltip = "An index move smaller than this counts as directionless and scores 0.5. Without a " +
"band a 0.01% drift would be graded as a full agreement or a full disagreement.")
idxConfirmedOnly = input.bool(false, "Index TF: use last CLOSED bar only", group = G_L8,
tooltip = "Only meaningful when the index timeframe above is HIGHER than the chart timeframe. " +
"At the chart timeframe the index bar closes when the chart bar does, so the offset would " +
"just add a bar of lag for nothing.")
// ---------------------------------------------------------------- Layer 9 ---
useL9 = input.bool(true, "Enable Layer 9", group = G_L9)
wL9 = input.float(5.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L9)
sweepMaxAge = input.int(2, "Sweep must have printed within N bars", minval = 0, maxval = 10,
group = G_L9,
tooltip = "The spec's 1-2 bar window. A sweep is only tradeable while it is fresh: the whole " +
"premise is that stops were just taken and the move immediately failed. 0 means the sweep must " +
"be on the current bar.")
sweepMinPierce = input.float(0.05, "Minimum pierce beyond the level (ATR units)", minval = 0.0,
step = 0.01, group = G_L9,
tooltip = "A wick that merely grazed the level did not take anyone's stops. Requiring a real " +
"pierce - measured in ATR so it scales with the ticker - rejects that noise.")
sweepFloorConf = input.float(0.4, "Confidence at the oldest allowed bar", minval = 0.0, maxval = 1.0,
step = 0.05, group = G_L9,
tooltip = "Confidence decays linearly from 1.0 on the sweep bar itself to this value at the " +
"edge of the window above.")
// --------------------------------------------------------------- Layer 10 ---
useL10 = input.bool(true, "Enable Layer 10", group = G_L10)
wL10 = input.float(5.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L10)
divLeft = input.int(5, "Divergence pivot leftbars", minval = 1, maxval = 50, group = G_L10,
tooltip = "LOOKBACK JUSTIFICATION - leftbars = 5: the same swing context layer 6 uses, so the " +
"turning points the two layers talk about are comparable in size.")
divRight = input.int(3, "Divergence pivot rightbars", minval = 1, maxval = 50, group = G_L10,
tooltip = "LOOKBACK JUSTIFICATION - rightbars = 3, one MORE than layer 6 uses. A divergence " +
"claim rests entirely on the pivot being a genuine turning point, so it is worth an extra bar " +
"of confirmation lag to discard marginal pivots that reverse again immediately. As with every " +
"pivot in this script the value publishes only after those bars have closed, so the level can " +
"never shift on the realtime bar.")
divMaxAge = input.int(10, "Divergence valid for N bars after the pivot", minval = 0, maxval = 200,
group = G_L10,
tooltip = "A divergence that formed twenty bars ago and has not resolved is no longer a " +
"signal. Confidence decays linearly across this window.")
divMinRsiGap = input.float(1.0, "Minimum RSI gap between the two pivots (points)", minval = 0.0,
step = 0.5, group = G_L10,
tooltip = "How far RSI must disagree with price before it counts. Without a gap, a 0.01-point " +
"difference would register as a divergence.")
divFloorConf = input.float(0.3, "Confidence at the oldest allowed bar", minval = 0.0, maxval = 1.0,
step = 0.05, group = G_L10)
// --------------------------------------------------------------- Layer 11 ---
useL11 = input.bool(true, "Enable Layer 11", group = G_L11)
wL11 = input.float(5.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L11)
cpMaxBody = input.float(0.35, "Pin bar: max body / range", minval = 0.01, maxval = 1.0, step = 0.05,
group = G_L11,
tooltip = "Deliberately SEPARATE from the 15m exhaustion gate's pin settings. This layer reads " +
"the signal timeframe, where a meaningful rejection candle looks different from one on a 15m " +
"chart, so the two are tuned independently.")
cpMinWick = input.float(2.0, "Pin bar: min wick / body", minval = 0.5, step = 0.1, group = G_L11)
cpPinConf = input.float(0.6, "Confidence: pin bar", minval = 0.0, maxval = 1.0, step = 0.05,
group = G_L11)
cpEngConf = input.float(0.8, "Confidence: engulfing", minval = 0.0, maxval = 1.0, step = 0.05,
group = G_L11,
tooltip = "Engulfing scores higher than a pin bar by default: it requires the whole prior body " +
"to be reversed rather than just a wick rejection. Both patterns on the same bar sum and clamp " +
"to 1.0.")
// --------------------------------------------------------------- Layer 12 ---
// Ships DISABLED: FX linkage is a per-company fact the script cannot derive
// from bar data. An exporter and an importer react to the same lira move in
// opposite directions, so a wrong setting here is worse than none at all.
const string LINK_NONE = "No FX linkage (neutral)"
const string LINK_EXP = "Exporter - a weaker lira helps"
const string LINK_IMP = "Importer - a weaker lira hurts"
useL12 = input.bool(false, "Enable Layer 12", group = G_L12,
tooltip = "Off by default. Turn it on only after setting the linkage below to match the " +
"company's actual FX exposure.")
wL12 = input.float(5.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L12)
fxLinkage = input.string(LINK_NONE, "FX linkage of this company",
options = [LINK_NONE, LINK_EXP, LINK_IMP], group = G_L12,
tooltip = "EXPORTER: revenue in hard currency, costs in lira - a rising USDTRY is a tailwind. " +
"IMPORTER: input costs or FX debt in hard currency - a rising USDTRY is a headwind. " +
"NEUTRAL leaves the layer at 0.5 even when it is enabled, which is the safe default if you " +
"are unsure.")
fxSymbol = input.symbol("FX_IDC:USDTRY", "FX symbol", group = G_L12,
tooltip = "Never hardcoded, per the spec. Point this at whichever USDTRY feed your " +
"subscription carries, or at another currency pair if that is the one this company is " +
"exposed to.")
fxTF = input.timeframe("", "FX timeframe (blank = chart timeframe)", group = G_L12)
fxLookback = input.int(1, "FX return lookback (bars)", minval = 1, maxval = 100, group = G_L12)
fxNeutralPct = input.float(0.10, "Neutral band (percent move)", minval = 0.0, step = 0.05,
group = G_L12,
tooltip = "An FX move smaller than this counts as directionless and scores 0.5.")
fxConfirmedOnly = input.bool(false, "FX TF: use last CLOSED bar only", group = G_L12,
tooltip = "Only meaningful if the FX timeframe above is HIGHER than the chart timeframe - " +
"same reasoning as the index layer.")
// --------------------------------------------------------------- Layer 13 ---
useL13 = input.bool(true, "Enable Layer 13", group = G_L13)
wL13 = input.float(5.0, "Weight", minval = 0.0, maxval = 100.0, step = 0.5, group = G_L13)
sessTz = input.string("Europe/Istanbul", "Session timezone", group = G_L13,
tooltip = "TIMEZONE AWARENESS: the windows below are read in THIS timezone, not the chart's " +
"and not the exchange's default. A user viewing BIST from a UTC chart gets the same " +
"classification as one on an Istanbul chart. Change it only if you deliberately want the " +
"windows interpreted somewhere else.")
sessOpenWin = input.session("1000-1015", "Opening window", group = G_L13,
tooltip = "The auction hangover: wide spreads and unreliable direction.")
sessLunchWin = input.session("1230-1330", "Lunch window", group = G_L13,
tooltip = "Thin volume; breakouts here fail more often than they follow through.")
sessCloseWin = input.session("1730-1800", "Closing window", group = G_L13,
tooltip = "Position squaring rather than fresh conviction.")
sessPenalty = input.float(0.3, "Confidence inside a flagged window", minval = 0.0, maxval = 1.0,
step = 0.05, group = G_L13,
tooltip = "Bars inside any of the three windows score this instead of 1.0. It is a penalty, " +
"not a block - the spec deliberately keeps it as a weighted layer rather than a veto.")
// ------------------------------------------------- Entry timing (1h) ---
useEntryTiming = input.bool(true, "Enable 1h entry timing", group = G_ENT,
tooltip = "Once the signal-timeframe score crosses the threshold, the entry timeframe must " +
"show a PAC touch AND a directional candle before the setup is called 'entry ready'. " +
"Turn OFF to treat any threshold cross as entry ready.")
entryTF = input.timeframe("60", "Entry timing timeframe", group = G_ENT)
entryPacLen = input.int(34, "Entry PAC length", minval = 1, group = G_ENT)
entryMinBody = input.float(0.30, "Min candle body / range ratio", minval = 0.0, maxval = 1.0,
step = 0.05, group = G_ENT,
tooltip = "Rejects doji-like confirmation candles. Body must be at least this fraction of the " +
"bar's full range. Uses REAL OHLC, never Heikin Ashi.")
entryConfirmedOnly = input.bool(true, "Entry TF: use last CLOSED bar only", group = G_ENT,
tooltip = "Offsets the read one bar inside the entry timeframe. See the ENTRY GATE TIMING note " +
"in the header for why this does not fully remove intrabar movement when the entry timeframe " +
"is LOWER than the chart timeframe.")
// ---------------------------------------------- Exhaustion gate (15m) ---
useExhaustGate = input.bool(true, "Enable 15m exhaustion gate", group = G_EXH,
tooltip = "Blocks the entry signal until the counter-move exhausts. Opens when ANY ONE of the " +
"three conditions below is met. Turn OFF to let entry-ready setups signal immediately.")
exhaustTF = input.timeframe("15", "Exhaustion timeframe", group = G_EXH)
exhRsiLen = input.int(14, "Exhaustion RSI length", minval = 1, group = G_EXH)
exhRsiFlat = input.float(1.0, "RSI flat threshold (points)", minval = 0.0, step = 0.1, group = G_EXH,
tooltip = "Condition 1: RSI is considered 'no longer progressing' when its bar-over-bar change " +
"is within this many points while the prior change ran against the signal direction.")
exhSwingLb = input.int(5, "Swing lookback (bars)", minval = 2, maxval = 50, group = G_EXH,
tooltip = "Condition 3: window used to define a 'new' extreme.")
exhSwingBars = input.int(3, "Bars without a new extreme", minval = 1, maxval = 50, group = G_EXH,
tooltip = "Condition 3 opens when this many bars have passed without a new lower-low (long) " +
"or higher-high (short) inside the swing lookback window.")
pinMaxBody = input.float(0.35, "Pin bar: max body / range", minval = 0.01, maxval = 1.0,
step = 0.05, group = G_EXH)
pinMinWick = input.float(2.0, "Pin bar: min wick / body", minval = 0.5, step = 0.1, group = G_EXH)
exhaustConfirmedOnly = input.bool(true, "Exhaustion TF: use last CLOSED bar only", group = G_EXH)
// --------------------------------------------------------------- Cooldown ---
useCooldown = input.bool(true, "Enable cooldown", group = G_CD,
tooltip = "Anti-whipsaw. After a signal fires, further signals in the SAME direction are " +
"suppressed until the cooldown expires. The opposite direction is never blocked - a genuine " +
"reversal must always be allowed to signal immediately.")
cooldownBars = input.int(4, "Bars before a new signal in the same direction", minval = 0,
maxval = 500, group = G_CD,
tooltip = "Counted in bars of the CHART timeframe, so the wall-clock length of the cooldown " +
"follows whatever timeframe you run the indicator on. 0 disables the wait without disabling " +
"the tracking.")
// ------------------------------------------------- Earnings / KAP dates ---
// Four fixed slots, per spec. Pine cannot enumerate a variable number of
// inputs, and four covers a year of quarterly reporting - which is as far
// ahead as a KAP calendar is ever reliable anyway.
//
// UNUSED SLOTS: any date at or before this sentinel counts as empty. Using a
// sentinel rather than na keeps the input a plain date picker with no
// "clear" affordance to explain.
const int KAP_UNSET = 946684800000 // 2000-01-01T00:00:00Z, in milliseconds
useEarningsFlag = input.bool(true, "Enable earnings risk flag", group = G_KAP,
tooltip = "FLAG ONLY - this never changes the score and never blocks a signal. Earnings can " +
"just as easily confirm a setup as destroy it, so the spec leaves the decision with you: the " +
"indicator's job is to make sure you knew the date was there.")
kapWindowDays = input.int(2, "Risk window (+/- days)", minval = 1, maxval = 10, group = G_KAP,
tooltip = "Bars within this many days either side of a listed date are flagged. The window " +
"is symmetric: the run-up is as treacherous as the reaction.")
kapDate1 = input.time(KAP_UNSET, "Earnings / KAP date 1", group = G_KAP,
tooltip = "Leave at the default (1 Jan 2000) to mark this slot unused.")
kapDate2 = input.time(KAP_UNSET, "Earnings / KAP date 2", group = G_KAP)
kapDate3 = input.time(KAP_UNSET, "Earnings / KAP date 3", group = G_KAP)
kapDate4 = input.time(KAP_UNSET, "Earnings / KAP date 4", group = G_KAP)
// ------------------------------------------------------------ Exit logic ---
// Stage 8.5. The original specification is entry machinery end to end; this
// group closes the positions it opens. Every rule is independently toggleable,
// in the same pattern as the thirteen scoring layers.
useExits = input.bool(true, "Enable exit logic", group = G_EXIT,
tooltip = "OFF returns the script to entry-only behaviour: no position is tracked, no exit " +
"fires, and the cooldown reverts to being measured from the entry bar.")
exitLiveModelStr = input.string("Structural", "Live exit model",
options = ["Fixed-R", "Structural"],
group = G_EXIT,
tooltip = "Which model actually closes the live position and drives the chart markers.\n\n" +
"FIXED-R : E1 stop, E2 target, optional E3 trail, E8 backstop. Mechanical - it knows nothing " +
"about the score, the bias or the channel, only distance from entry.\n\n" +
"STRUCTURAL: E1 stop kept as a floor, then E4-E7. Holds while the reasoning that opened the " +
"position still stands.\n\n" +
"Both are measured on every signal regardless of this setting (8.5c), so the panel compares " +
"them without you having to switch - this only decides which one closes the live position " +
"and drives the markers and alerts.")
useE1 = input.bool(true, "E1 - hard stop", group = G_EXIT,
tooltip = "The risk anchor. Its distance DEFINES one R, so every outcome in the panel is " +
"quoted against it whether or not the stop is what actually closed the trade.")
exitStopATR = input.float(1.0, "E1 stop distance (ATR)", minval = 0.1, step = 0.1, group = G_EXIT,
tooltip = "PLACEHOLDER. 1.0 is a round number, not a measured one. Once the log holds resolved " +
"signals, set this near the 75th percentile of the panel's MAE column - a stop that survives " +
"three quarters of the adverse excursions that preceded a working trade.")
useE2 = input.bool(true, "E2 - fixed target", group = G_EXIT)
exitTargetATR = input.float(2.0, "E2 target distance (ATR)", minval = 0.1, step = 0.1,
group = G_EXIT,
tooltip = "PLACEHOLDER, as E1. Set it near the median of the panel's MFE column - the distance " +
"half of the signals actually reached.")
exitStopFirst = input.bool(true, "Same bar: resolve the stop before the target", group = G_EXIT,
tooltip = "When one bar's range covers both levels, nothing in the data says which came " +
"first - Pine does not expose the intrabar sequence. Resolving the stop first is the only " +
"assumption that cannot flatter the result. Turn OFF to see how much of the record depends " +
"on that assumption.")
exitGapFill = input.bool(true, "Gap through the stop fills at the bar open", group = G_EXIT,
tooltip = "A bar that opened beyond the stop never offered the stop price. With this ON the " +
"fill is the worse of the two, which is what actually happens on a gap. Applied to the STOP " +
"only: crediting the same price improvement on a target would flatter the result.")
useE3 = input.bool(false, "E3 - trailing stop", group = G_EXIT,
tooltip = "OFF - MEASURED, NOT ASSUMED. The trail is not a bad rule on its own; it scores " +
"respectably in isolation. It loses money by DISPLACEMENT: it fires first on bars where E4 " +
"score decay would also have triggered, and because a trail sits a fixed distance under the " +
"high-water mark, the trades it intercepts are disproportionately the biggest winners. On " +
"TOASO it cut E4 from a 16% share at +8.08 average R to 4% at +1.95. Read the E3 section of " +
"stage_8_5_exit_logic.md before turning it back on.\n\n" +
"Available to both models when enabled. On the structural track it is the trailing FLOOR, " +
"with E4-E7 live as early-exit overrides. Note it is shared, so enabling it also turns the " +
"fixed-R model from a two-rule stop-and-target into a three-rule one, and fixed-R numbers " +
"recorded either side of that change are not comparable.")
exitTrailATR = input.float(3.0, "E3 trail distance (ATR)", minval = 0.1, step = 0.1,
group = G_EXIT,
tooltip = "Distance held below the best price reached so far. Ratchets on CLOSED bars only.\n\n" +
"Only has effect when E3 is enabled, which by default it is not - see that toggle.\n\n" +
"3.0 against a 1.0 hard stop means the trail takes over once the trade has run 2 ATR in " +
"favour, reaches breakeven at 3 ATR and locks 1R at 4 ATR. Widening it was the obvious answer " +
"to a trail that cuts trends early, and 5.0 measured no better than 3.0 - the problem is not " +
"the width.\n\n" +
"The full geometry is in the position management block. Do NOT set this from the panel's " +
"suggested-target figure: that is computed from the horizon-bounded excursion and understates " +
"what a trend offers.")
useE4 = input.bool(true, "E4 - score decay", group = G_EXIT,
tooltip = "Exits when the case for the position has dissolved: the directional score has sat " +
"under the floor for N consecutive bars.")
exitScoreFloor = input.float(40.0, "E4 score floor", minval = 0.0, maxval = 100.0, step = 1.0,
group = G_EXIT)
exitScoreBars = input.int(2, "E4 bars under the floor", minval = 1, maxval = 20, group = G_EXIT,
tooltip = "Consecutive bars required, so one noisy bar cannot close a good position.")
useE5 = input.bool(true, "E5 - 1D bias flip", group = G_EXIT,
tooltip = "Fires only when the daily bias turns OPPOSITE, never when it merely goes neutral. " +
"Neutral is whatever ADX below the threshold produces - it is the common state, and exiting " +
"on it would close most positions within a few bars and make E5 a disguised time stop.")
useE6 = input.bool(true, "E6 - PAC cross against", group = G_EXIT,
tooltip = "Price closes back through the channel against the position. The earliest structural " +
"warning of the four, and the noisiest.")
exitPacLineStr = input.string("PAC mid", "E6 line", options = ["PAC mid", "Opposite band"],
group = G_EXIT,
tooltip = "PAC MID is the earlier, cleaner signal. OPPOSITE BAND only triggers once the move " +
"is well gone, which holds positions longer at the cost of giving more back.")
useE7 = input.bool(true, "E7 - opposite signal", group = G_EXIT,
tooltip = "A full accepted-quality signal in the other direction. If the system is confident " +
"enough to open the reverse, it is confident enough to close this one.")
useE8 = input.bool(true, "E8 - time stop", group = G_EXIT,
tooltip = "Backstop for BOTH models. Guarantees every position resolves, so the panel cannot " +
"fill with trades that never end.")
exitMaxBars = input.int(250, "E8 max bars held", minval = 1, maxval = 500, group = G_EXIT,
tooltip = "A BACKSTOP, not a decision. At 40 bars it was neither: on a 4h BIST chart that is " +
"roughly a calendar month, so it was truncating trends rather than catching stuck positions, " +
"and a quarter of structural exits were hitting it.\n\n" +
"250 costs the fixed-R model nothing - with a 1 ATR stop and a 2 ATR target those trades " +
"resolve long before it - so raising it only frees the structural model to hold.")
// -------------------------------------------------------- Performance log ---
logMaxSignals = input.int(100, "Signals kept in the log", minval = 5, maxval = 300, group = G_LOG,
tooltip = "Rolling window of the most recent accepted signals. Older ones are dropped, so the " +
"win rates describe recent behaviour rather than the whole chart's history.\n\n" +
"THIS IS THE ONLY SAMPLE CAP. Every signal statistic in the panel - the band breakdown, the " +
"exit-model comparison, the exit-reason shares and the suggested multiples - is computed by " +
"scanning this log, so this number sets the n behind all of them. The SCORE REACH section is " +
"the exception: it counts bars, not signals, and is never capped.\n\n" +
"It also bounds a quieter problem. A record is only dropped when the log overflows, but its " +
"exit tracks can still be open - E8 alone allows 250 bars - and a record dropped while " +
"pending is lost from the statistics silently. The window needs to be comfortably larger " +
"than the number of signals that fire inside one position's maximum lifetime.\n\n" +
"Cost of raising it: the per-bar update loop walks the whole log, so very large values add " +
"execution time on long histories.")
logHorizon = input.int(10, "Outcome horizon (bars after signal)", minval = 1, maxval = 200,
group = G_LOG,
tooltip = "How many bars after a signal the outcome is measured. A signal younger than this " +
"is reported as PENDING rather than being counted as a win or a loss - counting it early " +
"would flatter whichever direction the last few bars happened to run.")
usePanel = input.bool(true, "Show performance panel", group = G_LOG)
panelPosStr = input.string("Top right", "Panel position",
options = ["Top right", "Middle right", "Bottom right", "Bottom left"], group = G_LOG)
panelSizeStr = input.string("Small", "Panel text size", options = ["Tiny", "Small", "Normal"],
group = G_LOG)
panelShowReach = input.bool(true, "Panel: score reach distribution", group = G_LOG,
tooltip = "The threshold-calibration section. Read the peak row first: if the peak net score " +
"sits below your threshold, the threshold is unreachable on this symbol and no amount of " +
"waiting will produce a signal.")
panelShowBands = input.bool(true, "Panel: win rate and excursions by band", group = G_LOG)
panelShowModels = input.bool(true, "Panel: exit model comparison", group = G_LOG,
tooltip = "Fixed-R against structural against the exit-free control, all on the same signals " +
"and all quoted in the same R.")
panelShowBench = input.bool(true, "Panel: buy-and-hold benchmark", group = G_LOG,
tooltip = "THE REFERENCE POINT. An average R means nothing on its own - the question is " +
"whether all this machinery beat simply buying the stock and doing nothing.\n\n" +
"WINDOW: from the oldest signal still in the log to the current bar. That is the span the " +
"system's own numbers come from, so the two are measured over the same ground.\n\n" +
"SUM, NOT AVERAGE. The system takes many trades and holding takes one, so the honest " +
"comparison is total R against total R. sys R assumes the same risk on every trade, which is " +
"the standard fixed-fraction assumption and is not the same thing as an account curve.\n\n" +
"READ IT WITH THESE IN MIND: gross R, no commission, slippage or compounding; the benchmark " +
"is long-only while the system can also go short; the hold's risk unit is the ATR at the " +
"start of the window; and unresolved trades are excluded, which understates the system " +
"slightly. A positive edge over one symbol and one window is a data point, not a result.")
panelShowReasons = input.bool(true, "Panel: exit reason breakdown", group = G_LOG,
tooltip = "Which rule is actually doing the work in the LIVE model. A structural model where " +
"most exits come from the time stop is not a structural model.")
panelShowAblation = input.bool(false, "Panel: layer ablation", group = G_LOG,
tooltip = "WHICH LAYERS EARN THEIR WEIGHT. Fourteen streams run in parallel on this one " +
"chart: BASE, plus the same system with each layer removed in turn. No setting changes and " +
"no reloading - read the dR column and a layer with a persistently negative dR is costing " +
"you score.\n\n" +
"OFF by default because it is fifteen rows of panel you only need while calibrating.\n\n" +
"MEASURED ON THE HORIZON, so it grades ENTRY QUALITY alone and no exit rule can flatter or " +
"spoil it. Every row is renormalised over its own enabled weights, which is what stops the " +
"test from measuring threshold sensitivity instead of layer contribution.\n\n" +
"BASE WILL NOT MATCH THE HORIZON ROW of the exit-model section, and is not meant to: these " +
"streams count every accepted gate crossing, while the log drops signals that arrive while a " +
"position is open. Compare rows here WITH EACH OTHER, not against the sections above.\n\n" +
"READ n FIRST. A dR of 0.2 across 30 signals on one symbol is noise. Check the sign holds " +
"on several tickers before acting on it - the trailing-stop finding was symbol-dependent, " +
"and two names could not settle it.")
panelShowDetail = input.bool(true, "Panel: last signals detail", group = G_LOG)
panelDetailRows = input.int(10, "Panel: detail rows", minval = 1, maxval = 50, group = G_LOG,
tooltip = "DISPLAY ONLY - how many individual signals the LAST SIGNALS section prints. It has " +
"no effect on any statistic; the sample behind the panel is set by 'Signals kept in the log'.")
// ---------------------------------------------------------------- Display ---
// TWO MASTER SWITCHES, per spec. Everything a trader acts on sits under the
// first; everything that exists only to explain WHY sits under the second.
// They are independent: you can run the indicator clean, run it as a
// diagnostic with no markers, or both at once.
showEntrySignals = input.bool(true, "Show entry signals", group = G_DISP,
tooltip = "Entry markers on the price chart, the colour-coded score line and the score label. " +
"Turn OFF for a bare score pane.")
showExitMarkers = input.bool(true, "Show exit markers", group = G_DISP,
tooltip = "A cross on the exit bar, opposite the entry triangle, coloured by outcome rather " +
"than by direction: teal for a non-negative R, maroon for a loss.")
showExitLabels = input.bool(true, "Show exit labels", group = G_DISP,
tooltip = "Reason and R on each exit, so a losing exit says immediately whether it was " +
"stopped, decayed or timed out.")
showRiskLevels = input.bool(true, "Show stop / target levels", group = G_DISP,
tooltip = "Traces the live stop and target for as long as each position is open. Drawn with " +
"a break-on-gap style, so separate positions never appear joined.")
showDebugLayers = input.bool(false, "Show debug layers", group = G_DISP,
tooltip = "MASTER SWITCH for every diagnostic trace below. With it off, none of them draw no " +
"matter how their individual switches are set - so you can leave those configured and flip " +
"the whole diagnostic view on and off with one click.")
// -- Individual debug traces. Each ALSO requires the master switch above. ----
showComponents = input.bool(false, "Debug: long / short component scores", group = G_DISP)
showRawScore = input.bool(false, "Debug: raw pre-veto score", group = G_DISP)
showBias = input.bool(false, "Debug: 1D bias (0 short / 50 neutral / 100 long)",
group = G_DISP)
showRisk = input.bool(true, "Debug: earnings / KAP risk shading", group = G_DISP,
tooltip = "Tints the score pane while a bar sits inside a flagged window.")
showGates = input.bool(false, "Debug: entry / exhaustion gate states", group = G_DISP,
tooltip = "Stepped traces: score armed (30), entry ready (60), entry signal (90), cooldown " +
"blocked (100). The stack shows exactly which gate a setup died at.")
showPivots = input.bool(false, "Debug: pivot fractals and active S/R (price chart)",
group = G_DISP,
tooltip = "Marks every confirmed pivot at the bar it actually printed on, and traces the two " +
"levels layers 6 and 9 are currently measuring against. This is the view for checking that " +
"the structure the score reacts to is the structure you would have drawn by hand.")
showLayers = input.bool(false, "Debug: individual layer confidences", group = G_DISP,
tooltip = "Each layer's own 0.0-1.0 confidence, scaled to the pane's 0-100 axis, for the " +
"CANDIDATE direction (pre-veto, so the traces stay readable while the score is zeroed). " +
"Twelve overlapping lines is a lot: hide the ones you are not chasing in the Style tab.")
// ============================================================================
// TYPES
// ============================================================================
// One record per accepted signal, for the performance log. A user type rather
// than ten parallel arrays: parallel arrays drift out of sync the first time
// somebody adds a push to one and forgets the others, and nothing catches it.
//
// Excursions are stored in PRICE units and divided by atrAt only at display
// time, so the record stays meaningful even if the ATR input is changed
// later.
type SigRec
int barIdx // chart bar_index the signal fired on
int tm // bar time in ms, for the date column
int dir // +1 long, -1 short
float score // post-veto totalScore at the moment of the signal
float entry // close at the signal
float atrAt // ATR at the signal, to express excursions in ATR units
float mfe // max favourable excursion, price units - HORIZON-BOUNDED
float mae // max adverse excursion, price units - HORIZON-BOUNDED
float tMfe // favourable extreme over the whole TRADE, for the trail
float ret // return at the horizon, percent - na while pending
bool resolved // horizon reached, ret is final
// --- track A, fixed-R: E1 stop, E2 target, optional E3 trail, E8 ------
float fR // outcome in R - na while this track is still open
int fReason // exit reason code
int fBars // bars held
bool fDone
// --- track B, structural: E1 stop as a floor, then E4-E7, E8 ----------
float sR
int sReason
int sBars
bool sDone
// Result of one exit test. An object rather than a tuple because Pine only
// destructures tuples in the global scope, and the per-signal simulation has
// to run these tests inside a loop.
type ExitRes
int reason
float fill
// ============================================================================
// HELPERS
// ============================================================================
// Explicit leading sign rather than a "positive;negative" format pattern, so
// the output does not depend on how Pine handles multi-section patterns.
// Declared here rather than with the panel formatters because the score label
// uses it too, and the label is built earlier in the file.
f_signed(float v, string fmt) =>
na(v) ? "-" : (v > 0.0 ? "+" : "") + str.tostring(v, fmt)
// Clamp v into [lo, hi]. na in -> na out (callers guard before calling).
f_clamp(float v, float lo, float hi) =>
math.max(lo, math.min(hi, v))
// A percentage return expressed in R. ret% of the entry price IS the price
// move, so this is exact rather than an approximation. Declared up here with
// the other shared helpers because two callers now need it: the panel's
// exit-model comparison, which converts the horizon control track, and the
// Stage 10 ablation harness, which quotes its streams in the same currency.
f_retToR(float retPct, float entry, float atrAt, float stopMult) =>
na(retPct) or na(entry) or na(atrAt) or atrAt <= 0.0 or stopMult <= 0.0 ? na :
retPct / 100.0 * entry / (stopMult * atrAt)
// ----------------------------------------------------- Candle primitives ---
// These take explicit OHLC arguments rather than reading the globals, so the
// caller decides which candle type is fed in. Per MIXED-CANDLE POLICY every
// caller of these MUST pass REAL OHLC - pattern recognition on Heikin Ashi
// bars is meaningless (HA opens are synthetic midpoints, so nearly every HA
// bar looks like an engulfing). Shared by the 1h confirmation, the 15m
// exhaustion gate, and layer 11 in Stage 5.
// Body size as a fraction of the bar's full range. Zero-range bar -> 0.0.
f_bodyRatio(float o, float h, float l, float c) =>
float rng = h - l
na(o) or na(h) or na(l) or na(c) or rng <= 0.0 ? 0.0 : math.abs(c - o) / rng
// Pin bar / hammer. dir > 0 wants a long LOWER wick (lower prices rejected),
// dir < 0 a long UPPER wick. Requires a small body and the rejection wick to
// dominate the opposite wick.
f_isPinBar(int dir, float o, float h, float l, float c, float maxBody, float minWick) =>
bool res = false
float rng = h - l
if na(o) or na(h) or na(l) or na(c) or rng <= 0.0
res := false
else
float body = math.abs(c - o)
float upWick = h - math.max(o, c)
float dnWick = math.min(o, c) - l
bool smallBody = body / rng <= maxBody
// Floor the divisor at 1% of range so a doji (body ~ 0) cannot make
// the wick/body ratio explode to infinity or divide by zero.
float bodySafe = math.max(body, rng * 0.01)
res := dir > 0 ? (smallBody and dnWick >= minWick * bodySafe and dnWick > upWick)
: (smallBody and upWick >= minWick * bodySafe and upWick > dnWick)
res
// Engulfing: current real body fully covers the previous real body and the
// two bars are opposite colours.
f_isEngulfing(int dir, float o, float c, float po, float pc) =>
bool res = false
if na(o) or na(c) or na(po) or na(pc)
res := false
else if dir > 0
res := c > o and pc < po and c >= po and o <= pc
else
res := c < o and pc > po and c <= po and o >= pc
res
// ============================================================================
// HEIKIN ASHI (computed from this chart's real OHLC - no security call)
// ============================================================================
haClose = (open + high + low + close) / 4.0
var float haOpen = na
haOpen := na(haOpen[1]) ? (open + close) / 2.0 : (haOpen[1] + haClose[1]) / 2.0
haHigh = math.max(high, math.max(haOpen, haClose))
haLow = math.min(low, math.min(haOpen, haClose))
// Source selector for PAC / EMA layers only.
srcOpen = useHA ? haOpen : open
srcClose = useHA ? haClose : close
srcHigh = useHA ? haHigh : high
srcLow = useHA ? haLow : low
// ============================================================================
// SHARED INDICATOR SERIES
// Computed once at global scope and passed into the scoring functions as
// parameters. This keeps every f_*Score() a PURE function (no ta.* calls in
// conditional branches, no hidden state), so each layer can be called twice
// - once for the long hypothesis, once for the short - and unit-tested in
// isolation by feeding it fixed values.
// ============================================================================
[diPlus, diMinus, adxVal] = ta.dmi(adxLen, adxSmooth) // real OHLC
smaVal = ta.sma(close, smaLen) // real OHLC
emaFast = ta.ema(srcClose, emaFastLen) // HA-aware
emaMed = ta.ema(srcClose, emaMedLen) // HA-aware
emaSlow = ta.ema(srcClose, emaSlowLen) // HA-aware
pacUpper = ta.ema(srcHigh, pacLen) // HA-aware
pacLower = ta.ema(srcLow, pacLen) // HA-aware
pacMid = ta.ema(srcClose, pacLen) // HA-aware
rsiVal = ta.rsi(close, rsiLen) // real OHLC
rsiMaVal = ta.sma(rsiVal, rsiSmoothLen)
[macdLine, macdSigLine, macdHist] = ta.macd(close, macdFastLen, macdSlowLen, macdSigLen)
volAvg = ta.sma(volume, volLen) // real volume
atrVal = ta.atr(atrLen) // real OHLC (true range)
atrAvg = ta.sma(atrVal, atrAvgLen) // layer 7 baseline
// ------------------------------------------- Confirmed pivot S/R levels ---
// REAL high/low deliberately, not the HA-aware source: a support or
// resistance level is a price other market participants can see and react to.
// Heikin Ashi extremes are synthetic averages that were never traded at.
//
// REPAINT SAFETY: ta.pivothigh/ta.pivotlow return a value only srRight bars
// AFTER the pivot bar printed. Every level that reaches these arrays is
// therefore already confirmed and can never shift or vanish on the realtime
// bar. Unconfirmed "live" pivots are deliberately not used anywhere.
pivotHi = ta.pivothigh(high, srLeft, srRight)
pivotLo = ta.pivotlow(low, srLeft, srRight)
var array<float> srHighs = array.new_float(0)
var array<float> srLows = array.new_float(0)
// ------------------------------------- Confirmed pivots for divergence ---
// Layer 10 keeps its OWN pivot pair rather than reusing layer 6's, because
// the two layers want different confirmation strictness - see the lookback
// justification in the layer 10 tooltips.
divPivotHi = ta.pivothigh(high, divLeft, divRight)
divPivotLo = ta.pivotlow(low, divLeft, divRight)
// The two most recent confirmed pivots per side, each paired with the RSI
// reading taken AT THE PIVOT BAR. Suffix 1 is the most recent, 2 the one
// before it - a divergence needs exactly that pair.
var float divPxHi1 = na
var float divPxHi2 = na
var float divRsiHi1 = na
var float divRsiHi2 = na
var float divPxLo1 = na
var float divPxLo2 = na
var float divRsiLo1 = na
var float divRsiLo2 = na
// ------------------------------------------------ Cooldown state (Stage 6) ---
// Chart bar_index of the last ACCEPTED signal per direction: slot 0 = long,
// slot 1 = short. Held as an array rather than two scalars so the whole of
// this script's persistent state resets through one code path below, and so
// Stage 8's log can extend the same pattern. na = no signal yet this symbol.
var array<int> cdLastSignalBar = array.new_int(2, na)
// ---------------------------------------------- Performance log (Stage 8) ---
// Rolling window of the most recent accepted signals, oldest dropped first.
var array<SigRec> sigLog = array.new<SigRec>()
// ------------------------------------- Layer ablation counters (Stage 10) ---
// Fourteen parallel streams: index 0 is the unablated BASE, 1-13 are the same
// stream with one layer removed. COUNTERS ONLY - the harness keeps no records
// at all, because it settles each outcome in a single step H bars later rather
// than tracking it. See the ablation block after the signal assembly.
var array<int> abN = array.new_int(14, 0)
var array<int> abWin = array.new_int(14, 0)
var array<float> abR = array.new_float(14, 0.0)
// -------------------------------------------- Position state (Stage 8.5) ---
// The script was stateless about positions until this stage: it logged
// signals and had no concept of being in one. Exits require that concept.
// One position at a time, no pyramiding.
//
// posAtr freezes the ATR at entry so the stop and target never drift under an
// open position as volatility changes.
var int posDir = 0 // 0 flat, +1 long, -1 short
var float posEntry = na
var float posAtr = na
var int posBar = na
var float posStop = na
var float posTarget = na
var float posMfe = 0.0
var float posMae = 0.0
// ------------------------------------------------ Score reach (Stage 8) ---
// How high the score ACTUALLY gets on this symbol, counted over every warmed
// -up bar rather than only over bars that signalled. This exists to answer a
// question the signal log structurally cannot: if the threshold is set above
// anything the score ever reaches, there are no signals to have a win rate,
// and an empty log looks identical to a well-tuned one that simply has not
// triggered yet.
//
// Counted for the raw pre-veto score AND the post-veto score, so the two
// columns separate "the layers never agree strongly enough" from "the layers
// agree but the 1D bias keeps vetoing them" - completely different fixes.
//
// Bands: 0 = 80+, 1 = 70-79, 2 = 60-69, 3 = 50-59, 4 = under 50.
var array<int> reachRaw = array.new_int(5, 0)
var array<int> reachFinal = array.new_int(5, 0)
var int reachBars = 0
var float peakRaw = na
var float peakFinal = na
var string srTicker = ""
// STATE RESET on symbol change: none of this var state - pivot levels,
// divergence anchors or cooldown timestamps - may carry over from a
// previously loaded ticker. Prices from another instrument would be
// meaningless as levels and actively misleading as divergence anchors, and a
// stale bar_index would suppress the new symbol's first signal for no reason.
// TradingView normally recalculates the whole script when the symbol changes,
// which reinitialises everything anyway; this guard makes the contract
// explicit and holds in any context where it does not. Stage 8's log will
// reset through this same block.
if syminfo.ticker != srTicker
array.clear(srHighs)
array.clear(srLows)
array.fill(cdLastSignalBar, na)
array.clear(sigLog)
posDir := 0
posEntry := na
posAtr := na
posBar := na
posStop := na
posTarget := na
posMfe := 0.0
posMae := 0.0
array.fill(reachRaw, 0)
array.fill(reachFinal, 0)
reachBars := 0
peakRaw := na
peakFinal := na
// The ablation counters reset here too. The per-stream cooldown anchors
// live inside f_ablSignal as function-local state and cannot be reached
// from out here - which costs nothing in practice for the reason the
// comment above gives: TradingView recalculates the whole script on a
// symbol change, so those anchors are reinitialised with everything else.
array.fill(abN, 0)
array.fill(abWin, 0)
array.fill(abR, 0.0)
divPxHi1 := na
divPxHi2 := na
divRsiHi1 := na
divRsiHi2 := na
divPxLo1 := na
divPxLo2 := na
divRsiLo1 := na
divRsiLo2 := na
srTicker := syminfo.ticker
// Rolling FIFO window of the most recent confirmed pivots on each side.
if not na(pivotHi)
array.push(srHighs, pivotHi)
if array.size(srHighs) > srKeep
array.shift(srHighs)
if not na(pivotLo)
array.push(srLows, pivotLo)
if array.size(srLows) > srKeep
array.shift(srLows)
// Shift the divergence history when a new pivot confirms. The RSI reading is
// taken divRight bars back, which is the PIVOT bar - not the confirmation
// bar we are standing on. Pairing the pivot price with an RSI value from a
// different bar is the classic way to manufacture divergences that were
// never there.
if not na(divPivotHi)
divPxHi2 := divPxHi1
divRsiHi2 := divRsiHi1
divPxHi1 := divPivotHi
divRsiHi1 := rsiVal[divRight]
if not na(divPivotLo)
divPxLo2 := divPxLo1
divRsiLo2 := divRsiLo1
divPxLo1 := divPivotLo
divRsiLo1 := rsiVal[divRight]
// Bars since the most recent confirmed pivot published, for the freshness
// decay. na until the first pivot of that kind exists - the score function
// guards for it.
divBarsSinceHi = ta.barssince(not na(divPivotHi))
divBarsSinceLo = ta.barssince(not na(divPivotLo))
// ============================================================================
// LAYER 1 - REGIME / TREND FILTER (design weight 20)
// confidence = (# of met sub-conditions) / 3
// a) ADX(len) > threshold (non-directional)
// b) close vs SMA(50) (directional, real OHLC)
// c) EMA ordering fast > med > slow (directional, HA-aware)
// NA GUARD: during warm-up on a newly listed / short-history ticker any of
// smaVal / emaSlow is na -> return neutral 0.5 rather than na or 0.
// ============================================================================
f_regimeScore(int dir, float adxv, float adxThreshold, float px, float sma, float eFast, float eMed, float eSlow) =>
float res = 0.5
if na(adxv) or na(px) or na(sma) or na(eFast) or na(eMed) or na(eSlow)
res := 0.5
else
bool cAdx = adxv > adxThreshold
bool cSma = dir > 0 ? px > sma : px < sma
bool cEma = dir > 0 ? (eFast > eMed and eMed > eSlow) : (eFast < eMed and eMed < eSlow)
res := ((cAdx ? 1.0 : 0.0) + (cSma ? 1.0 : 0.0) + (cEma ? 1.0 : 0.0)) / 3.0
res
// ============================================================================
// LAYER 2 - PAC CROSSOVER (design weight 10)
// LONG : HA_open < PAC_upper AND HA_close > PAC_upper -> fresh breakout 0.70
// already trading above PAC_upper (no fresh cross) -> 0.35
// PLUS closed below PAC_mid within last N bars -> +0.30
// SHORT: mirrored on PAC_lower / above PAC_mid.
// Result clamped to [0, 1].
// ta.barssince() is evaluated UNCONDITIONALLY at the top of the function for
// both directions so no ta.* call ever sits inside an if-branch.
// ============================================================================
f_pacScore(int dir, float o, float c, float pUp, float pLo, float pMid, int dipBars) =>
int barsSinceBelowMid = ta.barssince(c < pMid)
int barsSinceAboveMid = ta.barssince(c > pMid)
float res = 0.5
if na(pUp) or na(pLo) or na(pMid) or na(o) or na(c)
res := 0.5
else
float sc = 0.0
if dir > 0
bool fresh = o < pUp and c > pUp
sc := fresh ? 0.7 : (c > pUp ? 0.35 : 0.0)
bool pulled = not na(barsSinceBelowMid) and barsSinceBelowMid <= dipBars
sc := sc + (pulled ? 0.3 : 0.0)
else
bool fresh = o > pLo and c < pLo
sc := fresh ? 0.7 : (c < pLo ? 0.35 : 0.0)
bool pulled = not na(barsSinceAboveMid) and barsSinceAboveMid <= dipBars
sc := sc + (pulled ? 0.3 : 0.0)
res := f_clamp(sc, 0.0, 1.0)
res
// ============================================================================
// LAYER 3 - MOMENTUM (RSI cross + MACD histogram) (design weight 15)
// 0.5 sub-weight: RSI vs its smoothing line
// fresh cross in signal direction -> 0.50 ; merely on the right side -> 0.25
// 0.5 sub-weight: MACD histogram direction CHANGE
// turning bar (slope flips into signal direction) -> 0.50 ; still
// moving in the right direction -> 0.25
// NA GUARD: needs hist[2]; during warm-up -> neutral 0.5.
// ============================================================================
f_momentumScore(int dir, float rsi, float rsiMa, float hist) =>
float res = 0.5
if na(rsi) or na(rsiMa) or na(rsi[1]) or na(rsiMa[1]) or na(hist) or na(hist[1]) or na(hist[2])
res := 0.5
else
bool rsiCrossUp = rsi > rsiMa and rsi[1] <= rsiMa[1]
bool rsiCrossDown = rsi < rsiMa and rsi[1] >= rsiMa[1]
bool histTurnUp = hist > hist[1] and hist[1] <= hist[2]
bool histTurnDown = hist < hist[1] and hist[1] >= hist[2]
float rsiPart = 0.0
float macdPart = 0.0
if dir > 0
rsiPart := rsiCrossUp ? 0.5 : (rsi > rsiMa ? 0.25 : 0.0)
macdPart := histTurnUp ? 0.5 : (hist > hist[1] ? 0.25 : 0.0)
else
rsiPart := rsiCrossDown ? 0.5 : (rsi < rsiMa ? 0.25 : 0.0)
macdPart := histTurnDown ? 0.5 : (hist < hist[1] ? 0.25 : 0.0)
res := f_clamp(rsiPart + macdPart, 0.0, 1.0)
res
// ============================================================================
// LAYER 4 - VOLUME CONFIRMATION (design weight 10)
// ratio = volume / SMA(volume, 20), mapped linearly through two anchors
// (default 0.5x -> 0.20 and 2.0x -> 1.00), clamped to [0, 1].
// Non-directional: identical confidence for the long and short hypothesis.
// DIV-BY-ZERO GUARD: volAvg na or <= 0 (illiquid / no-trade bar, or a feed
// that reports zero volume) -> neutral 0.5.
// Also guards against the user setting hi anchor == lo anchor.
// ============================================================================
f_volumeScore(float vol, float avg, float rLo, float cLo, float rHi, float cHi) =>
float res = 0.5
if na(vol) or na(avg) or avg <= 0.0 or math.abs(rHi - rLo) < 1e-10
res := 0.5
else
float ratio = vol / avg
float slope = (cHi - cLo) / (rHi - rLo)
res := f_clamp(cLo + (ratio - rLo) * slope, 0.0, 1.0)
res
// ============================================================================
// LAYER 6 - STRUCTURAL LEVEL PROXIMITY (S/R) (design weight 10)
// Distance from price to the nearest confirmed pivot level, measured in ATR
// units so the layer behaves identically on a 12 TL bank and a 900 TL
// industrial: <= srNearAtr (0.5 ATR) -> 1.0, >= srFarAtr (3 ATR) -> 0.0,
// linear in between.
//
// WHICH LEVEL: direction-aware. A long is graded on its distance to the
// nearest level BELOW price (support it can lean on); a short on the nearest
// level ABOVE it. Either side may be an old pivot high or an old pivot low -
// a broken resistance that price now sits above is support, the classic
// polarity flip - so both arrays are searched on both sides.
//
// HEADROOM (optional, ON by default): proximity alone would score a long
// pinned between support and resistance as highly as one with open sky
// above. When the opposing level is nearer than srHeadroomAtr the score is
// multiplied by srHeadroomMult. Turn the toggle off for plain spec
// behaviour.
//
// NA GUARD: no confirmed pivot on the relevant side yet (warm-up, a new
// listing, or price beyond all known structure) -> neutral 0.5, never na.
// ============================================================================
// Nearest stored level at or below px (na if every level sits above it).
// The array.size() > 0 guard is REQUIRED: Pine's for-loop counts DOWNWARD
// when `to` is below `from`, so `0 to size - 1` on an empty array would
// iterate rather than skip.
f_maxBelow(array<float> a, float px) =>
float best = na
if array.size(a) > 0
for i = 0 to array.size(a) - 1
float v = array.get(a, i)
if not na(v) and v <= px and (na(best) or v > best)
best := v
best
// Nearest stored level at or above px (na if every level sits below it).
f_minAbove(array<float> a, float px) =>
float best = na
if array.size(a) > 0
for i = 0 to array.size(a) - 1
float v = array.get(a, i)
if not na(v) and v >= px and (na(best) or v < best)
best := v
best
srBelowHi = f_maxBelow(srHighs, close)
srBelowLo = f_maxBelow(srLows, close)
srSupport = na(srBelowHi) ? srBelowLo : (na(srBelowLo) ? srBelowHi : math.max(srBelowHi, srBelowLo))
srAboveHi = f_minAbove(srHighs, close)
srAboveLo = f_minAbove(srLows, close)
srResist = na(srAboveHi) ? srAboveLo : (na(srAboveLo) ? srAboveHi : math.min(srAboveHi, srAboveLo))
// Linear fade from 1.0 at nearU ATR to 0.0 at farU ATR. Kept as its own
// helper so the mapping is defined once and can be reused. Bad anchors or a
// dead ATR -> neutral 0.5.
f_atrProximity(float dist, float atrv, float nearU, float farU) =>
float out = 0.5
if na(dist) or na(atrv) or atrv <= 0.0 or (farU - nearU) < 1e-10
out := 0.5
else
float d = dist / atrv
out := f_clamp(1.0 - (d - nearU) / (farU - nearU), 0.0, 1.0)
out
f_srProximityScore(int dir, float px, float sup, float resist, float atrv, float nearU, float farU,
bool useHead, float headU, float headMult) =>
float out = 0.5
float anchor = dir > 0 ? sup : resist
float opposed = dir > 0 ? resist : sup
if na(px) or na(anchor) or na(atrv) or atrv <= 0.0
out := 0.5
else
// Both distances are non-negative by construction: `anchor` was
// selected from the correct side of price, and so was `opposed`.
float dist = dir > 0 ? px - anchor : anchor - px
out := f_atrProximity(dist, atrv, nearU, farU)
if useHead and not na(opposed)
float room = dir > 0 ? opposed - px : px - opposed
if room / atrv < headU
out := out * headMult
out := f_clamp(out, 0.0, 1.0)
out
// ============================================================================
// LAYER 7 - VOLATILITY REGIME (ATR) (design weight 5)
// Is volatility expanding or contracting relative to its own recent norm?
// Expansion raises breakout confidence; a compressing range means any
// breakout signal is more likely to stall inside the noise.
//
// Two tests, blended by atrSlopeWeight (default 0.3 slope / 0.7 level):
// LEVEL : ATR / SMA(ATR, atrAvgLen), mapped through the two anchors
// (default 0.8x -> 0.20, 1.5x -> 1.00). Answers "is the range wide
// for this ticker right now?"
// SLOPE : ATR / ATR[atrSlopeLb], mapped through a deadband centred on 1.0.
// Answers "is it still widening?" - a range that is already wide
// but has stopped expanding is a worse breakout backdrop than one
// actively opening up.
//
// NON-DIRECTIONAL: expansion favours a breakout either way, so the long and
// short hypotheses receive the same confidence (as with layer 4).
//
// DIV-BY-ZERO / NA GUARDS: a na or non-positive ATR, baseline, or historical
// ATR yields neutral 0.5 for the affected sub-test rather than na - which
// is what happens during warm-up on a newly listed ticker, and on a halted
// name whose true range collapses to zero.
// ============================================================================
// Generic two-anchor linear map with clamping to the anchor range. Degenerate
// anchors (xHi == xLo) or a na input -> neutral 0.5.
f_mapLinear(float v, float xLo, float yLo, float xHi, float yHi) =>
float out = 0.5
if na(v) or math.abs(xHi - xLo) < 1e-10
out := 0.5
else
float raw = yLo + (v - xLo) * (yHi - yLo) / (xHi - xLo)
out := f_clamp(raw, math.min(yLo, yHi), math.max(yLo, yHi))
out
f_volatilityScore(float atrv, float baseline, float atrPast, float rLo, float cLo, float rHi,
float cHi, float band, float slopeW) =>
float out = 0.5
if na(atrv) or atrv <= 0.0
out := 0.5
else
float lvl = na(baseline) or baseline <= 0.0 ? 0.5 :
f_mapLinear(atrv / baseline, rLo, cLo, rHi, cHi)
float slp = na(atrPast) or atrPast <= 0.0 ? 0.5 :
f_mapLinear(atrv / atrPast, 1.0 - band, 0.0, 1.0 + band, 1.0)
float w = f_clamp(slopeW, 0.0, 1.0)
out := f_clamp((1.0 - w) * lvl + w * slp, 0.0, 1.0)
out
// ============================================================================
// LAYER 8 - MARKET / INDEX CORRELATION (design weight 5)
// Does the broad market agree with the trade? A long into a falling XU100 is
// fighting the tape even when the single name looks perfect.
//
// Grading is the spec's three-state one, deliberately coarse: index moving
// the same way -> 1.0, flat inside the neutral band -> 0.5, moving the other
// way -> 0.0. This is a sanity check worth 5 points, not a beta model.
//
// NA GUARD: no index data (bad symbol, a feed the user is not subscribed to,
// or warm-up before idxLookback bars exist) -> neutral 0.5. The layer never
// penalises a ticker for the user's data entitlements.
// ============================================================================
// Evaluated INSIDE the index context by request.security (call #4). `off`
// steps back one INDEX bar when idxConfirmedOnly is ON; as with the bias
// call the shift has to happen here, not on the returned series, or it would
// step back one CHART bar instead.
f_indexExpr(simple int off, simple int lb) =>
float _prev = close[lb]
// Guard the denominator: an index print of exactly zero is impossible in
// practice but a broken or empty feed can still deliver one.
float _chg = na(_prev) or _prev == 0.0 ? na : (close - _prev) / _prev * 100.0
_chg[off]
idxChgRaw = request.security(idxSymbol, idxTF,
f_indexExpr(idxConfirmedOnly ? 1 : 0, idxLookback),
gaps = barmerge.gaps_off, lookahead = barmerge.lookahead_off)
f_bist100Score(int dir, float chgPct, float neutralPct) =>
float out = 0.5
if na(chgPct) or dir == 0
out := 0.5
else if math.abs(chgPct) <= neutralPct
out := 0.5
else
bool same = dir > 0 ? chgPct > 0.0 : chgPct < 0.0
out := same ? 1.0 : 0.0
out
// ============================================================================
// LAYER 9 - LIQUIDITY SWEEP (design weight 5)
// REAL OHLC IS MANDATORY HERE (mixed-candle policy). A sweep is defined by
// where the WICK went and where the candle CLOSED; Heikin Ashi bars have
// synthetic opens and smoothed extremes, so the pattern simply does not
// survive the transformation.
//
// Pattern: price pushes through a known level, takes the stops resting the
// other side of it, and closes back inside within a bar or two.
// LONG : low pierced BELOW a level by >= sweepMinPierce ATR, close came
// back ABOVE it.
// SHORT : high pierced ABOVE a level, close came back BELOW it.
//
// LEVELS: the confirmed pivot arrays maintained for layer 6 are reused, so
// srLeft / srRight / srKeep shape this layer too, and the confirmed-pivot
// guarantee carries over - no level in those arrays can shift on the
// realtime bar. The arrays are maintained unconditionally, so layer 9 keeps
// working with layer 6 switched off. Both arrays are searched in both
// directions: the geometry test itself rejects a level on the wrong side,
// and a former pivot high that price now trades above is exactly the kind
// of level that gets swept.
//
// NO RETROACTIVE LEVELS: the scan looks back up to sweepMaxAge bars using
// the level set known AS OF THE CURRENT BAR. That is not lookahead - it
// uses only past prices and presently-known levels - and the value printed
// for a bar never changes afterwards.
//
// Confidence decays linearly with the age of the sweep. No sweep in the
// window -> 0.0 (an absent pattern is a real zero, not an unknown). No
// levels or no ATR yet -> neutral 0.5.
// ============================================================================
// True if the bar `i` bars back swept any level in `a` in direction `dir`.
// The array.size() guard is required for the same reason as in layer 6:
// Pine's for-loop counts downward when `to` is below `from`.
f_barSweeps(int dir, array<float> a, int i, float atrv, float minPierce) =>
bool hit = false
if array.size(a) > 0
float hi = high[i]
float lo = low[i]
float cl = close[i]
if not na(hi) and not na(lo) and not na(cl)
for k = 0 to array.size(a) - 1
float lvl = array.get(a, k)
if not na(lvl)
if dir > 0
if lo < lvl and cl > lvl and (lvl - lo) >= minPierce * atrv
hit := true
else
if hi > lvl and cl < lvl and (hi - lvl) >= minPierce * atrv
hit := true
hit
f_liquiditySweepScore(int dir, array<float> hiLvls, array<float> loLvls, simple int maxAge,
float atrv, float minPierce, float floorConf) =>
float out = 0.0
if na(atrv) or atrv <= 0.0 or (array.size(hiLvls) == 0 and array.size(loLvls) == 0)
out := 0.5
else
int foundAt = -1
for i = 0 to maxAge
if foundAt < 0 and (f_barSweeps(dir, hiLvls, i, atrv, minPierce) or
f_barSweeps(dir, loLvls, i, atrv, minPierce))
foundAt := i
if foundAt < 0
out := 0.0
else if maxAge <= 0
out := 1.0
else
out := f_clamp(1.0 - (1.0 - floorConf) * foundAt / maxAge, 0.0, 1.0)
out
// ============================================================================
// LAYER 10 - RSI / PRICE DIVERGENCE (design weight 5)
// Classic divergence between the last two CONFIRMED pivots on one side:
// LONG (bullish) : price made a lower low, RSI made a higher low.
// SHORT (bearish) : price made a higher high, RSI made a lower high.
//
// CONFIRMED PIVOTS ONLY: divPivotHi/divPivotLo publish divRight bars after
// the pivot bar, so neither anchor can move once it has been recorded. An
// unconfirmed "live" pivot would let a divergence appear and vanish on the
// realtime bar, which is the single most common way divergence indicators
// repaint.
//
// RSI IS SAMPLED AT THE PIVOT BAR (rsiVal[divRight]), not at the bar where
// the pivot was confirmed.
//
// NA GUARD: fewer than two confirmed pivots on the relevant side - a new
// listing, a short history, or simply early in the chart - returns neutral
// 0.5. A divergence that exists but has aged past divMaxAge returns 0.0,
// as does no divergence at all: both are known negatives, not unknowns.
// ============================================================================
f_divergenceScore(int dir, float px1, float px2, float rsi1, float rsi2, int barsSince,
simple int maxAge, float minGap, float floorConf) =>
float out = 0.0
if na(px1) or na(px2) or na(rsi1) or na(rsi2) or na(barsSince)
out := 0.5
else
// dir > 0 reads the pivot LOWS, dir < 0 the pivot HIGHS; the caller
// passes the matching pair, so the test here is symmetric.
bool priceOk = dir > 0 ? px1 < px2 : px1 > px2
bool rsiOk = dir > 0 ? rsi1 >= rsi2 + minGap : rsi1 <= rsi2 - minGap
if not (priceOk and rsiOk)
out := 0.0
else if barsSince > maxAge
out := 0.0
else if maxAge <= 0
out := 1.0
else
out := f_clamp(1.0 - (1.0 - floorConf) * barsSince / maxAge, 0.0, 1.0)
out
// ============================================================================
// LAYER 11 - CANDLE PATTERN CONFIRMATION (design weight 5)
// REAL OHLC IS MANDATORY HERE (mixed-candle policy) - the candle primitives
// near the top of the file carry the full explanation: Heikin Ashi opens are
// synthetic midpoints, so nearly every HA bar reads as an engulfing and the
// pattern loses all meaning.
//
// Reuses f_isPinBar() and f_isEngulfing(), the same primitives the 1h entry
// confirmation and the 15m exhaustion gate call, with this layer's own
// thresholds. One implementation of each pattern, three callers.
//
// Scores sum and clamp: a bar that is both a pin and an engulfing is the
// strongest possible confirmation. No pattern -> 0.0, a known negative.
// na OHLC (a gap in the feed) -> neutral 0.5.
// ============================================================================
f_candlePatternScore(int dir, float o, float h, float l, float c, float po, float pc,
float maxBody, float minWick, float pinConf, float engConf) =>
float out = 0.0
if na(o) or na(h) or na(l) or na(c)
out := 0.5
else
bool pin = f_isPinBar(dir, o, h, l, c, maxBody, minWick)
bool eng = f_isEngulfing(dir, o, c, po, pc)
out := f_clamp((pin ? pinConf : 0.0) + (eng ? engConf : 0.0), 0.0, 1.0)
out
// ============================================================================
// LAYER 12 - USDTRY CORRELATION (design weight 5, OFF by default)
// A rising USDTRY means a weaker lira. Whether that helps or hurts is a
// property of the company, not of the chart, so the user declares it:
// EXPORTER : weaker lira -> tailwind -> a rising USDTRY supports a LONG
// IMPORTER : weaker lira -> headwind -> a rising USDTRY supports a SHORT
// NEUTRAL : 0.5 regardless, the safe default
//
// Grading matches layer 8's three-state scheme: agreement 1.0, inside the
// neutral band 0.5, disagreement 0.0.
//
// WEIGHT REDISTRIBUTION: with this layer disabled the live weights total 95
// of 100. The "Renormalise score across ENABLED layers only" switch in the
// General group decides whether the score still spans 0-100 (ON, default)
// or simply loses those five points (OFF).
//
// NA GUARD: no FX data - a feed the user is not subscribed to, or warm-up -
// returns neutral 0.5.
// ============================================================================
// Reuses f_indexExpr from layer 8: percentage change over lb bars, evaluated
// inside the requested symbol's context, with `off` stepping back one bar of
// THAT symbol when the confirmed-bar toggle is on.
fxChgRaw = request.security(fxSymbol, fxTF,
f_indexExpr(fxConfirmedOnly ? 1 : 0, fxLookback),
gaps = barmerge.gaps_off, lookahead = barmerge.lookahead_off)
// Linkage resolved to a sign once, at global scope, so the score function
// never has to compare strings.
fxSign = fxLinkage == LINK_EXP ? 1 : (fxLinkage == LINK_IMP ? -1 : 0)
f_usdtryScore(int dir, float chgPct, float neutralPct, int linkSign) =>
float out = 0.5
if na(chgPct) or linkSign == 0 or dir == 0
out := 0.5
else if math.abs(chgPct) <= neutralPct
out := 0.5
else
// The FX move implies a direction for the STOCK once the linkage
// sign is applied: a rising pair times an exporter's +1 implies a
// long, times an importer's -1 implies a short.
int impliedDir = (chgPct > 0.0 ? 1 : -1) * linkSign
out := impliedDir == dir ? 1.0 : 0.0
out
// ============================================================================
// LAYER 13 - SESSION / TIME FILTER (design weight 5)
// Bars inside the opening auction hangover, the lunch lull, or the closing
// squaring window score sessPenalty (0.3); every other bar scores 1.0.
// Non-directional - a bad hour is bad for longs and shorts alike.
//
// TIMEZONE AWARENESS: time(timeframe.period, session, sessTz) resolves the
// window in the user-selected timezone, so the layer behaves identically
// whether the chart is set to Istanbul, UTC or New York. Comparing raw
// hour()/minute() against the chart's local time would silently misfire for
// anyone not on exchange time - exactly the failure the spec calls out.
//
// HALF-DAYS: nothing here assumes a session runs 10:00-18:00. Each window is
// a time-of-day test evaluated against whatever bars the exchange actually
// produced, so a half-day simply has fewer bars to classify and no window is
// ever "missed" because the day ended early.
//
// TIMEFRAME CAVEAT: time() flags a bar when the bar's OPEN falls inside the
// window. On the recommended 4h chart only the opening window can therefore
// match (a 4h bar opens at 10:00, but none opens at 12:30 or 17:30), so the
// layer effectively penalises the day's first bar and nothing else. That is
// the graceful degradation, and it is the reason for choosing this test over
// a bar-span overlap: an overlap test on a 4h chart would flag EVERY bar,
// since a 10:00-14:00 bar overlaps both the opening and the lunch window.
// These windows are built for intraday work - on a 15m or 1h chart all three
// behave as intended.
// ============================================================================
inSessOpen = not na(time(timeframe.period, sessOpenWin, sessTz))
inSessLunch = not na(time(timeframe.period, sessLunchWin, sessTz))
inSessClose = not na(time(timeframe.period, sessCloseWin, sessTz))
sessFlagged = inSessOpen or inSessLunch or inSessClose
f_sessionFilterScore(bool flagged, float penalty) =>
f_clamp(flagged ? penalty : 1.0, 0.0, 1.0)
// ============================================================================
// SCORE AGGREGATION
// Both hypotheses are scored every bar; the stronger one becomes the
// candidate direction. Every layer is computed unconditionally (keeps ta.*
// calls out of conditional branches); the enable toggles act at aggregation
// time. Layers 4, 7 and 13 are non-directional and score both hypotheses
// alike. With all 13 layers shipped the design weights sum to exactly 100
// (layer 5 is the veto and carries none).
// ============================================================================
s1Long = f_regimeScore( 1, adxVal, adxThr, close, smaVal, emaFast, emaMed, emaSlow)
s1Short = f_regimeScore(-1, adxVal, adxThr, close, smaVal, emaFast, emaMed, emaSlow)
s2Long = f_pacScore( 1, srcOpen, srcClose, pacUpper, pacLower, pacMid, pacDipBars)
s2Short = f_pacScore(-1, srcOpen, srcClose, pacUpper, pacLower, pacMid, pacDipBars)
s3Long = f_momentumScore( 1, rsiVal, rsiMaVal, macdHist)
s3Short = f_momentumScore(-1, rsiVal, rsiMaVal, macdHist)
s4Both = f_volumeScore(volume, volAvg, volRatioLo, volConfLo, volRatioHi, volConfHi)
s6Long = f_srProximityScore( 1, close, srSupport, srResist, atrVal, srNearAtr, srFarAtr,
srUseHeadroom, srHeadroomAtr, srHeadroomMult)
s6Short = f_srProximityScore(-1, close, srSupport, srResist, atrVal, srNearAtr, srFarAtr,
srUseHeadroom, srHeadroomAtr, srHeadroomMult)
s7Both = f_volatilityScore(atrVal, atrAvg, atrVal[atrSlopeLb], atrRatioLo, atrConfLo,
atrRatioHi, atrConfHi, atrSlopeBand, atrSlopeWeight)
s8Long = f_bist100Score( 1, idxChgRaw, idxNeutralPct)
s8Short = f_bist100Score(-1, idxChgRaw, idxNeutralPct)
s9Long = f_liquiditySweepScore( 1, srHighs, srLows, sweepMaxAge, atrVal, sweepMinPierce,
sweepFloorConf)
s9Short = f_liquiditySweepScore(-1, srHighs, srLows, sweepMaxAge, atrVal, sweepMinPierce,
sweepFloorConf)
s10Long = f_divergenceScore( 1, divPxLo1, divPxLo2, divRsiLo1, divRsiLo2, divBarsSinceLo,
divMaxAge, divMinRsiGap, divFloorConf)
s10Short = f_divergenceScore(-1, divPxHi1, divPxHi2, divRsiHi1, divRsiHi2, divBarsSinceHi,
divMaxAge, divMinRsiGap, divFloorConf)
// REAL OHLC, never the HA-aware source - see the layer 11 header.
s11Long = f_candlePatternScore( 1, open, high, low, close, open[1], close[1],
cpMaxBody, cpMinWick, cpPinConf, cpEngConf)
s11Short = f_candlePatternScore(-1, open, high, low, close, open[1], close[1],
cpMaxBody, cpMinWick, cpPinConf, cpEngConf)
s12Long = f_usdtryScore( 1, fxChgRaw, fxNeutralPct, fxSign)
s12Short = f_usdtryScore(-1, fxChgRaw, fxNeutralPct, fxSign)
s13Both = f_sessionFilterScore(sessFlagged, sessPenalty)
// The sum of the ENABLED layer weights. Depends only on the toggles and the
// weight inputs - never on the confidences - so it is computed on its own
// rather than falling out of the aggregation once per direction. One
// definition, three readers: both score hypotheses and the Stage 10 ablation
// harness, which cannot renormalise correctly without it.
// No layer 5 term: it is the hard veto and carries no weight.
f_enabledWeight() =>
(useL1 ? wL1 : 0.0) + (useL2 ? wL2 : 0.0) + (useL3 ? wL3 : 0.0) +
(useL4 ? wL4 : 0.0) + (useL6 ? wL6 : 0.0) + (useL7 ? wL7 : 0.0) +
(useL8 ? wL8 : 0.0) + (useL9 ? wL9 : 0.0) + (useL10 ? wL10 : 0.0) +
(useL11 ? wL11 : 0.0) + (useL12 ? wL12 : 0.0) + (useL13 ? wL13 : 0.0)
// Returns BOTH the finished score and the raw weighted numerator behind it.
// The numerator is what makes the ablation harness cheap: because the score is
// a weighted sum, removing layer k is (num - ck*wk) / (den - wk) in closed
// form, so no layer ever has to be recomputed to ask what the score would have
// been without it.
// No c5 parameter: layer 5 is the hard veto and carries no weight.
f_aggregate(float den, float c1, float c2, float c3, float c4, float c6, float c7, float c8,
float c9, float c10, float c11, float c12, float c13) =>
float num = 0.0
if useL1
num += c1 * wL1
if useL2
num += c2 * wL2
if useL3
num += c3 * wL3
if useL4
num += c4 * wL4
if useL6
num += c6 * wL6
if useL7
num += c7 * wL7
if useL8
num += c8 * wL8
if useL9
num += c9 * wL9
if useL10
num += c10 * wL10
if useL11
num += c11 * wL11
if useL12
num += c12 * wL12
if useL13
num += c13 * wL13
float outv = 0.0
if normalizeWeights
outv := den > 0.0 ? num / den * 100.0 : 0.0
else
outv := num
[f_clamp(outv, 0.0, 100.0), num]
aggDen = f_enabledWeight()
[longScore, aggNumL] = f_aggregate(aggDen, s1Long, s2Long, s3Long, s4Both, s6Long,
s7Both, s8Long, s9Long, s10Long, s11Long, s12Long, s13Both)
[shortScore, aggNumS] = f_aggregate(aggDen, s1Short, s2Short, s3Short, s4Both, s6Short,
s7Both, s8Short, s9Short, s10Short, s11Short, s12Short, s13Both)
// Candidate direction: +1 long, -1 short, 0 tie/flat.
sigDir = longScore > shortScore ? 1 : (shortScore > longScore ? -1 : 0)
rawScore = math.max(longScore, shortScore)
// ============================================================================
// LAYER 5 - MTF DIRECTIONAL LOCK (1D) HARD VETO, NOT WEIGHTED
// ============================================================================
// All three sub-conditions must agree for the bias to lock:
// a) ADX(14) > 25 - is there a trend at all (non-directional)
// b) close vs SMA(50) - which side of the mean
// c) EMA fast > med > slow - stacking order confirms the same side
// Any disagreement, or ADX below threshold, or na during warm-up -> 0
// (neutral) and the system produces no signal. Neutral is the SAFE default:
// a newly listed ticker without 200 daily bars yields no signals rather
// than false ones. Lower the 1D EMA slow length for short-history names.
// ============================================================================
f_biasFrom(float adxv, float adxThreshold, float px, float sma, float ef, float em, float es) =>
int b = 0
if na(adxv) or na(px) or na(sma) or na(ef) or na(em) or na(es)
b := 0
else if adxv <= adxThreshold
b := 0
else
bool bullAll = px > sma and ef > em and em > es
bool bearAll = px < sma and ef < em and em < es
b := bullAll ? 1 : (bearAll ? -1 : 0)
b
// Evaluated INSIDE the bias timeframe by request.security (call #1).
// `off` shifts the read one bias-bar back when biasConfirmedOnly is ON; the
// shift must happen here, in the daily context, not on the returned series.
// Real OHLC is used here deliberately - see MIXED-CANDLE POLICY. The daily
// lock is a coarse regime gate where HA smoothing adds lag without benefit,
// and keeping it on real candles avoids mixing candle types across a veto.
f_dailyBiasExpr(simple int off) =>
[_dp, _dm, _adxRaw] = ta.dmi(dAdxLen, dAdxSmooth)
float _adx = _adxRaw[off]
float _px = close[off]
float _sma = ta.sma(close, dSmaLen)[off]
float _ef = ta.ema(close, dEmaFastLen)[off]
float _em = ta.ema(close, dEmaMedLen)[off]
float _es = ta.ema(close, dEmaSlowLen)[off]
int _bias = f_biasFrom(_adx, dAdxThr, _px, _sma, _ef, _em, _es)
int _smaSide = na(_px) or na(_sma) ? 0 : (_px > _sma ? 1 : -1)
int _emaSide = na(_ef) or na(_em) or na(_es) ? 0 :
((_ef > _em and _em > _es) ? 1 : ((_ef < _em and _em < _es) ? -1 : 0))
[_bias, _adx, _smaSide, _emaSide]
[dBiasRaw, dAdxVal, dSmaSide, dEmaSide] = request.security(syminfo.tickerid, biasTF,
f_dailyBiasExpr(biasConfirmedOnly ? 1 : 0),
gaps = barmerge.gaps_off, lookahead = barmerge.lookahead_off)
// nz guard: before the first bias bar resolves the tuple is na -> neutral.
dBias = nz(dBiasRaw, 0)
// ============================================================================
// ENTRY TIMING (default 1h)
// ============================================================================
// Activates once the signal-timeframe score crosses the threshold. Looks for
// a micro PAC touch plus a directional candle confirmation.
//
// PAC touch (long) : the bar wicked down into the channel (low <= PAC upper)
// but closed at or above PAC mid - it touched and held,
// rather than falling through. Short is mirrored.
// Confirmation : a real-bodied candle in the signal direction, body at
// least entryMinBody of the bar range so dojis are
// rejected.
//
// MIXED-CANDLE POLICY: the PAC and its touch test use the selected candle
// source (Heikin Ashi by default). The confirmation candle uses REAL OHLC
// unconditionally - see the note on the candle primitives.
// ============================================================================
f_entryExpr(simple int off, simple int pacLen, float minBody) =>
// Heikin Ashi rebuilt inside THIS timeframe's context from its own real
// OHLC, so no extra security call is needed for the HA series.
float _haC = (open + high + low + close) / 4.0
var float _haO = na
_haO := na(_haO[1]) ? (open + close) / 2.0 : (_haO[1] + _haC[1]) / 2.0
float _haH = math.max(high, math.max(_haO, _haC))
float _haL = math.min(low, math.min(_haO, _haC))
// PAC source honours the global Heikin Ashi toggle.
float _sC = useHA ? _haC : close
float _sH = useHA ? _haH : high
float _sL = useHA ? _haL : low
float _pU = ta.ema(_sH, pacLen)
float _pL = ta.ema(_sL, pacLen)
float _pM = ta.ema(_sC, pacLen)
bool _touchL = not na(_pU) and not na(_pM) and _sL <= _pU and _sC >= _pM
bool _touchS = not na(_pL) and not na(_pM) and _sH >= _pL and _sC <= _pM
// Confirmation candle: REAL OHLC only.
float _br = f_bodyRatio(open, high, low, close)
bool _confL = close > open and _br >= minBody
bool _confS = close < open and _br >= minBody
// Ints rather than bools across the security boundary: an int carries an
// unambiguous nz() default, whereas an na bool is easy to mis-handle.
int _tL = _touchL ? 1 : 0
int _tS = _touchS ? 1 : 0
int _cL = _confL ? 1 : 0
int _cS = _confS ? 1 : 0
[_tL[off], _tS[off], _cL[off], _cS[off]]
[eTouchLr, eTouchSr, eConfLr, eConfSr] = request.security(syminfo.tickerid, entryTF,
f_entryExpr(entryConfirmedOnly ? 1 : 0, entryPacLen, entryMinBody),
gaps = barmerge.gaps_off, lookahead = barmerge.lookahead_off)
entryTouchLong = nz(eTouchLr, 0) == 1
entryTouchShort = nz(eTouchSr, 0) == 1
entryConfLong = nz(eConfLr, 0) == 1
entryConfShort = nz(eConfSr, 0) == 1
// ============================================================================
// EXHAUSTION GATE (default 15m) BOOLEAN, NOT SCORED
// ============================================================================
// Opens when ANY ONE of the three spec conditions is met, evaluated in the
// direction of the pending signal. For a long the gate is asking "has the
// pullback stopped falling?", for a short "has the bounce stopped rising?".
//
// 1) RSI no longer progressing - either it turned (slope flipped into the
// signal direction) or it flattened (|change| within exhRsiFlat) while
// the previous bar was still running against us.
// 2) Pin bar or engulfing in the signal direction - REAL OHLC.
// 3) Last N bars stopped making new lower-lows (long) / higher-highs
// (short): the most recent extreme inside the swing window was set at
// least exhSwingBars bars ago.
//
// Reason code returned for debugging: 0 none, 1 RSI, 2 pattern, 3 swing
// (priority order, first match wins - it is a diagnostic, not a ranking).
// ============================================================================
f_exhaustExpr(simple int off, simple int rsiLen, float flatThr, simple int swingLb,
simple int swingBars, float maxBody, float minWick) =>
float _rsi = ta.rsi(close, rsiLen)
float _d1 = _rsi - _rsi[1]
float _d2 = _rsi[1] - _rsi[2]
bool _rsiOkL = not na(_d1) and not na(_d2) and
((_d1 > 0 and _d2 <= 0) or (math.abs(_d1) <= flatThr and _d2 < 0))
bool _rsiOkS = not na(_d1) and not na(_d2) and
((_d1 < 0 and _d2 >= 0) or (math.abs(_d1) <= flatThr and _d2 > 0))
// REAL OHLC for the pattern tests, per MIXED-CANDLE POLICY.
bool _patL = f_isPinBar(1, open, high, low, close, maxBody, minWick) or
f_isEngulfing(1, open, close, open[1], close[1])
bool _patS = f_isPinBar(-1, open, high, low, close, maxBody, minWick) or
f_isEngulfing(-1, open, close, open[1], close[1])
// ta.barssince is computed unconditionally - never inside a branch.
int _sinceLow = ta.barssince(low <= ta.lowest(low, swingLb))
int _sinceHigh = ta.barssince(high >= ta.highest(high, swingLb))
bool _swingOkL = nz(_sinceLow, 0) >= swingBars
bool _swingOkS = nz(_sinceHigh, 0) >= swingBars
int _eL = (_rsiOkL or _patL or _swingOkL) ? 1 : 0
int _eS = (_rsiOkS or _patS or _swingOkS) ? 1 : 0
int _rL = _rsiOkL ? 1 : (_patL ? 2 : (_swingOkL ? 3 : 0))
int _rS = _rsiOkS ? 1 : (_patS ? 2 : (_swingOkS ? 3 : 0))
[_eL[off], _eS[off], _rL[off], _rS[off]]
[exhLr, exhSr, exhReasonLr, exhReasonSr] = request.security(syminfo.tickerid, exhaustTF,
f_exhaustExpr(exhaustConfirmedOnly ? 1 : 0, exhRsiLen, exhRsiFlat, exhSwingLb,
exhSwingBars, pinMaxBody, pinMinWick),
gaps = barmerge.gaps_off, lookahead = barmerge.lookahead_off)
exhaustLong = nz(exhLr, 0) == 1
exhaustShort = nz(exhSr, 0) == 1
exhReasonL = nz(exhReasonLr, 0)
exhReasonS = nz(exhReasonSr, 0)
// ---------------------------------------------------------------- The veto ---
// Score is zeroed BEFORE display, per spec - not merely hidden downstream.
biasLocked = dBias != 0
vetoPassed = not useVeto or (biasLocked and sigDir != 0 and sigDir == dBias)
totalScore = vetoPassed ? rawScore : 0.0
finalDir = vetoPassed ? sigDir : 0
// ============================================================================
// CONTROL MECHANISMS (Stage 6)
// ============================================================================
// ------------------------------------------------- Cooldown (anti-whipsaw) ---
// "No new signal in the same direction until N bars pass since the last one."
// Per direction, deliberately: a long followed immediately by a short is a
// reversal, not whipsaw, and blocking it would hide exactly the trade the
// user most needs to see.
f_cooldownClear(array<int> lastBars, int dir, int nowBar, simple int bars) =>
bool ok = true
if dir == 0 or na(nowBar)
ok := false
else
int last = array.get(lastBars, dir > 0 ? 0 : 1)
// na = nothing has fired yet on this symbol, so nothing to wait for.
ok := na(last) or (nowBar - last) >= bars
ok
// ------------------------------------------------ Earnings / KAP blacklist ---
// Within +/- kapWindowDays of a listed date the bar is flagged HIGH RISK.
//
// THIS IS A FLAG, NOT A GATE. It does not touch totalScore, it is not part of
// the signal assembly below, and it cannot suppress an entry - exactly as the
// spec requires. A result can confirm a setup as easily as it can destroy
// one; the indicator's job is to guarantee the user knew the date was there.
//
// Comparison is on absolute UTC milliseconds, so it is immune to the chart's
// timezone. The +/- window is orders of magnitude larger than any timezone
// offset, so a date entered in the picker's timezone cannot fall out of its
// own window at the edges.
f_nearKapDate(int barTime, int evTime, simple int windowDays, simple int unsetBefore) =>
bool res = false
if na(barTime) or na(evTime) or evTime <= unsetBefore
res := false
else
res := math.abs(barTime - evTime) <= windowDays * 86400000
res
f_earningsRisk(int barTime, int d1, int d2, int d3, int d4, simple int windowDays,
simple int unsetBefore) =>
f_nearKapDate(barTime, d1, windowDays, unsetBefore) or
f_nearKapDate(barTime, d2, windowDays, unsetBefore) or
f_nearKapDate(barTime, d3, windowDays, unsetBefore) or
f_nearKapDate(barTime, d4, windowDays, unsetBefore)
earningsRisk = useEarningsFlag and f_earningsRisk(time, kapDate1, kapDate2, kapDate3, kapDate4,
kapWindowDays, KAP_UNSET)
// ------------------------------------------------------------ Exit rules ---
// Reason codes double as the resolution order: when several rules fire on one
// bar the lowest code wins. Stops before targets, structure before the time
// stop, so a genuine reason always beats the backstop.
// 1 stop 2 target 3 trail 4 score decay
// 5 bias flip 6 PAC cross 7 opposite 8 time stop
// Codes 3 to 8 are declared here and wired in 8.5b.
const int EXIT_NONE = 0
const int EXIT_STOP = 1
const int EXIT_TARGET = 2
const int EXIT_TRAIL = 3
const int EXIT_SCORE = 4
const int EXIT_BIAS = 5
const int EXIT_PAC = 6
const int EXIT_OPPOSITE = 7
const int EXIT_TIME = 8
f_exitName(int reason) =>
reason == EXIT_STOP ? "stop" :
reason == EXIT_TARGET ? "target" :
reason == EXIT_TRAIL ? "trail" :
reason == EXIT_SCORE ? "decay" :
reason == EXIT_BIAS ? "bias" :
reason == EXIT_PAC ? "PAC" :
reason == EXIT_OPPOSITE ? "opposite" :
reason == EXIT_TIME ? "time" : "-"
// PRICE-LEVEL exits: E1 hard stop, E3 trailing stop, E2 fixed target. These
// are the rules that resolve against a bar's own range rather than against
// the state of the system.
//
// Both stops are tested before the target, so a bar covering a stop and the
// target resolves as a loss when stopFirst is on. E1 outranks E3 because a
// lower reason code wins, which matters only in the rare bar that trips both.
//
// Pure, so the live position and the per-signal simulation in 8.5c share one
// implementation and cannot drift apart.
// A flat direction or missing levels return reason EXIT_NONE.
f_fixedExit(int dir, float stop, float trail, float target, bool eStop, bool eTrail,
bool eTarget, bool stopFirst, bool gapFill, float o, float h, float l) =>
ExitRes res = ExitRes.new(EXIT_NONE, na)
if dir != 0
bool hitStop = eStop and not na(stop) and (dir > 0 ? l <= stop : h >= stop)
bool hitTrail = eTrail and not na(trail) and (dir > 0 ? l <= trail : h >= trail)
bool hitTarget = eTarget and not na(target) and (dir > 0 ? h >= target : l <= target)
if (hitStop or hitTrail) and (not hitTarget or stopFirst)
// When a bar takes out BOTH stops, the one nearer the price on the
// way out was reached first, and it is the one that closed the
// trade. Early in a position that is the hard stop; once the trail
// has ratcheted past it, it is the trail. Attributing such a bar to
// E1 by default would credit the wrong rule and book the worse of
// two fills - which understates exactly the rule being evaluated.
bool trailBinds = hitTrail and
(not hitStop or (dir > 0 ? trail > stop : trail < stop))
float lvl = trailBinds ? trail : stop
res.reason := trailBinds ? EXIT_TRAIL : EXIT_STOP
// A bar that opened beyond the level never offered the level.
res.fill := gapFill ? (dir > 0 ? math.min(lvl, o) : math.max(lvl, o)) : lvl
else if hitTarget
res.reason := EXIT_TARGET
res.fill := target
res
// STATE exits: E4 score decay, E5 bias flip, E6 PAC cross, E7 opposite signal,
// E8 time stop. These resolve against the state of the system rather than a
// price level, so they always fill at the bar's close - there is no level to
// have been touched.
//
// Evaluated in reason-code order; the first match is recorded. E8 is last so a
// genuine reason always beats the backstop, and it carries its own enable flag
// because BOTH models use it while only the structural model uses E4 to E7.
//
// E4 reads the PRE-VETO directional score deliberately. The post-veto score is
// zeroed whenever the daily bias goes neutral, so using it would make E4 fire
// on every neutral daily bar - duplicating E5, far more often, and for a
// reason that has nothing to do with the position's own case decaying.
f_structExit(int dir, int age, bool eScore, int decayBars, int decayNeed,
bool eBias, int bias, bool ePac, float src, float pacLine,
bool eOpp, bool oppSig, bool eTime, int maxBars) =>
int reason = EXIT_NONE
if dir != 0
if eScore and not na(decayBars) and decayBars >= decayNeed
reason := EXIT_SCORE
else if eBias and bias != 0 and bias != dir
reason := EXIT_BIAS
else if ePac and not na(pacLine) and not na(src) and
(dir > 0 ? src < pacLine : src > pacLine)
reason := EXIT_PAC
else if eOpp and oppSig
reason := EXIT_OPPOSITE
else if eTime and age >= maxBars
reason := EXIT_TIME
reason
// THE combiner. Price levels first, then state rules filling at the close.
// Three callers - the live position and both simulated tracks - so a model is
// defined entirely by the enable flags handed to it and the live path cannot
// behave differently from the path that measures it.
//
// The parameter list is long on purpose: every input is passed in rather than
// read from globals, so the same call resolves a record that fired 300 bars
// ago as correctly as it resolves the position open right now.
f_modelExit(int dir, int age, float stop, float trail, float target,
bool e1, bool e3, bool e2, bool e4, bool e5, bool e6, bool e7, bool e8,
int decayBars, int decayNeed, int bias, float src, float pacLine, bool oppSig,
int maxBars, bool stopFirst, bool gapFill,
float o, float h, float l, float c) =>
ExitRes res = f_fixedExit(dir, stop, trail, target, e1, e3, e2, stopFirst, gapFill, o, h, l)
if res.reason == EXIT_NONE
int st = f_structExit(dir, age, e4, decayBars, decayNeed, e5, bias, e6, src, pacLine,
e7, oppSig, e8, maxBars)
if st != EXIT_NONE
res.reason := st
res.fill := c
res
// ============================================================================
// SIGNAL ASSEMBLY - four sequential gates
// ============================================================================
// scoreArmed : post-veto score crossed the threshold with a locked direction
// entryReady : entry timeframe shows a PAC touch AND a confirmation candle
// entrySignal : exhaustion gate has opened in the same direction
// cooldown : no accepted signal in this direction within the last N bars
// Each stage can be disabled independently, in which case it passes through.
// ============================================================================
scoreArmed = totalScore >= scoreThreshold and finalDir != 0
entryTouchOk = finalDir > 0 ? entryTouchLong : entryTouchShort
entryConfOk = finalDir > 0 ? entryConfLong : entryConfShort
entryReady = scoreArmed and (not useEntryTiming or (entryTouchOk and entryConfOk))
exhaustOk = finalDir > 0 ? exhaustLong : exhaustShort
exhaustWhy = finalDir > 0 ? exhReasonL : exhReasonS
entrySignal = entryReady and (not useExhaustGate or exhaustOk)
// Rising edge of the gate stack: the bar the setup completes, not every bar
// it stays complete.
entryFreshRaw = entrySignal and not entrySignal[1]
// The cooldown is evaluated BEFORE this bar's acceptance is recorded, so a
// signal never blocks itself.
cooldownClear = not useCooldown or f_cooldownClear(cdLastSignalBar, finalDir, bar_index,
cooldownBars)
// The accepted signal. Stage 9 attaches the unified alert to this.
entrySignalFresh = entryFreshRaw and cooldownClear
// Diagnostic: a setup that completed but was refused by the cooldown.
cooldownBlocked = entryFreshRaw and not cooldownClear
// ============================================================================
// LAYER ABLATION HARNESS (Stage 10)
// ============================================================================
// The question no earlier stage could answer: which of the thirteen layers
// actually earns its weight? A layer that contributes nothing is not free -
// it dilutes the score, shifts where the threshold has to sit, and takes a
// slot in every calibration decision made from here on.
//
// THE MANUAL ALTERNATIVE, AND WHY IT IS NOT ENOUGH. Every layer already has a
// toggle, so the test can be run by hand: turn one off, reload, read the
// panel. That is fourteen chart reloads per symbol with the numbers copied
// out by hand - and it has a trap in it, see RENORMALISATION below. The E3
// trailing-stop finding turned out to be symbol-dependent and two tickers
// could not settle it, so this test will be repeated across several names.
// By hand that is seventy chart reads; here it is one pass.
//
// HOW IT COSTS ALMOST NOTHING. Two facts carry the whole harness:
//
// 1. The score is a WEIGHTED SUM, so removing layer k is closed-form -
// (num - ck*wk) / (den - wk). No layer is ever recomputed, and there is
// no ta.* call anywhere in this block.
// 2. A signal fired at bar t has a known horizon return at bar t+H. So
// rather than keeping records and revisiting them - which the Stage 8
// log must do, because it tracks excursions and exit rules bar by bar -
// this looks BACKWARD H bars and settles the outcome in one step.
// Fourteen streams cost fourteen O(1) updates per bar: no loops, no
// arrays of records, no drawing objects.
//
// RENORMALISATION IS FORCED ON here, whatever normalizeWeights is set to, and
// this is exactly the trap in the manual method. With renormalisation off,
// removing a layer lowers the maximum attainable score, so fewer bars clear a
// fixed threshold and the run measures THRESHOLD SENSITIVITY rather than the
// layer's contribution. Every row below - BASE included - is renormalised
// over its own enabled weights, so all fourteen sit on the same 0-100 scale
// and the threshold means the same thing in each.
//
// WHAT THE STREAMS DELIBERATELY OMIT: the position filter. The live log drops
// any signal that arrives while a position is already open, which is a fact
// about timing rather than about the signal's quality. For grading entries
// the honest population is every accepted gate crossing. The consequence is
// that BASE here will NOT equal the panel's HORIZON row - it normally sees
// more signals. That is precisely why BASE is computed the same simplified
// way as the other thirteen: comparison BETWEEN ROWS is like-for-like, and
// that is the only comparison this section asks you to make.
//
// LAYER 5 is ablated differently. It carries no weight - it is the hard veto -
// so its row switches the veto off instead of subtracting a weight. A
// different kind of question, and arguably the most interesting of the five.
// ============================================================================
// One ablated signal stream. Returns whether this bar was an accepted signal
// for this stream, and in which direction.
//
// PER-CALL-SITE STATE: Pine gives every call site of a function its own
// instance of the `var`s it declares and its own series history. The fresh-edge
// test and the two cooldown anchors below therefore belong to THIS stream
// alone, which is what lets one function serve all fourteen without a single
// shared array or index.
//
// The parameter list is long for the same reason f_modelExit's is: everything
// is passed in rather than read from globals, so the function has no hidden
// dependency on which stream is calling it.
f_ablSignal(float numL, float numS, float den, float wk, float ckL, float ckS,
bool layerOn, float thr, bool vetoOn, int bias,
bool touchL, bool touchS, bool confL, bool confS, bool entryOn,
bool exhL, bool exhS, bool exhOn, bool cdOn, simple int cdBars) =>
var int lastLong = na
var int lastShort = na
// The previous bar's gate state, held explicitly rather than read as
// sig[1]. A `var` local is per-call-site and unambiguously supported;
// history-referencing a local is the murkier construct, and this block has
// fourteen call sites riding on it. Updated unconditionally at the end of
// the function on every bar, so it is exactly equivalent.
var bool prevSig = false
// A layer the user has already disabled contributes nothing to either the
// numerator or the denominator, so there is nothing to remove: layerOn
// false leaves the stream identical to BASE, and the panel prints "off"
// for that row rather than a duplicate figure.
float sub = layerOn ? wk : 0.0
float d2 = den - sub
bool ok = d2 > 0.0
float sL = ok ? f_clamp((numL - (layerOn ? ckL * wk : 0.0)) / d2 * 100.0, 0.0, 100.0) : na
float sS = ok ? f_clamp((numS - (layerOn ? ckS * wk : 0.0)) / d2 * 100.0, 0.0, 100.0) : na
int dir = not ok ? 0 : (sL > sS ? 1 : (sS > sL ? -1 : 0))
float raw = not ok ? 0.0 : math.max(sL, sS)
// The same gate chain as the live assembly above, in the same order:
// veto -> score armed -> entry ready -> entry signal -> fresh edge ->
// cooldown. Only the position filter is absent, per the header.
bool vp = not vetoOn or (bias != 0 and dir != 0 and dir == bias)
int fdir = vp ? dir : 0
float tot = vp ? raw : 0.0
bool armed = tot >= thr and fdir != 0
bool ready = armed and (not entryOn or
((fdir > 0 ? touchL : touchS) and (fdir > 0 ? confL : confS)))
bool sig = ready and (not exhOn or (fdir > 0 ? exhL : exhS))
bool fresh = sig and not prevSig
int last = fdir > 0 ? lastLong : lastShort
bool cdOk = not cdOn or na(last) or (bar_index - last) >= cdBars
bool acc = fresh and cdOk
if acc
if fdir > 0
lastLong := bar_index
else
lastShort := bar_index
prevSig := sig
[acc, fdir]
// The fourteen streams. BASE passes layerOn = false with a zero weight, so it
// runs the identical code path with nothing removed - the control has to be
// produced by the same machinery as the treatments or it is not a control.
[ab0Acc, ab0Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, 0.0, 0.0, 0.0, false,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab1Acc, ab1Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL1, s1Long, s1Short, useL1,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab2Acc, ab2Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL2, s2Long, s2Short, useL2,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab3Acc, ab3Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL3, s3Long, s3Short, useL3,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab4Acc, ab4Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL4, s4Both, s4Both, useL4,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
// Layer 5: no weight to remove - the veto flag is what gets switched off.
[ab5Acc, ab5Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, 0.0, 0.0, 0.0, false,
scoreThreshold, false, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab6Acc, ab6Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL6, s6Long, s6Short, useL6,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab7Acc, ab7Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL7, s7Both, s7Both, useL7,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab8Acc, ab8Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL8, s8Long, s8Short, useL8,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab9Acc, ab9Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL9, s9Long, s9Short, useL9,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab10Acc, ab10Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL10, s10Long, s10Short, useL10,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab11Acc, ab11Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL11, s11Long, s11Short, useL11,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab12Acc, ab12Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL12, s12Long, s12Short, useL12,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
[ab13Acc, ab13Dir] = f_ablSignal(aggNumL, aggNumS, aggDen, wL13, s13Both, s13Both, useL13,
scoreThreshold, useVeto, dBias, entryTouchLong, entryTouchShort, entryConfLong,
entryConfShort, useEntryTiming, exhaustLong, exhaustShort, useExhaustGate,
useCooldown, cooldownBars)
// Settle one stream's outcome. Quoted in R through the same f_retToR the panel
// uses for the horizon control track, so an ablation figure and a panel figure
// are in the same currency and can be read against each other directly.
f_ablAccum(array<int> nA, array<int> wA, array<float> rA, int idx, bool fired, int dir,
float pxThen, float atrThen, float pxNow, float stopMult) =>
if fired and dir != 0
float retPct = na(pxThen) or pxThen <= 0.0 ? na :
(pxNow - pxThen) / pxThen * 100.0 * dir
float rr = f_retToR(retPct, pxThen, atrThen, stopMult)
// na means the ATR had not warmed up at signal time; the signal is
// dropped from the count rather than folded in at zero.
if not na(rr)
array.set(nA, idx, array.get(nA, idx) + 1)
array.set(rA, idx, array.get(rA, idx) + rr)
if rr > 0.0
array.set(wA, idx, array.get(wA, idx) + 1)
// THE BACKWARD LOOK. Everything referenced here is offset by logHorizon, so
// each bar settles the signal that fired exactly one horizon ago - the moment
// its outcome first became knowable, and not one bar sooner.
if bar_index > logHorizon
f_ablAccum(abN, abWin, abR, 0, ab0Acc[logHorizon], ab0Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 1, ab1Acc[logHorizon], ab1Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 2, ab2Acc[logHorizon], ab2Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 3, ab3Acc[logHorizon], ab3Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 4, ab4Acc[logHorizon], ab4Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 5, ab5Acc[logHorizon], ab5Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 6, ab6Acc[logHorizon], ab6Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 7, ab7Acc[logHorizon], ab7Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 8, ab8Acc[logHorizon], ab8Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 9, ab9Acc[logHorizon], ab9Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 10, ab10Acc[logHorizon], ab10Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 11, ab11Acc[logHorizon], ab11Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 12, ab12Acc[logHorizon], ab12Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
f_ablAccum(abN, abWin, abR, 13, ab13Acc[logHorizon], ab13Dir[logHorizon],
close[logHorizon], atrVal[logHorizon], close, exitStopATR)
// ============================================================================
// POSITION MANAGEMENT (Stage 8.5a)
// ============================================================================
// Order within a bar is deliberate: the open position is managed FIRST, then
// a new entry is considered. That is the order a trader operates in, and it
// means the exit bar's own high and low are judged against the levels that
// were live during it rather than against levels a same-bar re-entry set.
//
// The entry bar itself can never trigger an exit. Entry is that bar's close,
// so the bar's own high and low were not reachable from it - the same
// reasoning that keeps the signal bar out of the MFE/MAE window.
// ============================================================================
// ----------------------------------------------- Which rules are live ------
// E1, E3 and E8 belong to both models. E2 is the fixed-R model; E4 to E7 are
// the structural one. Nominating a model is therefore just a matter of which
// enable flags reach the shared exit functions.
//
// E3 IS SHARED DELIBERATELY. Every other structural rule is an exit rule -
// none of E4 to E7 can hold a position, they can only end one - so a
// structural model without a trail has no mechanism for riding a trend at
// all. Its floor stays the fixed E1 stop for the life of the trade, which on
// a strong trend means it is stopped out early or timed out by E8, and the
// structural rules never get a chance to speak. Observed on BIMAS: 72% E1,
// 28% E8, zero structural exits, on a move from 80 to 425 - the structural
// rules were right to stay silent, and the floor was what closed the trade.
//
// On the structural track E3 is the trailing FLOOR: E4 to E7 remain live and
// close the position first whenever the case for it dissolves.
//
// E3 SHIPS DISABLED ANYWAY, and the reason is subtler than the trail being a
// poor rule - in isolation it scores respectably. It loses money by
// DISPLACEMENT. Price levels resolve before state rules, so on any bar where
// both the trail and E4 score decay would fire, the trail wins. And because a
// trail sits a fixed distance below the high-water mark, the longer a
// position runs the more certain it becomes that some pullback reaches it -
// so the trades it intercepts are disproportionately the biggest winners, the
// ones E4 was holding for. Measured on TOASO: E4 fell from a 16% share at
// +8.08 average R to 4% at +1.95.
//
// This is NOT fixable by resolving the structural rules first. The trail is
// tested against the bar's low and is therefore hit intrabar, while E4 is only
// final at the close; a position cannot un-hit a stop that already filled.
// The ordering reflects real execution. See stage_8_5_exit_logic.md.
liveStructural = exitLiveModelStr == "Structural"
liveE1 = useE1
liveE2 = useE2 and not liveStructural
liveE3 = useE3
liveE4 = useE4 and liveStructural
liveE5 = useE5 and liveStructural
liveE6 = useE6 and liveStructural
liveE7 = useE7 and liveStructural
liveE8 = useE8
// ------------------------------------------------- Inputs to the state rules ---
// Both barssince values are computed unconditionally so no ta.* call ever sits
// inside a branch, then the one matching the position direction is selected.
decayBarsLong = ta.barssince(longScore >= exitScoreFloor)
decayBarsShort = ta.barssince(shortScore >= exitScoreFloor)
posDecayBars = posDir > 0 ? decayBarsLong : decayBarsShort
// E6 compares the SAME candle source the PAC itself is built from, so the test
// stays consistent with layer 2 rather than mixing real and Heikin Ashi closes.
posPacLine = posDir == 0 ? na :
(exitPacLineStr == "PAC mid" ? pacMid : (posDir > 0 ? pacLower : pacUpper))
// E7: a fresh signal in the other direction. Read before the position block so
// it reflects this bar's signal rather than anything the block has changed.
oppSignalNow = entrySignalFresh and finalDir != 0 and posDir != 0 and finalDir != posDir
// E3 trail level, derived from the favourable extreme achieved through the
// PREVIOUS bar - posMfe is not updated until the block below. Ratcheting the
// trail on the current bar's own high and then testing that same bar's low
// against it would be intrabar lookahead: the stop would appear to have been
// raised before the price that raised it had printed.
//
// TRAIL GEOMETRY - read this before choosing exitTrailATR.
// For a long the trail sits at entry + tMfe - trailATR * atr, while the
// hard stop sits at entry - stopATR * atr. So the trail only becomes the
// binding floor once
// tMfe > (trailATR - stopATR) * atr
// and until then the hard stop is what protects the position. Two more
// landmarks follow from the same line: the trail reaches breakeven at
// tMfe = trailATR * atr, and locks in X R at tMfe = (trailATR + X) * atr.
//
// A trail TIGHTER than the hard stop therefore binds from the first bar and
// makes E1 dead weight; a trail much wider gives the position room but leaves
// the initial risk at a full 1R for longer. Neither is wrong - it is the
// choice between protecting the entry and riding the trend.
posTrail = posDir == 0 or na(posAtr) or na(posEntry) ? na :
(posDir > 0 ? posEntry + posMfe - exitTrailATR * posAtr
: posEntry - posMfe + exitTrailATR * posAtr)
// Evaluated unconditionally at global scope - never inside a branch. The
// guards below decide whether the verdict is acted on. When flat, posDir is 0
// and the combiner returns no exit.
liveRes = f_modelExit(posDir, na(posBar) ? 0 : bar_index - posBar, posStop, posTrail, posTarget,
liveE1, liveE3, liveE2, liveE4, liveE5, liveE6, liveE7, liveE8,
posDecayBars, exitScoreBars, dBias, srcClose, posPacLine, oppSignalNow,
exitMaxBars, exitStopFirst, exitGapFill, open, high, low, close)
int exitReason = EXIT_NONE
float exitFill = na
float exitR = na
bool exitFired = false
int exitDirWas = 0
float exitEntryWas = na
int exitBarsHeld = 0
float exitStopWas = na
float exitTargetWas = na
// Toggling exits off mid-chart must not strand an open position forever.
if not useExits and posDir != 0
posDir := 0
posEntry := na
posAtr := na
posBar := na
posStop := na
posTarget := na
// ------------------------------------------------ 1. Manage what is open ---
if useExits and posDir != 0
int age = bar_index - posBar
if age > 0
posMfe := math.max(posMfe, posDir > 0 ? high - posEntry : posEntry - low)
posMae := math.max(posMae, posDir > 0 ? posEntry - low : high - posEntry)
if liveRes.reason != EXIT_NONE
exitReason := liveRes.reason
exitFill := liveRes.fill
exitFired := true
exitDirWas := posDir
exitEntryWas := posEntry
exitBarsHeld := age
// Kept so the exit bar can still draw the levels that were live
// during it - the position itself is cleared a few lines below.
exitStopWas := posStop
exitTargetWas := posTarget
// R is quoted against the E1 stop distance whatever closed the
// trade, so every exit rule is measured in the same currency.
float oneR = na(posAtr) or posAtr <= 0.0 ? na : exitStopATR * posAtr
exitR := na(oneR) or oneR <= 0.0 ? na : (exitFill - posEntry) * posDir / oneR
posDir := 0
posEntry := na
posAtr := na
posBar := na
posStop := na
posTarget := na
// --------------------------------------------- 2. Then consider an entry ---
// A signal arriving while a position is open is ignored - no pyramiding, and
// no reversal in one step. Re-entry is also barred on the exit bar itself: a
// position closes and the next opportunity is the following bar at the
// earliest, which keeps the cooldown from being sidestepped by an exit and a
// re-entry landing on the same bar.
entryAccepted = entrySignalFresh and (not useExits or (posDir == 0 and not exitFired))
if useExits and entryAccepted and finalDir != 0
posDir := finalDir
posEntry := close
posAtr := atrVal
posBar := bar_index
posMfe := 0.0
posMae := 0.0
posStop := na(atrVal) ? na :
(finalDir > 0 ? close - exitStopATR * atrVal : close + exitStopATR * atrVal)
posTarget := na(atrVal) ? na :
(finalDir > 0 ? close + exitTargetATR * atrVal : close - exitTargetATR * atrVal)
// Unrealised R on the open position, in the same currency as every closed
// outcome. na when flat or before the ATR has warmed up.
openR = posDir == 0 or na(posAtr) or posAtr <= 0.0 or na(posEntry) ? na :
(close - posEntry) * posDir / (exitStopATR * posAtr)
// ------------------------------------------------------- Cooldown anchor ---
// D7: anchored at the EXIT once exits exist. While a position is open the
// position itself blocks re-entry, so an entry-anchored timer would be inert
// exactly when it is supposed to be counting. With exits disabled the Stage 6
// entry-anchored behaviour is preserved unchanged.
//
// A setup refused by the cooldown must go quiet and re-form to signal again -
// the cooldown does not queue a blocked signal and release it when the timer
// expires, because by then the setup that produced it is stale. Pine re-runs
// the current bar from the last committed state on every realtime tick, so an
// intrabar flicker cannot leave a false entry behind.
if useExits
if exitFired and exitDirWas != 0
array.set(cdLastSignalBar, exitDirWas > 0 ? 0 : 1, bar_index)
else
if entryAccepted and finalDir != 0
array.set(cdLastSignalBar, finalDir > 0 ? 0 : 1, bar_index)
// ============================================================================
// ALERTS (Stage 9) UNIFIED alertcondition()
// ============================================================================
// Two conditions, no more. The spec is explicit that layer-level events (a
// PAC touch alone, a divergence alone) must never reach an alert - only the
// fully-gated entry and the position-closing exit do. Both states are already
// computed above for the chart markers and the score label; this section adds
// no new logic, it only narrates the two moments TradingView can fire on.
//
// DYNAMIC TEXT IN alertcondition(): the `message` argument is a compile-time
// string, so it cannot concatenate a SERIES value directly - str.tostring()
// inside it does not compile. TradingView's documented workaround is the
// {{plot("Title")}} placeholder: it is substituted, AT ALERT-FIRE TIME, with
// the value the named plot() call held on the bar the condition became true.
// "Total Score" already exists as a plot (the score line, above); direction
// and the exit fields below get small display.none plots purely so the alert
// text can reach them. Direction travels as +1 / -1 rather than the word
// LONG/SHORT: a placeholder can only ever resolve to a number, so the mapping
// is spelled out in the message text itself instead of computed.
// ============================================================================
plot(finalDir, "Alert: Direction", display = display.none)
plot(exitDirWas, "Alert: Exit Dir", display = display.none)
plot(exitR, "Alert: Exit R", display = display.none)
plot(exitReason, "Alert: Exit Reason", display = display.none)
// ---------------------------------------------------------- Entry alert ---
// Fires on the same bar as the entry triangle: entryAccepted already IS
// "score crossed the threshold AND the 1D veto passed AND the 15m exhaustion
// gate opened" (scoreArmed -> entryReady -> entrySignal, taken fresh, then
// cooldown-clear and no position already open) - the three-part condition the
// spec asks for, plus the cooldown / no-pyramiding rules this build adds on
// top of it. ONE unified condition, not a long one and a short one: direction
// is read from the placeholder, per spec.
alertcondition(entryAccepted, title = "BIST MTF - Entry Signal",
message = "BIST MTF entry | {{ticker}} {{interval}} | " +
"dir={{plot(\"Alert: Direction\")}} (+1 LONG / -1 SHORT) | " +
"score={{plot(\"Total Score\")}}")
// ----------------------------------------------------------- Exit alert ---
// The second alert agreed alongside the Stage 8.5 exit logic: fires once, on
// the bar a live position actually closes (exitFired), whichever rule closed
// it and whichever model (Fixed-R / Structural) is live. Reason travels as
// the numeric E1-E8 code defined earlier in this file, not its name - a
// placeholder cannot resolve f_exitName(), because that is a function call,
// not a plot.
alertcondition(exitFired, title = "BIST MTF - Exit Signal",
message = "BIST MTF exit | {{ticker}} {{interval}} | " +
"dir={{plot(\"Alert: Exit Dir\")}} (+1 LONG / -1 SHORT) | " +
"R={{plot(\"Alert: Exit R\")}} | " +
"reasonCode={{plot(\"Alert: Exit Reason\")}} " +
"(1 stop 2 target 3 trail 4 decay 5 bias 6 PAC 7 opposite 8 time)")
// ============================================================================
// PERFORMANCE LOG - signal recording and outcome tracking (Stage 8)
// ============================================================================
// Every accepted signal is recorded, then followed bar by bar until the
// horizon is reached. Three things are measured, because "did it win?" alone
// cannot tell you how to trade the signal:
//
// ret : close-to-close return at the horizon, direction-adjusted. The
// outcome the spec asks for.
// MFE : maximum FAVOURABLE excursion - how far it went the right way at
// its best. This is what a take-profit could have captured.
// MAE : maximum ADVERSE excursion - how far it went the wrong way before
// it worked. This is what a stop would have had to survive.
//
// MFE and MAE are the inputs an exit rule is designed from: a signal set
// with MFE 2.5 ATR and MAE 0.4 ATR wants a wide target and a tight stop,
// and one with the reverse wants neither. The panel reports them per band.
// ============================================================================
if entryAccepted and finalDir != 0
array.push(sigLog, SigRec.new(barIdx = bar_index, tm = time, dir = finalDir,
score = totalScore, entry = close, atrAt = atrVal, mfe = 0.0, mae = 0.0,
ret = na, resolved = false, tMfe = 0.0,
fR = na, fReason = EXIT_NONE, fBars = 0, fDone = false,
sR = na, sReason = EXIT_NONE, sBars = 0, sDone = false))
if array.size(sigLog) > logMaxSignals
array.shift(sigLog)
// ----------------------------------------------------------- Score reach ---
// Warm-up bars are excluded. While the long averages are still filling, most
// layers return their neutral 0.5 and the score sits artificially near 50,
// which would put a false floor under the distribution.
warmedUp = bar_index >= math.max(math.max(emaSlowLen, smaLen), math.max(atrAvgLen, volLen))
f_reachBand(float score) =>
score >= 80.0 ? 0 : (score >= 70.0 ? 1 : (score >= 60.0 ? 2 : (score >= 50.0 ? 3 : 4)))
if warmedUp and not na(rawScore) and not na(totalScore)
int bRaw = f_reachBand(rawScore)
int bFin = f_reachBand(totalScore)
array.set(reachRaw, bRaw, array.get(reachRaw, bRaw) + 1)
array.set(reachFinal, bFin, array.get(reachFinal, bFin) + 1)
reachBars := reachBars + 1
peakRaw := na(peakRaw) ? rawScore : math.max(peakRaw, rawScore)
peakFinal := na(peakFinal) ? totalScore : math.max(peakFinal, totalScore)
// Advance every record that still has an unresolved track. Objects held in an
// array are references, so mutating `r` writes straight through to the stored
// record.
//
// The age > 0 guard means the signal bar itself contributes no excursion: the
// entry is that bar's close, so its own high and low are not reachable from
// the entry and counting them would invent excursion that never existed.
//
// DUAL TRACK (8.5c): every record carries three independent outcomes.
// ret - the control. Fixed horizon, no exit rule applied at all.
// fR - fixed-R: E1 + E2 + optional E3, backstopped by E8.
// sR - structural: E1 and optional E3 as the floor, then E4-E7,
// backstopped by E8.
// Both models are simulated on every record regardless of which one is live,
// so the panel compares them without the user reconfiguring anything. All
// three are quoted in the same R, defined by the E1 stop distance, so a
// wide-stop model and a tight-stop model stay comparable.
if array.size(sigLog) > 0
for i = 0 to array.size(sigLog) - 1
SigRec r = array.get(sigLog, i)
bool allDone = r.resolved and r.fDone and r.sDone
if not allDone and not na(r.entry) and r.entry > 0.0
int age = bar_index - r.barIdx
if age > 0
// Trail from the extreme through the PREVIOUS bar, exactly as
// the live position does - tMfe is not updated until below.
float rTrail = na(r.atrAt) ? na :
(r.dir > 0 ? r.entry + r.tMfe - exitTrailATR * r.atrAt
: r.entry - r.tMfe + exitTrailATR * r.atrAt)
// TWO EXCURSION WINDOWS, and the distinction matters.
//
// mfe/mae stop at the HORIZON. They exist to calibrate the
// stop and target, and a stop calibrated from excursions
// measured inside a stopped trade is circular: a trade cut at
// 1.0 ATR can never report an MAE above 1.0 ATR, so the data
// would only ever confirm whatever stop was already set. The
// horizon window is independent of every exit rule, which is
// what makes it usable as evidence.
//
// tMfe runs for the whole trade, because the trailing stop
// needs the real high-water mark and has no interest in where
// the horizon fell.
if not r.resolved
r.mfe := math.max(r.mfe, r.dir > 0 ? high - r.entry : r.entry - low)
r.mae := math.max(r.mae, r.dir > 0 ? r.entry - low : high - r.entry)
r.tMfe := math.max(r.tMfe, r.dir > 0 ? high - r.entry : r.entry - low)
// ---- control track: horizon only ------------------------
if not r.resolved and age >= logHorizon
r.ret := (close - r.entry) / r.entry * 100.0 * r.dir
r.resolved := true
// Per-record inputs to the exit rules. Levels are recomputed
// from the frozen entry ATR rather than stored, so they cannot
// fall out of step with the inputs that define them.
float rStop = na(r.atrAt) ? na :
(r.dir > 0 ? r.entry - exitStopATR * r.atrAt
: r.entry + exitStopATR * r.atrAt)
float rTgt = na(r.atrAt) ? na :
(r.dir > 0 ? r.entry + exitTargetATR * r.atrAt
: r.entry - exitTargetATR * r.atrAt)
float rOneR = na(r.atrAt) or r.atrAt <= 0.0 ? na : exitStopATR * r.atrAt
int rDecay = r.dir > 0 ? decayBarsLong : decayBarsShort
float rPac = exitPacLineStr == "PAC mid" ? pacMid :
(r.dir > 0 ? pacLower : pacUpper)
bool rOpp = entrySignalFresh and finalDir != 0 and finalDir != r.dir
// ---- track A: fixed-R -----------------------------------
if not r.fDone
ExitRes fx = f_modelExit(r.dir, age, rStop, rTrail, rTgt,
useE1, useE3, useE2, false, false, false, false, useE8,
rDecay, exitScoreBars, dBias, srcClose, rPac, rOpp,
exitMaxBars, exitStopFirst, exitGapFill, open, high, low, close)
if fx.reason != EXIT_NONE
r.fReason := fx.reason
r.fBars := age
r.fR := na(rOneR) ? na : (fx.fill - r.entry) * r.dir / rOneR
r.fDone := true
// ---- track B: structural --------------------------------
if not r.sDone
ExitRes sx = f_modelExit(r.dir, age, rStop, rTrail, na,
useE1, useE3, false, useE4, useE5, useE6, useE7, useE8,
rDecay, exitScoreBars, dBias, srcClose, rPac, rOpp,
exitMaxBars, exitStopFirst, exitGapFill, open, high, low, close)
if sx.reason != EXIT_NONE
r.sReason := sx.reason
r.sBars := age
r.sR := na(rOneR) ? na : (sx.fill - r.entry) * r.dir / rOneR
r.sDone := true
// ============================================================================
// VISUALS (Stage 7)
// ============================================================================
// Colour convention used everywhere below, so one glance reads the same way
// in the pane and on the price chart:
// teal = long maroon = short gray = no direction
// Hue carries DIRECTION. Opacity carries CONVICTION - a 45-score long and an
// 85-score long are the same colour at different strengths, so score bands
// are legible without reading the number.
// ============================================================================
// A debug trace draws only when its own switch AND the master switch are on.
dbgComponents = showDebugLayers and showComponents
dbgRawScore = showDebugLayers and showRawScore
dbgBias = showDebugLayers and showBias
dbgRisk = showDebugLayers and showRisk
dbgGates = showDebugLayers and showGates
dbgPivots = showDebugLayers and showPivots
dbgLayers = showDebugLayers and showLayers
f_dirColor(int dir) =>
dir > 0 ? color.teal : (dir < 0 ? color.maroon : color.gray)
// Band -> transparency. The 80 / 60 / 40 cuts are the same bands the Stage 8
// performance panel will report win rates against, so the eye and the table
// agree on what "a strong signal" means.
f_scoreColor(int dir, float score) =>
int transp = score >= 80 ? 0 : (score >= 60 ? 25 : (score >= 40 ? 50 : 70))
color.new(f_dirColor(dir), transp)
// ---------------------------------------------------------- The score line ---
pScore = plot(totalScore, "Total Score", color = f_scoreColor(finalDir, totalScore), linewidth = 2)
pZero = plot(0, "Zero base", color = color.new(color.gray, 100), display = display.none)
fill(pScore, pZero, color = color.new(f_dirColor(finalDir), 88), title = "Score fill")
// ------------------------------------------------- Entry markers on price ---
// force_overlay puts these on the PRICE chart while the indicator itself
// stays in its own pane - the score belongs on a 0-100 axis, but an entry
// marker is worthless anywhere except next to the candle that produced it.
//
// Only the ACCEPTED signal is marked (post-veto, post-gates, post-cooldown).
// Layer-level events are deliberately absent here: the spec forbids letting
// intermediate signals compete with the real one, so they live under the
// debug master switch instead.
//
// There is no neutral marker by construction - a signal cannot exist without
// a locked direction - so neutral shows up only as the gray score line.
//
// REALTIME NOTE: these follow entrySignalFresh, which can move within a
// forming bar. See the ENTRY GATE TIMING block in the header; judge signals
// on chart-bar close.
plotshape(showEntrySignals and entryAccepted and finalDir > 0, "Long Entry",
shape.triangleup, location.belowbar, color = color.new(color.teal, 0),
size = size.small, force_overlay = true)
plotshape(showEntrySignals and entryAccepted and finalDir < 0, "Short Entry",
shape.triangledown, location.abovebar, color = color.new(color.maroon, 0),
size = size.small, force_overlay = true)
// A signal that landed inside an earnings window still fires - the flag never
// gates - but it is marked so the user cannot take it without noticing.
plotchar(showEntrySignals and entryAccepted and earningsRisk, "Entry in earnings window",
"!", location.top, color = color.new(color.orange, 0), size = size.tiny,
force_overlay = true)
// -------------------------------------------------- Exit markers on price ---
// Direction is already carried by which side of the bar the cross sits on, so
// colour is free to carry the thing the entry marker cannot: whether the
// trade worked. Teal for a non-negative R, maroon for a loss, gray when the
// ATR was unavailable at entry and R could not be computed.
exitOutcomeCol = na(exitR) ? color.new(color.gray, 0) :
(exitR >= 0.0 ? color.new(color.teal, 0) : color.new(color.maroon, 0))
plotshape(showEntrySignals and showExitMarkers and exitFired and exitDirWas > 0, "Long Exit",
shape.xcross, location.abovebar, color = exitOutcomeCol, size = size.tiny,
force_overlay = true)
plotshape(showEntrySignals and showExitMarkers and exitFired and exitDirWas < 0, "Short Exit",
shape.xcross, location.belowbar, color = exitOutcomeCol, size = size.tiny,
force_overlay = true)
if showEntrySignals and showExitLabels and exitFired
label.new(bar_index, exitDirWas > 0 ? high : low,
f_exitName(exitReason) + " " + (na(exitR) ? "-" : f_signed(exitR, "#.0") + "R"),
style = exitDirWas > 0 ? label.style_label_down : label.style_label_up,
color = exitOutcomeCol, textcolor = color.white, size = size.tiny,
force_overlay = true)
// ------------------------------------------------- Stop and target levels ---
// plot() with a break style rather than line objects: the levels then need no
// per-position drawing budget, every historical position keeps its levels, and
// a flat stretch between positions breaks the line instead of joining two
// unrelated trades. The exit bar falls back to the levels captured above, so
// the bar that resolved the trade still shows what it was resolved against.
riskStopPx = posDir != 0 ? posStop : (exitFired ? exitStopWas : na)
riskTargetPx = posDir != 0 ? posTarget : (exitFired ? exitTargetWas : na)
plot(showEntrySignals and showRiskLevels ? riskStopPx : na, "Stop level",
color = color.new(color.maroon, 25), style = plot.style_linebr, linewidth = 1,
force_overlay = true)
plot(showEntrySignals and showRiskLevels ? riskTargetPx : na, "Target level",
color = color.new(color.teal, 25), style = plot.style_linebr, linewidth = 1,
force_overlay = true)
// ------------------------------------------------------------- Score label ---
// Rebuilt on the LAST BAR ONLY. One label per historical bar would be
// unreadable and would burn through Pine's drawing-object budget in a few
// hundred bars; the label describes the live state, which only the last bar
// has. Same discipline the Stage 8 performance panel will follow.
//
// The state line answers the question the score alone cannot: if this is not
// a signal, which gate is it stuck at? That is the difference between "the
// setup is worthless" and "the setup is one exhaustion bar away".
f_bandName(float score) =>
score >= 80 ? "80+" : (score >= 60 ? "60-79" : (score >= 40 ? "40-59" : "<40"))
var label scoreLbl = na
if barstate.islast
label.delete(scoreLbl)
if showEntrySignals
string dirTxt = finalDir > 0 ? "LONG" : (finalDir < 0 ? "SHORT" : "NEUTRAL")
string biasTxt = dBias > 0 ? "long" : (dBias < 0 ? "short" : "neutral")
// Ordered most-advanced first, so the first match is the furthest the
// setup actually got.
string stateTxt = "below threshold"
if entryAccepted
stateTxt := "ENTRY SIGNAL"
else if exitFired
stateTxt := "EXIT - " + f_exitName(exitReason) +
(na(exitR) ? "" : " " + f_signed(exitR, "#.00") + "R")
else if posDir != 0
stateTxt := "in " + (posDir > 0 ? "LONG" : "SHORT") + ", " +
str.tostring(bar_index - posBar) + " bars, " +
(na(openR) ? "-" : f_signed(openR, "#.00") + "R")
else if entrySignalFresh
stateTxt := "signal suppressed - position already open"
else if cooldownBlocked
stateTxt := "setup blocked by cooldown"
else if entrySignal
stateTxt := "gates open - signal already fired"
else if entryReady
stateTxt := "entry ready - waiting on exhaustion gate"
else if scoreArmed
stateTxt := "armed - waiting on entry timing"
else if not vetoPassed
stateTxt := "vetoed by 1D bias"
string txt = dirTxt + " " + str.tostring(totalScore, "#.0") +
" (band " + f_bandName(totalScore) + ")" +
"\n" + stateTxt +
"\n1D bias: " + biasTxt +
(earningsRisk ? "\n! earnings / KAP window" : "")
scoreLbl := label.new(bar_index + 2, totalScore, txt,
xloc = xloc.bar_index, yloc = yloc.price,
color = color.new(f_dirColor(finalDir), 15), style = label.style_label_left,
textcolor = color.white, size = size.small, textalign = text.align_left)
// ------------------------------------------------------------ Debug traces ---
plot(dbgRawScore ? rawScore : na, "Debug: Raw Score (pre-veto)",
color = color.new(color.gray, 60))
plot(dbgComponents ? longScore : na, "Debug: Long Score", color = color.new(color.teal, 35))
plot(dbgComponents ? shortScore : na, "Debug: Short Score", color = color.new(color.maroon, 35))
plot(dbgBias ? (dBias + 1) * 50 : na, "Debug: 1D Bias", color = color.new(color.orange, 40),
style = plot.style_stepline)
// Gate traces: stacked so the stages are distinguishable at a glance, and so
// the height the trace reaches tells you which gate a setup died at.
plot(dbgGates and scoreArmed ? 30 : na, "Debug: Score Armed", color = color.new(color.blue, 50),
style = plot.style_stepline)
plot(dbgGates and entryReady ? 60 : na, "Debug: Entry Ready", color = color.new(color.purple, 30),
style = plot.style_stepline)
plot(dbgGates and entrySignal ? 90 : na, "Debug: Entry Signal",
color = color.new(color.fuchsia, 10), style = plot.style_stepline)
plot(dbgGates and cooldownBlocked ? 100 : na, "Debug: Cooldown Blocked",
color = color.new(color.red, 0), style = plot.style_stepline)
// Earnings / KAP risk as a pane tint rather than a trace: it annotates every
// gate at once and costs no vertical space. Still purely informational - it
// does not touch the score and cannot suppress a signal.
bgcolor(dbgRisk and earningsRisk ? color.new(color.yellow, 90) : na,
title = "Debug: Earnings risk window")
// ----------------------------------------- Debug: structure on the price ---
// The pivot markers are shifted back by srRight bars so each one sits on the
// bar that actually formed the pivot, not on the later bar where it
// confirmed. Seeing them in their true position is the point: the gap
// between the marker and the right-hand edge IS the confirmation lag that
// keeps these levels repaint-safe.
plotshape(dbgPivots ? pivotHi : na, "Debug: Pivot High", shape.xcross, location.absolute,
color = color.new(color.maroon, 30), size = size.tiny, offset = -srRight,
force_overlay = true)
plotshape(dbgPivots ? pivotLo : na, "Debug: Pivot Low", shape.xcross, location.absolute,
color = color.new(color.teal, 30), size = size.tiny, offset = -srRight,
force_overlay = true)
// The two levels layer 6 grades distance against and layer 9 watches for
// sweeps. Circles rather than a line: these jump discontinuously as price
// crosses a level, and a joined line would imply movement that is not there.
plot(dbgPivots ? srSupport : na, "Debug: Nearest support", color = color.new(color.teal, 40),
style = plot.style_circles, linewidth = 1, force_overlay = true)
plot(dbgPivots ? srResist : na, "Debug: Nearest resistance", color = color.new(color.maroon, 40),
style = plot.style_circles, linewidth = 1, force_overlay = true)
// ------------------------------------- Debug: individual layer confidences ---
// Scaled to the pane's 0-100 axis. Read against the CANDIDATE direction
// (sigDir, pre-veto) rather than finalDir, so the traces keep saying
// something while the veto holds the score at zero - which is exactly when
// you want to know what the layers thought.
f_layerTrace(int dir, float longV, float shortV) =>
(dir < 0 ? shortV : longV) * 100.0
plot(dbgLayers ? f_layerTrace(sigDir, s1Long, s1Short) : na, "Debug: L1 Regime",
color = color.new(color.red, 30))
plot(dbgLayers ? f_layerTrace(sigDir, s2Long, s2Short) : na, "Debug: L2 PAC",
color = color.new(color.orange, 30))
plot(dbgLayers ? f_layerTrace(sigDir, s3Long, s3Short) : na, "Debug: L3 Momentum",
color = color.new(color.yellow, 30))
plot(dbgLayers ? s4Both * 100.0 : na, "Debug: L4 Volume",
color = color.new(color.lime, 30))
plot(dbgLayers ? f_layerTrace(sigDir, s6Long, s6Short) : na, "Debug: L6 S/R proximity",
color = color.new(color.green, 30))
plot(dbgLayers ? s7Both * 100.0 : na, "Debug: L7 Volatility",
color = color.new(color.aqua, 30))
plot(dbgLayers ? f_layerTrace(sigDir, s8Long, s8Short) : na, "Debug: L8 Index",
color = color.new(color.blue, 30))
plot(dbgLayers ? f_layerTrace(sigDir, s9Long, s9Short) : na, "Debug: L9 Sweep",
color = color.new(color.navy, 30))
plot(dbgLayers ? f_layerTrace(sigDir, s10Long, s10Short) : na, "Debug: L10 Divergence",
color = color.new(color.purple, 30))
plot(dbgLayers ? f_layerTrace(sigDir, s11Long, s11Short) : na, "Debug: L11 Candle",
color = color.new(color.fuchsia, 30))
plot(dbgLayers ? f_layerTrace(sigDir, s12Long, s12Short) : na, "Debug: L12 USDTRY",
color = color.new(color.olive, 30))
plot(dbgLayers ? s13Both * 100.0 : na, "Debug: L13 Session",
color = color.new(color.silver, 30))
// ============================================================================
// PERFORMANCE PANEL (Stage 8)
// ============================================================================
// Built ONLY on barstate.islast, per spec. The table is deleted and rebuilt
// rather than updated in place: it is rebuilt at most once per bar on one
// bar of the chart, and a fresh table cannot carry stale cells or stale
// merges when a section toggle changes its row count.
//
// Three sections, answering three different questions:
// SCORE REACH - can the threshold even be hit on this symbol? Raw and net
// columns separate a weak-layer problem from a veto problem.
// BY BAND - of the signals that did fire, which score bands actually
// paid, and what did they cost on the way?
// LAST SIGNALS - the individual records behind those averages.
// ============================================================================
const int PANEL_COLS = 7
var array<string> reachNames = array.from("80+", "70-79", "60-69", "50-59", "<50")
var array<string> sigBandNames = array.from("80+", "60-79", "40-59", "<40")
// Per-band accumulators, refilled on each render rather than reallocated.
var array<int> pbN = array.new_int(4, 0)
var array<int> pbWin = array.new_int(4, 0)
var array<float> pbRet = array.new_float(4, 0.0)
var array<float> pbMfe = array.new_float(4, 0.0)
var array<float> pbMae = array.new_float(4, 0.0)
var array<int> pbPend = array.new_int(4, 0)
// Exit-model accumulators. 0 = horizon control, 1 = fixed-R, 2 = structural.
var array<int> pmN = array.new_int(3, 0)
var array<int> pmWin = array.new_int(3, 0)
var array<float> pmR = array.new_float(3, 0.0)
var array<float> pmWinR = array.new_float(3, 0.0)
var array<float> pmLossR = array.new_float(3, 0.0)
var array<int> pmLoss = array.new_int(3, 0)
var array<int> pmBars = array.new_int(3, 0)
var array<string> pmNames = array.from("HORIZON", "FIXED-R", "STRUCT")
// Exit-reason accumulators, indexed by reason code 0-8.
var array<int> prN = array.new_int(9, 0)
var array<float> prR = array.new_float(9, 0.0)
// Ablation row labels, the weight each row removes, and whether that layer is
// available to be ablated at all. Index order matches the abN / abWin / abR
// counters exactly: 0 = BASE, 1-13 = layers 1-13. BASE and L5 remove no weight
// - BASE removes nothing and L5 is the unweighted veto - so both show "-".
var array<string> abNames = array.from("BASE", "L1 regime", "L2 PAC", "L3 momentum",
"L4 volume", "L5 veto", "L6 S/R", "L7 volatility", "L8 index", "L9 sweep",
"L10 divergence", "L11 candle", "L12 USDTRY", "L13 session")
var array<float> abWts = array.from(0.0, wL1, wL2, wL3, wL4, 0.0, wL6, wL7, wL8, wL9,
wL10, wL11, wL12, wL13)
var array<bool> abOn = array.from(true, useL1, useL2, useL3, useL4, useVeto, useL6, useL7,
useL8, useL9, useL10, useL11, useL12, useL13)
// Scratch arrays for the suggested-multiple percentiles.
var array<float> pcMae = array.new_float(0)
var array<float> pcMfe = array.new_float(0)
var table perfTable = na
// f_retToR is declared with the shared helpers near the top of the file: the
// ablation harness needs it too, and it has to exist before its first caller.
f_sigBand(float score) =>
score >= 80.0 ? 0 : (score >= 60.0 ? 1 : (score >= 40.0 ? 2 : 3))
f_pctStr(float part, float total) =>
total <= 0.0 ? "-" : str.tostring(part / total * 100.0, "#.0") + "%"
f_avgStr(float total, float n, string fmt) =>
n <= 0.0 ? "-" : str.tostring(total / n, fmt)
f_avgSigned(float total, float n) =>
n <= 0.0 ? "-" : f_signed(total / n, "#.00")
// Excursion in ATR units. A record whose ATR was na at signal time (deep
// warm-up) simply reports "-" rather than poisoning the average.
f_atrStr(float px, float atrv) =>
na(px) or na(atrv) or atrv <= 0.0 ? "-" : str.tostring(px / atrv, "#.00")
f_pcell(table t, int c, int r, string txt, color col, string sz, string halign) =>
table.cell(t, c, r, txt, text_color = col, text_size = sz, text_halign = halign)
if barstate.islast
if not na(perfTable)
table.delete(perfTable)
perfTable := na
if usePanel
string pSize = panelSizeStr == "Tiny" ? size.tiny :
(panelSizeStr == "Normal" ? size.normal : size.small)
string pPos = panelPosStr == "Top right" ? position.top_right :
(panelPosStr == "Middle right" ? position.middle_right :
(panelPosStr == "Bottom right" ? position.bottom_right : position.bottom_left))
// LEGIBILITY: the panel used a flat mid-grey for headers and drew over
// whatever candles happened to be behind it. Both are fixed here - the
// table gets its own near-opaque ground below, and every text colour is
// derived from chart.fg_color so it tracks the user's theme instead of
// assuming a dark one.
color cText = chart.fg_color
color cHead = color.new(chart.fg_color, 30)
color cWin = color.new(color.teal, 0)
color cLoss = color.new(color.red, 0)
int logN = array.size(sigLog)
int detailN = math.min(panelDetailRows, logN)
// Row budget must match exactly what is written below: an out-of-range
// table.cell is a runtime error, not a silent no-op.
int nRows = 1
nRows := nRows + (panelShowReach ? 8 : 0)
nRows := nRows + (panelShowBands ? 6 : 0)
nRows := nRows + (panelShowModels ? 4 : 0)
nRows := nRows + (panelShowBench ? 2 : 0)
nRows := nRows + (panelShowReasons ? 9 : 0)
nRows := nRows + (panelShowAblation ? 15 : 0)
nRows := nRows + (panelShowDetail ? 1 + math.max(detailN, 1) : 0)
// The near-opaque ground is what actually makes the text readable:
// transparent cells put every figure on top of moving candles.
perfTable := table.new(pPos, PANEL_COLS, nRows,
bgcolor = color.new(chart.bg_color, 8), border_width = 1,
frame_color = color.new(chart.fg_color, 70), frame_width = 1,
border_color = color.new(chart.fg_color, 85))
int row = 0
f_pcell(perfTable, 0, row, "BIST MTF " + str.tostring(logN) + " logged / " +
str.tostring(logHorizon) + "-bar horizon", cHead, pSize, text.align_left)
table.merge_cells(perfTable, 0, row, PANEL_COLS - 1, row)
row := row + 1
// ------------------------------------------------------ SCORE REACH ---
if panelShowReach
f_pcell(perfTable, 0, row, "SCORE REACH", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, "bars raw", cHead, pSize, text.align_right)
f_pcell(perfTable, 2, row, "%", cHead, pSize, text.align_right)
f_pcell(perfTable, 3, row, "bars net", cHead, pSize, text.align_right)
f_pcell(perfTable, 4, row, "%", cHead, pSize, text.align_right)
row := row + 1
for b = 0 to 4
int cr = array.get(reachRaw, b)
int cf = array.get(reachFinal, b)
f_pcell(perfTable, 0, row, array.get(reachNames, b), cText, pSize, text.align_left)
f_pcell(perfTable, 1, row, str.tostring(cr), cText, pSize, text.align_right)
f_pcell(perfTable, 2, row, f_pctStr(cr, reachBars), cText, pSize, text.align_right)
f_pcell(perfTable, 3, row, str.tostring(cf), cText, pSize, text.align_right)
f_pcell(perfTable, 4, row, f_pctStr(cf, reachBars), cText, pSize, text.align_right)
row := row + 1
// The calibration verdict in one row: if peak net is below the
// threshold, the threshold cannot fire on this symbol.
bool unreachable = not na(peakFinal) and peakFinal < scoreThreshold
f_pcell(perfTable, 0, row, "peak", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, na(peakRaw) ? "-" : str.tostring(peakRaw, "#.0"),
cText, pSize, text.align_right)
f_pcell(perfTable, 3, row, na(peakFinal) ? "-" : str.tostring(peakFinal, "#.0"),
unreachable ? cLoss : cText, pSize, text.align_right)
f_pcell(perfTable, 5, row, "threshold", cHead, pSize, text.align_right)
f_pcell(perfTable, 6, row, str.tostring(scoreThreshold),
unreachable ? cLoss : cText, pSize, text.align_right)
row := row + 1
// Suggested E1 / E2 multiples, beside what is currently set.
// Computed from the HORIZON-bounded excursions, which is the only
// window independent of the stop being calibrated.
array.clear(pcMae)
array.clear(pcMfe)
if logN > 0
for i = 0 to logN - 1
SigRec r = array.get(sigLog, i)
if r.resolved and not na(r.atrAt) and r.atrAt > 0.0
array.push(pcMae, r.mae / r.atrAt)
array.push(pcMfe, r.mfe / r.atrAt)
float sugStop = array.size(pcMae) > 0 ?
array.percentile_linear_interpolation(pcMae, 75) : na
float sugTgt = array.size(pcMfe) > 0 ?
array.percentile_linear_interpolation(pcMfe, 50) : na
f_pcell(perfTable, 0, row, "suggest", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, "stop", cHead, pSize, text.align_right)
f_pcell(perfTable, 2, row, na(sugStop) ? "-" : str.tostring(sugStop, "#.00"),
cText, pSize, text.align_right)
f_pcell(perfTable, 3, row, "target", cHead, pSize, text.align_right)
f_pcell(perfTable, 4, row, na(sugTgt) ? "-" : str.tostring(sugTgt, "#.00"),
cText, pSize, text.align_right)
f_pcell(perfTable, 5, row, "set", cHead, pSize, text.align_right)
f_pcell(perfTable, 6, row, str.tostring(exitStopATR, "#.0") + " / " +
str.tostring(exitTargetATR, "#.0"), cText, pSize, text.align_right)
row := row + 1
// --------------------------------------------------------- BY BAND ---
if panelShowBands
array.fill(pbN, 0)
array.fill(pbWin, 0)
array.fill(pbRet, 0.0)
array.fill(pbMfe, 0.0)
array.fill(pbMae, 0.0)
array.fill(pbPend, 0)
if logN > 0
for i = 0 to logN - 1
SigRec r = array.get(sigLog, i)
int b = f_sigBand(r.score)
if r.resolved
array.set(pbN, b, array.get(pbN, b) + 1)
if r.ret > 0.0
array.set(pbWin, b, array.get(pbWin, b) + 1)
array.set(pbRet, b, array.get(pbRet, b) + nz(r.ret))
// Excursions are averaged over RESOLVED records only,
// so every one has had the full horizon to develop.
if not na(r.atrAt) and r.atrAt > 0.0
array.set(pbMfe, b, array.get(pbMfe, b) + r.mfe / r.atrAt)
array.set(pbMae, b, array.get(pbMae, b) + r.mae / r.atrAt)
else
array.set(pbPend, b, array.get(pbPend, b) + 1)
f_pcell(perfTable, 0, row, "BY BAND", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, "n", cHead, pSize, text.align_right)
f_pcell(perfTable, 2, row, "win%", cHead, pSize, text.align_right)
f_pcell(perfTable, 3, row, "avg ret", cHead, pSize, text.align_right)
f_pcell(perfTable, 4, row, "MFE atr", cHead, pSize, text.align_right)
f_pcell(perfTable, 5, row, "MAE atr", cHead, pSize, text.align_right)
f_pcell(perfTable, 6, row, "pend", cHead, pSize, text.align_right)
row := row + 1
int allN = 0
int allWin = 0
float allRet = 0.0
float allMfe = 0.0
float allMae = 0.0
int allPend = 0
for b = 0 to 3
int n = array.get(pbN, b)
int w = array.get(pbWin, b)
allN := allN + n
allWin := allWin + w
allRet := allRet + array.get(pbRet, b)
allMfe := allMfe + array.get(pbMfe, b)
allMae := allMae + array.get(pbMae, b)
allPend := allPend + array.get(pbPend, b)
float wr = n > 0 ? w / (n * 1.0) * 100.0 : na
f_pcell(perfTable, 0, row, array.get(sigBandNames, b), cText, pSize, text.align_left)
f_pcell(perfTable, 1, row, str.tostring(n), cText, pSize, text.align_right)
f_pcell(perfTable, 2, row, n > 0 ? str.tostring(wr, "#.0") + "%" : "-",
n > 0 ? (wr >= 50.0 ? cWin : cLoss) : cText, pSize, text.align_right)
f_pcell(perfTable, 3, row, f_avgSigned(array.get(pbRet, b), n),
cText, pSize, text.align_right)
f_pcell(perfTable, 4, row, f_avgStr(array.get(pbMfe, b), n, "#.00"),
cText, pSize, text.align_right)
f_pcell(perfTable, 5, row, f_avgStr(array.get(pbMae, b), n, "#.00"),
cText, pSize, text.align_right)
f_pcell(perfTable, 6, row, str.tostring(array.get(pbPend, b)), cText, pSize,
text.align_right)
row := row + 1
float allWr = allN > 0 ? allWin / (allN * 1.0) * 100.0 : na
f_pcell(perfTable, 0, row, "ALL", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, str.tostring(allN), cHead, pSize, text.align_right)
f_pcell(perfTable, 2, row, allN > 0 ? str.tostring(allWr, "#.0") + "%" : "-",
allN > 0 ? (allWr >= 50.0 ? cWin : cLoss) : cHead, pSize, text.align_right)
f_pcell(perfTable, 3, row, f_avgSigned(allRet, allN), cHead, pSize,
text.align_right)
f_pcell(perfTable, 4, row, f_avgStr(allMfe, allN, "#.00"), cHead, pSize,
text.align_right)
f_pcell(perfTable, 5, row, f_avgStr(allMae, allN, "#.00"), cHead, pSize,
text.align_right)
f_pcell(perfTable, 6, row, str.tostring(allPend), cHead, pSize, text.align_right)
row := row + 1
// ------------------------------------------------- BY EXIT MODEL ---
if panelShowModels
array.fill(pmN, 0)
array.fill(pmWin, 0)
array.fill(pmR, 0.0)
array.fill(pmWinR, 0.0)
array.fill(pmLossR, 0.0)
array.fill(pmLoss, 0)
array.fill(pmBars, 0)
if logN > 0
for i = 0 to logN - 1
SigRec r = array.get(sigLog, i)
for t = 0 to 2
bool done = t == 0 ? r.resolved : (t == 1 ? r.fDone : r.sDone)
float rr = t == 0 ? f_retToR(r.ret, r.entry, r.atrAt, exitStopATR) :
(t == 1 ? r.fR : r.sR)
int bars = t == 0 ? logHorizon : (t == 1 ? r.fBars : r.sBars)
if done and not na(rr)
array.set(pmN, t, array.get(pmN, t) + 1)
array.set(pmR, t, array.get(pmR, t) + rr)
array.set(pmBars, t, array.get(pmBars, t) + bars)
if rr > 0.0
array.set(pmWin, t, array.get(pmWin, t) + 1)
array.set(pmWinR, t, array.get(pmWinR, t) + rr)
else
array.set(pmLoss, t, array.get(pmLoss, t) + 1)
array.set(pmLossR, t, array.get(pmLossR, t) + rr)
f_pcell(perfTable, 0, row, "BY EXIT MODEL", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, "n", cHead, pSize, text.align_right)
f_pcell(perfTable, 2, row, "win%", cHead, pSize, text.align_right)
f_pcell(perfTable, 3, row, "avg R", cHead, pSize, text.align_right)
f_pcell(perfTable, 4, row, "avg win", cHead, pSize, text.align_right)
f_pcell(perfTable, 5, row, "avg loss", cHead, pSize, text.align_right)
f_pcell(perfTable, 6, row, "bars", cHead, pSize, text.align_right)
row := row + 1
for t = 0 to 2
int n = array.get(pmN, t)
float wr = n > 0 ? array.get(pmWin, t) / (n * 1.0) * 100.0 : na
float avgR = n > 0 ? array.get(pmR, t) / n : na
bool isLive = (t == 1 and not liveStructural) or (t == 2 and liveStructural)
f_pcell(perfTable, 0, row, array.get(pmNames, t) + (isLive ? " *" : ""),
isLive ? cHead : cText, pSize, text.align_left)
f_pcell(perfTable, 1, row, str.tostring(n), cText, pSize, text.align_right)
f_pcell(perfTable, 2, row, n > 0 ? str.tostring(wr, "#.0") + "%" : "-",
n > 0 ? (wr >= 50.0 ? cWin : cLoss) : cText, pSize, text.align_right)
// avg R is the expectancy: it already folds win rate and payoff
// together, and it is the column that decides between models.
f_pcell(perfTable, 3, row, f_signed(avgR, "#.00"),
na(avgR) ? cText : (avgR > 0.0 ? cWin : cLoss), pSize, text.align_right)
f_pcell(perfTable, 4, row,
f_avgStr(array.get(pmWinR, t), array.get(pmWin, t), "#.00"),
cText, pSize, text.align_right)
f_pcell(perfTable, 5, row,
f_avgStr(array.get(pmLossR, t), array.get(pmLoss, t), "#.00"),
cText, pSize, text.align_right)
f_pcell(perfTable, 6, row, n > 0 ?
str.tostring(array.get(pmBars, t) / (n * 1.0), "#.0") : "-",
cText, pSize, text.align_right)
row := row + 1
// -------------------------------------------------------- BENCHMARK ---
// Did any of this beat holding the stock? Measured over the log's own
// span - from the oldest surviving record to now - so the system and
// the hold are judged on the same ground.
//
// TOTAL against TOTAL. The system takes many trades and the hold takes
// one, so comparing an average-per-trade against a single hold would
// flatter whichever side happened to trade less. sys R is therefore the
// SUM of the live model's resolved outcomes.
//
// Computed from its own pass over the log rather than by reading the
// exit-model accumulators, so this section does not silently depend on
// BY EXIT MODEL being switched on.
if panelShowBench
f_pcell(perfTable, 0, row, "BENCHMARK", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, "bars", cHead, pSize, text.align_right)
f_pcell(perfTable, 2, row, "B&H %", cHead, pSize, text.align_right)
f_pcell(perfTable, 3, row, "B&H R", cHead, pSize, text.align_right)
f_pcell(perfTable, 4, row, "sys R", cHead, pSize, text.align_right)
f_pcell(perfTable, 5, row, "edge", cHead, pSize, text.align_right)
f_pcell(perfTable, 6, row, "n", cHead, pSize, text.align_right)
row := row + 1
float sysR = 0.0
int sysN = 0
if logN > 0
for i = 0 to logN - 1
SigRec r = array.get(sigLog, i)
bool done = liveStructural ? r.sDone : r.fDone
float rr = liveStructural ? r.sR : r.fR
if done and not na(rr)
sysR := sysR + rr
sysN := sysN + 1
// The oldest surviving record defines the window: buy at its entry
// price, hold to the current bar, long-only.
//
// Every field read happens INSIDE the guard. Pine does not promise
// to short-circuit `and`, so testing logN and dereferencing the
// record in one expression could still touch an na object.
float bhPct = na
float bhR = na
int bhBars = na
string bhFrom = "hold"
if logN > 0
SigRec first = array.get(sigLog, 0)
bhBars := bar_index - first.barIdx
bhFrom := "from " + str.format_time(first.tm, "dd MMM yy", syminfo.timezone)
if not na(first.entry) and first.entry > 0.0
bhPct := (close - first.entry) / first.entry * 100.0
bhR := f_retToR(bhPct, first.entry, first.atrAt, exitStopATR)
float edge = na(bhR) ? na : sysR - bhR
f_pcell(perfTable, 0, row, bhFrom, cText, pSize, text.align_left)
f_pcell(perfTable, 1, row, na(bhBars) ? "-" : str.tostring(bhBars),
cText, pSize, text.align_right)
f_pcell(perfTable, 2, row, na(bhPct) ? "-" : f_signed(bhPct, "#.0") + "%",
cText, pSize, text.align_right)
f_pcell(perfTable, 3, row, f_signed(bhR, "#.00"), cText, pSize, text.align_right)
f_pcell(perfTable, 4, row, sysN > 0 ? f_signed(sysR, "#.00") : "-",
cText, pSize, text.align_right)
f_pcell(perfTable, 5, row, sysN > 0 ? f_signed(edge, "#.00") : "-",
na(edge) or sysN <= 0 ? cText : (edge > 0.0 ? cWin : cLoss), pSize,
text.align_right)
f_pcell(perfTable, 6, row, str.tostring(sysN), cText, pSize, text.align_right)
row := row + 1
// ------------------------------------------------ BY EXIT REASON ---
if panelShowReasons
array.fill(prN, 0)
array.fill(prR, 0.0)
int reasonTotal = 0
if logN > 0
for i = 0 to logN - 1
SigRec r = array.get(sigLog, i)
bool done = liveStructural ? r.sDone : r.fDone
int rsn = liveStructural ? r.sReason : r.fReason
float rr = liveStructural ? r.sR : r.fR
if done and rsn >= 1 and rsn <= 8
array.set(prN, rsn, array.get(prN, rsn) + 1)
array.set(prR, rsn, array.get(prR, rsn) + nz(rr))
reasonTotal := reasonTotal + 1
f_pcell(perfTable, 0, row, "EXIT REASON", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, liveStructural ? "structural" : "fixed-R", cHead, pSize,
text.align_right)
f_pcell(perfTable, 2, row, "n", cHead, pSize, text.align_right)
f_pcell(perfTable, 3, row, "share", cHead, pSize, text.align_right)
f_pcell(perfTable, 4, row, "avg R", cHead, pSize, text.align_right)
row := row + 1
for rsn = 1 to 8
int n = array.get(prN, rsn)
float avgR = n > 0 ? array.get(prR, rsn) / n : na
f_pcell(perfTable, 0, row, "E" + str.tostring(rsn), cText, pSize, text.align_left)
f_pcell(perfTable, 1, row, f_exitName(rsn), cText, pSize, text.align_right)
f_pcell(perfTable, 2, row, str.tostring(n), cText, pSize, text.align_right)
f_pcell(perfTable, 3, row, f_pctStr(n, reasonTotal), cText, pSize,
text.align_right)
f_pcell(perfTable, 4, row, f_signed(avgR, "#.00"),
na(avgR) ? cText : (avgR > 0.0 ? cWin : cLoss), pSize, text.align_right)
row := row + 1
// --------------------------------------------------------- ABLATION ---
// Which layers earn their weight. Every row but BASE is the same system
// with one layer removed, measured on the horizon so no exit rule can
// colour the result. dR is the whole point: positive means the system
// scored BETTER without that layer.
if panelShowAblation
f_pcell(perfTable, 0, row, "ABLATION", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, "n", cHead, pSize, text.align_right)
f_pcell(perfTable, 2, row, "win%", cHead, pSize, text.align_right)
f_pcell(perfTable, 3, row, "avg R", cHead, pSize, text.align_right)
f_pcell(perfTable, 4, row, "dR", cHead, pSize, text.align_right)
f_pcell(perfTable, 5, row, "wt", cHead, pSize, text.align_right)
row := row + 1
int baseN = array.get(abN, 0)
float baseR = baseN > 0 ? array.get(abR, 0) / baseN : na
for k = 0 to 13
int n = array.get(abN, k)
float avgR = n > 0 ? array.get(abR, k) / n : na
float wr = n > 0 ? array.get(abWin, k) / (n * 1.0) * 100.0 : na
// A layer the user has already switched off has nothing to
// remove, so its stream is BASE again. Printing that number
// would invite a comparison that means nothing.
bool dead = k > 0 and not array.get(abOn, k)
float d = dead or k == 0 or na(avgR) or na(baseR) ? na : avgR - baseR
float wt = array.get(abWts, k)
f_pcell(perfTable, 0, row, array.get(abNames, k),
k == 0 ? cHead : cText, pSize, text.align_left)
f_pcell(perfTable, 1, row, dead ? "-" : str.tostring(n), cText, pSize,
text.align_right)
f_pcell(perfTable, 2, row,
dead or n <= 0 ? "-" : str.tostring(wr, "#.0") + "%",
cText, pSize, text.align_right)
f_pcell(perfTable, 3, row, dead ? "-" : f_signed(avgR, "#.00"),
cText, pSize, text.align_right)
f_pcell(perfTable, 4, row, dead ? "off" : f_signed(d, "#.00"),
na(d) ? cText : (d > 0.0 ? cWin : cLoss), pSize, text.align_right)
f_pcell(perfTable, 5, row, wt <= 0.0 ? "-" : str.tostring(wt, "#.#"),
cText, pSize, text.align_right)
row := row + 1
// ---------------------------------------------------- LAST SIGNALS ---
if panelShowDetail
f_pcell(perfTable, 0, row, "LAST", cHead, pSize, text.align_left)
f_pcell(perfTable, 1, row, "date", cHead, pSize, text.align_right)
f_pcell(perfTable, 2, row, "dir", cHead, pSize, text.align_right)
f_pcell(perfTable, 3, row, "score", cHead, pSize, text.align_right)
f_pcell(perfTable, 4, row, "ret", cHead, pSize, text.align_right)
f_pcell(perfTable, 5, row, "MFE", cHead, pSize, text.align_right)
f_pcell(perfTable, 6, row, "MAE", cHead, pSize, text.align_right)
row := row + 1
if detailN <= 0
f_pcell(perfTable, 0, row, "no signals logged yet", cText, pSize, text.align_left)
table.merge_cells(perfTable, 0, row, PANEL_COLS - 1, row)
row := row + 1
else
// Newest first - the rows a user checks are the recent ones.
for k = 0 to detailN - 1
SigRec r = array.get(sigLog, logN - 1 - k)
color dirCol = r.dir > 0 ? cWin : cLoss
f_pcell(perfTable, 0, row, str.tostring(k + 1), cText, pSize, text.align_left)
f_pcell(perfTable, 1, row, str.format_time(r.tm, "dd MMM", syminfo.timezone),
cText, pSize, text.align_right)
f_pcell(perfTable, 2, row, r.dir > 0 ? "LONG" : "SHORT", dirCol, pSize,
text.align_right)
f_pcell(perfTable, 3, row, str.tostring(r.score, "#.0"), cText, pSize,
text.align_right)
f_pcell(perfTable, 4, row,
r.resolved ? f_signed(r.ret, "#.00") + "%" : "pending",
r.resolved ? (r.ret > 0.0 ? cWin : cLoss) : cText, pSize, text.align_right)
f_pcell(perfTable, 5, row, f_atrStr(r.mfe, r.atrAt), cText, pSize,
text.align_right)
f_pcell(perfTable, 6, row, f_atrStr(r.mae, r.atrAt), cText, pSize,
text.align_right)
row := row + 1
hline(scoreThreshold, "Threshold", color = color.new(color.gray, 40), linestyle = hline.style_dashed)
hline(80, "Band 80", color = color.new(color.gray, 80), linestyle = hline.style_dotted)
hline(60, "Band 60", color = color.new(color.gray, 80), linestyle = hline.style_dotted)
hline(40, "Band 40", color = color.new(color.gray, 80), linestyle = hline.style_dotted)
hline(0, "Zero", color = color.new(color.gray, 70))
Overview
Daily ADX, SMA and EMA alignment lock direction and can veto a candidate. The one-hour timeframe needs a PAC touch plus a confirmation candle, and the fifteen-minute timeframe applies an exhaustion gate. Settings change the score threshold, layer weights, Heikin Ashi use, and ATR, RSI, MACD, volume and moving-average lengths. It draws signals, alerts and a performance log of gross outcomes without placing orders. Lower-timeframe gates can move on the forming bar.
The inputs you can change from the gear icon on the chart. Defaults are the author's.
Setting
Type
Default
Variable
Signal score threshold
int
60
scoreThreshold
Renormalise score across ENABLED layers only
bool
true
normalizeWeights
ATR length (shared by layers 6 and 7)
int
14
atrLen
Use Heikin Ashi for PAC / EMA layers
bool
true
useHA
PAC length (EMA of HA high/low/close)
int
34
pacLen
Enable Layer 1
bool
true
useL1
Weight
float
20.0
wL1
ADX / DI length
int
14
adxLen
ADX smoothing
int
14
adxSmooth
ADX trend threshold
float
25.0
adxThr
SMA length (price vs SMA)
int
50
smaLen
EMA fast
int
34
emaFastLen
Show all 40 settings
Setting
Type
Default
Variable
EMA medium
int
89
emaMedLen
EMA slow
int
200
emaSlowLen
Enable Layer 2
bool
true
useL2
Weight
float
10.0
wL2
PAC-mid pullback lookback (N bars)
int
10
pacDipBars
Enable Layer 3
bool
true
useL3
Weight
float
15.0
wL3
RSI length
int
14
rsiLen
RSI smoothing (SMA)
int
9
rsiSmoothLen
MACD fast
int
12
macdFastLen
MACD slow
int
26
macdSlowLen
MACD signal
int
9
macdSigLen
Enable Layer 4
bool
true
useL4
Weight
float
10.0
wL4
Volume SMA length
int
20
volLen
Low anchor: vol / avg
float
0.5
volRatioLo
Low anchor: confidence
float
0.2
volConfLo
High anchor: vol / avg
float
2.0
volRatioHi
High anchor: confidence
float
1.0
volConfHi
Enable 1D hard veto
bool
true
useVeto
Bias timeframe
timeframe
1D
biasTF
Use last CLOSED bias bar only (no repaint)
bool
true
biasConfirmedOnly
1D ADX / DI length
int
14
dAdxLen
1D ADX smoothing
int
14
dAdxSmooth
1D ADX threshold
float
25.0
dAdxThr
1D SMA length
int
50
dSmaLen
1D EMA fast
int
21
dEmaFastLen
1D EMA medium
int
50
dEmaMedLen
Third-party code. ForexR did not write, test or endorse this script, and the summary above was generated automatically from its code and README. Indicators describe the past, and a strategy's backtest can look better than live trading, especially when a script repaints. Check it in the Strategy Tester and on a demo account before you rely on it.
This indicator combines Qualitative Quantitative Estimation (QQE), the AK Trend Line, and Elder's Force Index (EFI) into a single separate pane tool. It calculates QQE using smoothed RSI and Wilder's ATR, computes AK Trend values with dynamic scale factors mat
RedK Volume-Accelerated Directional Energy Ratio (VADER) measures directional momentum accelerated by volume activity. It calculates price change relative to the average bar range, limits the ratio, and scales it using relative, raw, or static volume weighting
This indicator generates trend-following trade signals on the price chart by combining moving average crossovers with multi-factor filters. It triggers entry alerts when fast and slow EMAs cross in the direction of a baseline trend EMA, confirmed by non-extrem
This overlay indicator plots a SuperTrend using a 10-period ATR with a multiplier of 3, drawing up and down stop lines and a faint trend fill on the price chart. It compares a 9-period EMA with an 18-period EMA and a 12/26/9 MACD against its signal, and it req
Bte Acc is a chart indicator form of the PineCoders Backtesting and Trading Engine. It seeks an entry trigger from a main or alternate entry method, then checks trade direction, filters, date range, ATR readiness, and remaining capital before filling on the ne
This indicator overlays a Hull Moving Average and three Exponential Moving Averages on the price chart while monitoring signals from MACD, Stochastic Oscillator, and Time Segmented Volume. It tracks trend direction changes, MACD histogram color shifts from bri