Konatus Bearing · Pine Script v6 · free

Know the bearing.
Respect the conditions.

One bearing line, colored candles and a dashboard that says, in plain words, what the market is doing and what the plan is waiting for. It reads stocks, ETFs, futures, bonds, forex and crypto, and it writes the stop before you enter.

Get the Pine script New: Market Anatomy code ↓ Read the quick guide ↓

Experimental. Version 0.3.1 has not yet been compiled in TradingView, and no market edge has been measured. Plans on the chart are illustrations, not orders.

Brass navigation compass with a northeast-pointing needle on dark slate
Konatus Bearing
The dashboard as it sits in the corner of your chart. Example values, not a TradingView screenshot.

Set it up in five minutes

  1. InstallCopy the whole script into a blank Pine Editor indicator, save, and add it to an ordinary time-based price chart. Remove any older Konatus Bearing first.
  2. MarketLeave Instrument profile on Auto, or pick Stocks, Bonds, Futures, Forex, Crypto or Generic. Keep the chart's session (regular or extended) consistent.
  3. RiskEnter reference account, risk % and costs. Check point value and quantity increment for the symbol.
  4. FuturesEnter a per-contract margin allocation. Until you do, the panel reads SIZE UNAVAILABLE.
  5. SetupChoose Breakout, Pullback or Either. To enable shorts, open Settings → Inputs → 00 · Entry direction → Enable short entries. Shorts default off. If the control is missing, replace the older script with the complete 0.3.1 version below.
  6. AlertsUse “Any alert() function call”. Recreate alerts after changing settings.

Entry triangles and exit X marks

Long entry Green up-triangle below the candle
Short entry Red down-triangle above the candle
Exit X mark above the candle

Entry markers have no text. Triangles mark confirmed entry intentions; the illustrative fill is at the next open. Shorts must be enabled and pass the same setup and risk gates.

Candle colors show direction, not an order

Green Upward bearing with enough persistence
Coral Downward bearing with enough persistence
Gray Not enough evidence either way

Only closed candles are colored, and the dashboard reads the last closed bar. A color is never advice to enter.

Read the big word first

The large line on the panel is the plan. The small line under it says why.

LONG PLAN / SHORT PLAN
An illustrative position is open. Read its frozen stop and quantity.
ENTRY QUEUED
The setup fired at the close. The illustration fills at the next open.
EXIT QUEUED
A reversal queued an illustrative exit for the next open. Stop exits are marked with an X when reached in the illustration.
DATA UNAVAILABLE
The bar is invalid. Any open stop is kept on screen.

With no plan open, the big word is the New entry status: the first gate that is still closed.

READY
Every gate is open and the setup agrees with the bearing.
WAIT SETUP
The trend evidence is there; the breakout or pullback has not happened yet.
EXTENDED
The trend is real but price is too far from the bearing (1.5 ATR by default). Wait for it to come back.
WEAK EVIDENCE
Persistence is below the minimum. The direction is not settled.
CONGESTION
Price is moving without going anywhere; directional efficiency is too low.
SHOCK PAUSE
A bar moved more than 4 ATR. New plans wait a few bars.
LOW PARTICIPATION / VOLUME MISSING
The profile needs volume and the bar did not have enough of it.
VWAP CONTEXT
You asked for session VWAP alignment and price is on the wrong side.
SHORT DISABLED
The bearing points down and short plans are off. The analysis still shows.
SIZE UNAVAILABLE
Check the contract, margin allocation and risk inputs.
WARMUP / FLAT
Not enough history yet, or price has not moved.

Two ways in, one way out

Breakout waits for two closes beyond the prior 20-bar range. Pullback waits for a close that crosses back over the bearing in the direction of the trend. Either takes whichever comes first. Every other gate still applies. At entry the stop is set 3 ATR away and frozen; size comes from account, risk % and that distance, capped by your capacity limit, and never from how confident the reading looks.

Switch the panel to Detailed for drift, model uncertainty, run length and efficiency. Theme follows your chart on Auto.

Get the Pine script

Konatus Bearing 0.3.1, complete Pine v6 source, free to copy, use and share. Download or copy the entire file, paste it into a blank Pine Editor indicator, save and add it to your chart.

Download Konatus_Bearing_0.3.1.txt

View the full source
//@version=6
indicator("Konatus Bearing · Cross-market 0.3.1", "Konatus031", overlay = true, max_bars_back = 2000)
// Copyright (c) 2026 Kontaus. Experimental kb-0.3; no established market edge.
bool allowShort = input.bool(false, "Enable short entries", group = "00 · Entry direction", tooltip = "Long entries are enabled. Turn this on to also allow red short-entry triangles and short plans. Signals still require all setup and risk conditions. This control is in the cross-market 0.3.1 indicator; the old 0.2 baseline is long-only.")
string profileInput = input.string("Auto", "Instrument profile", options = ["Auto", "Stocks", "Bonds", "Futures", "Forex", "Crypto", "Generic"], group = "01 · Market")
string profile = profileInput == "Auto" ? (syminfo.type == "stock" or syminfo.type == "fund" ? "Stocks" : syminfo.type == "futures" ? "Futures" : syminfo.type == "forex" ? "Forex" : syminfo.type == "crypto" ? "Crypto" : "Generic") : profileInput
string volumePolicy = input.string("Profile", "Participation policy", options = ["Profile", "Required", "Price only"], group = "01 · Market", tooltip = "Profile requires volume for Stocks/Futures/Crypto. Bonds/Forex/Generic use price only. Missing required volume blocks new plans.")
string volumeMode = input.string("Prior EW", "Volume baseline", options = ["Prior EW", "Session slot"], group = "01 · Market", tooltip = "Prior EW uses 20 earlier volume observations. Session slot uses at least 5 earlier trading days at this exchange-local minute. Minute or hourly charts only; no current-bar volume in the baseline.")
bool vwapGate = input.bool(false, "Require session VWAP alignment", group = "01 · Market", tooltip = "Requires a fully observed trading-day anchor and positive current volume. VWAP is context, not order flow.")
int resetAt = input.time(0, "Reset model at contract/data boundary", group = "01 · Market", tooltip = "Optional timestamp. Resets analytical history once; never cancels an open illustrative stop. Does not construct or adjust a futures roll.")
string setupMode = input.string("Breakout", "Setup", options = ["Breakout", "Pullback", "Either"], group = "02 · Signals")
float decay = input.float(0.9, "Evidence memory", minval = 0.01, maxval = 0.99, step = 0.01, group = "02 · Signals")
float persistenceMin = input.float(20, "Minimum directional evidence", minval = 0, maxval = 100, group = "02 · Signals")
float extensionLimit = input.float(1.5, "Maximum entry extension · ATR", minval = 0.1, group = "02 · Signals")
float efficiencyMin = input.float(0.25, "Minimum directional efficiency", minval = 0, maxval = 1, group = "02 · Signals")
float shockAtr = input.float(4, "Shock threshold · prior ATR", minval = 0.1, group = "02 · Signals")
int cooldown = input.int(3, "Shock cooldown · bars", minval = 0, maxval = 100, group = "02 · Signals")
float account = input.float(100000, "Reference account", minval = 1, group = "03 · Risk")
float riskPct = input.float(1, "Planned risk %", minval = 0, maxval = 100, group = "03 · Risk")
float stopAtr = input.float(3, "Frozen distance · ATR", minval = 0.1, group = "03 · Risk")
float conversion = input.float(1, "Account currency per quote currency", minval = 0.00000001, group = "03 · Risk", tooltip = "Explicit fixed conversion for the illustration, not an FX feed. Use 1 only when account and quote currency match. Recompute when conversion changes.")
float pointOverride = input.float(0, "Point-value override · 0 = symbol", minval = 0, group = "03 · Risk")
float pointValue = pointOverride > 0 ? pointOverride : syminfo.pointvalue
float tick = syminfo.mintick
float lot = input.float(1, "Quantity increment", minval = 0.00000001, group = "03 · Risk", tooltip = "Quantity uses symbol units/contracts, not broker-defined FX lots. Confirm the point value and increment for this instrument.")
float margin = input.float(0, "Capacity per unit · account currency", minval = 0, group = "03 · Risk", tooltip = "Required for Futures and nonpositive-price plans. Enter a conservative per-contract allocation. It is not a live broker margin model. Zero uses cash notional for other positive-price instruments.")
float capitalFraction = input.float(1, "Maximum capacity · account fraction", minval = 0, maxval = 1, group = "03 · Risk")
float costBps = input.float(5, "One-way notional costs · bps", minval = 0, group = "03 · Risk")
float feePerUnit = input.float(0, "One-way fee per unit · quote currency", minval = 0, group = "03 · Risk")
int slipTicks = input.int(1, "Adverse slippage · ticks", minval = 0, maxval = 100, group = "03 · Risk")
string theme = input.string("Auto", "Theme", options = ["Auto", "Dark", "Light"], group = "04 · Appearance")
string density = input.string("Essential", "Dashboard", options = ["Essential", "Detailed", "Hidden"], group = "04 · Appearance")
bool candleColors = input.bool(true, "Color confirmed candles", group = "04 · Appearance")
bool showVwap = input.bool(false, "Show session VWAP", group = "04 · Appearance")
if not chart.is_standard or timeframe.isticks
    runtime.error("Konatus requires standard time-based OHLC charts.")
if na(pointValue) or na(tick) or math.sign(pointValue) <= 0 or math.sign(tick) <= 0
    runtime.error("A positive tick and point value are required.")
if volumeMode == "Session slot" and (not timeframe.isintraday or timeframe.in_seconds() < 60)
    runtime.error("Session-slot volume needs intraday time bars of at least one minute.")
bool needsVolume = volumePolicy == "Required" or (volumePolicy == "Profile" and (profile == "Stocks" or profile == "Futures" or profile == "Crypto"))

if vwapGate and not timeframe.isintraday
    runtime.error("Session VWAP gating requires intraday time bars. Disable it on daily and higher charts.")

// Shared confirmed-bar analytical engine. Price units allow zero/negative prices.
var float level = na
var float drift = 0
var float p00 = 0
var float p01 = 0
var float p11 = 0
var float atr = 0
var float trSum = 0
var float previous = na
var float evidence = 0
var int count = 0
var int direction = 0
var int runLength = 0
var int pause = 0
var array<float> highs = array.new_float()
var array<float> lows = array.new_float()
var array<float> closes = array.new_float()
var array<float> slotMean = array.new_float(1440, 0)
var array<int> slotCount = array.new_int(1440, 0)
var array<int> slotAnchor = array.new_int(1440, na)
var float volMean = 0
var int volCount = 0
var int anchor = na
var bool anchorComplete = false
var bool anchorValid = true
var float pv = 0
var float vSum = 0
var float vwap = na
var float relativeVolume = na
var float efficiency = 0
var float extension = na
var float surprise = na
var float predictiveSd = na
var float distance = 0
var float quantity = 0
var string volumeStatus = "WARMUP"
var string condition = "WARMUP"
var string entryStatus = "WARMUP"
var string setupName = "NONE"
var int breakoutSide = 0
var float breakoutLevel = na
var bool ready = false
int signal = 0
bool valid = not na(open) and not na(high) and not na(low) and not na(close) and (math.min(open, close) - low) / tick >= 0 and (high - math.max(open, close)) / tick >= 0
bool resetNow = resetAt > 0 and time >= resetAt and (na(time[1]) or time[1] < resetAt)
if barstate.isconfirmed
    if not valid or resetNow
        level := na
        drift := 0
        p00 := 0
        p01 := 0
        p11 := 0
        atr := 0
        trSum := 0
        previous := na
        evidence := 0
        count := 0
        direction := 0
        runLength := 0
        pause := 0
        array.clear(highs)
        array.clear(lows)
        array.clear(closes)
        array.fill(slotMean, 0)
        array.fill(slotCount, 0)
        array.fill(slotAnchor, na)
        volMean := 0
        volCount := 0
        anchor := na
        anchorComplete := false
        anchorValid := true
        pv := 0
        vSum := 0
        vwap := na
        breakoutSide := 0
        ready := false
        quantity := 0
        distance := 0
        extension := na
        efficiency := 0
        surprise := na
        predictiveSd := na
        relativeVolume := na
        volumeStatus := "UNAVAILABLE"
        condition := "INVALID DATA"
        entryStatus := "INVALID DATA"
        setupName := "NONE"
    if valid
        float priorLevel = level
        float tr = na(previous) ? high - low : math.max(high - low, math.abs(high - previous), math.abs(low - previous))
        float scale = math.max(count >= 14 ? atr : count > 0 ? trSum / count : tr, tick)
        float variance = scale * scale
        float observation = 0.25 * variance
        if na(level)
            level := close
            p00 := observation
            p11 := observation
            surprise := 0
            predictiveSd := math.sqrt(2 * observation)
        else
            float a = p00 + 2 * p01 + p11 + 0.01 * variance
            float b = p01 + p11
            float d = p11 + 0.0001 * variance
            float predictive = a + observation
            float innovation = close - level - drift
            surprise := innovation / math.sqrt(predictive)
            predictiveSd := math.sqrt(predictive)
            float k0 = a / predictive
            float k1 = b / predictive
            float m = 1 - k0
            level += drift + k0 * innovation
            drift += k1 * innovation
            p00 := m * m * a + k0 * k0 * observation
            p01 := m * (b - k1 * a) + k0 * k1 * observation
            p11 := d - 2 * k1 * b + k1 * k1 * a + k1 * k1 * observation
        count += 1
        if count <= 14
            trSum += tr
            atr := trSum / count
        else
            atr += (tr - atr) / 14
        int priorDirection = direction
        direction := drift / tick > 0.000001 ? 1 : drift / tick < -0.000001 ? -1 : 0
        runLength := direction != 0 and direction == priorDirection ? runLength + 1 : direction != 0 ? 1 : 0
        evidence := decay * evidence + (1 - decay) * math.max(-1, math.min(1, drift / (0.1 * scale)))
        array.push(closes, close)
        if array.size(closes) > 21
            array.shift(closes)
        float travel = 0
        if array.size(closes) > 1
            for j = 1 to array.size(closes) - 1
                travel += math.abs(array.get(closes, j) - array.get(closes, j - 1))
        efficiency := travel > 0 ? math.abs(close - array.get(closes, 0)) / travel : 0
        extension := math.abs(close - level) / math.max(atr, tick)
        bool shock = not na(previous) and math.abs(close - previous) / scale > shockAtr
        pause := shock ? cooldown : math.max(0, pause - 1)
        ready := count >= 40 and atr / tick > 0.000001
        condition := count < 40 ? "WARMUP" : not ready ? "FLAT" : shock or pause > 0 ? "SHOCK" : efficiency < efficiencyMin ? "CONGESTION" : "TREND"
        bool haveVolume = not na(volume) and volume >= 0
        int slot = hour(time, syminfo.timezone) * 60 + minute(time, syminfo.timezone)
        int dayAnchor = time_tradingday
        float baseline = volumeMode == "Session slot" ? array.get(slotMean, slot) : volMean
        int samples = volumeMode == "Session slot" ? array.get(slotCount, slot) : volCount
        int lastAnchor = array.get(slotAnchor, slot)
        bool duplicateSlot = volumeMode == "Session slot" and not na(lastAnchor) and lastAnchor == dayAnchor
        bool enough = samples >= (volumeMode == "Session slot" ? 5 : 20)
        relativeVolume := haveVolume and enough and baseline > 0 and not duplicateSlot ? volume / baseline : na
        volumeStatus := not haveVolume ? "VOLUME MISSING" : duplicateSlot ? "SLOT REPEATED" : volume == 0 ? "ZERO VOLUME" : na(relativeVolume) ? "VOLUME WARMUP" : "AVAILABLE"
        if haveVolume and not duplicateSlot
            float updated = samples == 0 ? volume : baseline + (volume - baseline) * 2 / 21
            if volumeMode == "Session slot"
                array.set(slotMean, slot, updated)
                array.set(slotCount, slot, samples + 1)
                array.set(slotAnchor, slot, dayAnchor)
            else
                volMean := updated
                volCount += 1
        if not needsVolume
            volumeStatus := "PRICE ONLY"
        bool volumeOk = not needsVolume or (volumeStatus == "AVAILABLE" and relativeVolume >= 1)
        if na(anchor) or dayAnchor != anchor
            anchorComplete := not na(anchor)
            anchor := dayAnchor
            anchorValid := true
            pv := 0
            vSum := 0
        if not haveVolume
            anchorValid := false
        if haveVolume and volume > 0
            pv += hlc3 * volume
            vSum += volume
        vwap := vSum > 0 ? pv / vSum : na
        bool contextOk = not vwapGate or (anchorValid and anchorComplete and not na(vwap) and haveVolume and volume > 0 and direction * (close - vwap) / tick > 0)
        int fire = 0
        if not ready or shock or pause > 0
            breakoutSide := 0
        else if breakoutSide != 0
            if breakoutSide * (close - breakoutLevel) / tick > 0
                fire := breakoutSide
            breakoutSide := 0
        else if array.size(highs) == 20
            if (close - array.max(highs)) / tick > 0
                breakoutSide := 1
                breakoutLevel := array.max(highs)
            else if (array.min(lows) - close) / tick > 0
                breakoutSide := -1
                breakoutLevel := array.min(lows)
        int pull = not na(previous) and not na(priorLevel) and direction * (previous - priorLevel) / tick <= 0 and direction * (close - level) / tick > 0 ? direction : 0
        int setupSide = setupMode == "Breakout" ? fire : setupMode == "Pullback" ? pull : fire != 0 ? fire : pull
        setupName := setupSide != 0 and setupSide == fire ? "BREAKOUT" : setupSide != 0 ? "PULLBACK" : "NONE"
        distance := math.ceil(stopAtr * atr / tick - 1e-9) * tick
        float slip = slipTicks * tick
        float fees = 2 * feePerUnit + (2 * math.abs(close) + distance + 2 * slip) * pointValue * costBps / 10000
        float unitRisk = ((distance + slip + tick) * pointValue + fees) * conversion
        float capacity = profile == "Futures" or math.sign(close) <= 0 ? margin : margin > 0 ? margin : (math.abs(close) + slip) * pointValue * conversion
        float capacityWithFees = capacity + (feePerUnit + (math.abs(close) + slip) * pointValue * costBps / 10000) * conversion
        quantity := math.sign(capacity) > 0 and math.sign(distance) > 0 and math.sign(unitRisk) > 0 ? math.max(0, math.floor(math.min(account * riskPct / 100 / unitRisk, account * capitalFraction / capacityWithFees) / lot) * lot) : 0
        entryStatus := count < 40 ? "WARMUP" : not ready ? "FLAT" : condition == "SHOCK" ? "SHOCK PAUSE" : condition == "CONGESTION" ? "CONGESTION" : direction == 0 or direction * evidence * 100 < persistenceMin ? "WEAK EVIDENCE" : direction < 0 and not allowShort ? "SHORT DISABLED" : not volumeOk and volumeStatus != "AVAILABLE" ? volumeStatus : not volumeOk ? "LOW PARTICIPATION" : not contextOk ? "VWAP CONTEXT" : extension > extensionLimit ? "EXTENDED" : quantity <= 0 ? "SIZE UNAVAILABLE" : setupSide == direction ? "READY" : "WAIT SETUP"
        signal := entryStatus == "READY" ? direction : 0
        array.push(highs, high)
        array.push(lows, low)
        if array.size(highs) > 20
            array.shift(highs)
            array.shift(lows)
        previous := close

// Illustrative next-open ledger. Orders are not sent to a broker.
var int heldSide = 0
var int pendingSide = 0
var float pendingQuantity = 0
var float pendingDistance = 0
var float heldQuantity = 0
var float entryPrice = na
var float stopPrice = na
var float heldDistance = na
var int reversalCount = 0
var bool pendingExit = false
var bool capacityBreached = false
bool entryIntent = false
bool exitIntent = false
bool entered = false
bool exited = false
string exitReason = ""
if barstate.isconfirmed and valid
    bool dueExit = pendingExit
    pendingExit := false
    float slip = slipTicks * tick
    if heldSide != 0
        if heldSide * (open - stopPrice) / tick <= 0
            exited := true
            exitReason := "GAP_STOP"
        else if dueExit
            exited := true
            exitReason := "REVERSAL"
    if heldSide == 0 and pendingSide != 0
        heldSide := pendingSide
        heldQuantity := pendingQuantity
        heldDistance := pendingDistance
        entryPrice := open + heldSide * slip
        float rawStop = entryPrice - heldSide * heldDistance
        stopPrice := (heldSide > 0 ? math.floor(rawStop / tick + 1e-9) : math.ceil(rawStop / tick - 1e-9)) * tick
        float entryFee = heldQuantity * (feePerUnit + math.abs(entryPrice) * pointValue * costBps / 10000) * conversion
        float filledCapacity = margin > 0 ? margin : math.abs(entryPrice) * pointValue * conversion
        capacityBreached := filledCapacity * heldQuantity + entryFee > account * capitalFraction
        pendingSide := 0
        entered := true
    if heldSide != 0 and not exited and (heldSide > 0 ? (stopPrice - low) / tick >= 0 : (high - stopPrice) / tick >= 0)
        exited := true
        exitReason := "STOP"
    if exited
        heldSide := 0
        reversalCount := 0
    if heldSide != 0
        reversalCount := direction == -heldSide ? reversalCount + 1 : 0
        if reversalCount >= 2
            pendingExit := true
            exitIntent := true
    else if not exited and signal != 0
        pendingSide := signal
        pendingQuantity := quantity
        pendingDistance := distance
        entryIntent := true
// Alerts are decision events. Namespace separates configurations/alert instances.
string alertNamespace = input.string("default", "Alert instance ID", group = "05 · Alerts", tooltip = "Use a distinct ID for each configuration. Allowed characters: letters, numbers, dash and underscore. Recreate TradingView alerts after settings change.")
if str.length(alertNamespace) == 0 or str.match(alertNamespace, "^[A-Za-z0-9_-]+$") != alertNamespace
    runtime.error("Alert instance ID must contain only letters, numbers, dash or underscore.")
if entryIntent or exitIntent
    string eventName = entryIntent ? "ENTRY_INTENT" : "EXIT_INTENT"
    int eventSide = entryIntent ? pendingSide : heldSide
    string eventId = "kb-0.3:" + alertNamespace + ":" + syminfo.tickerid + ":" + timeframe.period + ":" + str.tostring(time_close) + ":" + eventName + ":" + str.tostring(eventSide)
    string payload = '{"version":"kb-0.3-experimental","id":"' + eventId + '","event":"' + eventName + '","symbol":"' + syminfo.tickerid + '","timeframe":"' + timeframe.period + '","time_close":' + str.tostring(time_close) + ',"side":' + str.tostring(eventSide) + ',"quantity":' + str.tostring(entryIntent ? pendingQuantity : heldQuantity, "#.########") + ',"distance":' + str.tostring(entryIntent ? pendingDistance : heldDistance, "#.########") + ',"illustrative":true}'
    alert(payload, alert.freq_once_per_bar_close)

bool dark = theme == "Dark" or (theme == "Auto" and color.r(chart.bg_color) + color.g(chart.bg_color) + color.b(chart.bg_color) < 384)
color surface = dark ? #151B18 : #FCFCF8
color ink = dark ? #E8ECE6 : #202E25
color secondary = dark ? #A5B0A7 : #546559
color up = dark ? #A6CF9A : #37672C
color down = dark ? #EDABA0 : #9E453A
color amber = dark ? #E7C184 : #835B1C
color edge = dark ? #344238 : #BDC7BB
color bearingInk = not ready or math.abs(evidence * 100) < persistenceMin ? secondary : direction > 0 ? up : down
plot(level, "Bearing price", color = bearingInk, linewidth = 2)
plot(showVwap ? vwap : na, "Session VWAP", color = secondary)
plot(heldSide != 0 ? entryPrice : na, "Illustrative entry", color = secondary, style = plot.style_linebr)
plot(heldSide != 0 ? stopPrice : na, "Frozen protective stop", color = amber, linewidth = 2, style = plot.style_linebr)
barcolor(candleColors and barstate.isconfirmed ? bearingInk : na)
plotshape(entryIntent and pendingSide == 1, "Long intent", style = shape.triangleup, location = location.belowbar, color = color.green, size = size.tiny)
plotshape(entryIntent and pendingSide == -1, "Short intent", style = shape.triangledown, location = location.abovebar, color = color.red, size = size.tiny)
plotshape(exited, "Illustrative exit", style = shape.xcross, location = location.abovebar, color = amber, size = size.tiny)
bool displayValid = barstate.isconfirmed ? valid : valid[1]
string state = not displayValid ? "DATA UNAVAILABLE" : pendingExit ? "EXIT QUEUED" : heldSide != 0 ? (heldSide > 0 ? "LONG PLAN" : "SHORT PLAN") : pendingSide != 0 ? "ENTRY QUEUED" : entryStatus
string reason = heldSide != 0 ? (not displayValid ? "Data invalid; last stop retained" : capacityBreached ? "Capacity exceeded at fill" : "Frozen stop active") : pendingSide != 0 ? "Illustrative next-open fill" : entryStatus == "EXTENDED" ? "Trend persists; wait for entry" : entryStatus == "SIZE UNAVAILABLE" ? "Check contract / margin / risk" : entryStatus == "SHOCK PAUSE" ? "New entries paused" : entryStatus == "WAIT SETUP" ? "Evidence ready; wait for setup" : "Experimental · closed-bar state"
var table panel = table.new(position.top_right, 2, 15, bgcolor = surface, frame_color = edge, frame_width = 1)
row(table t, int n, string label, string value, color valueInk, color bg, color labelInk) =>
    table.cell(t, 0, n, " " + label + " ", bgcolor = bg, text_color = labelInk, text_halign = text.align_left, text_size = size.small)
    table.cell(t, 1, n, " " + value + " ", bgcolor = bg, text_color = valueInk, text_halign = text.align_right, text_size = size.small)
if barstate.isfirst
    table.merge_cells(panel, 0, 1, 1, 1)
    table.merge_cells(panel, 0, 2, 1, 2)
    table.merge_cells(panel, 0, 14, 1, 14)
if barstate.islast
    table.clear(panel, 0, 0, 1, 14)
    if density != "Hidden" or (heldSide != 0 and (capacityBreached or not displayValid))
        row(panel, 0, "KONATUS BEARING", profile, ink, surface, secondary)
        table.cell(panel, 0, 1, " " + state + " ", text_color = heldSide != 0 and not pendingExit ? bearingInk : amber, bgcolor = surface, text_size = size.large, text_halign = text.align_left)
        table.cell(panel, 0, 2, " " + reason + " ", text_color = secondary, bgcolor = surface, text_size = size.small, text_halign = text.align_left)
        row(panel, 3, "DIRECTION", direction > 0 ? "Upward" : direction < 0 ? "Downward" : "Unclear", bearingInk, surface, secondary)
        row(panel, 4, "PERSISTENCE", str.tostring(evidence * 100, "0.0") + " / 100", ink, surface, secondary)
        row(panel, 5, "CONDITIONS", condition, ink, surface, secondary)
        row(panel, 6, "NEW ENTRY", entryStatus, ink, surface, secondary)
        row(panel, 7, "EXTENSION", na(extension) ? "—" : str.tostring(extension, "0.00") + " ATR", ink, surface, secondary)
        row(panel, 8, "PARTICIPATION", volumeStatus, secondary, surface, secondary)
        row(panel, 9, "FROZEN STOP", heldSide != 0 ? str.tostring(stopPrice, format.mintick) : "—", amber, surface, secondary)
        row(panel, 10, "ACTIVE / QUEUED QTY", str.tostring(heldSide != 0 ? heldQuantity : pendingSide != 0 ? pendingQuantity : 0, "#.########"), ink, surface, secondary)
        if density == "Detailed"
            row(panel, 11, "DRIFT / MODEL SD", str.tostring(drift, "#.########") + " / " + str.tostring(math.sqrt(math.max(p11, 0)), "#.########"), ink, surface, secondary)
            row(panel, 12, "RUN / EFFICIENCY", str.tostring(runLength) + " / " + str.tostring(efficiency, "0.00"), ink, surface, secondary)
            row(panel, 13, "QUOTE → ACCOUNT", syminfo.currency + " × " + str.tostring(conversion, "#.########"), secondary, surface, secondary)
        table.cell(panel, 0, 14, " kb-0.3 · experimental · illustrative ", text_color = secondary, bgcolor = surface, text_size = size.small)
plot(evidence * 100, "Persistence", display = display.data_window)
plot(direction, "Direction", display = display.data_window)
plot(extension, "Extension ATR", display = display.data_window)
plot(efficiency, "Efficiency", display = display.data_window)
plot(atr, "ATR", display = display.data_window)
plot(relativeVolume, "Prior relative volume", display = display.data_window)
plot(surprise, "Standardized forecast innovation", display = display.data_window)
plot(predictiveSd, "Prior predictive SD", display = display.data_window)
plot(drift, "Price drift", display = display.data_window)
plot(math.sqrt(math.max(p11, 0)), "Model drift SD", display = display.data_window)
plot(signal, "Setup signal", display = display.data_window)
plot(entryIntent ? pendingSide : 0, "Entry intent", display = display.data_window)
plot(exitIntent ? 1 : 0, "Exit intent", display = display.data_window)
plot(quantity, "Planned quantity", display = display.data_window)
plot(distance, "Planned distance", display = display.data_window)
plot(ready ? 1 : 0, "Ready", display = display.data_window)
// Raw observation exports allow an actual chart-to-Python parity check.
plot(bar_index, "Observation index", display = display.data_window)
plot(open, "Input open", display = display.data_window)
plot(high, "Input high", display = display.data_window)
plot(low, "Input low", display = display.data_window)
plot(close, "Input close", display = display.data_window)
plot(volume, "Input volume", display = display.data_window)
plot(time_tradingday, "Input anchor", display = display.data_window)
plot(hour(time, syminfo.timezone) * 60 + minute(time, syminfo.timezone), "Input slot", display = display.data_window)
plot(anchorComplete ? 1 : 0, "Input anchor complete", display = display.data_window)
plot(resetNow ? 1 : 0, "Input reset", display = display.data_window)

plot(heldSide, "Held side", display = display.data_window)
plot(heldSide != 0 ? heldQuantity : 0, "Held quantity", display = display.data_window)
plot(exited ? 1 : 0, "Illustrative exit flag", display = display.data_window)
plot(exitReason == "STOP" ? 1 : exitReason == "GAP_STOP" ? 2 : exitReason == "REVERSAL" ? 3 : 0, "Illustrative exit code", display = display.data_window)

What changed in 0.3.1

Version 0.3.1 moves Enable short entries to the first input group, 00 · Entry direction. Long entries use green triangles, shorts use red triangles, and exits keep their X marks. Entry text is removed. The underlying cross-market signal rules and sizing are unchanged. 68 tests pass on the Python reference implementation; TradingView compilation is still pending.

Why I built it

I built this overlay because the usual chart was teaching me the wrong lesson. A moving average, another moving average, MACD, RSI, a stochastic, a channel: the same prices recoded until they looked like a committee. Spinoza’s name for that error is inadequate ideas stacked until they feel like knowledge. When the stack disagreed I invented a reason. When it agreed I felt confirmed. Neither feeling was a plan.

What I needed was one readable heading, one named refusal, and one invalidation written at the moment of the event. Konatus Bearing is that instrument. It names the conditions, names what is missing, and writes the stop before hope can rewrite it. That is discipline, not a forecast.

How a “losing” indicator can still be profitable

People call a rule a loser when it is wrong most of the time. That is a hit-rate complaint. Money does not settle hit rate. Chapter 3 of The Spinozan Trader writes expectancy as pW − (1 − p)L − C: the chance of a win times the average win, minus the chance of a loss times the average loss, minus costs. A 90% rule that wins a little and loses a lot can drain an account. A 30% rule that wins enough and loses little can grow one.

What to look at before calling a rule a winner or a loser.
Look atWhat it answersWhat it does not answer
Win rateHow often the row is green.How much money moved.
Average winConditional size of a gain.Whether the next trade wins.
Average lossConditional size of a loss.Whether the next trade loses.
ExpectancypW − (1−p)L − C. The question that replaces “does it win often?”Whether buy-and-hold would have done better.

Full statement: proposition 4 in chapter 3. Why a useful indicator can still fail to produce a lasting edge: chapter 7.

What it does not do

  • It does not forecast the next bar or a target. Persistence is a signed score from −100 to +100, not a probability.
  • It does not place orders. ENTRY_INTENT and EXIT_INTENT alerts are notes to yourself, not broker fills.
  • It does not model borrow, locate, funding, financing, dividends, live margin or futures roll adjustments. Gaps can exceed planned risk.
  • It does not come with fitted settings. The defaults are research choices, not optimized parameters.
  • It does not replace an index fund for a person who does not need a timing rule.

Pair it with the Conatus Scanner

The Conatus Scanner ranks every US-listed company by its causes: power, persistence, self-funding, striving and independence. It tells you which businesses are worth watching. Konatus Bearing reads the chart and tells you when the conditions are right for a plan.

Open the scanner →

02 / New, separate indicator

Market Anatomy

A standalone KONATUS indicator with its own Pine script. Install it separately from Konatus Bearing.

Pine Script v6 · Research release 0.1.0

How to use it with Konatus Bearing →

Brass drafting compass and magnifying lens over geometric curves on dark paper
Market Anatomy

A common language for reading a market: its direction, pace, organization, volatility, location and participation. Built from The Spinozan Trader, this overlay explains the evidence behind its reading and separates observations from hypotheses about their economic causes.

Finite differences measure velocity and acceleration. Normalized curvature describes local bending; Euclidean path geometry compares distance traveled with progress made. A prior mean true range puts price movement on a common scale across instruments. These are measurements of the observed path, not a forecast.

Research release 0.1.0. 21 offline Python reference tests pass. Native TradingView compilation, chart behavior and alert delivery remain unverified. No trading edge has been measured.

Download Pine v6 sourceDownload research package (.zip)

View the complete Market Anatomy code
//@version=6
// Konatus Market Anatomy 0.1.0 — experimental interpretation tool.
// Copyright (c) 2026 Kutty Stauder. No profitability or causal identification claim.
// Formula/verification contract: SPEC.md and docs/VERIFICATION.md.
indicator("KONATUS | Market Anatomy [Research 0.1]", "K Anatomy", overlay = true, max_bars_back = 1200)

// One common measurement system; horizon is always measured in chart bars.
int horizon = input.int(20, "Observation horizon (bars)", minval = 5, maxval = 200, group = "Understanding")
string volumeInput = input.string("Auto", "Volume meaning", options = ["Auto", "Trade units", "Tick count", "Quote value", "Off"], group = "Data", tooltip = "Auto follows feed metadata. Only declared trade units enable VWAP. Unknown volume stays unverified; tick activity is not traded quantity.")
string anchorInput = input.string("Auto", "VWAP calendar anchor", options = ["Auto", "Day", "Week", "Month", "Year", "Off"], group = "Data", tooltip = "Exchange-calendar boundaries on the bars loaded in the chart. Auto: intraday/day, daily/month, weekly or monthly/year. First partial anchor is withheld. This is not a regular-session filter.")
bool detailed = input.bool(false, "Detailed explanation", group = "Display")
bool showPanel = input.bool(true, "Explanation panel", group = "Display")
bool showEnvelope = input.bool(true, "Descriptive extension envelope", group = "Display", tooltip = "Filtered price +/- 1.5 prior mean true ranges. This is not a probability interval or a stop.")
bool showVwap = input.bool(true, "Volume-weighted reference", group = "Display")
bool showScenario = input.bool(true, "Frozen scenario boundary", group = "Display")
bool paintBars = input.bool(false, "Color confirmed candles", group = "Display")
string corner = input.string("Top right", "Panel position", options = ["Top right", "Bottom right", "Top left", "Bottom left"], group = "Display")

f_number(float value) =>
    na(value) ? "Unavailable" : str.tostring(value, "#.###")

f_row(table panel, int row, string caption, string value, color tone) =>
    table.cell(panel, 0, row, caption, text_color = color.new(chart.fg_color, 30), text_size = size.small, text_halign = text.align_left)
    table.cell(panel, 1, row, value, text_color = tone, text_size = size.small, text_halign = text.align_left)

color upColor = color.rgb(38, 171, 145)
color downColor = color.rgb(224, 102, 108)
color cautionColor = color.rgb(213, 158, 66)
color neutralColor = color.new(chart.fg_color, 25)
bool timedChart = timeframe.isseconds or timeframe.isminutes or timeframe.isdaily or timeframe.isweekly or timeframe.ismonthly
bool supported = chart.is_standard and timedChart
bool validPrice = supported and not na(open) and not na(high) and not na(low) and not na(close) and low <= math.min(open, close) and high >= math.max(open, close)
var int priceCount = 0
priceCount := validPrice ? priceCount + 1 : 0
bool previousValid = validPrice[1]
float trueRange = validPrice ? (previousValid ? math.max(high - low, math.max(math.abs(high - close[1]), math.abs(low - close[1]))) : high - low) : na
float shortRange = ta.sma(trueRange, horizon)
float longRange = ta.sma(trueRange, 4 * horizon)
float scale = shortRange[1]
bool warm = priceCount >= 4 * horizon + 2
bool scaleKnown = warm and not na(scale) and scale > 0

// Two-pole SuperSmoother in signed price space. No log-price clipping.
float decay = math.exp(-math.sqrt(2.0) * math.pi / horizon)
float c2 = 2.0 * decay * math.cos(math.sqrt(2.0) * math.pi / horizon)
float c3 = -decay * decay
float c1 = 1.0 - c2 - c3
var float level = na
float level1 = previousValid ? nz(level[1], close) : close
float level2 = previousValid ? nz(level[2], level1) : close
level := validPrice ? c1 * (close + (previousValid ? close[1] : close)) / 2.0 + c2 * level1 + c3 * level2 : na
float velocity = scaleKnown ? (level - level1) / scale : na
float acceleration = scaleKnown ? (level - 2.0 * level1 + level2) / scale : na
float curvature = scaleKnown ? acceleration / math.pow(1.0 + velocity * velocity, 1.5) : na
float extension = scaleKnown ? (close - level) / scale : na
float distance = math.sum(math.abs(close - close[1]), horizon)
float efficiency = warm ? (distance > 0 ? math.abs(close - close[horizon]) / distance : 0.0) : na
float volatility = warm and longRange > 0 ? shortRange / longRange : na
float shockRatio = scaleKnown ? trueRange / scale : na
bool shock = scaleKnown and shockRatio >= 3.0
int direction = scaleKnown ? (velocity > 0.08 ? 1 : velocity < -0.08 ? -1 : 0) : 0

// Volume semantics remain separate from directional evidence.
string volumeKind = volumeInput == "Auto" ? (syminfo.volumetype == "base" ? "Trade units" : syminfo.volumetype == "tick" ? "Tick count" : syminfo.volumetype == "quote" ? "Quote value" : "Unverified") : volumeInput
bool validVolume = validPrice and not na(volume) and volume >= 0
var int volumeCount = 0
volumeCount := validVolume ? volumeCount + 1 : 0
float volumeMean = ta.sma(validVolume ? volume : na, horizon)[1]
float relativeVolume = volumeInput != "Off" and volumeCount >= horizon + 1 and volumeMean > 0 ? volume / volumeMean : na

string anchorTf = switch anchorInput
    "Day" => "1D"
    "Week" => "1W"
    "Month" => "1M"
    "Year" => "12M"
    => timeframe.isintraday ? "1D" : timeframe.isdaily ? "1M" : "12M"
float chartSeconds = timedChart ? timeframe.in_seconds() : na
bool anchorLonger = timedChart and timeframe.in_seconds(anchorTf) > chartSeconds
bool anchorEnabled = supported and anchorInput != "Off" and volumeKind == "Trade units" and anchorLonger
bool anchorChanged = timeframe.change(anchorTf) and not barstate.isfirst
var bool anchorComplete = false
var bool anchorPoisoned = false
var float anchorWeight = 0.0
var float anchorMean = 0.0
if anchorChanged
    anchorComplete := true
    anchorPoisoned := false
    anchorWeight := 0.0
    anchorMean := 0.0
if not validVolume
    anchorPoisoned := true
else if volume > 0 and anchorEnabled
    float totalWeight = anchorWeight + volume
    anchorMean += volume / totalWeight * (hlc3 - anchorMean)
    anchorWeight := totalWeight
float vwapReference = anchorEnabled and anchorComplete and not anchorPoisoned and anchorWeight > 0 ? anchorMean : na

// A scenario freezes a previously observable boundary. It is not a trade ledger.
float priorHigh = ta.highest(high, horizon)[1]
float priorLow = ta.lowest(low, horizon)[1]
var int scenario = 0
var float boundary = na
bool invalidated = false
int breakout = 0
if not scaleKnown
    scenario := 0
    boundary := na
else if scenario != 0 and (close - boundary) * scenario < 0
    invalidated := true
    scenario := 0
    boundary := na
else if scenario == 0
    if close > priorHigh
        scenario := 1
        boundary := priorHigh
        breakout := 1
    else if close < priorLow
        scenario := -1
        boundary := priorLow
        breakout := -1

// Each message is a direct explanation of a measurement or a named hypothesis.
string state = not supported ? "UNSUPPORTED CHART" : not validPrice ? "INVALID PRICE DATA" : not warm ? "BUILDING HISTORY" : not scaleKnown ? (trueRange == 0 ? "FLAT" : "SCALE UNAVAILABLE") : direction > 0 ? "UPWARD" : direction < 0 ? "DOWNWARD" : "BALANCED"
string organization = not warm ? "Awaiting history" : efficiency >= 0.55 ? "Directional path" : efficiency < 0.25 ? "Backtracking path" : "Mixed path"
float directionalAcceleration = acceleration * direction
string pace = not scaleKnown ? "Unavailable" : direction == 0 ? "No directional pace" : directionalAcceleration > 0.015 ? "Gaining pace" : directionalAcceleration < -0.015 ? "Losing pace" : "Steady pace"
string volatilityText = not warm ? "Awaiting history" : shock ? "SHOCK / " + f_number(shockRatio) + " x prior range" : na(volatility) ? "No range" : volatility > 1.3 ? "Expanding" : volatility < 0.7 ? "Contracting" : "Near longer baseline"
string locationText = not scaleKnown ? "Unavailable" : (math.abs(extension) >= 1.5 ? "Extended / " : "Near reference / ") + f_number(extension) + " range units"
string participation = volumeInput == "Off" ? "Off / price-only context" : not validVolume ? "Missing volume" : na(relativeVolume) ? "Baseline unavailable" : f_number(relativeVolume) + " x / " + volumeKind
if timeframe.isintraday and volumeInput != "Off"
    participation += "\nNot time-of-day adjusted"
string anchorText = anchorInput == "Off" ? "Off" : volumeKind != "Trade units" ? "Requires declared trade units" : not anchorLonger ? "Anchor must exceed chart interval" : not anchorComplete ? "First partial anchor withheld" : anchorPoisoned ? "Anchor has missing data" : anchorWeight <= 0 ? "No positive anchor volume" : (close > vwapReference ? "Above " : close < vwapReference ? "Below " : "At ") + anchorTf + " VWAP"
string scenarioText = invalidated ? "Prior breakout failed" : scenario > 0 ? "Upward range breakout" : scenario < 0 ? "Downward range breakout" : "No active breakout scenario"
string failureText = scenario > 0 ? "Close below " + str.tostring(boundary, format.mintick) : scenario < 0 ? "Close above " + str.tostring(boundary, format.mintick) : "No scenario boundary"
string reading = not warm ? "Waiting for " + str.tostring(4 * horizon + 2) + " consecutive valid bars." : not scaleKnown ? "Normalized movement is unavailable." : shock ? "Range jumped relative to its prior baseline.\nThe previous pace may no longer describe conditions." : direction != 0 and efficiency < 0.25 ? "The smoother points one way; raw price backtracks.\nDirection and path organization conflict." : direction != 0 and math.abs(extension) >= 1.5 ? "Price is far from the smoothed reference.\nExtension alone does not imply reversal." : direction != 0 ? "The filtered path is directional.\nRead pace and participation separately." : "Directional movement is weak under this definition.\nA balanced reading does not predict a breakout."
string hypothesis = not scaleKnown ? "Insufficient normalized evidence." : shock ? "Information arrival or a liquidity change are possible.\nThe chart does not identify the cause." : direction != 0 and efficiency >= 0.55 ? "Persistent execution demand is one possible explanation.\nParticipant identity and intent are unobserved." : "Two-sided trade is one possible explanation.\nOHLCV cannot identify who supplied liquidity."
color stateColor = not warm or not scaleKnown ? neutralColor : direction > 0 ? upColor : direction < 0 ? downColor : neutralColor

// Developing bars carry no final plot/marker. The table holds the last closed bar.
bool confirmed = barstate.isconfirmed
plot(confirmed and warm ? level : na, "Filtered price", color = stateColor, linewidth = 2, style = plot.style_linebr)
upperPlot = plot(confirmed and scaleKnown and showEnvelope ? level + 1.5 * scale : na, "Upper descriptive envelope", color = color.new(neutralColor, 80), style = plot.style_linebr)
lowerPlot = plot(confirmed and scaleKnown and showEnvelope ? level - 1.5 * scale : na, "Lower descriptive envelope", color = color.new(neutralColor, 80), style = plot.style_linebr)
fill(upperPlot, lowerPlot, color = color.new(stateColor, 95), title = "Descriptive extension area")
plot(confirmed and showVwap ? vwapReference : na, "Calendar VWAP", color = color.new(cautionColor, 15), style = plot.style_linebr)
plot(confirmed and showScenario ? boundary : na, "Scenario boundary (not stop)", color = cautionColor, linewidth = 1, style = plot.style_circles)
barcolor(paintBars and confirmed and warm ? stateColor : na)
plotshape(confirmed and breakout == 1, "Upward breakout observation", shape.circle, location.belowbar, upColor, size = size.tiny)
plotshape(confirmed and breakout == -1, "Downward breakout observation", shape.circle, location.abovebar, downColor, size = size.tiny)
plotshape(confirmed and invalidated, "Scenario invalidated", shape.xcross, location.abovebar, cautionColor, size = size.tiny)

string panelPosition = corner == "Top right" ? position.top_right : corner == "Bottom right" ? position.bottom_right : corner == "Top left" ? position.top_left : position.bottom_left
var table panel = table.new(panelPosition, 2, detailed ? 18 : 12, bgcolor = color.new(chart.bg_color, 5), frame_color = color.new(chart.fg_color, 80), frame_width = 1)
if barstate.islast and showPanel
    int snapshot = confirmed ? 0 : 1
    color tone = stateColor[snapshot]
    string clockText = na(time_close[snapshot]) ? "No confirmed bar" : str.format_time(time_close[snapshot], "MM-dd HH:mm", syminfo.timezone)
    f_row(panel, 0, "KONATUS", "MARKET ANATOMY / 0.1", chart.fg_color)
    f_row(panel, 1, syminfo.ticker + " / " + timeframe.period, state[snapshot], tone)
    f_row(panel, 2, "Path / efficiency", organization[snapshot] + " / " + f_number(efficiency[snapshot]), tone)
    f_row(panel, 3, "Pace", pace[snapshot], tone)
    f_row(panel, 4, "Volatility", volatilityText[snapshot], shock[snapshot] ? cautionColor : chart.fg_color)
    f_row(panel, 5, "Location", locationText[snapshot], chart.fg_color)
    f_row(panel, 6, "Activity / rolling", participation[snapshot], chart.fg_color)
    f_row(panel, 7, "Anchor context", anchorText[snapshot], chart.fg_color)
    f_row(panel, 8, "Scenario", scenarioText[snapshot] + "\nInvalidation: " + failureText[snapshot], cautionColor)
    f_row(panel, 9, "Reading", reading[snapshot], chart.fg_color)
    if detailed
        f_row(panel, 10, "Possible mechanism", hypothesis[snapshot], neutralColor)
        f_row(panel, 11, "Velocity / acceleration", f_number(velocity[snapshot]) + " / " + f_number(acceleration[snapshot]), tone)
        f_row(panel, 12, "Local curvature", f_number(curvature[snapshot]) + " / defined coordinates", neutralColor)
        f_row(panel, 13, "Prior range scale", f_number(scale[snapshot]) + " / SMA true range", neutralColor)
        f_row(panel, 14, "Data meaning", volumeKind + " / " + syminfo.type, neutralColor)
        f_row(panel, 15, "Activity limitation", "Rolling baseline; no time-of-day adjustment.", neutralColor)
    f_row(panel, detailed ? 16 : 10, "Confirmed through", clockText + " / " + syminfo.timezone, neutralColor)
    f_row(panel, detailed ? 17 : 11, "Evidence", "Descriptive / parameters unvalidated", neutralColor)

// Exports enable a native TradingView versus Python fixture comparison.
plot(confirmed ? velocity : na, "Velocity", display = display.data_window)
plot(confirmed ? acceleration : na, "Acceleration", display = display.data_window)
plot(confirmed ? efficiency : na, "Efficiency", display = display.data_window)
plot(confirmed ? extension : na, "Extension", display = display.data_window)
plot(confirmed ? curvature : na, "Curvature", display = display.data_window)
plot(confirmed and warm ? volatility : na, "Volatility ratio", display = display.data_window)
plot(confirmed ? relativeVolume : na, "Relative activity", display = display.data_window)
plot(confirmed and warm ? scale : na, "Prior scale", display = display.data_window)
plot(confirmed and warm ? direction : na, "Direction", display = display.data_window)

bool directionChanged = scaleKnown and scaleKnown[1] and direction != direction[1]
alertcondition(confirmed and directionChanged, "Direction changed", "KONATUS {{ticker}} {{interval}}: confirmed direction changed. Read the explanation panel; this is not an entry order.")
alertcondition(confirmed and shock and not shock[1], "Volatility shock", "KONATUS {{ticker}} {{interval}}: confirmed range shock relative to prior history.")
alertcondition(confirmed and breakout != 0, "Range breakout observed", "KONATUS {{ticker}} {{interval}}: confirmed range breakout. A descriptive scenario boundary is now frozen.")
alertcondition(confirmed and invalidated, "Scenario invalidated", "KONATUS {{ticker}} {{interval}}: a confirmed close invalidated the frozen breakout scenario.")

Install in TradingView

  1. PasteOpen TradingView’s Pine Editor, create a blank indicator, replace its contents with the complete source, save, then select Add to chart.
  2. ChartUse ordinary time-based candles. Start with the 20-bar observation horizon and allow at least 82 consecutive valid bars for warmup.
  3. ReadEnable Detailed explanation for the mathematical readouts and mechanism hypotheses. The panel holds the last confirmed bar’s assessment.
  4. VolumeStart on Auto and check the feed’s volume meaning. Tick counts and quote-value activity stay distinct from trade units; missing volume leaves the price measurements available.
  5. AlertsChoose a direction change, shock, breakout or invalidation alert and use Once Per Bar Close. Recreate alerts after changing the code or inputs.

Designed for valid time-based OHLC data across stocks, ETFs, futures, forex and crypto; feed and timeframe coverage still need testing. Relative activity uses a rolling baseline, without time-of-day adjustment. The range scale is an SMA of true range, not Wilder’s ATR. A frozen breakout boundary is a scenario reference, not an order or stop.

The research package includes the script, setup guide, mathematics, specification and reference tests. Back to Konatus Bearing ↑

03 / New integrated edition

KONATUS
Atlas.

Understand the conditions.
See the plan.

Bearing’s planning engine and Anatomy’s market readings, already integrated in one Pine v6 indicator. One installation, one coordinated dashboard, and optional context filters.

Get the integrated code ↓
Brass armillary instrument surrounding a glass sphere with fine geometric arcs
KONATUS Atlas

Research release 0.1.0. 35 offline Python tests pass. Native TradingView compilation, chart behavior and alert delivery remain unverified. The optional combined policies have no established trading edge.

01 / Understand

Read the market.

Direction, velocity, acceleration, curvature, path efficiency, volatility and participation retain Anatomy’s explicit definitions.

02 / Plan

Keep the conditions.

Bearing retains its setup checks, illustrative next-open ledger, sizing assumptions, frozen stops and exit logic.

03 / Connect

Make context visible.

A shared dashboard explains agreement and conflict. Optional policies let Anatomy withhold new Bearing candidates.

A calmer chart. More explanation.

Choose compact or detailed readings, dark or light styling, text size and panel corner. One system owns candle colors. Extra Anatomy lines, the envelope and calendar VWAP are optional.

A solid gold line represents Bearing’s illustrative stop. A dotted teal line represents Anatomy’s descriptive scenario boundary. They have different jobs.

Dashboard design illustration with example values. Not a TradingView screenshot or live signal.

Choose how the engines work together

Observe only · default
Bearing’s candidates remain unchanged. Anatomy contributes explanation and a direction comparison. Agreement uses shared price data, not independent votes.
Require direction
New candidates also need a valid, warmed-up Anatomy scale, matching nonzero direction and no Anatomy shock.
Require organized direction
Adds a raw-path efficiency threshold of 0.55. This is an unvalidated research choice; fewer candidates does not establish better results.

The filters cannot create a candidate Bearing rejected or increase its position size. A context change does not cancel an already queued illustrative fill, erase a frozen stop or suppress an exit. Changing inputs recomputes the chart history.

Install Atlas by itself

  1. Paste the entire Atlas source into a new Pine Editor indicator, save and add it to an ordinary time-based candle chart. Remove the separate Bearing and Anatomy instances from that chart to avoid duplicate displays and alerts.
  2. Start with Anatomy entry policy → Observe only, Dashboard detail → Compact and Dashboard text → Small. Allow both engines to warm up; Anatomy needs 82 valid bars at its default horizon.
  3. Review the Bearing market, risk, contract and cost inputs. Check Anatomy’s volume meaning. The two engines retain different range scales and volume rules; their readings are not interchangeable.
  4. Use Any alert() function call for Bearing plan events inside Atlas. Select a named Atlas condition and Once Per Bar Close for Anatomy or context-block alerts. Recreate alerts when code, inputs, symbol or timeframe changes.

Get KONATUS Atlas 0.1.0

Download Atlas Pine v6Research package (.zip)

View the complete integrated Pine code
//@version=6
indicator("KONATUS Atlas · Integrated Research 0.1.0", "K Atlas", overlay = true, max_bars_back = 2000)
// Copyright (c) 2026 Kutty Stauder. Research software; no established trading edge.
// Built from frozen Bearing 0.3.1 and Anatomy 0.1.0 engines. See SPEC.md.
string atlasPolicy = input.string("Observe only", "Anatomy entry policy", options = ["Observe only", "Require direction", "Require organized direction"], group = "00 · Atlas", tooltip = "Observe preserves Bearing entries. Optional policies require valid Anatomy context, no Anatomy shock and matching direction. Organized also requires efficiency >= 0.55. Changes new entry candidates only; exits and queued fills remain active. These policies are unvalidated research rules.")
string atlasCorner = input.string("Top right", "Dashboard position", options = ["Top right", "Top left", "Bottom right", "Bottom left"], group = "00 · Atlas")
string atlasDensity = input.string("Compact", "Dashboard detail", options = ["Compact", "Detailed", "Hidden"], group = "00 · Atlas")
string atlasFont = input.string("Small", "Dashboard text", options = ["Small", "Tiny", "Normal"], group = "00 · Atlas")
bool atlasCandles = input.bool(true, "Color confirmed candles", group = "00 · Atlas")
bool atlasAnatomyLine = input.bool(false, "Show Anatomy filtered price", group = "00 · Atlas")
bool atlasEnvelope = input.bool(false, "Show Anatomy descriptive envelope", group = "00 · Atlas")
bool atlasVwap = input.bool(false, "Show Anatomy calendar VWAP", group = "00 · Atlas")
bool atlasScenario = input.bool(true, "Show Anatomy scenario boundary", group = "00 · Atlas")
bool atlasEvents = input.bool(false, "Show Anatomy observation markers", group = "00 · Atlas")

// === ANATOMY ENGINE ===
// One common measurement system; horizon is always measured in chart bars.
int a_horizon = input.int(20, "Observation horizon (bars)", minval = 5, maxval = 200, group = "A · Understanding")
string a_volumeInput = input.string("Auto", "Volume meaning", options = ["Auto", "Trade units", "Tick count", "Quote value", "Off"], group = "A · Volume and anchors", tooltip = "Auto follows feed metadata. Only declared trade units enable VWAP. Unknown volume stays unverified; tick activity is not traded quantity.")
string a_anchorInput = input.string("Auto", "VWAP calendar anchor", options = ["Auto", "Day", "Week", "Month", "Year", "Off"], group = "A · Volume and anchors", tooltip = "Exchange-calendar boundaries on the bars loaded in the chart. Auto: intraday/day, daily/month, weekly or monthly/year. First partial anchor is withheld. This is not a regular-session filter.")

a_f_number(float value) =>
    na(value) ? "Unavailable" : str.tostring(value, "#.###")

color a_upColor = color.rgb(38, 171, 145)
color a_downColor = color.rgb(224, 102, 108)
color a_cautionColor = color.rgb(213, 158, 66)
color a_neutralColor = color.new(chart.fg_color, 25)
bool a_timedChart = timeframe.isseconds or timeframe.isminutes or timeframe.isdaily or timeframe.isweekly or timeframe.ismonthly
bool a_supported = chart.is_standard and a_timedChart
bool a_validPrice = a_supported and not na(open) and not na(high) and not na(low) and not na(close) and low <= math.min(open, close) and high >= math.max(open, close)
var int a_priceCount = 0
a_priceCount := a_validPrice ? a_priceCount + 1 : 0
bool a_previousValid = a_validPrice[1]
float a_trueRange = a_validPrice ? (a_previousValid ? math.max(high - low, math.max(math.abs(high - close[1]), math.abs(low - close[1]))) : high - low) : na
float a_shortRange = ta.sma(a_trueRange, a_horizon)
float a_longRange = ta.sma(a_trueRange, 4 * a_horizon)
float a_scale = a_shortRange[1]
bool a_warm = a_priceCount >= 4 * a_horizon + 2
bool a_scaleKnown = a_warm and not na(a_scale) and a_scale > 0

// Two-pole SuperSmoother in signed price space. No log-price clipping.
float a_decay = math.exp(-math.sqrt(2.0) * math.pi / a_horizon)
float a_c2 = 2.0 * a_decay * math.cos(math.sqrt(2.0) * math.pi / a_horizon)
float a_c3 = -a_decay * a_decay
float a_c1 = 1.0 - a_c2 - a_c3
var float a_level = na
float a_level1 = a_previousValid ? nz(a_level[1], close) : close
float a_level2 = a_previousValid ? nz(a_level[2], a_level1) : close
a_level := a_validPrice ? a_c1 * (close + (a_previousValid ? close[1] : close)) / 2.0 + a_c2 * a_level1 + a_c3 * a_level2 : na
float a_velocity = a_scaleKnown ? (a_level - a_level1) / a_scale : na
float a_acceleration = a_scaleKnown ? (a_level - 2.0 * a_level1 + a_level2) / a_scale : na
float a_curvature = a_scaleKnown ? a_acceleration / math.pow(1.0 + a_velocity * a_velocity, 1.5) : na
float a_extension = a_scaleKnown ? (close - a_level) / a_scale : na
float a_distance = math.sum(math.abs(close - close[1]), a_horizon)
float a_efficiency = a_warm ? (a_distance > 0 ? math.abs(close - close[a_horizon]) / a_distance : 0.0) : na
float a_volatility = a_warm and a_longRange > 0 ? a_shortRange / a_longRange : na
float a_shockRatio = a_scaleKnown ? a_trueRange / a_scale : na
bool a_shock = a_scaleKnown and a_shockRatio >= 3.0
int a_direction = a_scaleKnown ? (a_velocity > 0.08 ? 1 : a_velocity < -0.08 ? -1 : 0) : 0

// Volume semantics remain separate from directional evidence.
string a_volumeKind = a_volumeInput == "Auto" ? (syminfo.volumetype == "base" ? "Trade units" : syminfo.volumetype == "tick" ? "Tick count" : syminfo.volumetype == "quote" ? "Quote value" : "Unverified") : a_volumeInput
bool a_validVolume = a_validPrice and not na(volume) and volume >= 0
var int a_volumeCount = 0
a_volumeCount := a_validVolume ? a_volumeCount + 1 : 0
float a_volumeMean = ta.sma(a_validVolume ? volume : na, a_horizon)[1]
float a_relativeVolume = a_volumeInput != "Off" and a_volumeCount >= a_horizon + 1 and a_volumeMean > 0 ? volume / a_volumeMean : na

string a_anchorTf = switch a_anchorInput
    "Day" => "1D"
    "Week" => "1W"
    "Month" => "1M"
    "Year" => "12M"
    => timeframe.isintraday ? "1D" : timeframe.isdaily ? "1M" : "12M"
float a_chartSeconds = a_timedChart ? timeframe.in_seconds() : na
bool a_anchorLonger = a_timedChart and timeframe.in_seconds(a_anchorTf) > a_chartSeconds
bool a_anchorEnabled = a_supported and a_anchorInput != "Off" and a_volumeKind == "Trade units" and a_anchorLonger
bool a_anchorChanged = timeframe.change(a_anchorTf) and not barstate.isfirst
var bool a_anchorComplete = false
var bool a_anchorPoisoned = false
var float a_anchorWeight = 0.0
var float a_anchorMean = 0.0
if a_anchorChanged
    a_anchorComplete := true
    a_anchorPoisoned := false
    a_anchorWeight := 0.0
    a_anchorMean := 0.0
if not a_validVolume
    a_anchorPoisoned := true
else if volume > 0 and a_anchorEnabled
    float a_totalWeight = a_anchorWeight + volume
    a_anchorMean += volume / a_totalWeight * (hlc3 - a_anchorMean)
    a_anchorWeight := a_totalWeight
float a_vwapReference = a_anchorEnabled and a_anchorComplete and not a_anchorPoisoned and a_anchorWeight > 0 ? a_anchorMean : na

// A scenario freezes a previously observable boundary. It is not a trade ledger.
float a_priorHigh = ta.highest(high, a_horizon)[1]
float a_priorLow = ta.lowest(low, a_horizon)[1]
var int a_scenario = 0
var float a_boundary = na
bool a_invalidated = false
int a_breakout = 0
if not a_scaleKnown
    a_scenario := 0
    a_boundary := na
else if a_scenario != 0 and (close - a_boundary) * a_scenario < 0
    a_invalidated := true
    a_scenario := 0
    a_boundary := na
else if a_scenario == 0
    if close > a_priorHigh
        a_scenario := 1
        a_boundary := a_priorHigh
        a_breakout := 1
    else if close < a_priorLow
        a_scenario := -1
        a_boundary := a_priorLow
        a_breakout := -1

// Each message is a direct explanation of a measurement or a named hypothesis.
string a_state = not a_supported ? "UNSUPPORTED CHART" : not a_validPrice ? "INVALID PRICE DATA" : not a_warm ? "BUILDING HISTORY" : not a_scaleKnown ? (a_trueRange == 0 ? "FLAT" : "SCALE UNAVAILABLE") : a_direction > 0 ? "UPWARD" : a_direction < 0 ? "DOWNWARD" : "BALANCED"
string a_organization = not a_warm ? "Awaiting history" : a_efficiency >= 0.55 ? "Directional path" : a_efficiency < 0.25 ? "Backtracking path" : "Mixed path"
float a_directionalAcceleration = a_acceleration * a_direction
string a_pace = not a_scaleKnown ? "Unavailable" : a_direction == 0 ? "No directional pace" : a_directionalAcceleration > 0.015 ? "Gaining pace" : a_directionalAcceleration < -0.015 ? "Losing pace" : "Steady pace"
string a_volatilityText = not a_warm ? "Awaiting history" : a_shock ? "SHOCK / " + a_f_number(a_shockRatio) + " x prior range" : na(a_volatility) ? "No range" : a_volatility > 1.3 ? "Expanding" : a_volatility < 0.7 ? "Contracting" : "Near longer baseline"
string a_locationText = not a_scaleKnown ? "Unavailable" : (math.abs(a_extension) >= 1.5 ? "Extended / " : "Near reference / ") + a_f_number(a_extension) + " range units"
string a_participation = a_volumeInput == "Off" ? "Off / price-only context" : not a_validVolume ? "Missing volume" : na(a_relativeVolume) ? "Baseline unavailable" : a_f_number(a_relativeVolume) + " x / " + a_volumeKind
if timeframe.isintraday and a_volumeInput != "Off"
    a_participation += "\nNot time-of-day adjusted"
string a_anchorText = a_anchorInput == "Off" ? "Off" : a_volumeKind != "Trade units" ? "Requires declared trade units" : not a_anchorLonger ? "Anchor must exceed chart interval" : not a_anchorComplete ? "First partial anchor withheld" : a_anchorPoisoned ? "Anchor has missing data" : a_anchorWeight <= 0 ? "No positive anchor volume" : (close > a_vwapReference ? "Above " : close < a_vwapReference ? "Below " : "At ") + a_anchorTf + " VWAP"
string a_scenarioText = a_invalidated ? "Prior breakout failed" : a_scenario > 0 ? "Upward range breakout" : a_scenario < 0 ? "Downward range breakout" : "No active breakout scenario"
string a_failureText = a_scenario > 0 ? "Close below " + str.tostring(a_boundary, format.mintick) : a_scenario < 0 ? "Close above " + str.tostring(a_boundary, format.mintick) : "No scenario boundary"
string a_reading = not a_warm ? "Waiting for " + str.tostring(4 * a_horizon + 2) + " consecutive valid bars." : not a_scaleKnown ? "Normalized movement is unavailable." : a_shock ? "Range jumped relative to its prior baseline.\nThe previous pace may no longer describe conditions." : a_direction != 0 and a_efficiency < 0.25 ? "The smoother points one way; raw price backtracks.\nDirection and path organization conflict." : a_direction != 0 and math.abs(a_extension) >= 1.5 ? "Price is far from the smoothed reference.\nExtension alone does not imply reversal." : a_direction != 0 ? "The filtered path is directional.\nRead pace and participation separately." : "Directional movement is weak under this definition.\nA balanced reading does not predict a breakout."
string a_hypothesis = not a_scaleKnown ? "Insufficient normalized evidence." : a_shock ? "Information arrival or a liquidity change are possible.\nThe chart does not identify the cause." : a_direction != 0 and a_efficiency >= 0.55 ? "Persistent execution demand is one possible explanation.\nParticipant identity and intent are unobserved." : "Two-sided trade is one possible explanation.\nOHLCV cannot identify who supplied liquidity."
color a_stateColor = not a_warm or not a_scaleKnown ? a_neutralColor : a_direction > 0 ? a_upColor : a_direction < 0 ? a_downColor : a_neutralColor


// === BEARING ENGINE ===
// Copyright (c) 2026 Kontaus. Experimental kb-0.3; no established market edge.
bool b_allowShort = input.bool(false, "Enable short entries", group = "B · 00 · Entry direction", tooltip = "Long entries are enabled. Turn this on to also allow red short-entry triangles and short plans. Signals still require all setup and risk conditions. This control is in the cross-market 0.3.1 indicator; the old 0.2 baseline is long-only.")
string b_profileInput = input.string("Auto", "Instrument profile", options = ["Auto", "Stocks", "Bonds", "Futures", "Forex", "Crypto", "Generic"], group = "B · 01 · Market")
string b_profile = b_profileInput == "Auto" ? (syminfo.type == "stock" or syminfo.type == "fund" ? "Stocks" : syminfo.type == "futures" ? "Futures" : syminfo.type == "forex" ? "Forex" : syminfo.type == "crypto" ? "Crypto" : "Generic") : b_profileInput
string b_volumePolicy = input.string("Profile", "Participation policy", options = ["Profile", "Required", "Price only"], group = "B · 01 · Market", tooltip = "Profile requires volume for Stocks/Futures/Crypto. Bonds/Forex/Generic use price only. Missing required volume blocks new plans.")
string b_volumeMode = input.string("Prior EW", "Volume baseline", options = ["Prior EW", "Session slot"], group = "B · 01 · Market", tooltip = "Prior EW uses 20 earlier volume observations. Session slot uses at least 5 earlier trading days at this exchange-local minute. Minute or hourly charts only; no current-bar volume in the baseline.")
bool b_vwapGate = input.bool(false, "Require session VWAP alignment", group = "B · 01 · Market", tooltip = "Requires a fully observed trading-day anchor and positive current volume. VWAP is context, not order flow.")
int b_resetAt = input.time(0, "Reset model at contract/data boundary", group = "B · 01 · Market", tooltip = "Optional timestamp. Resets analytical history once; never cancels an open illustrative stop. Does not construct or adjust a futures roll.")
string b_setupMode = input.string("Breakout", "Setup", options = ["Breakout", "Pullback", "Either"], group = "B · 02 · Signals")
float b_decay = input.float(0.9, "Evidence memory", minval = 0.01, maxval = 0.99, step = 0.01, group = "B · 02 · Signals")
float b_persistenceMin = input.float(20, "Minimum directional evidence", minval = 0, maxval = 100, group = "B · 02 · Signals")
float b_extensionLimit = input.float(1.5, "Maximum entry extension · ATR", minval = 0.1, group = "B · 02 · Signals")
float b_efficiencyMin = input.float(0.25, "Minimum directional efficiency", minval = 0, maxval = 1, group = "B · 02 · Signals")
float b_shockAtr = input.float(4, "Shock threshold · prior ATR", minval = 0.1, group = "B · 02 · Signals")
int b_cooldown = input.int(3, "Shock cooldown · bars", minval = 0, maxval = 100, group = "B · 02 · Signals")
float b_account = input.float(100000, "Reference account", minval = 1, group = "B · 03 · Risk")
float b_riskPct = input.float(1, "Planned risk %", minval = 0, maxval = 100, group = "B · 03 · Risk")
float b_stopAtr = input.float(3, "Frozen distance · ATR", minval = 0.1, group = "B · 03 · Risk")
float b_conversion = input.float(1, "Account currency per quote currency", minval = 0.00000001, group = "B · 03 · Risk", tooltip = "Explicit fixed conversion for the illustration, not an FX feed. Use 1 only when account and quote currency match. Recompute when conversion changes.")
float b_pointOverride = input.float(0, "Point-value override · 0 = symbol", minval = 0, group = "B · 03 · Risk")
float b_pointValue = b_pointOverride > 0 ? b_pointOverride : syminfo.pointvalue
float b_tick = syminfo.mintick
float b_lot = input.float(1, "Quantity increment", minval = 0.00000001, group = "B · 03 · Risk", tooltip = "Quantity uses symbol units/contracts, not broker-defined FX lots. Confirm the point value and increment for this instrument.")
float b_margin = input.float(0, "Capacity per unit · account currency", minval = 0, group = "B · 03 · Risk", tooltip = "Required for Futures and nonpositive-price plans. Enter a conservative per-contract allocation. It is not a live broker margin model. Zero uses cash notional for other positive-price instruments.")
float b_capitalFraction = input.float(1, "Maximum capacity · account fraction", minval = 0, maxval = 1, group = "B · 03 · Risk")
float b_costBps = input.float(5, "One-way notional costs · bps", minval = 0, group = "B · 03 · Risk")
float b_feePerUnit = input.float(0, "One-way fee per unit · quote currency", minval = 0, group = "B · 03 · Risk")
int b_slipTicks = input.int(1, "Adverse slippage · ticks", minval = 0, maxval = 100, group = "B · 03 · Risk")
string b_theme = input.string("Auto", "Theme", options = ["Auto", "Dark", "Light"], group = "B · 04 · Appearance")
if not chart.is_standard or timeframe.isticks
    runtime.error("Konatus requires standard time-based OHLC charts.")
if na(b_pointValue) or na(b_tick) or math.sign(b_pointValue) <= 0 or math.sign(b_tick) <= 0
    runtime.error("A positive tick and point value are required.")
if b_volumeMode == "Session slot" and (not timeframe.isintraday or timeframe.in_seconds() < 60)
    runtime.error("Session-slot volume needs intraday time bars of at least one minute.")
bool b_needsVolume = b_volumePolicy == "Required" or (b_volumePolicy == "Profile" and (b_profile == "Stocks" or b_profile == "Futures" or b_profile == "Crypto"))

if b_vwapGate and not timeframe.isintraday
    runtime.error("Session VWAP gating requires intraday time bars. Disable it on daily and higher charts.")

// Shared confirmed-bar analytical engine. Price units allow zero/negative prices.
var float b_level = na
var float b_drift = 0
var float b_p00 = 0
var float b_p01 = 0
var float b_p11 = 0
var float b_atr = 0
var float b_trSum = 0
var float b_previous = na
var float b_evidence = 0
var int b_count = 0
var int b_direction = 0
var int b_runLength = 0
var int b_pause = 0
var array<float> b_highs = array.new_float()
var array<float> b_lows = array.new_float()
var array<float> b_closes = array.new_float()
var array<float> b_slotMean = array.new_float(1440, 0)
var array<int> b_slotCount = array.new_int(1440, 0)
var array<int> b_slotAnchor = array.new_int(1440, na)
var float b_volMean = 0
var int b_volCount = 0
var int b_anchor = na
var bool b_anchorComplete = false
var bool b_anchorValid = true
var float b_pv = 0
var float b_vSum = 0
var float b_vwap = na
var float b_relativeVolume = na
var float b_efficiency = 0
var float b_extension = na
var float b_surprise = na
var float b_predictiveSd = na
var float b_distance = 0
var float b_quantity = 0
var string b_volumeStatus = "WARMUP"
var string b_condition = "WARMUP"
var string b_entryStatus = "WARMUP"
var string b_setupName = "NONE"
var int b_breakoutSide = 0
var float b_breakoutLevel = na
var bool b_ready = false
int b_signal = 0
bool b_valid = not na(open) and not na(high) and not na(low) and not na(close) and (math.min(open, close) - low) / b_tick >= 0 and (high - math.max(open, close)) / b_tick >= 0
bool b_resetNow = b_resetAt > 0 and time >= b_resetAt and (na(time[1]) or time[1] < b_resetAt)
if barstate.isconfirmed
    if not b_valid or b_resetNow
        b_level := na
        b_drift := 0
        b_p00 := 0
        b_p01 := 0
        b_p11 := 0
        b_atr := 0
        b_trSum := 0
        b_previous := na
        b_evidence := 0
        b_count := 0
        b_direction := 0
        b_runLength := 0
        b_pause := 0
        array.clear(b_highs)
        array.clear(b_lows)
        array.clear(b_closes)
        array.fill(b_slotMean, 0)
        array.fill(b_slotCount, 0)
        array.fill(b_slotAnchor, na)
        b_volMean := 0
        b_volCount := 0
        b_anchor := na
        b_anchorComplete := false
        b_anchorValid := true
        b_pv := 0
        b_vSum := 0
        b_vwap := na
        b_breakoutSide := 0
        b_ready := false
        b_quantity := 0
        b_distance := 0
        b_extension := na
        b_efficiency := 0
        b_surprise := na
        b_predictiveSd := na
        b_relativeVolume := na
        b_volumeStatus := "UNAVAILABLE"
        b_condition := "INVALID DATA"
        b_entryStatus := "INVALID DATA"
        b_setupName := "NONE"
    if b_valid
        float b_priorLevel = b_level
        float b_tr = na(b_previous) ? high - low : math.max(high - low, math.abs(high - b_previous), math.abs(low - b_previous))
        float b_scale = math.max(b_count >= 14 ? b_atr : b_count > 0 ? b_trSum / b_count : b_tr, b_tick)
        float b_variance = b_scale * b_scale
        float b_observation = 0.25 * b_variance
        if na(b_level)
            b_level := close
            b_p00 := b_observation
            b_p11 := b_observation
            b_surprise := 0
            b_predictiveSd := math.sqrt(2 * b_observation)
        else
            float b_a = b_p00 + 2 * b_p01 + b_p11 + 0.01 * b_variance
            float b_b = b_p01 + b_p11
            float b_d = b_p11 + 0.0001 * b_variance
            float b_predictive = b_a + b_observation
            float b_innovation = close - b_level - b_drift
            b_surprise := b_innovation / math.sqrt(b_predictive)
            b_predictiveSd := math.sqrt(b_predictive)
            float b_k0 = b_a / b_predictive
            float b_k1 = b_b / b_predictive
            float b_m = 1 - b_k0
            b_level += b_drift + b_k0 * b_innovation
            b_drift += b_k1 * b_innovation
            b_p00 := b_m * b_m * b_a + b_k0 * b_k0 * b_observation
            b_p01 := b_m * (b_b - b_k1 * b_a) + b_k0 * b_k1 * b_observation
            b_p11 := b_d - 2 * b_k1 * b_b + b_k1 * b_k1 * b_a + b_k1 * b_k1 * b_observation
        b_count += 1
        if b_count <= 14
            b_trSum += b_tr
            b_atr := b_trSum / b_count
        else
            b_atr += (b_tr - b_atr) / 14
        int b_priorDirection = b_direction
        b_direction := b_drift / b_tick > 0.000001 ? 1 : b_drift / b_tick < -0.000001 ? -1 : 0
        b_runLength := b_direction != 0 and b_direction == b_priorDirection ? b_runLength + 1 : b_direction != 0 ? 1 : 0
        b_evidence := b_decay * b_evidence + (1 - b_decay) * math.max(-1, math.min(1, b_drift / (0.1 * b_scale)))
        array.push(b_closes, close)
        if array.size(b_closes) > 21
            array.shift(b_closes)
        float b_travel = 0
        if array.size(b_closes) > 1
            for j = 1 to array.size(b_closes) - 1
                b_travel += math.abs(array.get(b_closes, j) - array.get(b_closes, j - 1))
        b_efficiency := b_travel > 0 ? math.abs(close - array.get(b_closes, 0)) / b_travel : 0
        b_extension := math.abs(close - b_level) / math.max(b_atr, b_tick)
        bool b_shock = not na(b_previous) and math.abs(close - b_previous) / b_scale > b_shockAtr
        b_pause := b_shock ? b_cooldown : math.max(0, b_pause - 1)
        b_ready := b_count >= 40 and b_atr / b_tick > 0.000001
        b_condition := b_count < 40 ? "WARMUP" : not b_ready ? "FLAT" : b_shock or b_pause > 0 ? "SHOCK" : b_efficiency < b_efficiencyMin ? "CONGESTION" : "TREND"
        bool b_haveVolume = not na(volume) and volume >= 0
        int b_slot = hour(time, syminfo.timezone) * 60 + minute(time, syminfo.timezone)
        int b_dayAnchor = time_tradingday
        float b_baseline = b_volumeMode == "Session slot" ? array.get(b_slotMean, b_slot) : b_volMean
        int b_samples = b_volumeMode == "Session slot" ? array.get(b_slotCount, b_slot) : b_volCount
        int b_lastAnchor = array.get(b_slotAnchor, b_slot)
        bool b_duplicateSlot = b_volumeMode == "Session slot" and not na(b_lastAnchor) and b_lastAnchor == b_dayAnchor
        bool b_enough = b_samples >= (b_volumeMode == "Session slot" ? 5 : 20)
        b_relativeVolume := b_haveVolume and b_enough and b_baseline > 0 and not b_duplicateSlot ? volume / b_baseline : na
        b_volumeStatus := not b_haveVolume ? "VOLUME MISSING" : b_duplicateSlot ? "SLOT REPEATED" : volume == 0 ? "ZERO VOLUME" : na(b_relativeVolume) ? "VOLUME WARMUP" : "AVAILABLE"
        if b_haveVolume and not b_duplicateSlot
            float b_updated = b_samples == 0 ? volume : b_baseline + (volume - b_baseline) * 2 / 21
            if b_volumeMode == "Session slot"
                array.set(b_slotMean, b_slot, b_updated)
                array.set(b_slotCount, b_slot, b_samples + 1)
                array.set(b_slotAnchor, b_slot, b_dayAnchor)
            else
                b_volMean := b_updated
                b_volCount += 1
        if not b_needsVolume
            b_volumeStatus := "PRICE ONLY"
        bool b_volumeOk = not b_needsVolume or (b_volumeStatus == "AVAILABLE" and b_relativeVolume >= 1)
        if na(b_anchor) or b_dayAnchor != b_anchor
            b_anchorComplete := not na(b_anchor)
            b_anchor := b_dayAnchor
            b_anchorValid := true
            b_pv := 0
            b_vSum := 0
        if not b_haveVolume
            b_anchorValid := false
        if b_haveVolume and volume > 0
            b_pv += hlc3 * volume
            b_vSum += volume
        b_vwap := b_vSum > 0 ? b_pv / b_vSum : na
        bool b_contextOk = not b_vwapGate or (b_anchorValid and b_anchorComplete and not na(b_vwap) and b_haveVolume and volume > 0 and b_direction * (close - b_vwap) / b_tick > 0)
        int b_fire = 0
        if not b_ready or b_shock or b_pause > 0
            b_breakoutSide := 0
        else if b_breakoutSide != 0
            if b_breakoutSide * (close - b_breakoutLevel) / b_tick > 0
                b_fire := b_breakoutSide
            b_breakoutSide := 0
        else if array.size(b_highs) == 20
            if (close - array.max(b_highs)) / b_tick > 0
                b_breakoutSide := 1
                b_breakoutLevel := array.max(b_highs)
            else if (array.min(b_lows) - close) / b_tick > 0
                b_breakoutSide := -1
                b_breakoutLevel := array.min(b_lows)
        int b_pull = not na(b_previous) and not na(b_priorLevel) and b_direction * (b_previous - b_priorLevel) / b_tick <= 0 and b_direction * (close - b_level) / b_tick > 0 ? b_direction : 0
        int b_setupSide = b_setupMode == "Breakout" ? b_fire : b_setupMode == "Pullback" ? b_pull : b_fire != 0 ? b_fire : b_pull
        b_setupName := b_setupSide != 0 and b_setupSide == b_fire ? "BREAKOUT" : b_setupSide != 0 ? "PULLBACK" : "NONE"
        b_distance := math.ceil(b_stopAtr * b_atr / b_tick - 1e-9) * b_tick
        float b_slip = b_slipTicks * b_tick
        float b_fees = 2 * b_feePerUnit + (2 * math.abs(close) + b_distance + 2 * b_slip) * b_pointValue * b_costBps / 10000
        float b_unitRisk = ((b_distance + b_slip + b_tick) * b_pointValue + b_fees) * b_conversion
        float b_capacity = b_profile == "Futures" or math.sign(close) <= 0 ? b_margin : b_margin > 0 ? b_margin : (math.abs(close) + b_slip) * b_pointValue * b_conversion
        float b_capacityWithFees = b_capacity + (b_feePerUnit + (math.abs(close) + b_slip) * b_pointValue * b_costBps / 10000) * b_conversion
        b_quantity := math.sign(b_capacity) > 0 and math.sign(b_distance) > 0 and math.sign(b_unitRisk) > 0 ? math.max(0, math.floor(math.min(b_account * b_riskPct / 100 / b_unitRisk, b_account * b_capitalFraction / b_capacityWithFees) / b_lot) * b_lot) : 0
        b_entryStatus := b_count < 40 ? "WARMUP" : not b_ready ? "FLAT" : b_condition == "SHOCK" ? "SHOCK PAUSE" : b_condition == "CONGESTION" ? "CONGESTION" : b_direction == 0 or b_direction * b_evidence * 100 < b_persistenceMin ? "WEAK EVIDENCE" : b_direction < 0 and not b_allowShort ? "SHORT DISABLED" : not b_volumeOk and b_volumeStatus != "AVAILABLE" ? b_volumeStatus : not b_volumeOk ? "LOW PARTICIPATION" : not b_contextOk ? "VWAP CONTEXT" : b_extension > b_extensionLimit ? "EXTENDED" : b_quantity <= 0 ? "SIZE UNAVAILABLE" : b_setupSide == b_direction ? "READY" : "WAIT SETUP"
        b_signal := b_entryStatus == "READY" ? b_direction : 0
        array.push(b_highs, high)
        array.push(b_lows, low)
        if array.size(b_highs) > 20
            array.shift(b_highs)
            array.shift(b_lows)
        b_previous := close

// Integration changes candidate entries only; existing plans keep their original ledger.
var string atlasGateStatus = "OBSERVE ONLY"
var bool atlasBlocked = false
if barstate.isconfirmed
    atlasBlocked := false
    atlasGateStatus := atlasPolicy == "Observe only" ? "OBSERVE ONLY" : not a_scaleKnown ? "ANATOMY WARMUP / DATA" : a_shock ? "ANATOMY SHOCK" : a_direction == 0 or a_direction != b_direction ? "DIRECTION CONFLICT" : atlasPolicy == "Require organized direction" and (na(a_efficiency) or a_efficiency < 0.55) ? "PATH NOT ORGANIZED" : "CONTEXT ALIGNED"
    if atlasPolicy != "Observe only" and b_signal != 0 and atlasGateStatus != "CONTEXT ALIGNED"
        b_signal := 0
        b_entryStatus := atlasGateStatus
        atlasBlocked := true

// Illustrative next-open ledger. Orders are not sent to a broker.
var int b_heldSide = 0
var int b_pendingSide = 0
var float b_pendingQuantity = 0
var float b_pendingDistance = 0
var float b_heldQuantity = 0
var float b_entryPrice = na
var float b_stopPrice = na
var float b_heldDistance = na
var int b_reversalCount = 0
var bool b_pendingExit = false
var bool b_capacityBreached = false
bool b_entryIntent = false
bool b_exitIntent = false
bool b_entered = false
bool b_exited = false
string b_exitReason = ""
if barstate.isconfirmed and b_valid
    bool b_dueExit = b_pendingExit
    b_pendingExit := false
    float b_slip = b_slipTicks * b_tick
    if b_heldSide != 0
        if b_heldSide * (open - b_stopPrice) / b_tick <= 0
            b_exited := true
            b_exitReason := "GAP_STOP"
        else if b_dueExit
            b_exited := true
            b_exitReason := "REVERSAL"
    if b_heldSide == 0 and b_pendingSide != 0
        b_heldSide := b_pendingSide
        b_heldQuantity := b_pendingQuantity
        b_heldDistance := b_pendingDistance
        b_entryPrice := open + b_heldSide * b_slip
        float b_rawStop = b_entryPrice - b_heldSide * b_heldDistance
        b_stopPrice := (b_heldSide > 0 ? math.floor(b_rawStop / b_tick + 1e-9) : math.ceil(b_rawStop / b_tick - 1e-9)) * b_tick
        float b_entryFee = b_heldQuantity * (b_feePerUnit + math.abs(b_entryPrice) * b_pointValue * b_costBps / 10000) * b_conversion
        float b_filledCapacity = b_margin > 0 ? b_margin : math.abs(b_entryPrice) * b_pointValue * b_conversion
        b_capacityBreached := b_filledCapacity * b_heldQuantity + b_entryFee > b_account * b_capitalFraction
        b_pendingSide := 0
        b_entered := true
    if b_heldSide != 0 and not b_exited and (b_heldSide > 0 ? (b_stopPrice - low) / b_tick >= 0 : (high - b_stopPrice) / b_tick >= 0)
        b_exited := true
        b_exitReason := "STOP"
    if b_exited
        b_heldSide := 0
        b_reversalCount := 0
    if b_heldSide != 0
        b_reversalCount := b_direction == -b_heldSide ? b_reversalCount + 1 : 0
        if b_reversalCount >= 2
            b_pendingExit := true
            b_exitIntent := true
    else if not b_exited and b_signal != 0
        b_pendingSide := b_signal
        b_pendingQuantity := b_quantity
        b_pendingDistance := b_distance
        b_entryIntent := true
// Alerts are decision events. Namespace separates configurations/alert instances.
string b_alertNamespace = input.string("default", "Alert instance ID", group = "B · 05 · Alerts", tooltip = "Use a distinct ID for each configuration. Allowed characters: letters, numbers, dash and underscore. Recreate TradingView alerts after settings change.")
if str.length(b_alertNamespace) == 0 or str.match(b_alertNamespace, "^[A-Za-z0-9_-]+$") != b_alertNamespace
    runtime.error("Alert instance ID must contain only letters, numbers, dash or underscore.")
if b_entryIntent or b_exitIntent
    string b_eventName = b_entryIntent ? "ENTRY_INTENT" : "EXIT_INTENT"
    int b_eventSide = b_entryIntent ? b_pendingSide : b_heldSide
    string b_eventId = "atlas-0.1:" + b_alertNamespace + ":" + syminfo.tickerid + ":" + timeframe.period + ":" + str.tostring(time_close) + ":" + b_eventName + ":" + str.tostring(b_eventSide)
    string b_payload = '{"version":"atlas-0.1-experimental","id":"' + b_eventId + '","event":"' + b_eventName + '","symbol":"' + syminfo.tickerid + '","timeframe":"' + timeframe.period + '","time_close":' + str.tostring(time_close) + ',"side":' + str.tostring(b_eventSide) + ',"quantity":' + str.tostring(b_entryIntent ? b_pendingQuantity : b_heldQuantity, "#.########") + ',"distance":' + str.tostring(b_entryIntent ? b_pendingDistance : b_heldDistance, "#.########") + ',"illustrative":true}'
    alert(b_payload, alert.freq_once_per_bar_close)


// === ATLAS PRESENTATION: one dashboard, one candle-color owner ===
bool atlasConfirmed = barstate.isconfirmed
bool atlasDark = b_theme == "Dark" or (b_theme == "Auto" and color.r(chart.bg_color) + color.g(chart.bg_color) + color.b(chart.bg_color) < 384)
color atlasSurface = atlasDark ? #101B24 : #F8F6EF
color atlasInk = atlasDark ? #F3EAD6 : #202D35
color atlasMuted = atlasDark ? #A7B9BD : #4C6069
color atlasUp = atlasDark ? #63D4B7 : #096A55
color atlasDown = atlasDark ? #F1A394 : #9B322C
color atlasGold = atlasDark ? #E5C078 : #755308
color atlasBorder = atlasDark ? #34464C : #B6C4C2
color atlasBearingColor = not b_ready or math.abs(b_evidence * 100) < b_persistenceMin ? atlasMuted : b_direction > 0 ? atlasUp : atlasDown
color atlasAnatomyColor = a_direction > 0 ? atlasUp : a_direction < 0 ? atlasDown : atlasMuted
bool atlasDisplayValid = atlasConfirmed ? b_valid : b_valid[1]
string atlasState = not atlasDisplayValid ? "DATA UNAVAILABLE" : b_pendingExit ? "EXIT QUEUED" : b_heldSide != 0 ? (b_heldSide > 0 ? "LONG PLAN" : "SHORT PLAN") : b_pendingSide != 0 ? "ENTRY QUEUED" : b_entryStatus
string atlasReason = b_heldSide != 0 ? (not atlasDisplayValid ? "Data invalid; last stop retained" : b_capacityBreached ? "Capacity exceeded at illustrative fill" : "Illustrative stop remains active") : b_pendingSide != 0 ? "Illustrative next-open fill queued" : atlasBlocked ? "Anatomy policy blocked this entry candidate" : b_entryStatus == "EXTENDED" ? "Bearing: price exceeds entry extension limit" : b_entryStatus == "SIZE UNAVAILABLE" ? "Review contract, capacity and risk inputs" : b_entryStatus == "WAIT SETUP" ? "Bearing conditions available; wait for setup" : "Read conditions before interpreting a setup"
string atlasPosition = atlasCorner == "Top right" ? position.top_right : atlasCorner == "Top left" ? position.top_left : atlasCorner == "Bottom right" ? position.bottom_right : position.bottom_left
string atlasTextSize = atlasFont == "Tiny" ? size.tiny : atlasFont == "Normal" ? size.normal : size.small
bool atlasDetailed = atlasDensity == "Detailed"
var table atlasPanel = table.new(atlasPosition, 2, atlasDetailed ? 23 : 14, bgcolor = atlasSurface, frame_color = atlasBorder, frame_width = 1)
atlas_row(int n, string caption, string value, color tone) =>
    table.cell(atlasPanel, 0, n, " " + caption + " ", text_color = atlasMuted, bgcolor = atlasSurface, text_size = atlasTextSize, text_halign = text.align_left)
    table.cell(atlasPanel, 1, n, " " + value + " ", text_color = tone, bgcolor = atlasSurface, text_size = atlasTextSize, text_halign = text.align_right)
if barstate.isfirst
    table.merge_cells(atlasPanel, 0, 1, 1, 1)
    table.merge_cells(atlasPanel, 0, 2, 1, 2)
    table.merge_cells(atlasPanel, 0, atlasDetailed ? 22 : 13, 1, atlasDetailed ? 22 : 13)
if barstate.islast
    table.clear(atlasPanel, 0, 0, 1, atlasDetailed ? 22 : 13)
    if atlasDensity != "Hidden" or (b_heldSide != 0 and (b_capacityBreached or not atlasDisplayValid))
        int snap = atlasConfirmed ? 0 : 1
        bool anatomyAvailable = a_scaleKnown[snap]
        bool agreement = anatomyAvailable and b_ready and b_direction != 0 and b_direction == a_direction[snap]
        string alignment = not anatomyAvailable or not b_ready ? "Awaiting valid history" : b_direction == 0 or a_direction[snap] == 0 ? "At least one is balanced" : agreement ? "Directions agree / shared data" : "Directions disagree"
        string clockText = na(time_close[snap]) ? "No confirmed bar" : str.format_time(time_close[snap], "MM-dd HH:mm", syminfo.timezone)
        atlas_row(0, "KONATUS ATLAS", syminfo.ticker + " / " + timeframe.period, atlasGold)
        table.cell(atlasPanel, 0, 1, " " + atlasState + " ", text_color = b_heldSide != 0 ? atlasBearingColor : atlasGold, bgcolor = atlasSurface, text_size = atlasFont == "Tiny" ? size.normal : size.large, text_halign = text.align_left)
        table.cell(atlasPanel, 0, 2, " " + atlasReason + " ", text_color = atlasMuted, bgcolor = atlasSurface, text_size = atlasTextSize, text_halign = text.align_left)
        atlas_row(3, "BEARING / ANATOMY", (b_direction > 0 ? "Up" : b_direction < 0 ? "Down" : "Balanced") + " / " + a_state[snap], atlasInk)
        atlas_row(4, "ENTRY POLICY", atlasGateStatus, atlasGateStatus == "CONTEXT ALIGNED" ? atlasUp : atlasGold)
        atlas_row(5, "PACE / RAW PATH", a_pace[snap] + " / " + a_organization[snap], atlasInk)
        atlas_row(6, "ANATOMY VOLATILITY", a_volatilityText[snap], a_shock[snap] ? atlasGold : atlasInk)
        atlas_row(7, "BEARING NEW ENTRY", b_entryStatus, atlasInk)
        atlas_row(8, "ILLUSTRATIVE STOP", b_heldSide != 0 ? str.tostring(b_stopPrice, format.mintick) : "No open plan", atlasGold)
        atlas_row(9, "ACTIVE / QUEUED QTY", str.tostring(b_heldSide != 0 ? b_heldQuantity : b_pendingSide != 0 ? b_pendingQuantity : 0, "#.########"), atlasInk)
        atlas_row(10, "ALIGNMENT", alignment, agreement ? atlasUp : atlasMuted)
        atlas_row(11, "ACTIVITY / ROLLING", a_participation[snap], atlasMuted)
        atlas_row(12, "CONFIRMED THROUGH", clockText + " / " + syminfo.timezone, atlasMuted)
        if atlasDetailed
            atlas_row(13, "ANATOMY READING", a_reading[snap], atlasInk)
            atlas_row(14, "POSSIBLE MECHANISM", a_hypothesis[snap], atlasMuted)
            atlas_row(15, "VELOCITY / ACCELERATION", a_f_number(a_velocity[snap]) + " / " + a_f_number(a_acceleration[snap]), atlasInk)
            atlas_row(16, "CURVATURE / EFFICIENCY", a_f_number(a_curvature[snap]) + " / " + a_f_number(a_efficiency[snap]), atlasInk)
            atlas_row(17, "ANATOMY LOCATION", a_locationText[snap], atlasInk)
            atlas_row(18, "BEARING EXTENSION", str.tostring(b_extension, "0.00") + " ATR", atlasInk)
            atlas_row(19, "SCENARIO / NOT A STOP", a_scenarioText[snap] + "\n" + a_failureText[snap], atlasMuted)
            atlas_row(20, "ANATOMY ANCHOR", a_anchorText[snap], atlasMuted)
            atlas_row(21, "BEARING PARTICIPATION", b_volumeStatus, atlasMuted)
        table.cell(atlasPanel, 0, atlasDetailed ? 22 : 13, " ATLAS 0.1 / RESEARCH / ILLUSTRATIVE PLANS ", text_color = atlasMuted, bgcolor = atlasSurface, text_size = size.tiny)

// Restrain overlays. Gold = illustrative stop; dotted teal = descriptive scenario.
plot(b_level, "Bearing price", color = atlasBearingColor, linewidth = 2)
plot(atlasConfirmed and atlasAnatomyLine and a_warm ? a_level : na, "Anatomy filtered price", color = atlasMuted)
atlasUpper = plot(atlasConfirmed and atlasEnvelope and a_scaleKnown ? a_level + 1.5 * a_scale : na, "Anatomy upper envelope", color = color.new(atlasMuted, 80))
atlasLower = plot(atlasConfirmed and atlasEnvelope and a_scaleKnown ? a_level - 1.5 * a_scale : na, "Anatomy lower envelope", color = color.new(atlasMuted, 80))
fill(atlasUpper, atlasLower, color = color.new(atlasAnatomyColor, 94), title = "Anatomy descriptive range")
plot(atlasConfirmed and atlasVwap ? a_vwapReference : na, "Anatomy calendar VWAP", color = atlasMuted)
plot(atlasConfirmed and atlasScenario ? a_boundary : na, "Anatomy scenario / NOT stop", color = atlasUp, style = plot.style_circles)
plot(b_heldSide != 0 ? b_entryPrice : na, "Illustrative entry", color = atlasMuted, style = plot.style_linebr)
plot(b_heldSide != 0 ? b_stopPrice : na, "Bearing illustrative frozen stop", color = atlasGold, linewidth = 2, style = plot.style_linebr)
barcolor(atlasCandles and atlasConfirmed ? atlasBearingColor : na)
plotshape(b_entryIntent and b_pendingSide == 1, "Long plan intent", shape.triangleup, location.belowbar, atlasUp, size = size.tiny)
plotshape(b_entryIntent and b_pendingSide == -1, "Short plan intent", shape.triangledown, location.abovebar, atlasDown, size = size.tiny)
plotshape(b_exited, "Illustrative plan exit", shape.xcross, location.abovebar, atlasGold, size = size.tiny)
plotshape(atlasEvents and atlasConfirmed and a_breakout != 0, "Anatomy breakout observation", shape.circle, location.belowbar, atlasMuted, size = size.tiny)

// Selected Data Window diagnostics, deliberately within the plot budget.
plot(atlasConfirmed ? a_velocity : na, "Anatomy velocity", display = display.data_window)
plot(atlasConfirmed ? a_acceleration : na, "Anatomy acceleration", display = display.data_window)
plot(atlasConfirmed ? a_curvature : na, "Anatomy curvature", display = display.data_window)
plot(atlasConfirmed and a_warm ? a_efficiency : na, "Anatomy raw efficiency", display = display.data_window)
plot(atlasConfirmed ? a_scale : na, "Anatomy prior SMA range", display = display.data_window)
plot(atlasConfirmed ? a_relativeVolume : na, "Anatomy rolling activity", display = display.data_window)
plot(b_evidence * 100, "Bearing signed persistence", display = display.data_window)
plot(b_atr, "Bearing ATR", display = display.data_window)
plot(b_signal, "Atlas filtered candidate", display = display.data_window)
plot(b_heldSide, "Illustrative held side", display = display.data_window)
plot(b_quantity, "Bearing candidate quantity", display = display.data_window)
plot(atlasBlocked ? 1 : 0, "Atlas context blocked candidate", display = display.data_window)
bool atlasDirectionChanged = a_scaleKnown and a_scaleKnown[1] and a_direction != a_direction[1]
alertcondition(atlasConfirmed and atlasDirectionChanged, "Atlas: Anatomy direction changed", "ATLAS {{ticker}} {{interval}}: confirmed Anatomy direction changed; not an order.")
alertcondition(atlasConfirmed and a_shock and not a_shock[1], "Atlas: Anatomy volatility shock", "ATLAS {{ticker}} {{interval}}: new Anatomy range shock.")
alertcondition(atlasConfirmed and a_breakout != 0, "Atlas: Anatomy breakout observed", "ATLAS {{ticker}} {{interval}}: descriptive range breakout, not a plan entry.")
alertcondition(atlasConfirmed and a_invalidated, "Atlas: Anatomy scenario invalidated", "ATLAS {{ticker}} {{interval}}: descriptive scenario invalidated, not a plan exit.")
alertcondition(atlasConfirmed and atlasBlocked, "Atlas: candidate blocked by context", "ATLAS {{ticker}} {{interval}}: Anatomy entry policy blocked a Bearing candidate.")

The package includes frozen engine sources, the reproducible builder, reference tests and a native-verification checklist. Atlas is an integration of your KONATUS tools, not a claim to outperform every other indicator.

Prefer a standalone tool? Explore companion studies ↓

04 / Upgraded · 5-minute multitrend · v0.2

KONATUS
Flow.

The five-minute chart.
The bigger trend.

Read the local move against two higher timeframes. Adaptive structure, directional strength and volatility filters explain when a trend is strong, weak, conflicted or worth waiting out.

Get Flow 0.2 for TradingView ↓
A single brass ribbon curves above dark teal water, making delicate ripples
KONATUS Flow
05 / MINUTES

Read the move.

Fast and structural adaptive averages check price location, slope and local direction.

15 / MINUTES

Check the context.

The last completed 15-minute bar checks whether the surrounding trend agrees.

60 / MINUTES

Respect the larger trend.

The last completed hourly bar must also agree before a trend can qualify as strong.

Candle color tells you the condition.

Coloring is now on by default. Strong and weak are defined by the filter rules, not by the size of a candle or a promised outcome. The explanation panel names the first condition holding back a strong reading.

Strong up

Every upward filter agrees.

Weak up

Upward structure; a filter is still waiting.

Strong down

Every downward filter agrees.

Weak down

Downward structure; a filter is still waiting.

Timeframe conflict

A higher timeframe opposes the local trend.

Volatility pause

A range shock calls for a cooling period.

Neutral

Local structure does not establish a direction.

An open candle keeps its native chart color. Unsupported charts and incomplete or stale data are left unpainted; the panel explains why. The lines and panel hold the last closed reading. Colors are editable in Settings.

A stricter definition of strong.

  1. Structure first. The fast adaptive average, structural average, price and their slopes must agree.
  2. All three timeframes. Both 15m and 1h must match the 5m direction. Neutral context withholds Strong; opposing context produces amber Conflict.
  3. Strength and efficiency. ADX, directional DI spread and path efficiency must pass. High ADX cannot override an opposing direction.
  4. Do not chase. Too much distance from the adaptive line withholds Strong. A range shock pauses the shock candle and the next two closes.
  5. Wait for confirmation. By default, every strong condition must pass on two consecutive 5-minute closes. A failed condition resets the count.
Two presets. The same transparent rules.
FilterBalanced · defaultStrict
Minimum efficiency0.300.40
Minimum ADX2025
Directional DI spread58
Maximum extension1.5 × mean range1.0 × mean range

Strict admits fewer candidates; it has not been shown to produce better returns. These are unoptimized research settings. More filters add delay and can still whipsaw.

Install on your 5-minute chart.

  1. 1 · ReplacePaste the complete source below into a blank TradingView Pine Editor indicator, save and add it to the chart. Remove the old Flow instance when upgrading.
  2. 2 · Set 5 minutesUse standard candles. This edition deliberately blocks other timeframes and synthetic charts. Start with Balanced and 2 confirmation closes.
  3. 3 · Load historyFlow needs 120 valid 5m bars and 90 valid bars on each higher timeframe. Loaded history counts; you do not need to wait 90 hours after installation.
  4. 4 · Read the reasonReview the state, 5m/15m/1h directions, ADX and efficiency in the bottom-right panel. Triangles mark new strong states, not filled trades. Keep your own entry, stop and size rules.
  5. 5 · Recreate alertsSelect a new Flow 0.2 alert and Once Per Bar Close. Old alerts keep their saved script and settings. Use either individual alerts or the combined state-change alert to avoid overlapping notifications.
Timing, calculations and limitations

Higher-timeframe inputs use previous completed bars. An hourly bar ending at 10:00 first contributes to the closed 10:00–10:05 candle; it is not used on the 09:55–10:00 candle. Context older than 30 minutes for 15m or 120 minutes for 1h is blocked. After a session gap, Flow may wait for new context and withhold the opening portion of the session.

Fast KAMA uses ER10 and fast/slow constants 2/30; structural KAMA uses ER30 and 5/60. The quiet zone is 0.15 × the 20-bar simple mean of true range. The fast 3-bar slope must exceed 0.02 × mean range per bar and the structural 5-bar slope must agree. Higher-timeframe direction uses EMA21/55 and a normalized EMA21 slope. Wilder-style ADX/DI use 14-bar seeded smoothing, reset on invalid data. The research package documents the complete formulas and initialization.

These are correlated readings of price, not independent votes or economic causes. No volume, spread or scheduled-news filter is included. Sessions, futures rolls, revised data and loaded history affect readings. Flow carries history across sessions and does not close positions at session end. The lines and quiet zone are not protective stops.

Method references: Kaufman adaptive average, directional movement, and confirmed higher-timeframe requests.

Give candle colors one owner.

Flow can stand alone as your trend filter. Add session VWAP for location or Market Anatomy for more context. When using Bearing or Atlas, turn off their candle coloring if Flow owns the colors. The scripts do not communicate or change one another’s signals, stops or sizing. Turn off Flow’s panel if another study occupies the bottom-right corner.

Research release 0.2.0. 30 offline reference and timing tests pass. Native TradingView compilation, chart behavior and alert delivery remain unverified. No trading edge has been measured; Strong is a rule-based description, not a prediction.

Get the complete Flow 0.2 script

Download Flow 0.2 Pine v6Research package (.zip)

View the complete Flow 0.2 code
//@version=6
// KONATUS Flow 0.2.0 — Kutty Stauder. 5-minute multitrend research edition.
// Native TradingView compilation, chart execution and alert delivery unverified.
// A descriptive filter, not an order, protective stop or profitability claim.
indicator("KONATUS Flow · 5m Multitrend 0.2.0", shorttitle="Flow 5m", overlay=true, max_bars_back=500, dynamic_requests=true)

string quality = input.string("Balanced", "Filtering", options=["Balanced", "Strict"], tooltip="5-minute charts only. Both presets require closed 15m and 1h agreement. Strict raises strength/efficiency thresholds and reduces allowed extension. Settings are not performance-optimized.")
int confirmation = input.int(2, "Confirmation closes", minval=1, maxval=5)
bool paintBars = input.bool(true, "Color closed candles", group="Display")
bool showStructure = input.bool(true, "Show structural line and ribbon", group="Display")
bool showZone = input.bool(false, "Show quiet zone", group="Display")
bool showMarks = input.bool(true, "Show new strong trends", group="Display")
bool showPanel = input.bool(true, "Show explanation panel", group="Display")
color strongUp = input.color(#21CE91, "Strong up", group="Candle colors")
color weakUp = input.color(#689C91, "Weak up", group="Candle colors")
color strongDown = input.color(#F05F6D, "Strong down", group="Candle colors")
color weakDown = input.color(#AE858D, "Weak down", group="Candle colors")
color conflictColor = input.color(#E9B85D, "Timeframe conflict", group="Candle colors")
color pauseColor = input.color(#AE91E8, "Volatility pause", group="Candle colors")
color neutralColor = input.color(#939DAA, "Neutral", group="Candle colors")

f_valid() =>
    not na(open) and not na(high) and not na(low) and not na(close) and high >= math.max(open, close) and low <= math.min(open, close) and high >= low

f_range(bool valid, int count) =>
    valid ? (count > 1 ? math.max(high - low, math.max(math.abs(high - close[1]), math.abs(low - close[1]))) : high - low) : na

f_kama(int length, float fastLength, float slowLength, bool valid, int count) =>
    float step = valid and count > 1 ? math.abs(close - close[1]) : 0.0
    float path = math.sum(step, length)
    float er = count > length and path > 0 ? math.max(0.0, math.min(1.0, math.abs(close - close[length]) / path)) : 0.0
    float alpha = math.pow(er * (2.0 / (fastLength + 1.0) - 2.0 / (slowLength + 1.0)) + 2.0 / (slowLength + 1.0), 2)
    var float average = na
    average := not valid ? na : na(average[1]) ? close : average[1] + alpha * (close - average[1])
    [average, er]

// Seed and reset explicitly so missing OHLC never carries an old strength estimate.
f_wilder(float value, int length, int count) =>
    float total = math.sum(value, length)
    var float result = na
    result := count < length or na(value) ? na : count == length ? total / length : (result[1] * (length - 1) + value) / length
    result

f_context() =>
    bool valid = f_valid()
    var int count = 0
    count := valid ? count + 1 : 0
    var float fast = na
    var float slow = na
    fast := not valid ? na : na(fast[1]) ? close : fast[1] + (2.0 / 22.0) * (close - fast[1])
    slow := not valid ? na : na(slow[1]) ? close : slow[1] + (2.0 / 56.0) * (close - slow[1])
    float scale = ta.sma(f_range(valid, count), 14)
    float slope = (fast - fast[3]) / 3.0
    bool ready = count >= 90 and not na(scale) and not na(slope)
    int direction = not ready or scale <= 0 ? 0 : fast > slow and close > fast and slope > 0.02 * scale ? 1 : fast < slow and close < fast and slope < -0.02 * scale ? -1 : 0
    // Every field is offset: lookahead_on must NEVER expose an unfinished HTF bar.
    [direction[1], ready[1], time_close[1]]

f_direction(int direction) =>
    direction == 1 ? "UP" : direction == -1 ? "DOWN" : "NEUTRAL"

f_state(int state) =>
    switch state
        2 => "STRONG UP"
        1 => "WEAK UP"
        -2 => "STRONG DOWN"
        -1 => "WEAK DOWN"
        3 => "TIMEFRAME CONFLICT"
        4 => "VOLATILITY PAUSE"
        => "NEUTRAL"

f_color(int state) =>
    switch state
        2 => strongUp
        1 => weakUp
        -2 => strongDown
        -1 => weakDown
        3 => conflictColor
        4 => pauseColor
        => neutralColor

bool supported = chart.is_standard and timeframe.isminutes and timeframe.multiplier == 5
int direction15 = 0
int direction60 = 0
bool ready15 = false
bool ready60 = false
int closed15 = na
int closed60 = na
// Unsupported charts do not issue accidental lower-timeframe requests.
if supported
    [d15, r15, t15] = request.security(syminfo.tickerid, "15", f_context(), gaps=barmerge.gaps_off, lookahead=barmerge.lookahead_on)
    [d60, r60, t60] = request.security(syminfo.tickerid, "60", f_context(), gaps=barmerge.gaps_off, lookahead=barmerge.lookahead_on)
    direction15 := d15
    direction60 := d60
    ready15 := r15
    ready60 := r60
    closed15 := t15
    closed60 := t60

bool valid = f_valid()
var int count = 0
count := valid ? count + 1 : 0
[adaptive, efficiency] = f_kama(10, 2.0, 30.0, valid, count)
[structure, _] = f_kama(30, 5.0, 60.0, valid, count)
float trueRange = f_range(valid, count)
float scale = ta.sma(trueRange, 20)
float fastSlope = (adaptive - adaptive[3]) / 3.0
float slowSlope = (structure - structure[5]) / 5.0
float halfZone = 0.15 * scale
float upward = count > 1 ? high - high[1] : 0.0
float downward = count > 1 ? low[1] - low : 0.0
float plusDM = valid ? (upward > downward and upward > 0 ? upward : 0.0) : na
float minusDM = valid ? (downward > upward and downward > 0 ? downward : 0.0) : na
float smoothTR = f_wilder(trueRange, 14, count)
float smoothPlus = f_wilder(plusDM, 14, count)
float smoothMinus = f_wilder(minusDM, 14, count)
float plusDI = smoothTR > 0 ? 100.0 * smoothPlus / smoothTR : 0.0
float minusDI = smoothTR > 0 ? 100.0 * smoothMinus / smoothTR : 0.0
float dx = plusDI + minusDI > 0 ? 100.0 * math.abs(plusDI - minusDI) / (plusDI + minusDI) : 0.0
float adx = f_wilder(count >= 14 ? dx : na, 14, math.max(0, count - 13))
bool localReady = count >= 120 and not na(adx) and not na(scale) and not na(slowSlope)
int localDirection = not localReady or scale <= 0 ? 0 : adaptive > structure and close > adaptive + halfZone and fastSlope > 0.02 * scale and slowSlope > 0 ? 1 : adaptive < structure and close < adaptive - halfZone and fastSlope < -0.02 * scale and slowSlope < 0 ? -1 : 0
bool fresh15 = not na(closed15) and closed15 <= time and time - closed15 <= 30 * 60 * 1000
bool fresh60 = not na(closed60) and closed60 <= time and time - closed60 <= 120 * 60 * 1000
bool ready = supported and localReady and ready15 and ready60 and fresh15 and fresh60
bool shock = valid and count > 20 and scale[1] > 0 and trueRange > 3.0 * scale[1]
var int cooldown = 0
cooldown := not valid ? 0 : shock ? 3 : math.max(0, cooldown - 1)
bool paused = cooldown > 0
bool strict = quality == "Strict"
float minimumER = strict ? 0.40 : 0.30
float minimumADX = strict ? 25.0 : 20.0
float minimumSpread = strict ? 8.0 : 5.0
float maxExtension = strict ? 1.0 : 1.5
float extension = scale > 0 ? math.abs(close - adaptive) / scale : 0.0
bool opposing = localDirection != 0 and (direction15 == -localDirection or direction60 == -localDirection)
bool aligned = localDirection != 0 and direction15 == localDirection and direction60 == localDirection
bool efficient = efficiency >= minimumER
bool strength = adx >= minimumADX and localDirection * (plusDI - minusDI) >= minimumSpread
bool stretched = extension > maxExtension
int qualified = ready and not paused and aligned and efficient and strength and not stretched ? localDirection : 0
var int run = 0
run := qualified == 0 ? 0 : qualified == qualified[1] ? run + 1 : 1
int nextState = not ready ? 0 : paused ? 4 : localDirection == 0 ? 0 : opposing ? 3 : qualified != 0 and run >= confirmation ? 2 * localDirection : localDirection
string nextReason = not supported ? "Use standard 5-minute candles" : not valid ? "Invalid OHLC: rebuilding history" : not localReady ? "Warming up: 120 valid 5m bars" : not ready15 or not ready60 ? "Warming up: 90 bars on 15m and 1h" : not fresh15 or not fresh60 ? "Waiting for fresh closed higher-timeframe bars" : paused ? "Range shock: " + str.tostring(cooldown) + " pause bars including this close" : localDirection == 0 ? "Local structure is mixed or inside quiet zone" : opposing ? "Higher timeframe opposes the 5m direction" : not aligned ? "Higher timeframe neutral: strong withheld" : not efficient ? "Price path too inefficient" : not strength ? "ADX / directional spread below threshold" : stretched ? "Too far from adaptive line" : run < confirmation ? "Confirming: " + str.tostring(run) + "/" + str.tostring(confirmation) + " closes" : "All filters aligned"

// Publish one snapshot at each close. The open candle is intentionally not painted.
var int state = 0
var string reason = "Waiting for first closed bar"
var string snapshotDirections = "5m — / 15m — / 1h —"
var string snapshotStrength = "ADX — / ER —"
var string snapshotFilter = "Waiting for data"
if barstate.isconfirmed
    state := nextState
    reason := nextReason
    snapshotDirections := "5m " + f_direction(localDirection) + " / 15m " + (ready15 and fresh15 ? f_direction(direction15) : "WAIT") + " / 1h " + (ready60 and fresh60 ? f_direction(direction60) : "WAIT")
    snapshotStrength := "ADX " + (localReady ? str.tostring(adx, "#.0") : "—") + " / ER " + (localReady ? str.tostring(efficiency, "#.00") : "—")
    snapshotFilter := quality + " · " + str.tostring(confirmation) + " closes · last closed bar"

bool eligibleEvent = barstate.isconfirmed and ready and ready[1]
bool strongUpEvent = eligibleEvent and state == 2 and state[1] != 2
bool strongDownEvent = eligibleEvent and state == -2 and state[1] != -2
bool lostEvent = eligibleEvent and math.abs(state[1]) == 2 and math.abs(state) != 2
bool conflictEvent = eligibleEvent and state == 3 and state[1] != 3
bool pauseEvent = eligibleEvent and state == 4 and state[1] != 4
bool changeEvent = eligibleEvent and state != state[1]
bool shownLocalReady = supported and (barstate.isconfirmed ? localReady : localReady[1])
bool shownReady = supported and (barstate.isconfirmed ? ready : ready[1])
float shownAdaptive = shownLocalReady ? (barstate.isconfirmed ? adaptive : adaptive[1]) : na
float shownStructure = shownLocalReady ? (barstate.isconfirmed ? structure : structure[1]) : na
float shownZone = barstate.isconfirmed ? halfZone : halfZone[1]
color stateColor = f_color(state)
fastPlot = plot(shownAdaptive, "Adaptive trend", color=stateColor, linewidth=3, style=plot.style_linebr)
slowPlot = plot(showStructure ? shownStructure : na, "Structural trend", color=color.new(#D9BD7F, 15), linewidth=1, style=plot.style_linebr)
fill(fastPlot, slowPlot, color=showStructure ? color.new(stateColor, 93) : na, title="Trend structure ribbon", fillgaps=false)
zoneUpper = plot(showZone ? shownAdaptive + shownZone : na, "Quiet zone upper", color=color.new(neutralColor, 80), style=plot.style_linebr)
zoneLower = plot(showZone ? shownAdaptive - shownZone : na, "Quiet zone lower", color=color.new(neutralColor, 80), style=plot.style_linebr)
fill(zoneUpper, zoneLower, color=color.new(neutralColor, 94), title="Quiet zone", fillgaps=false)
barcolor(paintBars and ready and barstate.isconfirmed ? stateColor : na, title="Confirmed trend strength")
plotshape(showMarks and strongUpEvent, title="New strong up", style=shape.triangleup, location=location.belowbar, color=strongUp, text="S+", textcolor=strongUp, size=size.tiny)
plotshape(showMarks and strongDownEvent, title="New strong down", style=shape.triangledown, location=location.abovebar, color=strongDown, text="S-", textcolor=strongDown, size=size.tiny)

var table panel = table.new(position.bottom_right, 1, 6, bgcolor=#11191D, frame_color=#404D4E, frame_width=1)
if barstate.islast and showPanel
    table.cell(panel, 0, 0, "KONATUS FLOW / 5m · 0.2", text_color=#CFB98A, text_size=size.small)
    table.cell(panel, 0, 1, shownReady ? f_state(state) : "WAIT / DATA", text_color=stateColor, text_size=size.large)
    table.cell(panel, 0, 2, reason, text_color=#D5DEDF, text_size=size.small)
    table.cell(panel, 0, 3, snapshotDirections, text_color=#D5DEDF, text_size=size.small)
    table.cell(panel, 0, 4, snapshotStrength, text_color=#B4BEC0, text_size=size.small)
    table.cell(panel, 0, 5, snapshotFilter, text_color=#B4BEC0, text_size=size.tiny)

alertcondition(strongUpEvent, "Flow: strong up", "Flow 0.2: new confirmed STRONG UP on {{ticker}} / 5m. Descriptive state, not an order.")
alertcondition(strongDownEvent, "Flow: strong down", "Flow 0.2: new confirmed STRONG DOWN on {{ticker}} / 5m. Descriptive state, not an order.")
alertcondition(lostEvent, "Flow: strong trend lost", "Flow 0.2: strong conditions no longer hold on {{ticker}} / 5m. Review the panel; not an exit order.")
alertcondition(conflictEvent, "Flow: timeframe conflict", "Flow 0.2: 5m direction opposed by a closed higher-timeframe reading on {{ticker}}.")
alertcondition(pauseEvent, "Flow: volatility pause", "Flow 0.2: confirmed volatility pause on {{ticker}} / 5m.")
alertcondition(changeEvent, "Flow: any state change", "Flow 0.2: confirmed state change on {{ticker}} / 5m. Check the explanation panel.")

Prefer the original simpler version? Download the Flow 0.1 archive.

05 / Macro price trends · Research v0.1

KONATUS
Horizon.

Know the direction.
Understand the condition.

A daily and weekly view of the market you trade. Horizon separates a lasting directional bias from a temporary pullback, a stretched move or a genuine breakdown.

Get Horizon for TradingView ↓
An aged brass telescope looks across a dark sea toward a golden horizon
KONATUS Horizon
+ / LONG BIAS

The upward case holds.

Daily pressure, weekly evidence and range position established an upward regime. Its holding rules still pass.

0 / NEUTRAL

No side has earned it.

Evidence is conflicted, a new direction is still confirming, the old bias broke, or a shock calls for a pause.

− / SHORT BIAS

The downward case holds.

The same rules work in reverse. A short bias needs its own agreement and confirmation.

Illustrative reading · Not live market data

LONG BIASPULLBACK

The last five days moved against the long bias, but the macro holding rules remain intact. That describes a condition to investigate—not a promise that the dip will recover.

Direction and condition are different questions.

A single red candle should not have to mean a new macro short. Horizon uses separate rules for establishing and retaining a bias, then tells you what kind of move is happening inside it.

Trend

The bias holds and the recent move agrees.

Pullback

The five-day move opposes the bias; the holding rules still pass.

Stretched

Price is more than three mean ranges beyond its recent median in the direction of the bias. Direction is not an entry price.

Conflict or transition

Daily and weekly evidence disagree, confirmation is incomplete, or the previous bias has broken.

Shock pause

A daily range shock clears the bias for that observation and the next two. Fresh confirmation is required afterward.

Unavailable

History, freshness or chart requirements are not met. Missing information is kept separate from a neutral reading.

Three measurements. One explicit decision.

  1. Daily pressure. Signed path efficiency over 21, 63 and 126 daily observations measures net progress relative to back-and-forth travel.
  2. Weekly backbone. Completed 13- and 26-week paths establish the slower directional evidence.
  3. Range structure. The latest daily close is located within the preceding 63-day high–low range. The current bar cannot move its own reference boundaries.

Every entry gate must agree. The pane’s −100 to +100 evidence index summarizes these measurements; it is not a probability or a standalone trade trigger. The inputs are correlated readings of price, not independent economic evidence.

Balanced requires two qualifying daily observations. Patient raises the thresholds and requires three. Both use the same holding rules. If a holding rule fails, the bias clears to neutral; a reversal must build a fresh confirmation sequence on later days.

Set it up once. Read the bigger picture.

  1. 1 · InstallPaste the complete code into a blank TradingView Pine Editor indicator, save and add to chart. Horizon opens in its own pane.
  2. 2 · Start dailyUse standard candles on a minute chart below one day, or exactly 1D. Start with Balanced. Weekly, multi-day, seconds and synthetic charts are unsupported.
  3. 3 · Load historyAllow at least 127 valid daily bars and 27 weekly bars, plus the next daily interval to display the result. Loaded historical data counts; confirmation may require extra days.
  4. 4 · Read bothCheck the bias, condition, explanation and snapshot date. Entries, stops, size and session rules remain your separate decisions.
  5. 5 · Add alertsChoose long, short, bias cleared or any change, with Once Per Bar Close. Use an intraday chart for an earlier notification of a newly published daily snapshot.
Exact timing, formulas and limitations

Horizon publishes the previous completed daily assessment in the next daily interval, including on a daily chart. Weekly evidence is what was already confirmed at that assessment’s daily open. For a normal Monday–Friday market, Friday’s new weekly bar enters Monday’s calculation and is displayed Tuesday. This deliberate delay prevents developing weekly data from being treated as confirmed.

The regime engine and confirmation counters run inside the daily context. Moving to a five-minute chart does not speed them up. Alerts fire at the first chart-bar close carrying a new snapshot; on a daily chart that means the close, although the snapshot is visible from its open. Initial availability does not trigger a bias-change alert.

With signed efficiency E, daily pressure D = .20 E21 + .35 E63 + .45 E126; weekly backbone W = .50 E13 + .50 E26. Range structure S = 2 × (close − prior low)/(prior high − low) − 1, clipped to [−1,+1]. Index = 100 × (.45 D + .35 W + .20 S). Flat denominators produce zero.

Balanced entry thresholds for directional D/W/S are .20/.15/.20; Patient uses .30/.25/.30. Holding thresholds are D > −.05, W > 0 and S > −.20, after multiplying by the bias direction. A daily true range above three times the prior 20-day simple mean range triggers the shock pause. Stretch compares the close to the prior 21-day median, using the current 20-day mean range. These defaults are unoptimized research choices.

Daily snapshots older than seven calendar days and weekly context older than 21 days are blocked. A daily gap over seven days restarts the daily warmup. Invalid data resets history. Revised feeds, adjustments, futures rolls, sessions and loaded history can change results. Slow trend rules lag, can hold through losses and can miss fast reversals.

“Macro” here means the charted asset’s larger price trend. There are no inflation, interest-rate, news, volume, liquidity or execution-cost inputs. Research on diversified time-series momentum does not validate this specific model. The source follows TradingView’s confirmed higher-timeframe request patterns.

Horizon for perspective. Flow for the local move.

Use Horizon to frame the broader directional case, then Flow to inspect five-minute conditions. A local pullback can coexist with an intact macro trend. The scripts do not communicate, submit orders or override each other’s readings. Horizon leaves price candles untouched.

Research release 0.1.0. 28 offline reference tests pass. Native TradingView compilation, chart behavior and alert delivery remain unverified. No trading edge has been measured.

Get the complete Horizon script

Download Horizon Pine v6Research package (.zip)

View the complete Horizon code
//@version=6
// KONATUS Horizon 0.1.0 — Kutty Stauder. Macro price-trend research edition.
// Native TradingView compilation, chart behavior and alerts remain unverified.
// Bias is descriptive, not an entry, exit, protective stop or profit forecast.
indicator("KONATUS Horizon · Macro Regime 0.1.0", shorttitle="Horizon", overlay=false, max_bars_back=600, dynamic_requests=true)

string mode = input.string("Balanced", "Confirmation", options=["Balanced", "Patient"], tooltip="Balanced: 2 daily observations; Patient: 3 and higher entry thresholds. Both use identical holding rules. Neither preset is performance-optimized.")
bool showComponents = input.bool(false, "Show the three components", group="Display")
bool showBackground = input.bool(true, "Shade the active bias", group="Display")
bool showMarks = input.bool(true, "Mark bias changes", group="Display")
bool showPanel = input.bool(true, "Show explanation panel", group="Display")
color longColor = input.color(#36C9A0, "Long bias", group="Colors")
color shortColor = input.color(#F0717C, "Short bias", group="Colors")
color waitColor = input.color(#C3AC78, "Neutral / transition", group="Colors")

f_valid() =>
    not na(open) and not na(high) and not na(low) and not na(close) and high >= math.max(open, close) and low <= math.min(open, close) and high >= low

f_efficiency(int length, bool valid, int count) =>
    float step = valid and count > 1 ? math.abs(close - close[1]) : 0.0
    float path = math.sum(step, length)
    count > length and path > 0 ? math.max(-1.0, math.min(1.0, (close - close[length]) / path)) : 0.0

f_weekly() =>
    bool valid = f_valid()
    var int count = 0
    count := valid ? count + 1 : 0
    float backbone = 0.5 * f_efficiency(13, valid, count) + 0.5 * f_efficiency(26, valid, count)
    bool ready = count >= 27
    // Every requested field is shifted; no developing weekly vote.
    [backbone[1], ready[1], time_close[1]]

f_daily() =>
    // Executed inside the DAILY request, so counters cannot accelerate on 5m.
    // Weekly evidence here is what was confirmed at this daily bar's OPEN.
    [weekly, weeklyReady, weeklyClosed] = request.security(syminfo.tickerid, "1W", f_weekly(), gaps=barmerge.gaps_off, lookahead=barmerge.lookahead_on)
    bool valid = f_valid()
    bool longGap = not na(time_close[1]) and time - time_close[1] > 7 * 86400000
    var int count = 0
    count := not valid ? 0 : longGap ? 1 : count + 1
    float tr = valid ? (count > 1 ? math.max(high - low, math.max(math.abs(high - close[1]), math.abs(low - close[1]))) : high - low) : na
    float scale = ta.sma(tr, 20)
    float pressure = 0.20 * f_efficiency(21, valid, count) + 0.35 * f_efficiency(63, valid, count) + 0.45 * f_efficiency(126, valid, count)
    float priorHigh = ta.highest(high[1], 63)
    float priorLow = ta.lowest(low[1], 63)
    float structure = priorHigh > priorLow ? math.max(-1.0, math.min(1.0, 2.0 * (close - priorLow) / (priorHigh - priorLow) - 1.0)) : 0.0
    float balance = ta.median(close[1], 21)
    float extension = scale > 0 ? (close - balance) / scale : 0.0
    float impulse = close - close[5]
    bool freshWeek = not na(weeklyClosed) and time >= weeklyClosed and time - weeklyClosed <= 21 * 86400000
    bool ready = count >= 127 and weeklyReady and freshWeek and not na(scale) and not na(balance) and not na(priorHigh) and not na(priorLow)
    bool shock = valid and count >= 21 and not na(scale[1]) and tr > 3.0 * scale[1]
    float td = mode == "Patient" ? 0.30 : 0.20
    float tw = mode == "Patient" ? 0.25 : 0.15
    float ts = mode == "Patient" ? 0.30 : 0.20
    int required = mode == "Patient" ? 3 : 2
    var int bias = 0
    var int candidate = 0
    var int run = 0
    var int pause = 0
    bool invalidated = false
    if not ready
        bias := 0
        candidate := 0
        run := 0
        pause := 0
    else
        pause := shock ? 3 : math.max(0, pause - 1)
        if pause > 0
            bias := 0
            candidate := 0
            run := 0
        else
            invalidated := bias != 0 and (bias * pressure <= -0.05 or bias * weekly <= 0 or bias * structure <= -0.20)
            if invalidated
                bias := 0
                candidate := 0
                run := 0
            else if bias == 0
                int next = pressure >= td and weekly >= tw and structure >= ts ? 1 : pressure <= -td and weekly <= -tw and structure <= -ts ? -1 : 0
                run := next != 0 ? (next == candidate ? run + 1 : 1) : 0
                candidate := next
                if run >= required
                    bias := candidate
                    candidate := 0
                    run := 0
    // 0 transition, 1 trend, 2 pullback, 3 stretched, 4 conflict, 5 pause, 6 unavailable.
    int condition = not ready ? 6 : pause > 0 ? 5 : bias != 0 ? (bias * extension > 3 ? 3 : bias * impulse < 0 ? 2 : 1) : invalidated ? 0 : pressure * weekly < 0 ? 4 : 0
    float score = ready ? 100.0 * (0.45 * pressure + 0.35 * weekly + 0.20 * structure) : na
    // A daily result is published in the NEXT daily interval on every chart.
    [bias[1], condition[1], score[1], pressure[1], weekly[1], structure[1], ready[1], time_close[1], weeklyClosed[1], run[1], invalidated[1]]

f_condition(int value) =>
    switch value
        1 => "TREND"
        2 => "PULLBACK"
        3 => "STRETCHED"
        4 => "CONFLICT"
        5 => "SHOCK PAUSE"
        6 => "UNAVAILABLE"
        => "TRANSITION"

f_reason(int value, bool invalidated) =>
    switch value
        1 => "Macro hold rules intact"
        2 => "5-day counter-move; macro bias still intact"
        3 => "Extended from median; bias is not an entry"
        4 => "Daily and weekly evidence oppose"
        5 => "Range shock; wait for fresh confirmation"
        6 => "Need 127 daily and 27 weekly valid bars"
        => invalidated ? "Prior bias broke; fresh confirmation required" : "Waiting for agreement and daily confirmation"

bool supported = chart.is_standard and ((timeframe.isminutes and timeframe.multiplier < 1440) or (timeframe.isdaily and timeframe.multiplier == 1))
int sourceBias = 0
int condition = 6
float score = na
float dailyPressure = na
float weeklyBackbone = na
float structure = na
bool sourceReady = false
int dailyClosed = na
int weeklyClosed = na
int confirmationRun = 0
bool invalidated = false
if supported
    [b, c, s, d, w, r, ready, dc, wc, n, invalid] = request.security(syminfo.tickerid, "1D", f_daily(), gaps=barmerge.gaps_off, lookahead=barmerge.lookahead_on)
    sourceBias := b
    condition := c
    score := s
    dailyPressure := d
    weeklyBackbone := w
    structure := r
    sourceReady := ready
    dailyClosed := dc
    weeklyClosed := wc
    confirmationRun := n
    invalidated := invalid

bool fresh = not na(dailyClosed) and time >= dailyClosed and time - dailyClosed <= 7 * 86400000
bool available = supported and sourceReady and fresh
int bias = available ? sourceBias : 0
color biasColor = bias == 1 ? longColor : bias == -1 ? shortColor : waitColor
string title = not supported ? "USE STANDARD 1m–1D" : not available ? "UNAVAILABLE" : bias == 1 ? "LONG BIAS" : bias == -1 ? "SHORT BIAS" : "NEUTRAL"
string detail = not supported ? "Standard minute candles or exactly 1D" : not fresh and not na(dailyClosed) ? "Daily data stale; no active bias" : f_reason(available ? condition : 6, invalidated)

hline(0, "Balance", color=color.new(color.gray, 55))
hline(100, "Index ceiling", color=color.new(color.gray, 85))
hline(-100, "Index floor", color=color.new(color.gray, 85))
plot(available ? score : na, "Macro evidence index (not probability)", color=biasColor, linewidth=3, style=plot.style_stepline)
plot(showComponents and available ? 100 * dailyPressure : na, "Daily pressure", color=#5AA8D1)
plot(showComponents and available ? 100 * weeklyBackbone : na, "Weekly backbone", color=#B19AE1)
plot(showComponents and available ? 100 * structure : na, "Range structure", color=#C4A46B)
bgcolor(showBackground and available ? color.new(biasColor, bias == 0 ? 96 : 91) : na)

bool newSnapshot = not na(dailyClosed[1]) and dailyClosed != dailyClosed[1]
bool changed = available and available[1] and newSnapshot and sourceBias != sourceBias[1]
bool newLong = changed and sourceBias == 1
bool newShort = changed and sourceBias == -1
bool cleared = changed and sourceBias == 0
plotshape(showMarks and newLong, title="New long bias", style=shape.triangleup, location=location.bottom, color=longColor, size=size.tiny, text="L")
plotshape(showMarks and newShort, title="New short bias", style=shape.triangledown, location=location.top, color=shortColor, size=size.tiny, text="S")
plotshape(showMarks and cleared, title="Bias cleared", style=shape.xcross, location=location.bottom, color=waitColor, size=size.tiny)
alertcondition(newLong and barstate.isconfirmed, "New macro long bias", "KONATUS Horizon: new confirmed macro LONG bias on {{ticker}}. Review condition; this is not an entry order.")
alertcondition(newShort and barstate.isconfirmed, "New macro short bias", "KONATUS Horizon: new confirmed macro SHORT bias on {{ticker}}. Review condition; this is not an entry order.")
alertcondition(cleared and barstate.isconfirmed, "Macro bias cleared", "KONATUS Horizon: prior bias cleared to NEUTRAL on {{ticker}}.")
alertcondition(changed and barstate.isconfirmed, "Any macro bias change", "KONATUS Horizon: confirmed macro bias changed on {{ticker}}. Review the Horizon panel.")

var table panel = table.new(position.top_right, 2, 9, bgcolor=color.new(#101D29, 6), border_width=1, border_color=color.new(color.gray, 70))
if barstate.islast
    if showPanel
        table.cell(panel, 0, 0, "KONATUS HORIZON", text_color=color.white, text_size=size.small)
        table.cell(panel, 1, 0, title, text_color=available ? biasColor : color.gray, text_size=size.small)
        table.cell(panel, 0, 1, "Condition", text_color=color.silver)
        table.cell(panel, 1, 1, available ? f_condition(condition) : "UNAVAILABLE", text_color=color.white)
        table.cell(panel, 0, 2, "Why", text_color=color.silver)
        table.cell(panel, 1, 2, detail, text_color=color.white, text_size=size.small)
        table.cell(panel, 0, 3, "Daily / Weekly / Range", text_color=color.silver)
        table.cell(panel, 1, 3, available ? str.tostring(100*dailyPressure, "#.0") + " / " + str.tostring(100*weeklyBackbone, "#.0") + " / " + str.tostring(100*structure, "#.0") : "—", text_color=color.white)
        table.cell(panel, 0, 4, "Daily snapshot", text_color=color.silver)
        table.cell(panel, 1, 4, not na(dailyClosed) ? str.format_time(dailyClosed, "yyyy-MM-dd HH:mm", syminfo.timezone) : "—", text_color=color.white, text_size=size.small)
        table.cell(panel, 0, 5, "Weekly evidence closed", text_color=color.silver)
        table.cell(panel, 1, 5, not na(weeklyClosed) ? str.format_time(weeklyClosed, "yyyy-MM-dd", syminfo.timezone) : "—", text_color=color.white)
        table.cell(panel, 0, 6, "Confirmation", text_color=color.silver)
        table.cell(panel, 1, 6, mode + (available and bias == 0 ? " · " + str.tostring(confirmationRun) + "/" + (mode == "Patient" ? "3" : "2") : ""), text_color=color.white)
        table.cell(panel, 0, 7, "Timing", text_color=color.silver)
        table.cell(panel, 1, 7, "Previous daily close; weekly at its open", text_color=color.silver, text_size=size.small)
        table.cell(panel, 0, 8, "RESEARCH 0.1.0", text_color=waitColor, text_size=size.small)
        table.cell(panel, 1, 8, "Bias ≠ entry · Native validation pending", text_color=color.silver, text_size=size.small)
    else
        table.clear(panel, 0, 0, 1, 8)

06 / Daily breakouts · Pine Script v6 · Research 0.1

KONATUSVela.

Let the base form.
Let price prove it.

A quiet chart. A level that stays put. Follow a tightening base into a confirmed breakout—and see when its exit rules call time.

A golden brass sail rises over dark teal water as three fine lines converge and lift into the wind
Vela · A study in compression and release. Original generated artwork.

One setup. One attempt.

Coil. Break. Ride. Release.

Inspired by Kristjan Kullamägi, known as Qullamaggie: prior momentum, a tightening base, and rising moving averages. Vela adds a frozen breakout level and explicit rules for waiting, accepting, or rejecting the next move.

01 / Coil

A gold dot marks preparation. The base qualifies; its release level stops moving.

02 / Break

A teal triangle marks a close above the level with enough volume and a controlled extension.

03 / Ride

Follow the 10/20-day ribbon. A thin coral line holds the breakout day’s low.

04 / Exit

A coral cross marks a later risk touch or close below your chosen average.

READ THE SEQUENCESynthetic candles · Rule demonstration · Not a backtest
77828793C

A tightening base qualifies at the close. The release level is sealed at 91.96725 for the next five bars.

77828893CBREAK

The next close reaches 92 with 1.5× prior average volume. A single BREAK appears; the initial risk reference is 91.

77849299CBREAK

The campaign stays active above its initial reference and the selected moving average. No repeated entry triangles.

778593101CBREAKEXIT

A later close falls below SMA20. EXIT ends this observation; it does not open a short position.

Gold: SMA10 and frozen release. Teal: SMA20. Dashed coral: initial risk reference. The examples contain no live market data.

From source to chart

Four steps to begin.

  1. Install on a daily chart.Open TradingView’s Pine Editor, create a blank indicator, paste the complete source below, save, and add it to your chart. Select 1D and standard candles. Defaults need 55 valid daily bars of loaded history.
  2. Wait for a gold coil.Start with defaults on a stock chart. Review liquidity, earnings dates and the broader market yourself. COIL means watch; it is not an instruction to buy. WAIT explains the first missing condition.
  3. Let the close confirm.BREAK appears only after the daily close. Do not assume you could enter at an earlier intraday price—or that the signal close is still available. Decide your entry, stop and position size independently.
  4. Track the ride.Use SMA20 by default, or select SMA10 for a faster close-based exit. Create Vela alerts with Once Per Bar Close. Recreate them after changing inputs or code. Paper-test before relying on them.

This is long-only. EXIT is not a short signal. The script places no orders; a risk-touch alert arrives at the daily close and cannot replace an intraday protective stop. Gaps and slippage can exceed your intended risk.

The precise rules, without a black box

Trend + prior advanceClose > SMA10 > SMA20 > SMA50; SMA20 is higher than five bars earlier. The 40-bar advance ending before the 10-bar base must be at least 20%.

Contraction + proximityBase span ≤ 3 MTR. Last five bars’ mean true range ≤ 0.75× the pre-base 20-bar mean. Close within 1 MTR of SMA10 or SMA20. MTR is the simple 20-day mean true range, not Wilder ATR or percentage ADR.

A sealed release levelBase high + 0.10 MTR, frozen on the arming close. Valid for the next five closes. A low beneath the base or lost trend cancels it. A new coil needs the readiness condition to turn off and then on.

First attempt onlyFirst close above the level needs ≥ 1.5× prior 20-day average volume, close in the top 35% of its range, extension from SMA10 ≤ 2 frozen MTR, and positive close-to-low distance ≤ 1.5 frozen MTR. A failed attempt is consumed.

Two ways to finishFrom the bar after BREAK: low at or below the breakout-day low, or close below the chosen SMA10/20. Initial-risk touch takes precedence. No automatic partial sales or break-even adjustment.

Missing informationMissing volume blocks setups by default; disabling that filter changes the rules. Price exits continue without volume. Invalid price/history clears the campaign and shows UNAVAILABLE. Standard daily candles only.

Open source · No account required to download

A small instrument.
The complete code.

Three moving averages, a restrained ribbon, and clear event markers. Candle coloring is optional and off by default. Use the compact panel to read the state without covering the chart.

Research release. 24 offline reference tests pass, including a complete synthetic lifecycle. Independent source review completed. Native TradingView compilation, chart replay and alert delivery remain unverified. No trading edge or improvement in returns has been measured.

View the full Pine Script
//@version=6
// Konatus Vela 0.1.0 | Kutty Stauder | https://kuttystauder.com/trading/#konatus-vela
// Inspired by Qullamaggie's published breakout framework; independent adaptation.
// Research indicator. No orders, position sizing or demonstrated trading edge.
// Standard daily candles. Events at close; no future data or intrabar entries.
indicator("KONATUS Vela · Breakout Trend", shorttitle="VELA", overlay=true, max_bars_back=500)

string setupGroup = "01 · Coil"
int baseLen = input.int(10, "Base length", minval=5, maxval=30, group=setupGroup)
int impulseLen = input.int(40, "Prior advance lookback", minval=20, maxval=120, group=setupGroup)
float minImpulse = input.float(20.0, "Minimum prior advance (%)", minval=0, maxval=200, step=5, group=setupGroup)
float contraction = input.float(0.75, "Range contraction ratio", minval=0.1, maxval=1, step=0.05, group=setupGroup)
int expiry = input.int(5, "Coil expires after bars", minval=1, maxval=20, group=setupGroup)
string releaseGroup = "02 · Release and ride"
bool requireVolume = input.bool(true, "Require volume expansion", group=releaseGroup)
float volMultiple = input.float(1.5, "Volume / prior 20-day average", minval=1, maxval=5, step=0.1, group=releaseGroup)
int trailLen = input.int(20, "Close-based trailing average", options=[10, 20], group=releaseGroup)
string styleGroup = "03 · Appearance"
bool showMAs = input.bool(true, "Show 10 / 20 / 50 averages", group=styleGroup)
bool showPanel = input.bool(true, "Show compact status", group=styleGroup)
bool paintBars = input.bool(false, "Color confirmed campaign candles", group=styleGroup)
color gold = input.color(#D7B777, "Coil / SMA10", group=styleGroup)
color teal = input.color(#72CCB1, "Breakout / SMA20", group=styleGroup)
color coral = input.color(#E58C7A, "Exit / initial risk", group=styleGroup)
color muted = #8D969A

// Rolling series are evaluated unconditionally. MTR uses simple, not Wilder smoothing.
bool supported = chart.is_standard and timeframe.isdaily and timeframe.multiplier == 1
bool priceOK = not na(open) and not na(high) and not na(low) and not na(close) and low > 0 and high >= math.max(open, close) and low <= math.min(open, close)
var int validRun = 0
validRun := priceOK ? validRun + 1 : 0
int warmup = math.max(55, math.max(baseLen + impulseLen + 1, baseLen + 21))
float ma10 = ta.sma(close, 10)
float ma20 = ta.sma(close, 20)
float ma50 = ta.sma(close, 50)
float trueRange = ta.tr(true)
float mtr = ta.sma(trueRange, 20)
float shortRange = ta.sma(trueRange, 5)
float oldRange = mtr[baseLen]
float top = ta.highest(high, baseLen)
float bottom = ta.lowest(low, baseLen)
float impulse = 100 * (close[baseLen] / close[baseLen + impulseLen] - 1)
float priorVolume = ta.sma(volume, 20)[1]
float volumeValid = ta.sma(not na(volume) and volume > 0 ? 1.0 : 0.0, 20)[1]
bool volumeData = not na(volume) and volume > 0 and volumeValid == 1 and priorVolume > 0
bool volumeOK = not requireVolume or (volumeData and volume >= volMultiple * priorVolume)
bool dataOK = supported and priceOK and validRun >= warmup and not na(mtr)
bool trend = close > ma10 and ma10 > ma20 and ma20 > ma50 and ma20 > ma20[5]
bool advance = impulse >= minImpulse
bool narrow = top - bottom <= 3 * mtr
bool contracted = shortRange <= contraction * oldRange
bool nearMA = math.min(math.abs(close - ma10), math.abs(close - ma20)) <= mtr
bool ready = dataOK and mtr > 0 and trend and advance and narrow and contracted and nearMA and (not requireVolume or volumeData)
string waiting = not trend ? "Wait for rising averages" : not advance ? "Need a prior advance" : not narrow ? "Base is too wide" : not contracted ? "Wait for contraction" : not nearMA ? "Price away from averages" : requireVolume and not volumeData ? "Volume unavailable" : "Wait for a fresh coil"

// 0 WAIT, 1 COIL, 2 RIDE. One attempt per fresh readiness edge.
var int state = 0
var int armedBar = na
var float level = na
var float floor = na
var float frozenRange = na
var float initialRisk = na
var bool previousReady = false
var string reason = "Load history"
var bool available = false
bool coilEvent = false
bool breakEvent = false
bool exitEvent = false
bool rejectEvent = false

if barstate.isconfirmed
    available := dataOK
    if not dataOK
        state := 0
        level := na
        floor := na
        frozenRange := na
        initialRisk := na
        previousReady := false
        reason := not supported ? "Use standard 1D candles" : "Load valid price history"
    else
        if state == 2
            float trail = trailLen == 10 ? ma10 : ma20
            if low <= initialRisk or close < trail
                reason := low <= initialRisk ? "Initial risk touched" : "Close below trail"
                state := 0
                exitEvent := true
                level := na
                initialRisk := na
            else
                reason := "Follow the moving average"
        else if state == 1
            int age = bar_index - armedBar
            if age > expiry
                state := 0
                reason := "Coil expired"
            else if low < floor or not trend
                state := 0
                reason := "Base or trend lost"
            else if close > level
                float location = high > low ? (close - low) / (high - low) : 0
                bool quality = volumeOK and location >= 0.65 and close - ma10 <= 2 * frozenRange and close - low > 0 and close - low <= 1.5 * frozenRange
                if quality
                    state := 2
                    breakEvent := true
                    initialRisk := low
                    reason := "Confirmed breakout"
                else
                    state := 0
                    rejectEvent := true
                    reason := "Breakout filters failed"
            else
                reason := "Waiting for the release"
            if state == 0
                level := na
        else if ready and not previousReady
            state := 1
            armedBar := bar_index
            level := top + 0.10 * mtr
            floor := bottom
            frozenRange := mtr
            initialRisk := na
            coilEvent := true
            reason := "Range sealed"
        else
            reason := waiting
        previousReady := ready

// A live candle shows the previous completed averages, never a provisional event.
float confirmed10 = barstate.isconfirmed ? ma10 : ma10[1]
float confirmed20 = barstate.isconfirmed ? ma20 : ma20[1]
float confirmed50 = barstate.isconfirmed ? ma50 : ma50[1]
p10 = plot(supported and showMAs ? confirmed10 : na, "SMA10 · fast", color=color.new(gold, 15), linewidth=1)
p20 = plot(supported and showMAs ? confirmed20 : na, "SMA20 · trend", color=color.new(teal, 15), linewidth=2)
plot(supported and showMAs ? confirmed50 : na, "SMA50 · structure", color=color.new(muted, 45), linewidth=1)
fill(p10, p20, color=available and state > 0 ? color.new(state == 1 ? gold : teal, 93) : na, title="Campaign ribbon")
plot(available and (state == 1 or breakEvent) ? level : na, "Frozen release level", color=gold, linewidth=2, style=plot.style_linebr)
plot(available and state == 2 ? initialRisk : na, "Initial risk reference · not an order", color=color.new(coral, 20), linewidth=1, style=plot.style_linebr)
plotshape(coilEvent, title="New coil", style=shape.circle, location=location.belowbar, color=gold, size=size.tiny, text="C", textcolor=gold)
plotshape(breakEvent, title="Confirmed breakout", style=shape.triangleup, location=location.belowbar, color=teal, size=size.small, text="BREAK", textcolor=teal)
plotshape(exitEvent, title="Campaign exit", style=shape.xcross, location=location.abovebar, color=coral, size=size.tiny, text="EXIT", textcolor=coral)
barcolor(paintBars and barstate.isconfirmed and available ? exitEvent ? coral : state == 2 ? teal : state == 1 ? gold : na : na)

var table panel = table.new(position.bottom_right, 2, 3, bgcolor=color.new(#101D20, 7), frame_color=color.new(gold, 65), frame_width=1)
if barstate.islast
    if showPanel
        string label = not supported ? "1D ONLY" : not available ? "UNAVAILABLE" : breakEvent ? "BREAKOUT" : exitEvent ? "EXIT" : state == 2 ? "RIDE" : state == 1 ? "COIL" : "WAIT"
        color accent = exitEvent ? coral : state == 2 ? teal : gold
        table.cell(panel, 0, 0, "VELA", text_color=gold, text_size=size.small)
        table.cell(panel, 1, 0, label, text_color=accent, text_size=size.small)
        table.cell(panel, 0, 1, "At last close", text_color=muted, text_size=size.tiny)
        table.cell(panel, 1, 1, not supported ? "Use standard 1D candles" : reason, text_color=color.white, text_size=size.tiny)
        table.cell(panel, 0, 2, state == 2 ? "Initial risk" : "Release", text_color=muted, text_size=size.tiny)
        table.cell(panel, 1, 2, state == 2 ? str.tostring(initialRisk, format.mintick) : state == 1 ? str.tostring(level, format.mintick) : "—", text_color=accent, text_size=size.small)
    else
        table.clear(panel, 0, 0, 1, 2)

alertcondition(coilEvent, "Vela · New coil", "VELA: new daily coil on {{ticker}}. Preparation, not an entry.")
alertcondition(breakEvent, "Vela · Breakout", "VELA: confirmed daily breakout on {{ticker}} at close {{close}}. Review risk before acting.")
alertcondition(exitEvent, "Vela · Exit", "VELA: initial risk touched or close below the trailing average on {{ticker}}. Daily-close observation, not an executed order.")
alertcondition(rejectEvent, "Vela · Rejected release", "VELA: first release failed quality filters on {{ticker}}. Wait for a fresh coil.")

The idea and its boundaries

Inspired by a method.
Explicit about the differences.

Qullamaggie describes discretionary stock selection and often uses intraday opening-range entries, then partial profit-taking and moving-average exits. Vela’s daily-close confirmation, exact filters, five-bar expiry and frozen-level lifecycle are our own adaptation. They are not his complete system or a claim to his results.

Vela does not evaluate fundamentals, market breadth, news, liquidity or the visual quality of higher lows. Filters can delay good entries and still admit failed breakouts. Price history, sessions and data revisions can change readings. Not affiliated with or endorsed by Qullamaggie.

07 / Trend persistence · Pine Script v6 · Free research release

KONATUSContinuation.

Wait for the break.
Follow the structure.

A trend rarely moves in a straight line. Continuation waits for a breakout to hold across closed candles, scores the evidence behind it, then follows the structure through the pullbacks.

Brass compass and navigation ruler beside a winding gold course on a dark chart
Continuation · Staying with a course. Original generated illustration, not a chart screenshot.

Research prototype 0.1.0. 16 local reference and package tests pass. Native TradingView compilation, chart replay and market performance remain unverified. Better results or longer profitable trends than a moving-average crossover have not been established.

Three jobs, one chart

Confirm. Assess. Stay attentive.

01 / Confirm

Make the breakout hold.

The first close beyond the previous 20-bar range freezes a breakout threshold. By default, a second consecutive close must stay beyond it. A close back inside cancels the candidate. More confirmation can reject brief breaks, but adds delay and can miss fast moves.

02 / Assess

Read the quality of the move.

A 0–100 score combines directional efficiency, volatility-scaled slope, directional movement, candle location and volume participation. The default signal threshold is 60. Missing volume switches to price-only evidence; it does not invent buying or selling pressure.

03 / Follow

Let structure define the trend.

A lower score warns of a pullback or weakening. It does not end the trend. Protection tightens only after a confirmed swing and renewed price progress. Two consecutive closes through that level invalidate the trend by default.

The pullback question

Has the trend weakened,
or has it failed?

A moving-average crossover responds to the relationship between two smoothed prices. Continuation separates weakening evidence from a break in its structural rules. That gives a pullback room to develop while keeping an explicit invalidation level visible.

The tradeoff is real: waiting for structural failure can surrender more open profit. A slower exit is not automatically a better exit. The included comparison strategy lets you explore structure, EMA 10/30 and ATR Chandelier exits with the same signal engine.

These are close-based observations, not protective broker orders. Gaps, slippage and a move between candle closes can exceed the level shown on the chart.

A winding coastal path follows dark cliffs toward a distant lighthouse
A bend can belong to the same journey. Concept illustration; no market or performance data.

Understand the numbers

A score is not a probability.

Continuation score / 100

A transparent, heuristic summary of current evidence. An 80 does not mean an 80% chance of success. The component weights and default threshold have not been fitted or validated as a forecasting model.

Historical continuation rate

At entry, the script looks at previously resolved, sampled signals with the same direction, score band and volume mode. By default, success means touching a target 1.5 entry ATR away before touching the initial protection, within the next 20 bars.

The historical rate appears only after at least 20 resolved examples in that bucket, alongside a sample count and descriptive 95% interval. Otherwise it reads Insufficient history. The rate is frozen at entry; it is not a continuously updated forecast of the remaining trend.

Only one outcome window is tracked at a time. A candle touching both boundaries counts as a failure, as does a timeout. Unfinished observations are excluded. The historical touch-based outcome differs from the indicator’s close-confirmed exit. Market changes, different stop distances and correlated observations limit what the rate and interval can tell you.

From source to chart

Start with the defaults.

  1. Install. Open a standard time-based candlestick chart in TradingView. Create a blank Pine Editor indicator and replace its contents with the complete source below.
  2. Confirm. Start with two consecutive breakout closes. Choose one for earlier confirmation or three for more patience. Review the 60/100 entry threshold without treating it as a percentage chance.
  3. Read. Watch the trend state, score and structural protection. Weakening evidence alone does not invalidate the trend. Confirmed pivots take time; the script does not pretend they were known earlier.
  4. Test. Compile and replay before relying on it. For alerts, choose the up-trend, down-trend or invalidation condition and Once Per Bar Close. Recreate alerts after changing code or inputs.

Standard OHLCV only: no paid-only footprint API, external feed or paid script dependency. Exchange data, delays, history and alert limits still depend on your TradingView account. Different markets and timeframes need separate validation.

Version 0.1.0 · Complete source

Read it. Test it. Know its limits.

View the complete indicator code
//@version=6
indicator("Konatus Continuation [Research 0.1]", shorttitle="Konatus Trend", overlay=true, max_labels_count=200)

// Standard OHLCV only. Score is heuristic; historical rates are not calibrated forecasts.
int breakoutLength = input.int(20, "Breakout lookback", minval=5, maxval=200, group="Detection")
int confirmBars = input.int(2, "Consecutive breakout closes", minval=1, maxval=10, group="Detection")
int candidateLifetime = input.int(8, "Maximum candidate bars", minval=1, maxval=30, group="Detection")
float breakoutBuffer = input.float(0.10, "Breakout buffer (ATR)", minval=0, step=0.05, group="Detection")
int featureLength = input.int(14, "Evidence lookback", minval=5, maxval=100, group="Detection")
float minimumScore = input.float(60, "Minimum entry score / 100", minval=0, maxval=100, group="Detection")
int pivotBars = input.int(3, "Swing confirmation bars each side", minval=1, maxval=20, group="Structure")
int exitCloses = input.int(2, "Consecutive closes through protection", minval=1, maxval=10, group="Structure")
float structureBuffer = input.float(0.25, "Structure buffer (ATR)", minval=0, step=0.05, group="Structure")
float targetAtr = input.float(1.5, "Continuation target (entry ATR)", minval=0.25, step=0.25, group="Historical estimate")
int forecastBars = input.int(20, "Continuation horizon (bars)", minval=1, maxval=200, group="Historical estimate")
int minimumSamples = input.int(20, "Minimum resolved samples per bucket", minval=10, maxval=500, group="Historical estimate")
bool showLabels = input.bool(true, "Show entry and exit labels", group="Display")
bool shadeTrend = input.bool(true, "Shade active trend", group="Display")

clamp01(float value) =>
    math.max(0.0, math.min(1.0, value))

bucketFor(int direction, float score, bool hasVolume) =>
    (score < 70 ? 0 : score < 85 ? 1 : 2) + (direction == 1 ? 0 : 3) + (hasVolume ? 0 : 6)

wilson(int successes, int samples) =>
    float rate = samples > 0 ? float(successes) / samples : na
    float denominator = samples > 0 ? 1.0 + 3.8416 / samples : na
    float center = samples > 0 ? (rate + 3.8416 / (2.0 * samples)) / denominator : na
    float halfWidth = samples > 0 ? 1.96 * math.sqrt(rate * (1 - rate) / samples + 3.8416 / (4.0 * samples * samples)) / denominator : na
    [rate, math.max(0.0, center - halfWidth), math.min(1.0, center + halfWidth)]

// Calculate every bar; commit decisions only when the chart bar closes.
float atr = ta.atr(featureLength)
float priorHigh = ta.highest(high, breakoutLength)[1]
float priorLow = ta.lowest(low, breakoutLength)[1]
float pivotLow = ta.pivotlow(low, pivotBars, pivotBars)
float pivotHigh = ta.pivothigh(high, pivotBars, pivotBars)
float pathLength = ta.sma(math.abs(ta.change(close)), featureLength) * featureLength
float efficiency = pathLength > 0 ? (close - close[featureLength]) / pathLength : 0
float regressionSlope = ta.linreg(close, featureLength, 0) - ta.linreg(close, featureLength, 1)
float scaledSlope = atr > 0 ? regressionSlope * featureLength / (4.0 * atr) : 0
[plusDI, minusDI, adx] = ta.dmi(featureLength, featureLength)
float directionBalance = plusDI + minusDI > 0 ? (plusDI - minusDI) / (plusDI + minusDI) : 0
float closeLocation = high > low ? (2.0 * close - high - low) / (high - low) : 0
float meanLocation = ta.sma(closeLocation, featureLength)
float meanVolume = ta.sma(volume, featureLength)
float volumeCompleteness = ta.sma(not na(volume) and volume >= 0 ? 1.0 : 0.0, featureLength)
bool hasVolume = volumeCompleteness == 1 and meanVolume > 0
float relativeVolume = hasVolume ? volume / meanVolume : 1
float signedVolumeMean = ta.sma(volume * closeLocation, featureLength)
float volumePressure = hasVolume ? signedVolumeMean / meanVolume * math.min(relativeVolume, 2.0) : 0

evidenceScore(int direction) =>
    float efficiencyPart = clamp01(0.5 + 0.5 * direction * efficiency)
    float slopePart = clamp01(0.5 + direction * scaledSlope)
    float movementPart = clamp01(direction * directionBalance) * clamp01(adx / 35.0)
    float locationPart = clamp01(0.5 + 0.5 * direction * meanLocation)
    float volumePart = clamp01(0.5 + 0.5 * direction * volumePressure)
    float weighted = 25 * efficiencyPart + 25 * slopePart + 20 * movementPart + 15 * locationPart
    100.0 * (weighted + (hasVolume ? 15 * volumePart : 0)) / (hasVolume ? 100.0 : 85.0)

float longScore = evidenceScore(1)
float shortScore = evidenceScore(-1)
bool ready = bar_index >= math.max(breakoutLength + 1, featureLength * 3) and atr > 0 and not na(longScore) and not na(shortScore)

var int trend = 0
var float protection = na
var float favorableExtreme = na
var float pendingProtection = na
var int entryBar = na
var int breachCount = 0
var int candidate = 0
var int candidateCount = 0
var float candidateThreshold = na
var float confirmedScore = na
var bool confirmedVolume = false
var bool warmedUp = false

// Fixed-size counters: 3 score bands x 2 directions x 2 volume availability states.
var array<int> successes = array.new_int(12, 0)
var array<int> samples = array.new_int(12, 0)
var int observationDirection = 0
var int observationBar = na
var int observationBucket = na
var float observationTarget = na
var float observationStop = na
var float entryRate = na
var float entryLower = na
var float entryUpper = na
var int entrySamples = 0
var float entryScore = na
var int skippedObservations = 0
var bool entrySampled = false

bool longSignal = false
bool shortSignal = false
bool longExit = false
bool shortExit = false

if barstate.isconfirmed
    warmedUp := ready
    confirmedVolume := hasVolume
    // Mature prior observation BEFORE taking a new forecast snapshot.
    if observationDirection != 0 and bar_index > observationBar
        bool stopHit = observationDirection == 1 ? low <= observationStop : high >= observationStop
        bool targetHit = observationDirection == 1 ? high >= observationTarget : low <= observationTarget
        bool timedOut = bar_index - observationBar >= forecastBars
        if stopHit or targetHit or timedOut
            int count = array.get(samples, observationBucket)
            array.set(samples, observationBucket, count + 1)
            // Conservative ordering for candles containing both boundaries.
            if targetHit and not stopHit
                array.set(successes, observationBucket, array.get(successes, observationBucket) + 1)
            observationDirection := 0

    if trend != 0
        // Pivot origins must be after this trend's entry; no backdated signals.
        float newPivot = trend == 1 ? pivotLow : pivotHigh
        if not na(newPivot) and bar_index - pivotBars > entryBar
            pendingProtection := newPivot - trend * structureBuffer * atr
        bool broken = trend * (close - protection) < 0
        breachCount := broken ? breachCount + 1 : 0
        if breachCount >= exitCloses
            longExit := trend == 1
            shortExit := trend == -1
            if showLabels
                label.new(bar_index, close, "Trend invalidated", style=label.style_label_left, color=color.new(color.gray, 15), textcolor=color.white, size=size.tiny)
            trend := 0
            protection := na
            pendingProtection := na
            breachCount := 0
        else
            bool progress = trend * (close - favorableExtreme) > 0
            if not broken and progress and not na(pendingProtection) and trend * (close - pendingProtection) > 0
                protection := trend == 1 ? math.max(protection, pendingProtection) : math.min(protection, pendingProtection)
                pendingProtection := na
            favorableExtreme := trend == 1 ? math.max(favorableExtreme, high) : math.min(favorableExtreme, low)

    // Exit bars cannot also become entry bars.
    if ready and trend == 0 and not longExit and not shortExit
        if candidate == 0
            if close > priorHigh + breakoutBuffer * atr
                candidate := 1
                candidateThreshold := priorHigh + breakoutBuffer * atr
                candidateCount := 0
            else if close < priorLow - breakoutBuffer * atr
                candidate := -1
                candidateThreshold := priorLow - breakoutBuffer * atr
                candidateCount := 0
        if candidate != 0
            bool holdsBreakout = candidate * (close - candidateThreshold) > 0
            if not holdsBreakout
                candidate := 0
                candidateCount := 0
            else
                candidateCount += 1
                float setupScore = candidate == 1 ? longScore : shortScore
                if candidateCount >= confirmBars and setupScore >= minimumScore
                    trend := candidate
                    entryBar := bar_index
                    entryScore := setupScore
                    protection := trend == 1 ? priorLow - structureBuffer * atr : priorHigh + structureBuffer * atr
                    favorableExtreme := trend == 1 ? high : low
                    pendingProtection := na
                    breachCount := 0
                    longSignal := trend == 1
                    shortSignal := trend == -1
                    int key = bucketFor(trend, setupScore, hasVolume)
                    entrySamples := array.get(samples, key)
                    [rate, lower, upper] = wilson(array.get(successes, key), entrySamples)
                    entryRate := entrySamples >= minimumSamples ? rate : na
                    entryLower := entrySamples >= minimumSamples ? lower : na
                    entryUpper := entrySamples >= minimumSamples ? upper : na
                    entrySampled := observationDirection == 0
                    if entrySampled
                        observationDirection := trend
                        observationBar := bar_index
                        observationBucket := key
                        observationTarget := close + trend * targetAtr * atr
                        observationStop := protection
                    else
                        skippedObservations += 1
                    if showLabels
                        string probabilityText = na(entryRate) ? "History: n=" + str.tostring(entrySamples) : "Historical: " + str.tostring(entryRate * 100, "#.0") + "% (n=" + str.tostring(entrySamples) + ")"
                        string labelText = (trend == 1 ? "UP TREND" : "DOWN TREND") + "\nScore " + str.tostring(setupScore, "#.0") + "/100\n" + probabilityText
                        label.new(bar_index, trend == 1 ? low : high, labelText, style=trend == 1 ? label.style_label_up : label.style_label_down, color=trend == 1 ? color.teal : color.maroon, textcolor=color.white, size=size.small, tooltip="Historical rate uses prior resolved setups in this score band, direction and volume mode. Not a calibrated forecast. Target and horizon are in settings.")
                    candidate := 0
                    candidateCount := 0
                else if candidateCount >= math.max(candidateLifetime, confirmBars)
                    candidate := 0
                    candidateCount := 0
    confirmedScore := ready ? (trend == 1 ? longScore : trend == -1 ? shortScore : math.max(longScore, shortScore)) : na

// All displayed state is based on the last confirmed bar.
color trendColor = trend == 1 ? color.teal : trend == -1 ? color.maroon : color.gray
plot(trend != 0 ? protection : na, "Structural protection", color=trendColor, linewidth=2, style=plot.style_linebr)
plot(candidate != 0 ? candidateThreshold : na, "Pending breakout", color=color.orange, style=plot.style_linebr)
bgcolor(shadeTrend and trend != 0 ? color.new(trendColor, 92) : na)
plot(confirmedScore, "Continuation score", display=display.data_window)
plot(trend != 0 ? entryRate * 100 : na, "Entry historical continuation %", display=display.data_window)
plot(longSignal ? 1 : shortSignal ? -1 : 0, "Entry signal", display=display.data_window)
plot(longExit ? 1 : shortExit ? -1 : 0, "Exit signal", display=display.data_window)

alertcondition(longSignal, "Up trend confirmed", "Konatus: up trend confirmed on {{ticker}} {{interval}}. Score {{plot(\"Continuation score\")}}.")
alertcondition(shortSignal, "Down trend confirmed", "Konatus: down trend confirmed on {{ticker}} {{interval}}. Score {{plot(\"Continuation score\")}}.")
alertcondition(longExit or shortExit, "Trend invalidated", "Konatus: structural trend invalidated on {{ticker}} {{interval}}.")

var table panel = table.new(position.top_right, 2, 8, bgcolor=color.new(color.black, 15), border_width=1)
if barstate.islast
    string stateText = not warmedUp ? "Warming up" : trend == 0 ? (candidate == 0 ? "Waiting for breakout" : "Confirming " + str.tostring(candidateCount) + "/" + str.tostring(confirmBars)) : (trend == 1 ? "UP" : "DOWN") + (breachCount > 0 ? " / structure threatened" : confirmedScore < minimumScore ? " / pullback or weakening" : " / healthy")
    string rateText = trend == 0 ? "--" : na(entryRate) ? "Insufficient history" : str.tostring(entryRate * 100, "#.0") + "%"
    string intervalText = trend == 0 ? "--" : na(entryRate) ? "n=" + str.tostring(entrySamples) + " / need " + str.tostring(minimumSamples) : str.tostring(entryLower * 100, "#.0") + "–" + str.tostring(entryUpper * 100, "#.0") + "% / n=" + str.tostring(entrySamples)
    table.cell(panel, 0, 0, "KONATUS CONTINUATION", text_color=color.white)
    table.cell(panel, 1, 0, "Research 0.1", text_color=color.silver)
    table.cell(panel, 0, 1, "Trend", text_color=color.silver)
    table.cell(panel, 1, 1, stateText, text_color=breachCount > 0 ? color.orange : color.white)
    table.cell(panel, 0, 2, "Score (not probability)", text_color=color.silver)
    table.cell(panel, 1, 2, na(confirmedScore) ? "--" : str.tostring(confirmedScore, "#.0") + "/100", text_color=color.white)
    table.cell(panel, 0, 3, "Historical rate at entry", text_color=color.silver)
    table.cell(panel, 1, 3, rateText, text_color=color.white)
    table.cell(panel, 0, 4, "95% interval / samples", text_color=color.silver)
    table.cell(panel, 1, 4, intervalText, text_color=color.white)
    table.cell(panel, 0, 5, "Continuation definition", text_color=color.silver)
    table.cell(panel, 1, 5, str.tostring(targetAtr) + " ATR / " + str.tostring(forecastBars) + " bars", text_color=color.white)
    table.cell(panel, 0, 6, "Volume", text_color=color.silver)
    table.cell(panel, 1, 6, confirmedVolume ? "Chart feed volume" : "Unavailable / price only", text_color=color.white)
    table.cell(panel, 0, 7, "Observation at entry", text_color=color.silver)
    table.cell(panel, 1, 7, trend == 0 ? "--" : entrySampled ? "Tracked" : "Skipped: prior still active", text_color=color.silver)

Compare the exit rules

The separate strategy selects Structure, EMA 10/30 or ATR Chandelier exits. Set realistic costs and sizing in Strategy Properties. Separate runs may take different subsets of entries when one position stays open longer; that is not a controlled comparison of identical trades.

Download the comparison strategy

View the comparison strategy code
//@version=6
strategy("Konatus Continuation — Exit Comparison [Research 0.1]", overlay=true, max_labels_count=200, pyramiding=0, default_qty_type=strategy.fixed, default_qty_value=1, commission_type=strategy.commission.percent, commission_value=0.01, slippage=1, process_orders_on_close=false)

// Standard OHLCV only. Score is heuristic; historical rates are not calibrated forecasts.
int breakoutLength = input.int(20, "Breakout lookback", minval=5, maxval=200, group="Detection")
int confirmBars = input.int(2, "Consecutive breakout closes", minval=1, maxval=10, group="Detection")
int candidateLifetime = input.int(8, "Maximum candidate bars", minval=1, maxval=30, group="Detection")
float breakoutBuffer = input.float(0.10, "Breakout buffer (ATR)", minval=0, step=0.05, group="Detection")
int featureLength = input.int(14, "Evidence lookback", minval=5, maxval=100, group="Detection")
float minimumScore = input.float(60, "Minimum entry score / 100", minval=0, maxval=100, group="Detection")
int pivotBars = input.int(3, "Swing confirmation bars each side", minval=1, maxval=20, group="Structure")
int exitCloses = input.int(2, "Consecutive closes through protection", minval=1, maxval=10, group="Structure")
float structureBuffer = input.float(0.25, "Structure buffer (ATR)", minval=0, step=0.05, group="Structure")
float targetAtr = input.float(1.5, "Continuation target (entry ATR)", minval=0.25, step=0.25, group="Historical estimate")
int forecastBars = input.int(20, "Continuation horizon (bars)", minval=1, maxval=200, group="Historical estimate")
int minimumSamples = input.int(20, "Minimum resolved samples per bucket", minval=10, maxval=500, group="Historical estimate")
bool showLabels = input.bool(true, "Show entry and exit labels", group="Display")
bool shadeTrend = input.bool(true, "Shade active trend", group="Display")

clamp01(float value) =>
    math.max(0.0, math.min(1.0, value))

bucketFor(int direction, float score, bool hasVolume) =>
    (score < 70 ? 0 : score < 85 ? 1 : 2) + (direction == 1 ? 0 : 3) + (hasVolume ? 0 : 6)

wilson(int successes, int samples) =>
    float rate = samples > 0 ? float(successes) / samples : na
    float denominator = samples > 0 ? 1.0 + 3.8416 / samples : na
    float center = samples > 0 ? (rate + 3.8416 / (2.0 * samples)) / denominator : na
    float halfWidth = samples > 0 ? 1.96 * math.sqrt(rate * (1 - rate) / samples + 3.8416 / (4.0 * samples * samples)) / denominator : na
    [rate, math.max(0.0, center - halfWidth), math.min(1.0, center + halfWidth)]

// Calculate every bar; commit decisions only when the chart bar closes.
float atr = ta.atr(featureLength)
float priorHigh = ta.highest(high, breakoutLength)[1]
float priorLow = ta.lowest(low, breakoutLength)[1]
float pivotLow = ta.pivotlow(low, pivotBars, pivotBars)
float pivotHigh = ta.pivothigh(high, pivotBars, pivotBars)
float pathLength = ta.sma(math.abs(ta.change(close)), featureLength) * featureLength
float efficiency = pathLength > 0 ? (close - close[featureLength]) / pathLength : 0
float regressionSlope = ta.linreg(close, featureLength, 0) - ta.linreg(close, featureLength, 1)
float scaledSlope = atr > 0 ? regressionSlope * featureLength / (4.0 * atr) : 0
[plusDI, minusDI, adx] = ta.dmi(featureLength, featureLength)
float directionBalance = plusDI + minusDI > 0 ? (plusDI - minusDI) / (plusDI + minusDI) : 0
float closeLocation = high > low ? (2.0 * close - high - low) / (high - low) : 0
float meanLocation = ta.sma(closeLocation, featureLength)
float meanVolume = ta.sma(volume, featureLength)
float volumeCompleteness = ta.sma(not na(volume) and volume >= 0 ? 1.0 : 0.0, featureLength)
bool hasVolume = volumeCompleteness == 1 and meanVolume > 0
float relativeVolume = hasVolume ? volume / meanVolume : 1
float signedVolumeMean = ta.sma(volume * closeLocation, featureLength)
float volumePressure = hasVolume ? signedVolumeMean / meanVolume * math.min(relativeVolume, 2.0) : 0

evidenceScore(int direction) =>
    float efficiencyPart = clamp01(0.5 + 0.5 * direction * efficiency)
    float slopePart = clamp01(0.5 + direction * scaledSlope)
    float movementPart = clamp01(direction * directionBalance) * clamp01(adx / 35.0)
    float locationPart = clamp01(0.5 + 0.5 * direction * meanLocation)
    float volumePart = clamp01(0.5 + 0.5 * direction * volumePressure)
    float weighted = 25 * efficiencyPart + 25 * slopePart + 20 * movementPart + 15 * locationPart
    100.0 * (weighted + (hasVolume ? 15 * volumePart : 0)) / (hasVolume ? 100.0 : 85.0)

float longScore = evidenceScore(1)
float shortScore = evidenceScore(-1)
bool ready = bar_index >= math.max(breakoutLength + 1, featureLength * 3) and atr > 0 and not na(longScore) and not na(shortScore)

var int trend = 0
var float protection = na
var float favorableExtreme = na
var float pendingProtection = na
var int entryBar = na
var int breachCount = 0
var int candidate = 0
var int candidateCount = 0
var float candidateThreshold = na
var float confirmedScore = na
var bool confirmedVolume = false
var bool warmedUp = false

// Fixed-size counters: 3 score bands x 2 directions x 2 volume availability states.
var array<int> successes = array.new_int(12, 0)
var array<int> samples = array.new_int(12, 0)
var int observationDirection = 0
var int observationBar = na
var int observationBucket = na
var float observationTarget = na
var float observationStop = na
var float entryRate = na
var float entryLower = na
var float entryUpper = na
var int entrySamples = 0
var float entryScore = na
var int skippedObservations = 0
var bool entrySampled = false

bool longSignal = false
bool shortSignal = false
bool longExit = false
bool shortExit = false

if barstate.isconfirmed
    warmedUp := ready
    confirmedVolume := hasVolume
    // Mature prior observation BEFORE taking a new forecast snapshot.
    if observationDirection != 0 and bar_index > observationBar
        bool stopHit = observationDirection == 1 ? low <= observationStop : high >= observationStop
        bool targetHit = observationDirection == 1 ? high >= observationTarget : low <= observationTarget
        bool timedOut = bar_index - observationBar >= forecastBars
        if stopHit or targetHit or timedOut
            int count = array.get(samples, observationBucket)
            array.set(samples, observationBucket, count + 1)
            // Conservative ordering for candles containing both boundaries.
            if targetHit and not stopHit
                array.set(successes, observationBucket, array.get(successes, observationBucket) + 1)
            observationDirection := 0

    if trend != 0
        // Pivot origins must be after this trend's entry; no backdated signals.
        float newPivot = trend == 1 ? pivotLow : pivotHigh
        if not na(newPivot) and bar_index - pivotBars > entryBar
            pendingProtection := newPivot - trend * structureBuffer * atr
        bool broken = trend * (close - protection) < 0
        breachCount := broken ? breachCount + 1 : 0
        if breachCount >= exitCloses
            longExit := trend == 1
            shortExit := trend == -1
            if showLabels
                label.new(bar_index, close, "Trend invalidated", style=label.style_label_left, color=color.new(color.gray, 15), textcolor=color.white, size=size.tiny)
            trend := 0
            protection := na
            pendingProtection := na
            breachCount := 0
        else
            bool progress = trend * (close - favorableExtreme) > 0
            if not broken and progress and not na(pendingProtection) and trend * (close - pendingProtection) > 0
                protection := trend == 1 ? math.max(protection, pendingProtection) : math.min(protection, pendingProtection)
                pendingProtection := na
            favorableExtreme := trend == 1 ? math.max(favorableExtreme, high) : math.min(favorableExtreme, low)

    // Exit bars cannot also become entry bars.
    if ready and trend == 0 and not longExit and not shortExit
        if candidate == 0
            if close > priorHigh + breakoutBuffer * atr
                candidate := 1
                candidateThreshold := priorHigh + breakoutBuffer * atr
                candidateCount := 0
            else if close < priorLow - breakoutBuffer * atr
                candidate := -1
                candidateThreshold := priorLow - breakoutBuffer * atr
                candidateCount := 0
        if candidate != 0
            bool holdsBreakout = candidate * (close - candidateThreshold) > 0
            if not holdsBreakout
                candidate := 0
                candidateCount := 0
            else
                candidateCount += 1
                float setupScore = candidate == 1 ? longScore : shortScore
                if candidateCount >= confirmBars and setupScore >= minimumScore
                    trend := candidate
                    entryBar := bar_index
                    entryScore := setupScore
                    protection := trend == 1 ? priorLow - structureBuffer * atr : priorHigh + structureBuffer * atr
                    favorableExtreme := trend == 1 ? high : low
                    pendingProtection := na
                    breachCount := 0
                    longSignal := trend == 1
                    shortSignal := trend == -1
                    int key = bucketFor(trend, setupScore, hasVolume)
                    entrySamples := array.get(samples, key)
                    [rate, lower, upper] = wilson(array.get(successes, key), entrySamples)
                    entryRate := entrySamples >= minimumSamples ? rate : na
                    entryLower := entrySamples >= minimumSamples ? lower : na
                    entryUpper := entrySamples >= minimumSamples ? upper : na
                    entrySampled := observationDirection == 0
                    if entrySampled
                        observationDirection := trend
                        observationBar := bar_index
                        observationBucket := key
                        observationTarget := close + trend * targetAtr * atr
                        observationStop := protection
                    else
                        skippedObservations += 1
                    if showLabels
                        string probabilityText = na(entryRate) ? "History: n=" + str.tostring(entrySamples) : "Historical: " + str.tostring(entryRate * 100, "#.0") + "% (n=" + str.tostring(entrySamples) + ")"
                        string labelText = (trend == 1 ? "UP TREND" : "DOWN TREND") + "\nScore " + str.tostring(setupScore, "#.0") + "/100\n" + probabilityText
                        label.new(bar_index, trend == 1 ? low : high, labelText, style=trend == 1 ? label.style_label_up : label.style_label_down, color=trend == 1 ? color.teal : color.maroon, textcolor=color.white, size=size.small, tooltip="Historical rate uses prior resolved setups in this score band, direction and volume mode. Not a calibrated forecast. Target and horizon are in settings.")
                    candidate := 0
                    candidateCount := 0
                else if candidateCount >= math.max(candidateLifetime, confirmBars)
                    candidate := 0
                    candidateCount := 0
    confirmedScore := ready ? (trend == 1 ? longScore : trend == -1 ? shortScore : math.max(longScore, shortScore)) : na

// All displayed state is based on the last confirmed bar.
color trendColor = trend == 1 ? color.teal : trend == -1 ? color.maroon : color.gray
plot(trend != 0 ? protection : na, "Structural protection", color=trendColor, linewidth=2, style=plot.style_linebr)
plot(candidate != 0 ? candidateThreshold : na, "Pending breakout", color=color.orange, style=plot.style_linebr)
bgcolor(shadeTrend and trend != 0 ? color.new(trendColor, 92) : na)
plot(confirmedScore, "Continuation score", display=display.data_window)
plot(trend != 0 ? entryRate * 100 : na, "Entry historical continuation %", display=display.data_window)
plot(longSignal ? 1 : shortSignal ? -1 : 0, "Entry signal", display=display.data_window)
plot(longExit ? 1 : shortExit ? -1 : 0, "Exit signal", display=display.data_window)


var table panel = table.new(position.top_right, 2, 8, bgcolor=color.new(color.black, 15), border_width=1)
if barstate.islast
    string stateText = not warmedUp ? "Warming up" : trend == 0 ? (candidate == 0 ? "Waiting for breakout" : "Confirming " + str.tostring(candidateCount) + "/" + str.tostring(confirmBars)) : (trend == 1 ? "UP" : "DOWN") + (breachCount > 0 ? " / structure threatened" : confirmedScore < minimumScore ? " / pullback or weakening" : " / healthy")
    string rateText = trend == 0 ? "--" : na(entryRate) ? "Insufficient history" : str.tostring(entryRate * 100, "#.0") + "%"
    string intervalText = trend == 0 ? "--" : na(entryRate) ? "n=" + str.tostring(entrySamples) + " / need " + str.tostring(minimumSamples) : str.tostring(entryLower * 100, "#.0") + "–" + str.tostring(entryUpper * 100, "#.0") + "% / n=" + str.tostring(entrySamples)
    table.cell(panel, 0, 0, "KONATUS CONTINUATION", text_color=color.white)
    table.cell(panel, 1, 0, "Research 0.1", text_color=color.silver)
    table.cell(panel, 0, 1, "Trend", text_color=color.silver)
    table.cell(panel, 1, 1, stateText, text_color=breachCount > 0 ? color.orange : color.white)
    table.cell(panel, 0, 2, "Score (not probability)", text_color=color.silver)
    table.cell(panel, 1, 2, na(confirmedScore) ? "--" : str.tostring(confirmedScore, "#.0") + "/100", text_color=color.white)
    table.cell(panel, 0, 3, "Historical rate at entry", text_color=color.silver)
    table.cell(panel, 1, 3, rateText, text_color=color.white)
    table.cell(panel, 0, 4, "95% interval / samples", text_color=color.silver)
    table.cell(panel, 1, 4, intervalText, text_color=color.white)
    table.cell(panel, 0, 5, "Continuation definition", text_color=color.silver)
    table.cell(panel, 1, 5, str.tostring(targetAtr) + " ATR / " + str.tostring(forecastBars) + " bars", text_color=color.white)
    table.cell(panel, 0, 6, "Volume", text_color=color.silver)
    table.cell(panel, 1, 6, confirmedVolume ? "Chart feed volume" : "Unavailable / price only", text_color=color.white)
    table.cell(panel, 0, 7, "Observation at entry", text_color=color.silver)
    table.cell(panel, 1, 7, trend == 0 ? "--" : entrySampled ? "Tracked" : "Skipped: prior still active", text_color=color.silver)


// Benchmark harness. Signals occur at close; market orders fill next bar by default.
string exitMode = input.string("Structure", "Exit method", options=["Structure", "EMA 10/30", "ATR Chandelier"], group="Comparison")
float trailMultiple = input.float(3.0, "Chandelier ATR distance", minval=0.5, step=0.25, group="Comparison")
int testStart = input.time(timestamp("01 Jan 2020 00:00 +0000"), "First signal date", group="Comparison")
int testEnd = input.time(timestamp("01 Jan 2035 00:00 +0000"), "Last signal date", group="Comparison")
float fastEma = ta.ema(close, 10)
float slowEma = ta.ema(close, 30)
var float tradeExtreme = na
var float chandelier = na
bool tradeOpened = strategy.position_size != 0 and strategy.position_size[1] == 0
if barstate.isconfirmed
    if tradeOpened
        tradeExtreme := strategy.position_size > 0 ? high : low
        chandelier := strategy.position_size > 0 ? tradeExtreme - trailMultiple * atr : tradeExtreme + trailMultiple * atr
    if strategy.position_size != 0
        bool isLong = strategy.position_size > 0
        bool structureExit = isLong ? longExit : shortExit
        bool emaExit = isLong ? fastEma < slowEma : fastEma > slowEma
        // Test against the trail known BEFORE this candle, then ratchet for next bar.
        bool atrExit = not na(chandelier) and (isLong ? close < chandelier : close > chandelier)
        bool chosenExit = exitMode == "Structure" ? structureExit : exitMode == "EMA 10/30" ? emaExit : atrExit
        if chosenExit or time >= testEnd
            strategy.close_all(comment=time >= testEnd ? "End of window" : exitMode)
        tradeExtreme := isLong ? math.max(tradeExtreme, high) : math.min(tradeExtreme, low)
        float proposedTrail = isLong ? tradeExtreme - trailMultiple * atr : tradeExtreme + trailMultiple * atr
        chandelier := isLong ? math.max(chandelier, proposedTrail) : math.min(chandelier, proposedTrail)
    else
        tradeExtreme := na
        chandelier := na
        if time >= testStart and time < testEnd
            if longSignal
                strategy.entry("Long", strategy.long)
            if shortSignal
                strategy.entry("Short", strategy.short)
plot(exitMode == "ATR Chandelier" and strategy.position_size != 0 ? chandelier : na, "Comparison ATR trail", color=color.orange, style=plot.style_linebr)
// alertcondition() is omitted from this strategy; use strategy order-fill alerts.

Evaluate net results, drawdown and profit giveback on unseen periods. Longer holding time alone is not evidence of improvement. Read the trading and research disclaimer ↓

08 / Adaptive trend ribbon · Pine Script v6 · Research release

KONATUS Spectrum.

240 moving averages.
One picture of trend structure.

Spectrum blends four moving-average families into 20 colorful strands. Watch agreement spread across shorter and longer horizons, then use momentum and price efficiency to distinguish an orderly trend from a tangled market.

Twenty rainbow ribbon strands follow invented rising, falling and sideways prices; a lower panel identifies changing trend states
A synthetic illustration from the Python reference model, using invented prices. This is not a TradingView screenshot or evidence of trading performance. Open the image to inspect it at full size.

Research prototype · updated source. All 35 local reference and source tests pass. The current source still needs native TradingView compilation and chart validation; the earlier version passed those checks. Replay, alert delivery and broad runtime profiling remain unverified. No advantage over a simple moving-average ribbon has been established.

Adaptive at the core

Four responses to the same price.

KAMA · 32.5%
Kaufman’s adaptive average changes its smoothing according to directional movement versus back-and-forth travel.
FRAMA · 32.5%
The fractal adaptive average responds to price-range geometry across two half-windows and the full lookback.
Hull MA · 17.5%
A responsive combination of weighted averages gives another view of changing direction.
EMA · 17.5%
A fixed-smoothing baseline helps balance the adaptive filters.

The default adaptive share is 65%, adjustable in settings. Each family runs across 60 distinct lengths: 240 moving-average outputs. Three neighboring lengths, each blending all four families, form one visible strand.

Longer adaptive lookbacks do not always respond more slowly. Spectrum measures actual slopes and ordering; it never sorts lines by price to manufacture a neat ribbon. These averages share the same price history and are not independent confirmations.

Read the picture

Color identifies the horizon. Brightness shows agreement.

  • Gold → orange → pink → violet → blue → cyan: shorter to longer lookbacks. Colors keep their horizon identity in both rising and falling markets.
  • Bright strands: slopes agree, ROC supports the direction, and price movement is relatively efficient. Muted strands indicate weaker combined evidence.
  • Order and crossings: shorter strands above longer ones show bullish ordering; the reverse shows bearish ordering. Crossings expose disagreement.
  • Expansion and compression: ribbon width measures the separation of the 20 strands in ATR units. A narrow ribbon alone does not predict a breakout’s direction.

The live ribbon can change during an open candle. Dashboard states and alerts update only on closed candles, so they can temporarily differ from the ribbon.

Structure plus momentum

Follow the change in state.

ROC windows of 8, 21 and 55 bars compare immediate momentum with the broader move. A volatility-scaled blend joins slope votes, adjacent-strand ordering and 21-bar price efficiency. Scores describe structure; they are not probabilities.

Compression
No established direction and a narrow ribbon. Direction remains unresolved.
Building
Fast slopes and eight-bar price movement agree, with sufficient price efficiency.
Trending
A confirmed direction has Strong health and no active pullback.
Weakening
Direction is retained, but the full trend-entry evidence no longer agrees.
Stalled
Direction is retained, but the ribbon is compressed or price efficiency is below 20%.
Pullback
Meaningful opposing fast slopes or momentum have appeared while slower support remains. The state persists until recovery is confirmed or the trend fractures or loses its direction. This does not promise recovery.
Fracturing
Slow support for the established direction has weakened. This does not predict a reversal.
Mixed / Warming up
No other state applies, or the required history is not yet available.

By default, two consecutive closed candles must meet the trend criteria. A lower exit threshold reduces rapid direction changes. The dashboard also shows slope balance, normalized ROC, ribbon width and efficiency; the guide in the download explains every threshold.

Keep direction, health and structure separate.

A retained BULL or BEAR bias does not mean the trend is still healthy. The Trend health row reads Strong when all entry evidence supports that direction and the ribbon is not compressed. Slow support below 35% makes it Weakening; otherwise compression or efficiency below 20% makes it Stalled. Other failures of entry evidence make it Weakening. Without an established direction, health reads Unestablished. Fracturing and Pullback take priority in the main state row, with health still visible beside them.

The Structure row reads Expanding, Contracting or Steady. A Compressed prefix can appear with any of them, including Compressed · Expanding: the ribbon can be narrow and already starting to widen. Motion uses a five-bar EMA of ATR-normalized width and its average change over three bars; changes within ±0.01 width units per bar are Steady. Because the measure is relative to ATR, changing volatility can affect it too.

Filtered recovery

Let a pullback earn its recovery.

A tiny momentum crossing around zero no longer creates a pullback on its own. A pullback starts when signed fast slope balance falls below −10%, or when the eight-bar price change, divided by ATR × √8 and signed for the retained direction, falls below the −0.10 default deadband.

Recovery requires two consecutive supportive closes by default: signed fast slope balance at least 10%, signed momentum above +0.10, Strong trend health and no compression. The dashboard shows progress such as Pullback (1/2). A non-supportive close resets the count. Fracturing, invalidation, reversal or warmup cancels the pending recovery.

Both the momentum deadband and number of supportive closes are adjustable. The stricter defaults add confirmation delay. A Stalled or Weakening trend becoming Strong does not emit a recovery alert unless an actual pullback was observed and recovery confirmed. The ribbon’s 20 strands and colors are unchanged.

Start on a standard candle chart

Choose a pace. Let it warm up.

Designed for initial exploration on 5–60 minute charts. Lookbacks count chart bars, not minutes. Start with Balanced and 65% adaptive weight.

Fast
Lengths 4–122 bars; first eligible bar 367.
Balanced
Lengths 8–244 bars; first eligible bar 733.
Patient
Lengths 12–366 bars; first eligible bar 1,099.

The ROC windows remain 8/21/55 in every preset. Warmup reduces initialization effects but does not guarantee convergence. The first eligible bar emits no alert. Loaded history, data revisions and input changes can affect historical readings.

  1. Install: create a blank indicator in TradingView’s Pine Editor, replace its contents with the complete source below, save, and select Add to chart.
  2. Check: compile and replay each preset before relying on it. Compare against a simple EMA ribbon on unseen dates and instruments with realistic trading costs.
  3. Set alerts: choose Bull trend established, Bear trend established, Pullback recovery, or Trend fracturing. Select Once Per Bar Close. Recreate alerts after changing the code or inputs.

Upgrading an existing installation: replace the complete Pine source, start with 2 supportive closes and a 0.10 momentum deadband, and recreate existing alerts. This version changes pullback/recovery timing; bull/bear direction keeps its earlier confirmation and invalidation rules.

Recovery alerts require a confirmed transition from Pullback to Trending in the same direction. Alerts mark state changes rather than repeating on every candle. The ribbon has no BULL, BEAR or RESUME chart labels; the optional dashboard and all four alerts remain available. No volume feed, imported library or secondary symbol is required.

Complete Pine Script v6 source

Download, inspect and test.

View the complete indicator code
//@version=6
// Konatus Spectrum | kuttystauder.com
// Research version: local reference tests are not TradingView compilation.
// 240 MA outputs: KAMA + FRAMA + HMA + EMA across 60 independent lookbacks.
indicator("Konatus Spectrum | Adaptive Ribbon", shorttitle="KS Spectrum", overlay=true, max_bars_back=1500)

string engineGroup = "Adaptive engine"
string preset = input.string("Balanced", "Intraday sensitivity", options=["Fast", "Balanced", "Patient"], group=engineGroup)
float adaptiveShare = input.float(0.65, "Adaptive weight", minval=0.10, maxval=0.90, step=0.05, group=engineGroup, tooltip="Split between KAMA and FRAMA. Remaining weight is split between Hull and EMA. Higher is not necessarily better.")
int slopeBars = input.int(3, "Slope lookback", minval=1, maxval=10, group=engineGroup)
float slopeFloor = input.float(0.01, "Neutral slope (ATR per bar)", minval=0.0, maxval=0.10, step=0.005, group=engineGroup)
int confirmationBars = input.int(2, "Closed bars to confirm / invalidate", minval=1, maxval=5, group=engineGroup)
int recoveryBars = input.int(2, "Supportive closes for recovery", minval=1, maxval=5, group=engineGroup)
float pullbackDeadband = input.float(0.10, "Pullback momentum deadband", minval=0.01, maxval=0.50, step=0.01, group=engineGroup, tooltip="Eight-bar price change divided by ATR × sqrt(8). A pullback needs opposing momentum beyond this threshold or opposing fast slopes. Recovery needs supportive momentum and full trend evidence.")
string visualGroup = "Appearance"
bool showFill = input.bool(true, "Fill between strands", group=visualGroup)
bool showPanel = input.bool(true, "Show dashboard", group=visualGroup)
int ribbonWidth = input.int(2, "Strand thickness", minval=1, maxval=3, group=visualGroup)

int startLength = preset == "Fast" ? 4 : preset == "Patient" ? 12 : 8
int stride = preset == "Fast" ? 2 : preset == "Patient" ? 6 : 4
int longestLength = startLength + stride * 59
float atr = ta.atr(14)
float unit = math.max(nz(atr, syminfo.mintick), syminfo.mintick)

f_clip(float value, float lo, float hi) =>
    math.max(lo, math.min(hi, value))

f_efficiency(simple int n) =>
    float travel = math.sum(math.abs(ta.change(close)), n)
    not na(close[n]) and travel > 0 ? f_clip(math.abs(close - close[n]) / travel, 0.0, 1.0) : 0.0

f_vote(float value, float scale) =>
    float delta = value - value[slopeBars]
    float threshold = scale * slopeFloor * slopeBars
    na(delta) ? 0.0 : delta > threshold ? 1.0 : delta < -threshold ? -1.0 : 0.0

// Each written call owns separate history. Do not move f_bank into a loop.
f_bank(simple int n, float scale) =>
    float er = f_efficiency(n)
    float ka = math.pow(er * (2.0 / 3.0 - 2.0 / 31.0) + 2.0 / 31.0, 2)
    var float kama = na
    kama := na(kama[1]) ? close : kama[1] + ka * (close - kama[1])

    int half = int(n / 2)
    float recentHigh = ta.highest(high, half)
    float recentLow = ta.lowest(low, half)
    float recentRange = recentHigh - recentLow
    float olderRange = recentHigh[half] - recentLow[half]
    float wholeRange = ta.highest(high, n) - ta.lowest(low, n)
    float fa = 0.01
    if bar_index >= n - 1 and wholeRange > 0
        float rangeSum = recentRange + olderRange
        float dimension = rangeSum > 0 ? math.log(2.0 * rangeSum / wholeRange) / math.log(2.0) : 1.0
        fa := f_clip(math.exp(-4.6 * (dimension - 1.0)), 0.01, 1.0)
    var float frama = na
    frama := na(frama[1]) ? close : frama[1] + fa * (close - frama[1])

    // Explicit integer convention: even half length, rounded square-root length.
    float hull = ta.wma(2.0 * ta.wma(close, half) - ta.wma(close, n), int(math.round(math.sqrt(n))))
    float ema = ta.ema(close, n)
    float blend = adaptiveShare * (kama + frama) / 2.0 + (1.0 - adaptiveShare) * (hull + ema) / 2.0
    float votes = adaptiveShare * (f_vote(kama, scale) + f_vote(frama, scale)) / 2.0 + (1.0 - adaptiveShare) * (f_vote(hull, scale) + f_vote(ema, scale)) / 2.0
    [blend, votes]

// BEGIN GENERATED HORIZONS
[b00, v00] = f_bank(startLength + stride * 0, unit)
[b01, v01] = f_bank(startLength + stride * 1, unit)
[b02, v02] = f_bank(startLength + stride * 2, unit)
[b03, v03] = f_bank(startLength + stride * 3, unit)
[b04, v04] = f_bank(startLength + stride * 4, unit)
[b05, v05] = f_bank(startLength + stride * 5, unit)
[b06, v06] = f_bank(startLength + stride * 6, unit)
[b07, v07] = f_bank(startLength + stride * 7, unit)
[b08, v08] = f_bank(startLength + stride * 8, unit)
[b09, v09] = f_bank(startLength + stride * 9, unit)
[b10, v10] = f_bank(startLength + stride * 10, unit)
[b11, v11] = f_bank(startLength + stride * 11, unit)
[b12, v12] = f_bank(startLength + stride * 12, unit)
[b13, v13] = f_bank(startLength + stride * 13, unit)
[b14, v14] = f_bank(startLength + stride * 14, unit)
[b15, v15] = f_bank(startLength + stride * 15, unit)
[b16, v16] = f_bank(startLength + stride * 16, unit)
[b17, v17] = f_bank(startLength + stride * 17, unit)
[b18, v18] = f_bank(startLength + stride * 18, unit)
[b19, v19] = f_bank(startLength + stride * 19, unit)
[b20, v20] = f_bank(startLength + stride * 20, unit)
[b21, v21] = f_bank(startLength + stride * 21, unit)
[b22, v22] = f_bank(startLength + stride * 22, unit)
[b23, v23] = f_bank(startLength + stride * 23, unit)
[b24, v24] = f_bank(startLength + stride * 24, unit)
[b25, v25] = f_bank(startLength + stride * 25, unit)
[b26, v26] = f_bank(startLength + stride * 26, unit)
[b27, v27] = f_bank(startLength + stride * 27, unit)
[b28, v28] = f_bank(startLength + stride * 28, unit)
[b29, v29] = f_bank(startLength + stride * 29, unit)
[b30, v30] = f_bank(startLength + stride * 30, unit)
[b31, v31] = f_bank(startLength + stride * 31, unit)
[b32, v32] = f_bank(startLength + stride * 32, unit)
[b33, v33] = f_bank(startLength + stride * 33, unit)
[b34, v34] = f_bank(startLength + stride * 34, unit)
[b35, v35] = f_bank(startLength + stride * 35, unit)
[b36, v36] = f_bank(startLength + stride * 36, unit)
[b37, v37] = f_bank(startLength + stride * 37, unit)
[b38, v38] = f_bank(startLength + stride * 38, unit)
[b39, v39] = f_bank(startLength + stride * 39, unit)
[b40, v40] = f_bank(startLength + stride * 40, unit)
[b41, v41] = f_bank(startLength + stride * 41, unit)
[b42, v42] = f_bank(startLength + stride * 42, unit)
[b43, v43] = f_bank(startLength + stride * 43, unit)
[b44, v44] = f_bank(startLength + stride * 44, unit)
[b45, v45] = f_bank(startLength + stride * 45, unit)
[b46, v46] = f_bank(startLength + stride * 46, unit)
[b47, v47] = f_bank(startLength + stride * 47, unit)
[b48, v48] = f_bank(startLength + stride * 48, unit)
[b49, v49] = f_bank(startLength + stride * 49, unit)
[b50, v50] = f_bank(startLength + stride * 50, unit)
[b51, v51] = f_bank(startLength + stride * 51, unit)
[b52, v52] = f_bank(startLength + stride * 52, unit)
[b53, v53] = f_bank(startLength + stride * 53, unit)
[b54, v54] = f_bank(startLength + stride * 54, unit)
[b55, v55] = f_bank(startLength + stride * 55, unit)
[b56, v56] = f_bank(startLength + stride * 56, unit)
[b57, v57] = f_bank(startLength + stride * 57, unit)
[b58, v58] = f_bank(startLength + stride * 58, unit)
[b59, v59] = f_bank(startLength + stride * 59, unit)

float s00 = (b00 + b01 + b02) / 3.0
float a00 = (v00 + v01 + v02) / 3.0
float s01 = (b03 + b04 + b05) / 3.0
float a01 = (v03 + v04 + v05) / 3.0
float s02 = (b06 + b07 + b08) / 3.0
float a02 = (v06 + v07 + v08) / 3.0
float s03 = (b09 + b10 + b11) / 3.0
float a03 = (v09 + v10 + v11) / 3.0
float s04 = (b12 + b13 + b14) / 3.0
float a04 = (v12 + v13 + v14) / 3.0
float s05 = (b15 + b16 + b17) / 3.0
float a05 = (v15 + v16 + v17) / 3.0
float s06 = (b18 + b19 + b20) / 3.0
float a06 = (v18 + v19 + v20) / 3.0
float s07 = (b21 + b22 + b23) / 3.0
float a07 = (v21 + v22 + v23) / 3.0
float s08 = (b24 + b25 + b26) / 3.0
float a08 = (v24 + v25 + v26) / 3.0
float s09 = (b27 + b28 + b29) / 3.0
float a09 = (v27 + v28 + v29) / 3.0
float s10 = (b30 + b31 + b32) / 3.0
float a10 = (v30 + v31 + v32) / 3.0
float s11 = (b33 + b34 + b35) / 3.0
float a11 = (v33 + v34 + v35) / 3.0
float s12 = (b36 + b37 + b38) / 3.0
float a12 = (v36 + v37 + v38) / 3.0
float s13 = (b39 + b40 + b41) / 3.0
float a13 = (v39 + v40 + v41) / 3.0
float s14 = (b42 + b43 + b44) / 3.0
float a14 = (v42 + v43 + v44) / 3.0
float s15 = (b45 + b46 + b47) / 3.0
float a15 = (v45 + v46 + v47) / 3.0
float s16 = (b48 + b49 + b50) / 3.0
float a16 = (v48 + v49 + v50) / 3.0
float s17 = (b51 + b52 + b53) / 3.0
float a17 = (v51 + v52 + v53) / 3.0
float s18 = (b54 + b55 + b56) / 3.0
float a18 = (v54 + v55 + v56) / 3.0
float s19 = (b57 + b58 + b59) / 3.0
float a19 = (v57 + v58 + v59) / 3.0
array<float> strands = array.from(s00, s01, s02, s03, s04, s05, s06, s07, s08, s09, s10, s11, s12, s13, s14, s15, s16, s17, s18, s19)
array<float> votes = array.from(a00, a01, a02, a03, a04, a05, a06, a07, a08, a09, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19)
// END GENERATED HORIZONS

float broadVote = array.avg(votes)
float fastVote = 0.0
float slowVote = 0.0
float orderVote = 0.0
for i = 0 to 19
    if i < 7
        fastVote += array.get(votes, i) / 7.0
    if i >= 13
        slowVote += array.get(votes, i) / 7.0
    if i < 19
        float spacing = array.get(strands, i) - array.get(strands, i + 1)
        orderVote += (spacing > unit * 0.01 ? 1.0 : spacing < -unit * 0.01 ? -1.0 : 0.0) / 19.0

f_roc(simple int n) =>
    not na(close[n]) and close[n] != 0 ? 100.0 * (close - close[n]) / math.abs(close[n]) : 0.0

float roc8 = f_roc(8)
float roc21 = f_roc(21)
float roc55 = f_roc(55)
float fastMove = (close - close[8]) / (unit * math.sqrt(8.0))
float atrPercent = 100.0 * unit / math.max(math.abs(close), syminfo.mintick)
float momentum = (f_clip(roc8 / (atrPercent * math.sqrt(8.0)), -1.0, 1.0) + f_clip(roc21 / (atrPercent * math.sqrt(21.0)), -1.0, 1.0) + f_clip(roc55 / (atrPercent * math.sqrt(55.0)), -1.0, 1.0)) / 3.0
float efficiency = f_efficiency(21)
float spread = (array.max(strands) - array.min(strands)) / unit
float meanSpread = ta.sma(spread, 100)
bool compressed = spread < 0.40 or (not na(meanSpread) and spread < meanSpread * 0.65)
float smoothSpread = ta.ema(spread, 5)
float spreadChange = (smoothSpread - smoothSpread[3]) / 3.0
string widthMotion = spreadChange > 0.01 ? "Expanding" : spreadChange < -0.01 ? "Contracting" : "Steady"
bool ready = bar_index >= longestLength * 3 and not na(s19) and not na(atr) and not na(meanSpread)

// State and dashboard evidence are committed only when the bar closes.
var int direction = 0
var int pending = 0
var int streak = 0
var int weakBars = 0
var bool inPullback = false
var int recoveryStreak = 0
var string state = "Warming up"
var string health = "Warming up"
var string closedStructure = "Warming up"
var float closedAgreement = na
var float closedMomentum = na
var float closedSpread = na
var float closedEfficiency = na
if barstate.isconfirmed
    if not ready
        direction := 0
        pending := 0
        streak := 0
        weakBars := 0
        inPullback := false
        recoveryStreak := 0
        state := "Warming up"
        health := "Warming up"
        closedStructure := "Warming up"
        closedAgreement := na
        closedMomentum := na
        closedSpread := na
        closedEfficiency := na
    else
        int previousDirection = direction
        int candidate = 0
        if slowVote >= 0.50 and broadVote >= 0.35 and orderVote >= 0.20 and momentum > 0.10 and efficiency >= 0.20
            candidate := 1
        else if slowVote <= -0.50 and broadVote <= -0.35 and orderVote <= -0.20 and momentum < -0.10 and efficiency >= 0.20
            candidate := -1
        streak := candidate != 0 and candidate == pending ? streak + 1 : candidate != 0 ? 1 : 0
        pending := candidate
        bool weak = direction != 0 and slowVote * direction < 0.15
        weakBars := weak ? weakBars + 1 : 0
        if weakBars >= confirmationBars
            direction := 0
            weakBars := 0
        if candidate != 0 and streak >= confirmationBars
            direction := candidate
            weakBars := 0
        if direction == 0 or direction != previousDirection
            inPullback := false
            recoveryStreak := 0
        if direction == 0
            health := "Unestablished"
        else if slowVote * direction < 0.35
            health := "Weakening"
        else if compressed or efficiency < 0.20
            health := "Stalled"
        else
            health := candidate == direction ? "Strong" : "Weakening"
        if direction != 0
            if slowVote * direction < 0.35
                inPullback := false
                recoveryStreak := 0
                state := "Fracturing"
            else
                bool adverse = fastVote * direction < -0.10 or fastMove * direction < -pullbackDeadband
                bool supportive = fastVote * direction >= 0.10 and fastMove * direction > pullbackDeadband and health == "Strong"
                if adverse
                    inPullback := true
                recoveryStreak := inPullback and supportive ? recoveryStreak + 1 : 0
                if recoveryStreak >= recoveryBars
                    inPullback := false
                    recoveryStreak := 0
                state := inPullback ? "Pullback" : health == "Strong" ? "Trending" : health
        else
            state := compressed ? "Compression" : math.abs(fastVote) >= 0.40 and fastVote * fastMove > 0 and efficiency >= 0.20 ? "Building" : "Mixed"
        closedAgreement := broadVote * 100.0
        closedMomentum := momentum * 100.0
        closedSpread := spread
        closedEfficiency := efficiency * 100.0
        closedStructure := (compressed ? "Compressed · " : "") + widthMotion

bool eventGate = barstate.isconfirmed and ready and ready[1]
bool bullEvent = eventGate and direction == 1 and direction[1] != 1
bool bearEvent = eventGate and direction == -1 and direction[1] != -1
bool recoveryEvent = eventGate and direction != 0 and direction == direction[1] and state[1] == "Pullback" and state == "Trending"
bool fractureEvent = eventGate and state == "Fracturing" and state[1] != "Fracturing"

// Hue identifies horizon. Agreement/momentum/efficiency control brightness.
f_hue(simple int index) =>
    float x = float(index)
    x <= 4 ? color.from_gradient(x, 0, 4, #FFD166, #FF7043) : x <= 8 ? color.from_gradient(x, 4, 8, #FF7043, #EF5DA8) : x <= 12 ? color.from_gradient(x, 8, 12, #EF5DA8, #A775FF) : x <= 16 ? color.from_gradient(x, 12, 16, #A775FF, #548CFF) : color.from_gradient(x, 16, 19, #548CFF, #4DE3D0)

f_tint(simple int index, float agreement, bool filling) =>
    float support = math.max(0.0, math.sign(agreement) * momentum)
    float quality = ready ? f_clip(0.60 * math.abs(agreement) + 0.25 * support + 0.15 * efficiency, 0.0, 1.0) : 0.0
    color tint = color.from_gradient(quality, 0.0, 1.0, #737A90, f_hue(index))
    float transparency = filling ? 92.0 - 24.0 * quality : 68.0 - 58.0 * quality
    color.new(tint, transparency)

// BEGIN GENERATED VISUALS
p00 = plot(s00, "Spectrum 01", color=f_tint(0, a00, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p01 = plot(s01, "Spectrum 02", color=f_tint(1, a01, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p02 = plot(s02, "Spectrum 03", color=f_tint(2, a02, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p03 = plot(s03, "Spectrum 04", color=f_tint(3, a03, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p04 = plot(s04, "Spectrum 05", color=f_tint(4, a04, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p05 = plot(s05, "Spectrum 06", color=f_tint(5, a05, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p06 = plot(s06, "Spectrum 07", color=f_tint(6, a06, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p07 = plot(s07, "Spectrum 08", color=f_tint(7, a07, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p08 = plot(s08, "Spectrum 09", color=f_tint(8, a08, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p09 = plot(s09, "Spectrum 10", color=f_tint(9, a09, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p10 = plot(s10, "Spectrum 11", color=f_tint(10, a10, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p11 = plot(s11, "Spectrum 12", color=f_tint(11, a11, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p12 = plot(s12, "Spectrum 13", color=f_tint(12, a12, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p13 = plot(s13, "Spectrum 14", color=f_tint(13, a13, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p14 = plot(s14, "Spectrum 15", color=f_tint(14, a14, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p15 = plot(s15, "Spectrum 16", color=f_tint(15, a15, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p16 = plot(s16, "Spectrum 17", color=f_tint(16, a16, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p17 = plot(s17, "Spectrum 18", color=f_tint(17, a17, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p18 = plot(s18, "Spectrum 19", color=f_tint(18, a18, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p19 = plot(s19, "Spectrum 20", color=f_tint(19, a19, false), linewidth=ribbonWidth, editable=false, display=display.pane)
fill(p00, p01, color=showFill ? f_tint(0, (a00 + a01) / 2.0, true) : na, title="Weave 01", editable=false)
fill(p01, p02, color=showFill ? f_tint(1, (a01 + a02) / 2.0, true) : na, title="Weave 02", editable=false)
fill(p02, p03, color=showFill ? f_tint(2, (a02 + a03) / 2.0, true) : na, title="Weave 03", editable=false)
fill(p03, p04, color=showFill ? f_tint(3, (a03 + a04) / 2.0, true) : na, title="Weave 04", editable=false)
fill(p04, p05, color=showFill ? f_tint(4, (a04 + a05) / 2.0, true) : na, title="Weave 05", editable=false)
fill(p05, p06, color=showFill ? f_tint(5, (a05 + a06) / 2.0, true) : na, title="Weave 06", editable=false)
fill(p06, p07, color=showFill ? f_tint(6, (a06 + a07) / 2.0, true) : na, title="Weave 07", editable=false)
fill(p07, p08, color=showFill ? f_tint(7, (a07 + a08) / 2.0, true) : na, title="Weave 08", editable=false)
fill(p08, p09, color=showFill ? f_tint(8, (a08 + a09) / 2.0, true) : na, title="Weave 09", editable=false)
fill(p09, p10, color=showFill ? f_tint(9, (a09 + a10) / 2.0, true) : na, title="Weave 10", editable=false)
fill(p10, p11, color=showFill ? f_tint(10, (a10 + a11) / 2.0, true) : na, title="Weave 11", editable=false)
fill(p11, p12, color=showFill ? f_tint(11, (a11 + a12) / 2.0, true) : na, title="Weave 12", editable=false)
fill(p12, p13, color=showFill ? f_tint(12, (a12 + a13) / 2.0, true) : na, title="Weave 13", editable=false)
fill(p13, p14, color=showFill ? f_tint(13, (a13 + a14) / 2.0, true) : na, title="Weave 14", editable=false)
fill(p14, p15, color=showFill ? f_tint(14, (a14 + a15) / 2.0, true) : na, title="Weave 15", editable=false)
fill(p15, p16, color=showFill ? f_tint(15, (a15 + a16) / 2.0, true) : na, title="Weave 16", editable=false)
fill(p16, p17, color=showFill ? f_tint(16, (a16 + a17) / 2.0, true) : na, title="Weave 17", editable=false)
fill(p17, p18, color=showFill ? f_tint(17, (a17 + a18) / 2.0, true) : na, title="Weave 18", editable=false)
fill(p18, p19, color=showFill ? f_tint(18, (a18 + a19) / 2.0, true) : na, title="Weave 19", editable=false)
// END GENERATED VISUALS

// 20 plots × 2 counts + 19 series fills + 4 alertconditions = 63 plot counts.
alertcondition(bullEvent, "Bull trend established", "Konatus Spectrum: confirmed bullish trend on {{ticker}} {{interval}}. Close: {{close}}")
alertcondition(bearEvent, "Bear trend established", "Konatus Spectrum: confirmed bearish trend on {{ticker}} {{interval}}. Close: {{close}}")
alertcondition(recoveryEvent, "Pullback recovery", "Konatus Spectrum: confirmed pullback recovery on {{ticker}} {{interval}}. Check chart direction. Close: {{close}}")
alertcondition(fractureEvent, "Trend fracturing", "Konatus Spectrum: slow trend agreement is deteriorating on {{ticker}} {{interval}}. Close: {{close}}")

var table panel = table.new(position.top_right, 2, 11, bgcolor=color.new(#111725, 8), frame_color=#303B52, frame_width=1, border_width=0)
if barstate.islast and showPanel
    color accent = direction > 0 ? #4DE3D0 : direction < 0 ? #EF5DA8 : #FFD166
    string bias = direction > 0 ? "BULL" : direction < 0 ? "BEAR" : "MIXED"
    string phase = state == "Pullback" and recoveryStreak > 0 ? state + " (" + str.tostring(recoveryStreak) + "/" + str.tostring(recoveryBars) + ")" : state
    string agreementText = na(closedAgreement) ? "—" : (closedAgreement > 0 ? "+" : "") + str.tostring(closedAgreement, "#.0")
    string momentumText = na(closedMomentum) ? "—" : (closedMomentum > 0 ? "+" : "") + str.tostring(closedMomentum, "#.0")
    string chartNote = not chart.is_standard ? "Use standard candles" : not timeframe.isintraday ? "Designed for intraday" : "Last closed bar"
    table.cell(panel, 0, 0, "KONATUS", text_color=#F1EEE8, text_size=size.small)
    table.cell(panel, 1, 0, "SPECTRUM", text_color=#A775FF, text_size=size.small)
    table.cell(panel, 0, 1, "Trend", text_color=#A6AFC0, text_size=size.small)
    table.cell(panel, 1, 1, bias + " · " + phase, text_color=accent, text_size=size.small)
    table.cell(panel, 0, 2, "Trend health", text_color=#A6AFC0, text_size=size.small)
    table.cell(panel, 1, 2, health, text_color=health == "Strong" ? #4DE3D0 : health == "Unestablished" or health == "Warming up" ? #A6AFC0 : #FFD166, text_size=size.small)
    table.cell(panel, 0, 3, "Structure", text_color=#A6AFC0, text_size=size.small)
    table.cell(panel, 1, 3, closedStructure, text_color=#E1E6F0, text_size=size.small)
    table.cell(panel, 0, 4, "Slope balance", text_color=#A6AFC0, text_size=size.small)
    table.cell(panel, 1, 4, agreementText + " / ±100", text_color=#E1E6F0, text_size=size.small)
    table.cell(panel, 0, 5, "ROC blend", text_color=#A6AFC0, text_size=size.small)
    table.cell(panel, 1, 5, momentumText + " / ±100", text_color=#E1E6F0, text_size=size.small)
    table.cell(panel, 0, 6, "Ribbon width", text_color=#A6AFC0, text_size=size.small)
    table.cell(panel, 1, 6, na(closedSpread) ? "—" : str.tostring(closedSpread, "#.00") + " ATR", text_color=#E1E6F0, text_size=size.small)
    table.cell(panel, 0, 7, "Efficiency", text_color=#A6AFC0, text_size=size.small)
    table.cell(panel, 1, 7, na(closedEfficiency) ? "—" : str.tostring(closedEfficiency, "#.0") + "%", text_color=#E1E6F0, text_size=size.small)
    table.cell(panel, 0, 8, "240 MAs · 20 strands", text_color=#A6AFC0, text_size=size.tiny)
    table.cell(panel, 1, 8, preset + " · " + str.tostring(adaptiveShare * 100, "#") + "% adaptive", text_color=#A6AFC0, text_size=size.tiny)
    table.cell(panel, 0, 9, "kuttystauder.com", text_color=#737F96, text_size=size.tiny)
    table.cell(panel, 1, 9, chartNote, text_color=#737F96, text_size=size.tiny)
    table.cell(panel, 0, 10, "Live ribbon", text_color=#737F96, text_size=size.tiny)
    table.cell(panel, 1, 10, state == "Warming up" ? str.tostring(math.max(0, longestLength * 3 - bar_index)) + " bars to warmup" : "Scores are not probabilities", text_color=#737F96, text_size=size.tiny)

More averages and adaptive behavior do not establish better net returns. Read the trading and research disclaimer ↓

09 / Failed and sustained breakouts · Pine Script v6 · Research release

Konatus RAR.

Price left the range.
Did the market agree?

RAR stands for Range Acceptance & Rejection. Most breakout tools fire when price crosses a line. This one waits to see what happens next. It freezes a consolidation range, then separates two events: a rejection, where price probes past a boundary and closes back inside, and an acceptance, where price closes beyond it twice, holds a retest and continues. Each finished setup carries an entry reference, a stop, a structural target and an R multiple.

Two real daily candlestick charts with a frozen range box. XLE, April 2021: price trades below the range, closes back inside, then confirms, with entry 23.93, stop 23.10, and target 25.25 reached for plus 1.59R. XOM, December 2022: price closes below the range, retests the boundary, then confirms, with entry 106.46, target 102.37, and stop 108.56 hit for minus 1R.
Two real daily charts drawn from the Python reference model at default settings, one target and one stop. They were picked to show the mechanics and are not a sample of results. This is not a TradingView screenshot. Open the image to see it at full size.

Research prototype 0.3.0. All 27 reference-model scenarios and the source audits pass, and TradingView’s own translator accepts this exact source with zero errors and zero warnings. It has not yet been run on a live TradingView chart, so drawing, Bar Replay and alert delivery remain unverified. The study below comes from the Python model, not the Pine script. No edge has been established. At default settings, results before costs were near zero or negative.

Three jobs, in order

Frame. Test. Measure.

01 / Frame

Freeze a range.

The last 20 completed candles qualify when their high-low span is at most 3 ATR and each boundary has two touches at least 3 bars apart. The current bar must still close inside. Boundaries, ATR and buffers then stop moving. Manual mode uses your own levels from a chosen time onward.

02 / Test

Watch the boundary.

A breach means trading past a boundary by more than a buffer of 0.10 ATR, or one tick if that is larger. Every decision waits for a closed candle. Each range gets one completed signal, then retires and must be replaced by a window of entirely new bars.

03 / Measure

Price the idea.

Risk runs from the signal close to a stop beyond the structure that would prove the idea wrong. Reward runs to a target that already existed. A setup that reaches your minimum R, 2.0 by default, draws a triangle. One that does not draws a muted circle, so the filter never hides what happened.

The two events

Two ways a breakout resolves.

Rejection: the break failed

  1. Price trades through a boundary by more than the buffer.
  2. A candle closes back inside, on the same bar or within 3 bars.
  3. Within 5 bars, a close beyond that reclaim candle’s far side, still inside the range, completes the signal.

Stop: 2 ticks beyond the most extreme price of the whole excursion. Target: the opposite boundary.

Acceptance: the break held

  1. Two consecutive closes beyond the boundary plus the buffer.
  2. Within 5 bars, price comes back to within 0.15 ATR of the boundary and closes without giving it up.
  3. Within 5 bars, a close beyond that retest candle completes the signal.

Stop: 2 ticks beyond the extreme since the retest. Target: the nearest swing pivot past the entry that was already confirmed before the signal bar.

An acceptance can turn into a rejection. If price closes back inside after it armed, that close starts the opposite rejection with the whole run behind its stop. A candle breaching both boundaries cancels any live setup and starts nothing.

Read the chart

What you will see.

  • Thick amber lines: the frozen range high and low, each labelled with its price. Shading and a dotted midline mark the inside. A range known label marks the bar it was detected on; the lines reach back over the formation window, but nothing drew there at the time. When a range retires its lines thin and fade, so the live range is the one that stands out.
  • Triangle with R or A: a rejection or acceptance that met the minimum R. Muted circle: the same event below it, or structurally invalid.
  • Thick entry, stop and target lines: each labelled with its price at the right. The label at the signal names the setup and its R. The entry is the signal candle’s close, not a fill.
  • Dashboard: range state, pending setup, last signal and why it did or did not qualify.
  • Scoreboard rows: For this chart only, how many setups reached target or stop first. It charges no costs and counts a bar touching both as the stop. It is a reality check, not a backtest.

Signals, levels and alerts are set on closed candles and do not move afterward. Only a settings change or a data revision recalculates history. The indicator uses price alone, with no volume, footprint or order-flow input, so “acceptance” and “rejection” describe what price did, not who traded. It borrows its vocabulary from auction-market thinking, but it is not a volume profile, not a fair-value calculation and not any named trader’s strategy.

Evidence

What it did on real prices.

The reference model ran over about 1.13 million real bars at default settings, and no parameter was changed afterward. Each valid setup entered at its signal close and ended at the first touch of stop or target. A bar touching both counted as the stop, and a gap through the stop filled at the open. Commissions, spread and slippage were left out, so a real result would be lower.

Default settings, before all costs. Interval: 90% bootstrap, resampling calendar months.
SetupResolvedReached targetNeeded to break evenMedian planned RMean R per setup
Daily, 20 stocks and ETFs, 2010 to September 2026: 82,797 bars, 58 signals
Rejections4261.9%63.8%0.56R−0.064R (−0.322 to +0.192)
Acceptances1471.4%80.9%0.19R−0.159R (−0.450 to +0.129)
Qualified at 2R, both types333.3%29.1%2.58R+0.033R (−1.000 to +1.065)
Hourly, 5 crypto pairs, October 2019 to September 2026: 276,757 bars, 338 signals
Rejections27361.2%57.2%0.80R+0.056R (−0.029 to +0.140)
Acceptances6470.3%77.2%0.25R−0.077R (−0.198 to +0.045)
Qualified at 2R, both types2425.0%28.2%2.41R−0.188R (−0.608 to +0.271)
5-minute, AAPL AMZN META NFLX, regular session, 2016 to September 2026: 770,070 bars, 1,851 signals
Rejections1,44859.5%59.1%0.76R−0.063R (−0.111 to −0.014)
Acceptances34869.5%75.1%0.21R−0.114R (−0.185 to −0.042)
Qualified at 2R, both types13528.9%27.7%2.41R−0.100R (−0.349 to +0.168)
  • No result clears zero. Every interval for rejections, acceptances and qualified setups includes zero or sits below it. Hourly crypto rejections were the best case at +0.056R, and that interval still spans zero. On 5-minute large caps, the full set averaged −0.073R before costs, with an interval entirely below zero.
  • A high hit rate is not the same as profit. Acceptances reached target about 70% of the time, but the nearest pivot sits close to the entry, with a median of only about 0.2R. They needed 75 to 81% to break even.
  • The 2R filter is strict and did not rescue the results. About 7% of signals qualified, and their averages were no better in any set large enough to judge.
  • Signals depend heavily on timeframe. Daily charts produced just 58 signals across 20 instruments in over 16 years, because tight, well-touched 20-day ranges are rare. The daily rows are too small to conclude much.
  • The same-bar rule is not the cause. Counting those bars as wins instead moves the 5-minute average only to −0.049R.

Read plainly, this version labels what price did at a range. It does not show that trading those labels pays. Other instruments, session filters or exits might behave differently, but finding that out needs its own test on data kept separate from this study. These instruments are also correlated, and the results are hypothetical.

From 0.1.0 to 0.2.0

What the audit found.

The first version passed all of its own tests. Reading the code against its documentation found these defects, and each logic defect was first reproduced with a failing test. All are fixed. No default changed.

  • Alert filter ignored: “Alert only when minimum R is met” did not apply to the detailed alert stream, despite the documentation.
  • Stops too tight, R overstated: after a failed breakout, the stop could use only the failing candle’s extreme. A run to 106 could get a stop near 104.75 instead of 106.50.
  • Forgotten lows and highs: a first close beyond the buffer restarted the setup and dropped a deeper extreme printed one bar earlier.
  • A window that never closed: repeated wicks restarted the 3-bar reclaim window, so a stale reclaim could still count.
  • Bars counted too early: the activation candle’s close could be the first of the two acceptance closes, and a range could activate on a bar that had already left it. A cooldown was also off by one bar, and a candle breaching both sides could still arm acceptance.
  • Display faults: the midpoint setting did nothing, marker letters defaulted to black and vanished on dark charts, and label text was hard to read on light ones.
  • A test that passed for the wrong reason: the reclaim-window test was succeeding through a different rule. It now tests the rule it names.

The four duplicated signal blocks also became one, so a future fix cannot land in three of them and miss the fourth.

Version 0.3.0 changes the display only. Thick labelled lines replace the thin box and short dotted levels, and markers and text are larger. Signals, stops and targets are unchanged, so the study results above still apply.

From source to chart

Set it up.

  1. Install. Open a standard candlestick chart, create a blank indicator in TradingView’s Pine Editor, replace its contents with the source below, save and add it to the chart. It draws on the price pane.
  2. Check it first. Run the nine-row Bar Replay checklist in VALIDATION.md, included in the download. Anything that differs from a row is a defect worth reporting.
  3. Choose a mode. Automatic works at once. Manual needs an upper level, a lower level and an activation time, and stays inert until all three are valid. A manual range gives one signal, then you set new levels.
  4. Add alerts if you want them. Use one of the four named conditions, set to Once per bar close, or Any alert() function call for messages carrying entry, stop, target and R.
Formation
20 bars, ATR 14, maximum width 3.0 ATR, 2 touches per boundary, 3 bars between touches, touch tolerance 0.15 ATR.
Windows
Reclaim 3 bars, confirmation 5 bars, retest 5 bars, automatic range life 100 bars.
Risk
Stop buffer 2 ticks, pivots 3 left and 3 right, minimum R 2.0, optional 2R and 3R projection lines, always labelled as projections.
Display
Range lines 3 thick in amber, entry, stop and target lines 2 thick and 15 bars long, small text. Thickness, length, color and text size are all adjustable.

These are initial research values, not optimized ones. Limits: chart timeframe only, one active range at a time, one signal per range, no volume or order-flow input.

Version 0.3.0 · Complete Pine Script v6 source

Download, inspect and test.

View the complete indicator code
//@version=6
// Konatus RAR 0.3.0 (Range Acceptance & Rejection)
// An experimental implementation of auction-market concepts: established range,
// failed breakout (rejection), sustained breakout (acceptance), and structural
// reward relative to risk.
//
// This is not Patrick Nill's strategy and no edge is claimed. All numerical
// defaults are initial research parameters, not optimised values.
//
// Reliability contract:
//   - every state change and every signal is evaluated on confirmed bars only
//   - no negative plot offsets, no intrabar assumptions, no higher timeframes
//   - boundaries, ATR, tolerance and buffer freeze at range activation
//   - one active range, one completed signal per range
//   - confirmed history does not move when later bars print; a settings change
//     or a data revision can still recalculate it

indicator("Konatus RAR", "Konatus RAR", overlay = true,
     max_bars_back = 500, max_boxes_count = 100, max_lines_count = 500,
     max_labels_count = 500)

//==================================================================== inputs
string G_RANGE = "1. Range identification"
string G_SETUP = "2. Setup windows"
string G_RISK  = "3. Risk and target"
string G_DISP  = "4. Display"
string G_ALERT = "5. Alerts"

iMode      = input.string("Automatic", "Mode", options = ["Automatic", "Manual"], group = G_RANGE,
     tooltip = "Automatic derives the range from recent price. Manual uses the levels below, from a separate profile study.")
iN         = input.int(20, "Formation window (bars)", minval = 5, maxval = 200, group = G_RANGE,
     tooltip = "Initial research parameter. Number of completed candles whose highest high and lowest low define the range.")
iAtrLen    = input.int(14, "ATR length", minval = 2, maxval = 100, group = G_RANGE)
iMaxWidth  = input.float(3.0, "Max range width (x ATR)", minval = 0.5, maxval = 10.0, step = 0.1, group = G_RANGE,
     tooltip = "Initial research parameter. Rejects a formation window wider than this multiple of ATR.")
iTolAtr    = input.float(0.15, "Boundary touch tolerance (x ATR)", minval = 0.01, maxval = 1.0, step = 0.01, group = G_RANGE,
     tooltip = "Initial research parameter. A touch is a high or low within this distance of the boundary.")
iTouches   = input.int(2, "Required touches per boundary", minval = 1, maxval = 10, group = G_RANGE)
iTouchGap  = input.int(3, "Minimum bars between touches", minval = 1, maxval = 20, group = G_RANGE)
iRangeLife = input.int(100, "Automatic range life (bars)", minval = 10, maxval = 2000, group = G_RANGE,
     tooltip = "Initial research parameter. Age at which an automatic range retires. Manual ranges do not age out.")
iManHi     = input.float(0.0, "Manual upper boundary", group = G_RANGE)
iManLo     = input.float(0.0, "Manual lower boundary", group = G_RANGE)
iManTs     = input.time(timestamp("01 Jan 2024 00:00 +0000"), "Manual activation time", group = G_RANGE,
     tooltip = "Manual levels are inert on every bar before this timestamp.")

iBufAtr    = input.float(0.10, "Breakout buffer (x ATR)", minval = 0.0, maxval = 2.0, step = 0.01, group = G_SETUP,
     tooltip = "Initial research parameter. Effective buffer is the larger of one tick and this multiple of frozen ATR.")
iReclaimWin = input.int(3, "Reclaim window after breach (bars)", minval = 0, maxval = 10, group = G_SETUP,
     tooltip = "A breach may reclaim on the same candle or within this many following candles.")
iConfirmWin = input.int(5, "Confirmation window (bars)", minval = 1, maxval = 20, group = G_SETUP,
     tooltip = "Bars allowed after the reclaim or retest candle for the confirmation close.")
iRetestWin  = input.int(5, "Retest window after acceptance (bars)", minval = 1, maxval = 20, group = G_SETUP)

iPivL      = input.int(3, "Pivot left bars", minval = 1, maxval = 50, group = G_RISK,
     tooltip = "Confirmed swing definition. A pivot only becomes eligible after its right-side bars have printed.")
iPivR      = input.int(3, "Pivot right bars", minval = 1, maxval = 50, group = G_RISK)
iStopTicks = input.int(2, "Stop buffer (ticks)", minval = 0, maxval = 100, group = G_RISK)
iMinR      = input.float(2.0, "Minimum R to qualify", minval = 0.1, maxval = 20.0, step = 0.1, group = G_RISK,
     tooltip = "Initial research parameter. Signals below this multiple are still marked, in a muted style.")
iProject   = input.bool(false, "Draw 2R and 3R projection lines", group = G_RISK,
     tooltip = "Projections are labelled as projections and are never used as the structural target.")

iRangeColor = input.color(#FFB300, "Range line color", group = G_DISP)
iRangeWidth = input.int(3, "Range line thickness", minval = 1, maxval = 5, group = G_DISP)
iLevelWidth = input.int(2, "Entry, stop and target thickness", minval = 1, maxval = 5, group = G_DISP)
iLevelBars  = input.int(15, "Entry, stop and target length (bars)", minval = 5, maxval = 100, group = G_DISP)
iTextSize   = input.string("Small", "Text size", options = ["Small", "Normal", "Large"], group = G_DISP)
iShowRangeLbl = input.bool(true, "Range high and low price labels", group = G_DISP)
iShowLbl   = input.bool(true, "Signal labels", group = G_DISP)
iShowLines = input.bool(true, "Entry, stop and target lines", group = G_DISP)
iShowDash  = input.bool(true, "Dashboard", group = G_DISP)
iShowMid   = input.bool(true, "Range midpoint", group = G_DISP)

iShowScore = input.bool(true, "Scoreboard rows in the dashboard", group = G_DISP,
     tooltip = "First touch of stop or target for each valid signal on this chart. Hypothetical fills at the signal close, no costs. A bar touching both counts as the stop.")
iScoreQual = input.bool(true, "Scoreboard counts qualified signals only", group = G_DISP)

string txtSize = iTextSize == "Large" ? size.large : iTextSize == "Normal" ? size.normal : size.small

iAlertQual = input.bool(false, "Alert only when minimum R is met", group = G_ALERT,
     tooltip = "Applies to the four alert conditions and to the Any alert() function call stream.")

//=================================================================== palette
color C_RANGE  = #787b86
color C_FILL   = color.new(iRangeColor, 90)
color C_MID    = color.new(iRangeColor, 30)
color C_BULL   = #26a69a
color C_BEAR   = #ef5350
color C_MUTED  = #787b86
color C_ENTRY  = #b2b5be
color C_STOP   = #ef5350
color C_TARGET = #26a69a
color C_TEXT   = #d1d4dc
color C_BG     = color.new(#131722, 10)
color C_PANEL  = color.new(#131722, 15)

//============================================================== global series
float mintick = syminfo.mintick
float atr     = ta.atr(iAtrLen)
float hhN     = ta.highest(high, iN)[1]
float llN     = ta.lowest(low, iN)[1]
float atrRef  = atr[1]
float ph      = ta.pivothigh(high, iPivL, iPivR)
float pl      = ta.pivotlow(low, iPivL, iPivR)

//=============================================================== state storage
// range state: 0 seeking, 1 active
var int   rstate      = 0
var float rHi         = na
var float rLo         = na
var float rAtr        = na
var float rTol        = na
var float rBuf        = na
var int   rAct        = -1
var int   lastRetire  = -100000
var string lastRetireWhy = ""

// setup state: 0 none, 1 bull rejection awaiting reclaim, 2 bull rejection pending,
// 3 bear rejection awaiting reclaim, 4 bear rejection pending,
// 5 bull acceptance awaiting retest, 6 bull acceptance pending,
// 7 bear acceptance awaiting retest, 8 bear acceptance pending
var int   ss      = 0
var int   pBar    = -1
var int   pExp    = -1
var float exLow   = na
var float exHigh  = na
var float rcHigh  = na
var float rcLow   = na
var bool  accBReady = true
var bool  accSReady = true
var float outHi   = na       // highest high above the range in this excursion
var float outLo   = na       // lowest low below the range in this excursion
var bool  upUsed  = false    // this excursion has already spent its reclaim window
var bool  dnUsed  = false
var bool  signalled = false
var bool  manualDone = false

// pivots confirmed on or before the current bar
var array<float> pivHiPrice = array.new<float>()
var array<int>   pivHiKnown = array.new<int>()
var array<float> pivLoPrice = array.new<float>()
var array<int>   pivLoKnown = array.new<int>()

// signals awaiting first touch of stop or target, and the running score
var array<float> opEntry  = array.new<float>()
var array<float> opStop   = array.new<float>()
var array<float> opTarget = array.new<float>()
var array<float> opRisk   = array.new<float>()
var array<float> opPlanR  = array.new<float>()
var array<bool>  opLong   = array.new<bool>()
var array<bool>  opAcc    = array.new<bool>()
var int   rejWins = 0
var int   rejLoss = 0
var float rejSumR = 0.0
var int   accWins = 0
var int   accLoss = 0
var float accSumR = 0.0

// drawings
var box   rangeBox = na
var line  midLine  = na
var line  hiLine   = na
var line  loLine   = na
var label hiLabel  = na
var label loLabel  = na
var table dash     = na

// per-bar signal: 0 none, 1 bull rejection, 2 bear rejection,
// 3 bull acceptance, 4 bear acceptance
int  emit    = 0
bool nowQual = false

// last completed signal, for the dashboard
var int    lastSigBar  = -1
var string lastSigKind = "none"
var float  lastSigR    = na
var bool   lastSigQual = false
var string lastSigWhy  = ""

//================================================================== utilities
fmtPrice(float p) =>
    na(p) ? "n/a" : str.tostring(p, format.mintick)

calcRisk(float entry, float stop, float target, bool isLong) =>
    float risk = math.abs(entry - stop)
    bool  ok   = true
    string why = ""
    if na(target)
        ok  := false
        why := "No structural target"
    else if isLong and not (stop < entry and entry < target)
        ok  := false
        why := "Invalid price ordering"
    else if not isLong and not (stop > entry and entry > target)
        ok  := false
        why := "Invalid price ordering"
    else if risk <= 0
        ok  := false
        why := "Zero risk"
    float reward = ok ? math.abs(target - entry) : na
    float rmult  = ok ? reward / risk : na
    [ok, risk, reward, rmult, why]

nearestPivot(float entry, bool isLong) =>
    float best = na
    if isLong
        int n = array.size(pivHiPrice)
        if n > 0
            for k = 0 to n - 1
                float p     = array.get(pivHiPrice, k)
                int   known = array.get(pivHiKnown, k)
                if known < bar_index and p > entry
                    best := na(best) ? p : math.min(best, p)
    else
        int m = array.size(pivLoPrice)
        if m > 0
            for k = 0 to m - 1
                float p     = array.get(pivLoPrice, k)
                int   known = array.get(pivLoKnown, k)
                if known < bar_index and p < entry
                    best := na(best) ? p : math.max(best, p)
    best

fmtR(float r) =>
    na(r) ? "R n/a" : str.tostring(r, "#.##") + "R"

fmtScore(int wins, int losses, float sumR) =>
    int n = wins + losses
    n == 0 ? "no resolved signals" : str.tostring(wins) + " target, " + str.tostring(losses) + " stop, " + str.tostring(sumR / n, "+#.##;-#.##") + "R avg"

setupText(string kind, float entry, float stop, float target, float rmult, bool ok, string why, bool compact) =>
    string s = kind
    if not (compact and ok)
        s := s + "\nentry " + fmtPrice(entry) + "\nstop " + fmtPrice(stop) + "\ntarget " + fmtPrice(target)
    s := s + "\n" + fmtR(rmult)
    if not ok
        s := s + "\n" + why
    s

//=================================================== confirmed-bar state machine
if barstate.isconfirmed
    // 1. pivot bookkeeping. ta.pivothigh returns a value only on the bar that
    //    confirms the pivot, which is what keeps the target free of hindsight.
    if not na(ph)
        array.push(pivHiPrice, ph)
        array.push(pivHiKnown, bar_index)
        if array.size(pivHiPrice) > 100
            array.shift(pivHiPrice)
            array.shift(pivHiKnown)
    if not na(pl)
        array.push(pivLoPrice, pl)
        array.push(pivLoKnown, bar_index)
        if array.size(pivLoPrice) > 100
            array.shift(pivLoPrice)
            array.shift(pivLoKnown)

    // 2. score earlier signals first, so a signal cannot score on its own bar.
    //    A bar touching both levels counts as the stop, because the order inside
    //    a bar is unknowable, and a gap through the stop fills at the open.
    int nOpen = array.size(opStop)
    if nOpen > 0
        for k = nOpen - 1 to 0
            bool  tradeLong = array.get(opLong, k)
            float tradeStop = array.get(opStop, k)
            float tradeTgt  = array.get(opTarget, k)
            bool  hitStop   = tradeLong ? low <= tradeStop : high >= tradeStop
            bool  hitTgt    = tradeLong ? high >= tradeTgt : low <= tradeTgt
            if hitStop or hitTgt
                float tradeEntry = array.get(opEntry, k)
                float fillPx = tradeLong ? math.min(open, tradeStop) : math.max(open, tradeStop)
                float moved = tradeLong ? fillPx - tradeEntry : tradeEntry - fillPx
                float gotR = hitStop ? moved / array.get(opRisk, k) : array.get(opPlanR, k)
                if array.get(opAcc, k)
                    accWins := accWins + (hitStop ? 0 : 1)
                    accLoss := accLoss + (hitStop ? 1 : 0)
                    accSumR := accSumR + gotR
                else
                    rejWins := rejWins + (hitStop ? 0 : 1)
                    rejLoss := rejLoss + (hitStop ? 1 : 0)
                    rejSumR := rejSumR + gotR
                array.remove(opEntry, k)
                array.remove(opStop, k)
                array.remove(opTarget, k)
                array.remove(opRisk, k)
                array.remove(opPlanR, k)
                array.remove(opLong, k)
                array.remove(opAcc, k)

    // 3. range activation
    if rstate == 0
        float newHi   = na
        float newLo   = na
        float newAtr  = na
        int   newLeft = bar_index
        if iMode == "Automatic"
            bool fresh = bar_index - lastRetire > iN
            bool ready = bar_index >= iN + iAtrLen and not na(atrRef) and atrRef > 0
            if fresh and ready and close >= llN and close <= hhN
                float tolRef = iTolAtr * atrRef
                int   tHi    = 0
                int   tLo    = 0
                int   lastHi = -1
                int   lastLo = -1
                for k = 1 to iN
                    int off = iN + 1 - k
                    if high[off] >= hhN - tolRef
                        if lastHi < 0 or math.abs(off - lastHi) >= iTouchGap
                            tHi    := tHi + 1
                            lastHi := off
                    if low[off] <= llN + tolRef
                        if lastLo < 0 or math.abs(off - lastLo) >= iTouchGap
                            tLo    := tLo + 1
                            lastLo := off
                bool narrow = hhN - llN <= iMaxWidth * atrRef
                bool both   = tHi >= iTouches and tLo >= iTouches
                if narrow and both and hhN > llN
                    newHi   := hhN
                    newLo   := llN
                    newAtr  := atrRef
                    newLeft := bar_index - iN
        else if not manualDone and iManHi > iManLo and not na(atr) and atr > 0 and time >= iManTs
            manualDone := true
            newHi  := iManHi
            newLo  := iManLo
            newAtr := atr
        if not na(newHi)
            rHi       := newHi
            rLo       := newLo
            rAtr      := newAtr
            rTol      := iTolAtr * newAtr
            rBuf      := math.max(mintick, iBufAtr * newAtr)
            rAct      := bar_index
            rstate    := 1
            ss        := 0
            signalled := false
            accBReady := true
            accSReady := true
            outHi     := na
            outLo     := na
            upUsed    := false
            dnUsed    := false
            rangeBox := box.new(newLeft, rHi, bar_index, rLo,
                 border_color = color.new(iRangeColor, 100), border_width = 0, bgcolor = C_FILL,
                 xloc = xloc.bar_index)
            hiLine := line.new(newLeft, rHi, bar_index, rHi, color = iRangeColor, width = iRangeWidth, xloc = xloc.bar_index)
            loLine := line.new(newLeft, rLo, bar_index, rLo, color = iRangeColor, width = iRangeWidth, xloc = xloc.bar_index)
            hiLabel := na
            loLabel := na
            if iShowRangeLbl
                hiLabel := label.new(bar_index, rHi, "Range high " + fmtPrice(rHi), style = label.style_label_left, color = C_PANEL, textcolor = iRangeColor, size = txtSize)
                loLabel := label.new(bar_index, rLo, "Range low " + fmtPrice(rLo), style = label.style_label_left, color = C_PANEL, textcolor = iRangeColor, size = txtSize)
            if iShowMid
                midLine := line.new(newLeft, (rHi + rLo) / 2, bar_index,
                     (rHi + rLo) / 2, color = C_MID, style = line.style_dotted,
                     xloc = xloc.bar_index)
            else
                midLine := na
            label.new(bar_index, rLo, iMode == "Automatic" ? "range known" : "levels active",
                 style = label.style_label_up, color = C_PANEL, textcolor = C_TEXT, size = txtSize)

    // 4. extend the frozen drawings while the range is live, including the bar
    //    on which it retires
    bool wasLive = rstate == 1
    if rstate == 1 and not na(rangeBox)
        box.set_right(rangeBox, bar_index)
        line.set_x2(hiLine, bar_index)
        line.set_x2(loLine, bar_index)
        if not na(midLine)
            line.set_x2(midLine, bar_index)
        if not na(hiLabel)
            label.set_x(hiLabel, bar_index)
            label.set_x(loLabel, bar_index)

    // 5. evaluation, only after the activation bar
    if rstate == 1 and not na(rHi)
        if iMode == "Automatic" and (bar_index - rAct) > iRangeLife
            rstate        := 0
            ss            := 0
            pBar          := -1
            pExp          := -1
            lastRetire    := bar_index
            lastRetireWhy := "range life exceeded"
        else if bar_index > rAct
            bool breachUp  = high > rHi + rBuf
            bool breachDn  = low < rLo - rBuf
            bool ambiguous = breachUp and breachDn
            bool failUp    = false
            bool failDn    = false

            // excursion tracking: from the first trade beyond a boundary until a
            // bar after a close back inside, whichever setup branch is live, so
            // every rejection stop can clear all of it
            if close[1] < rHi
                outHi := na
            if close[1] > rLo
                outLo := na
            if high > rHi
                outHi := na(outHi) ? high : math.max(outHi, high)
            if low < rLo
                outLo := na(outLo) ? low : math.min(outLo, low)

            // latches: a fresh two-close sequence is required after price returns
            // to the boundary, and one excursion gets one reclaim window
            if close <= rHi + rBuf
                accBReady := true
            if close >= rLo - rBuf
                accSReady := true
            if close <= rHi
                upUsed := false
            if close >= rLo
                dnUsed := false

            // 5a. pending setup transitions, highest priority first
            if ambiguous
                if ss != 0
                    ss   := 0
                    pBar := -1
                    pExp := -1
            else if ss == 1
                exLow := outLo
                if close > rHi
                    ss := 0
                else if close >= rLo and close <= rHi and bar_index - pBar <= iReclaimWin
                    rcHigh := high
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                    ss     := 2
                else if bar_index - pBar > iReclaimWin
                    ss := 0
            else if ss == 2
                if low < exLow
                    ss := 0
                else if close > rHi
                    ss := 0
                else if close > rcHigh and close >= rLo and bar_index <= pExp
                    emit := 1
                else if bar_index > pExp
                    ss := 0
            else if ss == 3
                exHigh := outHi
                if close < rLo
                    ss := 0
                else if close >= rLo and close <= rHi and bar_index - pBar <= iReclaimWin
                    rcLow := low
                    pBar  := bar_index
                    pExp  := bar_index + iConfirmWin
                    ss    := 4
                else if bar_index - pBar > iReclaimWin
                    ss := 0
            else if ss == 4
                if high > exHigh
                    ss := 0
                else if close < rLo
                    ss := 0
                else if close < rcLow and close <= rHi and bar_index <= pExp
                    emit := 2
                else if bar_index > pExp
                    ss := 0
            else if ss == 5
                if close < rHi
                    ss := 0
                    failUp := close >= rLo
                else if low <= rHi + rTol and close >= rHi and bar_index <= pExp
                    rcHigh := high
                    exLow  := low
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                    ss     := 6
                else if bar_index > pExp
                    ss := 0
            else if ss == 6
                exLow := math.min(exLow, low)
                if close < rHi
                    ss := 0
                    failUp := close >= rLo
                else if close > rcHigh and bar_index <= pExp
                    emit := 3
                else if bar_index > pExp
                    ss := 0
            else if ss == 7
                if close > rLo
                    ss := 0
                    failDn := close <= rHi
                else if high >= rLo - rTol and close <= rLo and bar_index <= pExp
                    rcLow  := low
                    exHigh := high
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                    ss     := 8
                else if bar_index > pExp
                    ss := 0
            else if ss == 8
                exHigh := math.max(exHigh, high)
                if close > rLo
                    ss := 0
                    failDn := close <= rHi
                else if close < rcLow and bar_index <= pExp
                    emit := 4
                else if bar_index > pExp
                    ss := 0

            // 5b. a completed setup is measured, drawn and alerted once, here.
            //     It retires the range, including a structural failure.
            if emit != 0
                bool  isLong = emit == 1 or emit == 3
                bool  isAcc  = emit >= 3
                string kind  = (isLong ? "Bull " : "Bear ") + (isAcc ? "acceptance" : "rejection")
                float entryPx  = math.round_to_mintick(close)
                float stopPx   = math.round_to_mintick(isLong ? exLow - iStopTicks * mintick : exHigh + iStopTicks * mintick)
                float targetPx = math.round_to_mintick(isAcc ? nearestPivot(close, isLong) : (isLong ? rHi : rLo))
                [ok, risk, rew, rm, whyBad] = calcRisk(entryPx, stopPx, targetPx, isLong)
                nowQual := ok and rm >= iMinR
                lastSigBar  := bar_index
                lastSigKind := str.lower(kind)
                lastSigR    := rm
                lastSigQual := nowQual
                lastSigWhy  := ok and not nowQual ? "below minimum R" : whyBad
                if ok and (nowQual or not iScoreQual)
                    array.push(opEntry, entryPx)
                    array.push(opStop, stopPx)
                    array.push(opTarget, targetPx)
                    array.push(opRisk, risk)
                    array.push(opPlanR, rm)
                    array.push(opLong, isLong)
                    array.push(opAcc, isAcc)
                if iShowLines and ok
                    int levelEnd = bar_index + iLevelBars
                    line.new(bar_index, entryPx, levelEnd, entryPx, color = C_ENTRY, width = iLevelWidth)
                    line.new(bar_index, stopPx, levelEnd, stopPx, color = C_STOP, width = iLevelWidth)
                    line.new(bar_index, targetPx, levelEnd, targetPx, color = C_TARGET, width = iLevelWidth)
                    label.new(levelEnd, entryPx, "Entry " + fmtPrice(entryPx), style = label.style_label_left, color = C_PANEL, textcolor = C_ENTRY, size = txtSize)
                    label.new(levelEnd, stopPx, "Stop " + fmtPrice(stopPx), style = label.style_label_left, color = C_PANEL, textcolor = C_STOP, size = txtSize)
                    label.new(levelEnd, targetPx, "Target " + fmtPrice(targetPx), style = label.style_label_left, color = C_PANEL, textcolor = C_TARGET, size = txtSize)
                if iProject and isAcc and risk > 0
                    float dir = isLong ? 1.0 : -1.0
                    line.new(bar_index, entryPx + dir * 2 * risk, bar_index + 12, entryPx + dir * 2 * risk, color = color.new(C_TARGET, 60), style = line.style_dashed)
                    line.new(bar_index, entryPx + dir * 3 * risk, bar_index + 12, entryPx + dir * 3 * risk, color = color.new(C_TARGET, 60), style = line.style_dashed)
                    label.new(bar_index + 12, entryPx + dir * 2 * risk, "2R projection", style = label.style_label_left, color = C_PANEL, textcolor = C_TEXT, size = txtSize)
                    label.new(bar_index + 12, entryPx + dir * 3 * risk, "3R projection", style = label.style_label_left, color = C_PANEL, textcolor = C_TEXT, size = txtSize)
                if iShowLbl
                    label.new(bar_index, isLong ? low - 0.6 * rAtr : high + 0.6 * rAtr,
                         setupText(kind, entryPx, stopPx, targetPx, rm, ok, whyBad, iShowLines) + (ok and not nowQual ? "\nbelow minimum R" : ""),
                         style = isLong ? label.style_label_up : label.style_label_down,
                         color = C_PANEL, textcolor = isLong ? C_BULL : C_BEAR, size = txtSize)
                if nowQual or not iAlertQual
                    alert(kind + " | " + syminfo.ticker + " " + timeframe.period +
                         " | entry " + fmtPrice(entryPx) + " | stop " + fmtPrice(stopPx) +
                         " | target " + fmtPrice(targetPx) + " | " + fmtR(rm) +
                         " | " + (nowQual ? "qualified" : lastSigWhy),
                         alert.freq_once_per_bar_close)
                ss := 0
                signalled := true
                rstate := 0
                lastRetire := bar_index
                lastRetireWhy := "signal complete"

            // 5c. a close back inside after sustained closes beyond is the failed
            //     breakout this indicator exists to mark. It arms the opposite
            //     rejection whether or not the failing bar pokes through on its
            //     own, with the whole excursion behind the stop.
            if failUp
                ss     := 4
                exHigh := outHi
                rcLow  := low
                pBar   := bar_index
                pExp   := bar_index + iConfirmWin
            else if failDn
                ss     := 2
                exLow  := outLo
                rcHigh := high
                pBar   := bar_index
                pExp   := bar_index + iConfirmWin

            // 5d. acceptance arming outranks rejection initiation. Both closes
            //     must fall after the activation bar, and an ambiguous bar cannot arm.
            if not signalled and not ambiguous and bar_index - rAct >= 2
                if accBReady and close > rHi + rBuf and close[1] > rHi + rBuf and ss != 5 and ss != 6
                    ss     := 5
                    pBar   := bar_index
                    pExp   := bar_index + iRetestWin
                    accBReady := false
                    upUsed := true
                else if accSReady and close < rLo - rBuf and close[1] < rLo - rBuf and ss != 7 and ss != 8
                    ss     := 7
                    pBar   := bar_index
                    pExp   := bar_index + iRetestWin
                    accSReady := false
                    dnUsed := true

            // 5e. rejection initiation, only when flat and unambiguous. A same-bar
            //     reclaim is always a new event; a WAIT phase is not.
            if ss == 0 and not signalled and not ambiguous
                if breachDn and close >= rLo and close <= rHi
                    ss     := 2
                    exLow  := outLo
                    rcHigh := high
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                else if breachDn and close < rLo and not dnUsed
                    ss     := 1
                    exLow  := outLo
                    dnUsed := true
                    pBar   := bar_index
                else if breachUp and close >= rLo and close <= rHi
                    ss     := 4
                    exHigh := outHi
                    rcLow  := low
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                else if breachUp and close > rHi and not upUsed
                    ss     := 3
                    exHigh := outHi
                    upUsed := true
                    pBar   := bar_index

    // 6. a retired range fades, so the live range is the one that stands out
    if wasLive and rstate == 0
        color faded = color.new(iRangeColor, 65)
        line.set_color(hiLine, faded)
        line.set_color(loLine, faded)
        line.set_width(hiLine, 1)
        line.set_width(loLine, 1)
        box.set_bgcolor(rangeBox, color.new(iRangeColor, 96))
        if not na(hiLabel)
            label.set_textcolor(hiLabel, faded)
            label.set_textcolor(loLabel, faded)

//===================================================================== plots
// Qualified signals are triangles, unqualified signals are small muted circles.
// The marker is always printed: the minimum-R filter never erases a setup.
plotshape(emit == 1 and nowQual, "Bull rejection (qualified)", shape.triangleup, location.belowbar, C_BULL, text = "R", textcolor = C_BULL, size = size.normal)
plotshape(emit == 1 and not nowQual, "Bull rejection (below min R)", shape.circle, location.belowbar, color.new(C_MUTED, 30), text = "R", textcolor = C_MUTED, size = size.small)
plotshape(emit == 2 and nowQual, "Bear rejection (qualified)", shape.triangledown, location.abovebar, C_BEAR, text = "R", textcolor = C_BEAR, size = size.normal)
plotshape(emit == 2 and not nowQual, "Bear rejection (below min R)", shape.circle, location.abovebar, color.new(C_MUTED, 30), text = "R", textcolor = C_MUTED, size = size.small)
plotshape(emit == 3 and nowQual, "Bull acceptance (qualified)", shape.triangleup, location.belowbar, C_BULL, text = "A", textcolor = C_BULL, size = size.normal)
plotshape(emit == 3 and not nowQual, "Bull acceptance (below min R)", shape.circle, location.belowbar, color.new(C_MUTED, 30), text = "A", textcolor = C_MUTED, size = size.small)
plotshape(emit == 4 and nowQual, "Bear acceptance (qualified)", shape.triangledown, location.abovebar, C_BEAR, text = "A", textcolor = C_BEAR, size = size.normal)
plotshape(emit == 4 and not nowQual, "Bear acceptance (below min R)", shape.circle, location.abovebar, color.new(C_MUTED, 30), text = "A", textcolor = C_MUTED, size = size.small)

//==================================================================== alerts
bool gate = not iAlertQual or nowQual
alertcondition(emit == 1 and gate, "Bullish rejection", "Bullish rejection | {{ticker}} | {{interval}} | close {{close}}")
alertcondition(emit == 2 and gate, "Bearish rejection", "Bearish rejection | {{ticker}} | {{interval}} | close {{close}}")
alertcondition(emit == 3 and gate, "Bullish acceptance", "Bullish acceptance | {{ticker}} | {{interval}} | close {{close}}")
alertcondition(emit == 4 and gate, "Bearish acceptance", "Bearish acceptance | {{ticker}} | {{interval}} | close {{close}}")

//================================================================= dashboard
if iShowDash and barstate.islast
    if na(dash)
        dash := table.new(position.top_right, 2, 10, bgcolor = C_BG,
             frame_color = color.new(C_RANGE, 60), frame_width = 1,
             border_color = color.new(C_RANGE, 70), border_width = 1)
    string stat = "seeking"
    if rstate == 1
        stat := "active"
    else if iMode == "Manual"
        stat := manualDone ? "spent, set new levels" : "waiting for activation"
    else if lastRetire > 0 and bar_index - lastRetire <= iN
        stat := "cooldown, " + lastRetireWhy
    string rng = "none"
    if rstate == 1
        rng := fmtPrice(rLo) + " - " + fmtPrice(rHi)
    else if iMode == "Manual" and iManHi <= iManLo
        rng := "manual levels invalid"
    string pend = "bear acc, awaiting close"
    if ss == 0
        pend := "none"
    else if ss == 1
        pend := "bull rej, awaiting reclaim"
    else if ss == 2
        pend := "bull rej, awaiting close"
    else if ss == 3
        pend := "bear rej, awaiting reclaim"
    else if ss == 4
        pend := "bear rej, awaiting close"
    else if ss == 5
        pend := "bull acc, awaiting retest"
    else if ss == 6
        pend := "bull acc, awaiting close"
    else if ss == 7
        pend := "bear acc, awaiting retest"
    string sig = "none"
    string note = "-"
    if lastSigBar >= 0
        sig  := lastSigKind + " " + fmtR(lastSigR)
        note := lastSigQual ? "qualified" : lastSigWhy
    table.cell(dash, 0, 0, "Konatus RAR", text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 1, 0, iMode, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 1, "state", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 1, stat, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 2, "range", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 2, rng, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 3, "frozen ATR", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 3, rstate != 1 ? "-" : str.tostring(rAtr, format.mintick), text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 4, "pending", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 4, pend, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 5, "last signal", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 5, sig, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 6, "note", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 6, note, text_color = C_TEXT, text_size = txtSize)
    if iShowScore
        string scope = (iScoreQual ? "qualified" : "all valid") + ", no costs, " + str.tostring(array.size(opStop)) + " open"
        table.cell(dash, 0, 7, "rejection score", text_color = color.new(C_TEXT, 40), text_size = txtSize)
        table.cell(dash, 1, 7, fmtScore(rejWins, rejLoss, rejSumR), text_color = C_TEXT, text_size = txtSize)
        table.cell(dash, 0, 8, "acceptance score", text_color = color.new(C_TEXT, 40), text_size = txtSize)
        table.cell(dash, 1, 8, fmtScore(accWins, accLoss, accSumR), text_color = C_TEXT, text_size = txtSize)
        table.cell(dash, 0, 9, "score scope", text_color = color.new(C_TEXT, 40), text_size = txtSize)
        table.cell(dash, 1, 9, scope, text_color = C_TEXT, text_size = txtSize)

The download includes the Python model, all tests, the study script and its results, but not the price data. R multiples describe structure relative to a stop and are not expected returns. Read the trading and research disclaimer ↓

Bonus code · Konatus Spectrum + RAR 0.2.0 · Research prototype

The ribbon and the range, on one chart.

This bonus script runs Konatus Spectrum and Konatus RAR together. Spectrum describes the trend across 240 moving averages. RAR marks what price did at a specific range. Each answers a question the other cannot, and neither overrides the other.

Read this first. A test written before any result was seen found that Spectrum’s trend did not improve RAR’s outcomes. The two work together as context and event on one chart. That is not evidence of better trades. TradingView’s translator accepts this exact source with zero errors and zero warnings, but it has not been run on a live chart. It is a large script, and only a real compile can confirm it stays within TradingView’s size and execution-time limits. If TradingView rejects it, run the two indicators side by side.

How the two are balanced

RAR keeps its signals
The same signals, stops and targets as Konatus RAR. A check rejects the file if Spectrum’s state appears anywhere in RAR’s transition rules.
Spectrum keeps its reading
The ribbon, its lengths and its trend states are unchanged. A check rejects the file if that engine differs from the published Spectrum source by a single character.
Spectrum grades, never vetoes
Each signal is graded on its bar: + with Spectrum’s trend, − against it, and no suffix when no trend is established. An R− is still printed, with its full levels.
The chart stays readable
The range and its levels are drawn in thick, labelled lines. The ribbon dims while a range is live, so they read first, then returns to full strength. You can turn this off. One dashboard replaces the two separate ones.
Shared budget
Spectrum alone uses 63 of TradingView’s 64 plot counts. To fit, RAR’s markers became labels, which cost none, and Spectrum’s four events moved to the Any alert() function call stream. The four named alert conditions are RAR’s.

Did the grade help?

No. The test asked whether signals with Spectrum’s trend beat signals against it, across 2,175 signals on the same real-price data as above, before costs.

All data sets pooled. Interval: 90% bootstrap, resampling calendar months.
Spectrum gradeResolvedReached targetMean R per setup
With trend (R+, A+)52059.8%−0.057R (−0.136 to +0.023)
No trend (R, A)1,00663.6%−0.058R (−0.108 to −0.007)
Against trend (R−, A−)64960.4%−0.058R (−0.142 to +0.025)

The difference between with-trend and against-trend signals was +0.001R, with an interval from −0.115 to +0.119. In the most recent 40% of the data it was −0.057R. So the script hides nothing by default. Narrowing alerts to with-trend signals would discard signals with no shown benefit, though the option is there if you want fewer alerts. The dashboard scores each grade separately, so you can run the same comparison on your own charts.

Set it up

Install it the same way as above. It needs about 733 bars at the Balanced setting before Spectrum can grade anything; until then signals still print and read warming up. Use it instead of the two separate indicators, not alongside them. Alerts for Spectrum events need Any alert() function call.

View the bonus Spectrum + RAR code
//@version=6
// Konatus Spectrum + RAR 0.2.0 | kuttystauder.com
// Generated by build_combined.py from Konatus Spectrum and Konatus RAR 0.3.0.
//
// Two jobs on one chart. The ribbon describes the trend. RAR marks what price did
// at a range. Spectrum grades each RAR signal and never decides it: the same signals,
// stops and targets print as in Konatus RAR, every one marked.
//
// A test written before its results were seen found that the grade did not separate
// outcomes (With and Against both about -0.06R per signal before costs, on about
// 2,200 signals). The grade is context, not an edge. Scores are not probabilities.
//
// Plot budget: 20 plots x 2 counts + 19 series fills + 4 alertconditions = 63 of 64.
// Research prototype: not yet run on a live TradingView chart.
indicator("Konatus Spectrum + RAR", shorttitle="KS + RAR", overlay=true, max_bars_back=1500,
     max_boxes_count=100, max_lines_count=200, max_labels_count=500)

string comboGroup = "Combination"
bool iRecede = input.bool(true, "Ribbon recedes while a range is live", group=comboGroup, tooltip="Dims the ribbon so the range and its levels read first. Display only.")
string iAlertGrade = input.string("All grades", "RAR alerts by Spectrum grade", options=["All grades", "Exclude against trend", "With trend only"], group=comboGroup, tooltip="Markers are never hidden. The published test found no outcome difference between grades, so narrowing this has no shown benefit.")
bool iSpectrumAlerts = input.bool(true, "Spectrum events in the alert() stream", group=comboGroup, tooltip="Trend established, recovery and fracturing, delivered through Any alert() function call. Spectrum's four alert conditions would exceed the plot budget.")

string engineGroup = "Adaptive engine"
string preset = input.string("Balanced", "Intraday sensitivity", options=["Fast", "Balanced", "Patient"], group=engineGroup)
float adaptiveShare = input.float(0.65, "Adaptive weight", minval=0.10, maxval=0.90, step=0.05, group=engineGroup, tooltip="Split between KAMA and FRAMA. Remaining weight is split between Hull and EMA. Higher is not necessarily better.")
int slopeBars = input.int(3, "Slope lookback", minval=1, maxval=10, group=engineGroup)
float slopeFloor = input.float(0.01, "Neutral slope (ATR per bar)", minval=0.0, maxval=0.10, step=0.005, group=engineGroup)
int confirmationBars = input.int(2, "Closed bars to confirm / invalidate", minval=1, maxval=5, group=engineGroup)
int recoveryBars = input.int(2, "Supportive closes for recovery", minval=1, maxval=5, group=engineGroup)
float pullbackDeadband = input.float(0.10, "Pullback momentum deadband", minval=0.01, maxval=0.50, step=0.01, group=engineGroup, tooltip="Eight-bar price change divided by ATR × sqrt(8). A pullback needs opposing momentum beyond this threshold or opposing fast slopes. Recovery needs supportive momentum and full trend evidence.")
string visualGroup = "Appearance"
bool showFill = input.bool(true, "Fill between strands", group=visualGroup)
int ribbonWidth = input.int(2, "Strand thickness", minval=1, maxval=3, group=visualGroup)


string G_RANGE = "1. Range identification"
string G_SETUP = "2. Setup windows"
string G_RISK  = "3. Risk and target"
string G_DISP  = "4. Display"
string G_ALERT = "5. Alerts"

iMode      = input.string("Automatic", "Mode", options = ["Automatic", "Manual"], group = G_RANGE,
     tooltip = "Automatic derives the range from recent price. Manual uses the levels below, from a separate profile study.")
iN         = input.int(20, "Formation window (bars)", minval = 5, maxval = 200, group = G_RANGE,
     tooltip = "Initial research parameter. Number of completed candles whose highest high and lowest low define the range.")
iAtrLen    = input.int(14, "ATR length", minval = 2, maxval = 100, group = G_RANGE)
iMaxWidth  = input.float(3.0, "Max range width (x ATR)", minval = 0.5, maxval = 10.0, step = 0.1, group = G_RANGE,
     tooltip = "Initial research parameter. Rejects a formation window wider than this multiple of ATR.")
iTolAtr    = input.float(0.15, "Boundary touch tolerance (x ATR)", minval = 0.01, maxval = 1.0, step = 0.01, group = G_RANGE,
     tooltip = "Initial research parameter. A touch is a high or low within this distance of the boundary.")
iTouches   = input.int(2, "Required touches per boundary", minval = 1, maxval = 10, group = G_RANGE)
iTouchGap  = input.int(3, "Minimum bars between touches", minval = 1, maxval = 20, group = G_RANGE)
iRangeLife = input.int(100, "Automatic range life (bars)", minval = 10, maxval = 2000, group = G_RANGE,
     tooltip = "Initial research parameter. Age at which an automatic range retires. Manual ranges do not age out.")
iManHi     = input.float(0.0, "Manual upper boundary", group = G_RANGE)
iManLo     = input.float(0.0, "Manual lower boundary", group = G_RANGE)
iManTs     = input.time(timestamp("01 Jan 2024 00:00 +0000"), "Manual activation time", group = G_RANGE,
     tooltip = "Manual levels are inert on every bar before this timestamp.")

iBufAtr    = input.float(0.10, "Breakout buffer (x ATR)", minval = 0.0, maxval = 2.0, step = 0.01, group = G_SETUP,
     tooltip = "Initial research parameter. Effective buffer is the larger of one tick and this multiple of frozen ATR.")
iReclaimWin = input.int(3, "Reclaim window after breach (bars)", minval = 0, maxval = 10, group = G_SETUP,
     tooltip = "A breach may reclaim on the same candle or within this many following candles.")
iConfirmWin = input.int(5, "Confirmation window (bars)", minval = 1, maxval = 20, group = G_SETUP,
     tooltip = "Bars allowed after the reclaim or retest candle for the confirmation close.")
iRetestWin  = input.int(5, "Retest window after acceptance (bars)", minval = 1, maxval = 20, group = G_SETUP)

iPivL      = input.int(3, "Pivot left bars", minval = 1, maxval = 50, group = G_RISK,
     tooltip = "Confirmed swing definition. A pivot only becomes eligible after its right-side bars have printed.")
iPivR      = input.int(3, "Pivot right bars", minval = 1, maxval = 50, group = G_RISK)
iStopTicks = input.int(2, "Stop buffer (ticks)", minval = 0, maxval = 100, group = G_RISK)
iMinR      = input.float(2.0, "Minimum R to qualify", minval = 0.1, maxval = 20.0, step = 0.1, group = G_RISK,
     tooltip = "Initial research parameter. Signals below this multiple are still marked, in a muted style.")
iProject   = input.bool(false, "Draw 2R and 3R projection lines", group = G_RISK,
     tooltip = "Projections are labelled as projections and are never used as the structural target.")

iRangeColor = input.color(#FFB300, "Range line color", group = G_DISP)
iRangeWidth = input.int(3, "Range line thickness", minval = 1, maxval = 5, group = G_DISP)
iLevelWidth = input.int(2, "Entry, stop and target thickness", minval = 1, maxval = 5, group = G_DISP)
iLevelBars  = input.int(15, "Entry, stop and target length (bars)", minval = 5, maxval = 100, group = G_DISP)
iTextSize   = input.string("Small", "Text size", options = ["Small", "Normal", "Large"], group = G_DISP)
iShowRangeLbl = input.bool(true, "Range high and low price labels", group = G_DISP)
iShowLbl   = input.bool(true, "Signal labels", group = G_DISP)
iShowLines = input.bool(true, "Entry, stop and target lines", group = G_DISP)
iShowDash  = input.bool(true, "Dashboard", group = G_DISP)
iShowMid   = input.bool(true, "Range midpoint", group = G_DISP)

iShowScore = input.bool(true, "Scoreboard rows in the dashboard", group = G_DISP,
     tooltip = "First touch of stop or target for each valid signal on this chart. Hypothetical fills at the signal close, no costs. A bar touching both counts as the stop.")
iScoreQual = input.bool(false, "Scoreboard counts qualified signals only", group = G_DISP)

string txtSize = iTextSize == "Large" ? size.large : iTextSize == "Normal" ? size.normal : size.small

iAlertQual = input.bool(false, "Alert only when minimum R is met", group = G_ALERT,
     tooltip = "Applies to the four alert conditions and to the Any alert() function call stream.")


int startLength = preset == "Fast" ? 4 : preset == "Patient" ? 12 : 8
int stride = preset == "Fast" ? 2 : preset == "Patient" ? 6 : 4
int longestLength = startLength + stride * 59
float atr = ta.atr(14)
float unit = math.max(nz(atr, syminfo.mintick), syminfo.mintick)

f_clip(float value, float lo, float hi) =>
    math.max(lo, math.min(hi, value))

f_efficiency(simple int n) =>
    float travel = math.sum(math.abs(ta.change(close)), n)
    not na(close[n]) and travel > 0 ? f_clip(math.abs(close - close[n]) / travel, 0.0, 1.0) : 0.0

f_vote(float value, float scale) =>
    float delta = value - value[slopeBars]
    float threshold = scale * slopeFloor * slopeBars
    na(delta) ? 0.0 : delta > threshold ? 1.0 : delta < -threshold ? -1.0 : 0.0

// Each written call owns separate history. Do not move f_bank into a loop.
f_bank(simple int n, float scale) =>
    float er = f_efficiency(n)
    float ka = math.pow(er * (2.0 / 3.0 - 2.0 / 31.0) + 2.0 / 31.0, 2)
    var float kama = na
    kama := na(kama[1]) ? close : kama[1] + ka * (close - kama[1])

    int half = int(n / 2)
    float recentHigh = ta.highest(high, half)
    float recentLow = ta.lowest(low, half)
    float recentRange = recentHigh - recentLow
    float olderRange = recentHigh[half] - recentLow[half]
    float wholeRange = ta.highest(high, n) - ta.lowest(low, n)
    float fa = 0.01
    if bar_index >= n - 1 and wholeRange > 0
        float rangeSum = recentRange + olderRange
        float dimension = rangeSum > 0 ? math.log(2.0 * rangeSum / wholeRange) / math.log(2.0) : 1.0
        fa := f_clip(math.exp(-4.6 * (dimension - 1.0)), 0.01, 1.0)
    var float frama = na
    frama := na(frama[1]) ? close : frama[1] + fa * (close - frama[1])

    // Explicit integer convention: even half length, rounded square-root length.
    float hull = ta.wma(2.0 * ta.wma(close, half) - ta.wma(close, n), int(math.round(math.sqrt(n))))
    float ema = ta.ema(close, n)
    float blend = adaptiveShare * (kama + frama) / 2.0 + (1.0 - adaptiveShare) * (hull + ema) / 2.0
    float votes = adaptiveShare * (f_vote(kama, scale) + f_vote(frama, scale)) / 2.0 + (1.0 - adaptiveShare) * (f_vote(hull, scale) + f_vote(ema, scale)) / 2.0
    [blend, votes]

// BEGIN GENERATED HORIZONS
[b00, v00] = f_bank(startLength + stride * 0, unit)
[b01, v01] = f_bank(startLength + stride * 1, unit)
[b02, v02] = f_bank(startLength + stride * 2, unit)
[b03, v03] = f_bank(startLength + stride * 3, unit)
[b04, v04] = f_bank(startLength + stride * 4, unit)
[b05, v05] = f_bank(startLength + stride * 5, unit)
[b06, v06] = f_bank(startLength + stride * 6, unit)
[b07, v07] = f_bank(startLength + stride * 7, unit)
[b08, v08] = f_bank(startLength + stride * 8, unit)
[b09, v09] = f_bank(startLength + stride * 9, unit)
[b10, v10] = f_bank(startLength + stride * 10, unit)
[b11, v11] = f_bank(startLength + stride * 11, unit)
[b12, v12] = f_bank(startLength + stride * 12, unit)
[b13, v13] = f_bank(startLength + stride * 13, unit)
[b14, v14] = f_bank(startLength + stride * 14, unit)
[b15, v15] = f_bank(startLength + stride * 15, unit)
[b16, v16] = f_bank(startLength + stride * 16, unit)
[b17, v17] = f_bank(startLength + stride * 17, unit)
[b18, v18] = f_bank(startLength + stride * 18, unit)
[b19, v19] = f_bank(startLength + stride * 19, unit)
[b20, v20] = f_bank(startLength + stride * 20, unit)
[b21, v21] = f_bank(startLength + stride * 21, unit)
[b22, v22] = f_bank(startLength + stride * 22, unit)
[b23, v23] = f_bank(startLength + stride * 23, unit)
[b24, v24] = f_bank(startLength + stride * 24, unit)
[b25, v25] = f_bank(startLength + stride * 25, unit)
[b26, v26] = f_bank(startLength + stride * 26, unit)
[b27, v27] = f_bank(startLength + stride * 27, unit)
[b28, v28] = f_bank(startLength + stride * 28, unit)
[b29, v29] = f_bank(startLength + stride * 29, unit)
[b30, v30] = f_bank(startLength + stride * 30, unit)
[b31, v31] = f_bank(startLength + stride * 31, unit)
[b32, v32] = f_bank(startLength + stride * 32, unit)
[b33, v33] = f_bank(startLength + stride * 33, unit)
[b34, v34] = f_bank(startLength + stride * 34, unit)
[b35, v35] = f_bank(startLength + stride * 35, unit)
[b36, v36] = f_bank(startLength + stride * 36, unit)
[b37, v37] = f_bank(startLength + stride * 37, unit)
[b38, v38] = f_bank(startLength + stride * 38, unit)
[b39, v39] = f_bank(startLength + stride * 39, unit)
[b40, v40] = f_bank(startLength + stride * 40, unit)
[b41, v41] = f_bank(startLength + stride * 41, unit)
[b42, v42] = f_bank(startLength + stride * 42, unit)
[b43, v43] = f_bank(startLength + stride * 43, unit)
[b44, v44] = f_bank(startLength + stride * 44, unit)
[b45, v45] = f_bank(startLength + stride * 45, unit)
[b46, v46] = f_bank(startLength + stride * 46, unit)
[b47, v47] = f_bank(startLength + stride * 47, unit)
[b48, v48] = f_bank(startLength + stride * 48, unit)
[b49, v49] = f_bank(startLength + stride * 49, unit)
[b50, v50] = f_bank(startLength + stride * 50, unit)
[b51, v51] = f_bank(startLength + stride * 51, unit)
[b52, v52] = f_bank(startLength + stride * 52, unit)
[b53, v53] = f_bank(startLength + stride * 53, unit)
[b54, v54] = f_bank(startLength + stride * 54, unit)
[b55, v55] = f_bank(startLength + stride * 55, unit)
[b56, v56] = f_bank(startLength + stride * 56, unit)
[b57, v57] = f_bank(startLength + stride * 57, unit)
[b58, v58] = f_bank(startLength + stride * 58, unit)
[b59, v59] = f_bank(startLength + stride * 59, unit)

float s00 = (b00 + b01 + b02) / 3.0
float a00 = (v00 + v01 + v02) / 3.0
float s01 = (b03 + b04 + b05) / 3.0
float a01 = (v03 + v04 + v05) / 3.0
float s02 = (b06 + b07 + b08) / 3.0
float a02 = (v06 + v07 + v08) / 3.0
float s03 = (b09 + b10 + b11) / 3.0
float a03 = (v09 + v10 + v11) / 3.0
float s04 = (b12 + b13 + b14) / 3.0
float a04 = (v12 + v13 + v14) / 3.0
float s05 = (b15 + b16 + b17) / 3.0
float a05 = (v15 + v16 + v17) / 3.0
float s06 = (b18 + b19 + b20) / 3.0
float a06 = (v18 + v19 + v20) / 3.0
float s07 = (b21 + b22 + b23) / 3.0
float a07 = (v21 + v22 + v23) / 3.0
float s08 = (b24 + b25 + b26) / 3.0
float a08 = (v24 + v25 + v26) / 3.0
float s09 = (b27 + b28 + b29) / 3.0
float a09 = (v27 + v28 + v29) / 3.0
float s10 = (b30 + b31 + b32) / 3.0
float a10 = (v30 + v31 + v32) / 3.0
float s11 = (b33 + b34 + b35) / 3.0
float a11 = (v33 + v34 + v35) / 3.0
float s12 = (b36 + b37 + b38) / 3.0
float a12 = (v36 + v37 + v38) / 3.0
float s13 = (b39 + b40 + b41) / 3.0
float a13 = (v39 + v40 + v41) / 3.0
float s14 = (b42 + b43 + b44) / 3.0
float a14 = (v42 + v43 + v44) / 3.0
float s15 = (b45 + b46 + b47) / 3.0
float a15 = (v45 + v46 + v47) / 3.0
float s16 = (b48 + b49 + b50) / 3.0
float a16 = (v48 + v49 + v50) / 3.0
float s17 = (b51 + b52 + b53) / 3.0
float a17 = (v51 + v52 + v53) / 3.0
float s18 = (b54 + b55 + b56) / 3.0
float a18 = (v54 + v55 + v56) / 3.0
float s19 = (b57 + b58 + b59) / 3.0
float a19 = (v57 + v58 + v59) / 3.0
array<float> strands = array.from(s00, s01, s02, s03, s04, s05, s06, s07, s08, s09, s10, s11, s12, s13, s14, s15, s16, s17, s18, s19)
array<float> votes = array.from(a00, a01, a02, a03, a04, a05, a06, a07, a08, a09, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19)
// END GENERATED HORIZONS

float broadVote = array.avg(votes)
float fastVote = 0.0
float slowVote = 0.0
float orderVote = 0.0
for i = 0 to 19
    if i < 7
        fastVote += array.get(votes, i) / 7.0
    if i >= 13
        slowVote += array.get(votes, i) / 7.0
    if i < 19
        float spacing = array.get(strands, i) - array.get(strands, i + 1)
        orderVote += (spacing > unit * 0.01 ? 1.0 : spacing < -unit * 0.01 ? -1.0 : 0.0) / 19.0

f_roc(simple int n) =>
    not na(close[n]) and close[n] != 0 ? 100.0 * (close - close[n]) / math.abs(close[n]) : 0.0

float roc8 = f_roc(8)
float roc21 = f_roc(21)
float roc55 = f_roc(55)
float fastMove = (close - close[8]) / (unit * math.sqrt(8.0))
float atrPercent = 100.0 * unit / math.max(math.abs(close), syminfo.mintick)
float momentum = (f_clip(roc8 / (atrPercent * math.sqrt(8.0)), -1.0, 1.0) + f_clip(roc21 / (atrPercent * math.sqrt(21.0)), -1.0, 1.0) + f_clip(roc55 / (atrPercent * math.sqrt(55.0)), -1.0, 1.0)) / 3.0
float efficiency = f_efficiency(21)
float spread = (array.max(strands) - array.min(strands)) / unit
float meanSpread = ta.sma(spread, 100)
bool compressed = spread < 0.40 or (not na(meanSpread) and spread < meanSpread * 0.65)
float smoothSpread = ta.ema(spread, 5)
float spreadChange = (smoothSpread - smoothSpread[3]) / 3.0
string widthMotion = spreadChange > 0.01 ? "Expanding" : spreadChange < -0.01 ? "Contracting" : "Steady"
bool ready = bar_index >= longestLength * 3 and not na(s19) and not na(atr) and not na(meanSpread)

// State and dashboard evidence are committed only when the bar closes.
var int direction = 0
var int pending = 0
var int streak = 0
var int weakBars = 0
var bool inPullback = false
var int recoveryStreak = 0
var string state = "Warming up"
var string health = "Warming up"
var string closedStructure = "Warming up"
var float closedAgreement = na
var float closedMomentum = na
var float closedSpread = na
var float closedEfficiency = na
if barstate.isconfirmed
    if not ready
        direction := 0
        pending := 0
        streak := 0
        weakBars := 0
        inPullback := false
        recoveryStreak := 0
        state := "Warming up"
        health := "Warming up"
        closedStructure := "Warming up"
        closedAgreement := na
        closedMomentum := na
        closedSpread := na
        closedEfficiency := na
    else
        int previousDirection = direction
        int candidate = 0
        if slowVote >= 0.50 and broadVote >= 0.35 and orderVote >= 0.20 and momentum > 0.10 and efficiency >= 0.20
            candidate := 1
        else if slowVote <= -0.50 and broadVote <= -0.35 and orderVote <= -0.20 and momentum < -0.10 and efficiency >= 0.20
            candidate := -1
        streak := candidate != 0 and candidate == pending ? streak + 1 : candidate != 0 ? 1 : 0
        pending := candidate
        bool weak = direction != 0 and slowVote * direction < 0.15
        weakBars := weak ? weakBars + 1 : 0
        if weakBars >= confirmationBars
            direction := 0
            weakBars := 0
        if candidate != 0 and streak >= confirmationBars
            direction := candidate
            weakBars := 0
        if direction == 0 or direction != previousDirection
            inPullback := false
            recoveryStreak := 0
        if direction == 0
            health := "Unestablished"
        else if slowVote * direction < 0.35
            health := "Weakening"
        else if compressed or efficiency < 0.20
            health := "Stalled"
        else
            health := candidate == direction ? "Strong" : "Weakening"
        if direction != 0
            if slowVote * direction < 0.35
                inPullback := false
                recoveryStreak := 0
                state := "Fracturing"
            else
                bool adverse = fastVote * direction < -0.10 or fastMove * direction < -pullbackDeadband
                bool supportive = fastVote * direction >= 0.10 and fastMove * direction > pullbackDeadband and health == "Strong"
                if adverse
                    inPullback := true
                recoveryStreak := inPullback and supportive ? recoveryStreak + 1 : 0
                if recoveryStreak >= recoveryBars
                    inPullback := false
                    recoveryStreak := 0
                state := inPullback ? "Pullback" : health == "Strong" ? "Trending" : health
        else
            state := compressed ? "Compression" : math.abs(fastVote) >= 0.40 and fastVote * fastMove > 0 and efficiency >= 0.20 ? "Building" : "Mixed"
        closedAgreement := broadVote * 100.0
        closedMomentum := momentum * 100.0
        closedSpread := spread
        closedEfficiency := efficiency * 100.0
        closedStructure := (compressed ? "Compressed · " : "") + widthMotion

bool eventGate = barstate.isconfirmed and ready and ready[1]
bool bullEvent = eventGate and direction == 1 and direction[1] != 1
bool bearEvent = eventGate and direction == -1 and direction[1] != -1
bool recoveryEvent = eventGate and direction != 0 and direction == direction[1] and state[1] == "Pullback" and state == "Trending"
bool fractureEvent = eventGate and state == "Fracturing" and state[1] != "Fracturing"

//=================================================================== palette
color C_RANGE  = #787b86
color C_FILL   = color.new(iRangeColor, 90)
color C_MID    = color.new(iRangeColor, 30)
color C_BULL   = #26a69a
color C_BEAR   = #ef5350
color C_MUTED  = #787b86
color C_ENTRY  = #b2b5be
color C_STOP   = #ef5350
color C_TARGET = #26a69a
color C_TEXT   = #d1d4dc
color C_BG     = color.new(#131722, 10)
color C_PANEL  = color.new(#131722, 15)

//============================================================== global series
float mintick = syminfo.mintick
float rarAtr     = ta.atr(iAtrLen)
float hhN     = ta.highest(high, iN)[1]
float llN     = ta.lowest(low, iN)[1]
float atrRef  = rarAtr[1]
float ph      = ta.pivothigh(high, iPivL, iPivR)
float pl      = ta.pivotlow(low, iPivL, iPivR)

//=============================================================== state storage
// range state: 0 seeking, 1 active
var int   rstate      = 0
var float rHi         = na
var float rLo         = na
var float rAtr        = na
var float rTol        = na
var float rBuf        = na
var int   rAct        = -1
var int   lastRetire  = -100000
var string lastRetireWhy = ""

// setup state: 0 none, 1 bull rejection awaiting reclaim, 2 bull rejection pending,
// 3 bear rejection awaiting reclaim, 4 bear rejection pending,
// 5 bull acceptance awaiting retest, 6 bull acceptance pending,
// 7 bear acceptance awaiting retest, 8 bear acceptance pending
var int   ss      = 0
var int   pBar    = -1
var int   pExp    = -1
var float exLow   = na
var float exHigh  = na
var float rcHigh  = na
var float rcLow   = na
var bool  accBReady = true
var bool  accSReady = true
var float outHi   = na       // highest high above the range in this excursion
var float outLo   = na       // lowest low below the range in this excursion
var bool  upUsed  = false    // this excursion has already spent its reclaim window
var bool  dnUsed  = false
var bool  signalled = false
var bool  manualDone = false

// pivots confirmed on or before the current bar
var array<float> pivHiPrice = array.new<float>()
var array<int>   pivHiKnown = array.new<int>()
var array<float> pivLoPrice = array.new<float>()
var array<int>   pivLoKnown = array.new<int>()

// signals awaiting first touch of stop or target, and the running score
var array<float> opEntry  = array.new<float>()
var array<float> opStop   = array.new<float>()
var array<float> opTarget = array.new<float>()
var array<float> opRisk   = array.new<float>()
var array<float> opPlanR  = array.new<float>()
var array<bool>  opLong   = array.new<bool>()
var array<int>   opGrade  = array.new<int>()
var int   withWins = 0
var int   withLoss = 0
var float withSumR = 0.0
var int   flatWins = 0
var int   flatLoss = 0
var float flatSumR = 0.0
var int   contraWins = 0
var int   contraLoss = 0
var float contraSumR = 0.0

// drawings
var box   rangeBox = na
var line  midLine  = na
var line  hiLine   = na
var line  loLine   = na
var label hiLabel  = na
var label loLabel  = na
var table dash     = na

// per-bar signal: 0 none, 1 bull rejection, 2 bear rejection,
// 3 bull acceptance, 4 bear acceptance
int  emit    = 0
bool nowQual = false
bool gradePass = true

// last completed signal, for the dashboard
var int    lastSigBar  = -1
var string lastSigKind = "none"
var float  lastSigR    = na
var bool   lastSigQual = false
var string lastSigWhy  = ""
var string lastSigGrade = ""

//================================================================== utilities
fmtPrice(float p) =>
    na(p) ? "n/a" : str.tostring(p, format.mintick)

calcRisk(float entry, float stop, float target, bool isLong) =>
    float risk = math.abs(entry - stop)
    bool  ok   = true
    string why = ""
    if na(target)
        ok  := false
        why := "No structural target"
    else if isLong and not (stop < entry and entry < target)
        ok  := false
        why := "Invalid price ordering"
    else if not isLong and not (stop > entry and entry > target)
        ok  := false
        why := "Invalid price ordering"
    else if risk <= 0
        ok  := false
        why := "Zero risk"
    float reward = ok ? math.abs(target - entry) : na
    float rmult  = ok ? reward / risk : na
    [ok, risk, reward, rmult, why]

nearestPivot(float entry, bool isLong) =>
    float best = na
    if isLong
        int n = array.size(pivHiPrice)
        if n > 0
            for k = 0 to n - 1
                float p     = array.get(pivHiPrice, k)
                int   known = array.get(pivHiKnown, k)
                if known < bar_index and p > entry
                    best := na(best) ? p : math.min(best, p)
    else
        int m = array.size(pivLoPrice)
        if m > 0
            for k = 0 to m - 1
                float p     = array.get(pivLoPrice, k)
                int   known = array.get(pivLoKnown, k)
                if known < bar_index and p < entry
                    best := na(best) ? p : math.max(best, p)
    best

fmtR(float r) =>
    na(r) ? "R n/a" : str.tostring(r, "#.##") + "R"

fmtScore(int wins, int losses, float sumR) =>
    int n = wins + losses
    n == 0 ? "no resolved signals" : str.tostring(wins) + " target, " + str.tostring(losses) + " stop, " + str.tostring(sumR / n, "+#.##;-#.##") + "R avg"

setupText(string kind, float entry, float stop, float target, float rmult, bool ok, string why, bool compact) =>
    string s = kind
    if not (compact and ok)
        s := s + "\nentry " + fmtPrice(entry) + "\nstop " + fmtPrice(stop) + "\ntarget " + fmtPrice(target)
    s := s + "\n" + fmtR(rmult)
    if not ok
        s := s + "\n" + why
    s

//=================================================== confirmed-bar state machine
if barstate.isconfirmed
    // 1. pivot bookkeeping. ta.pivothigh returns a value only on the bar that
    //    confirms the pivot, which is what keeps the target free of hindsight.
    if not na(ph)
        array.push(pivHiPrice, ph)
        array.push(pivHiKnown, bar_index)
        if array.size(pivHiPrice) > 100
            array.shift(pivHiPrice)
            array.shift(pivHiKnown)
    if not na(pl)
        array.push(pivLoPrice, pl)
        array.push(pivLoKnown, bar_index)
        if array.size(pivLoPrice) > 100
            array.shift(pivLoPrice)
            array.shift(pivLoKnown)

    // 2. score earlier signals first, so a signal cannot score on its own bar.
    //    A bar touching both levels counts as the stop, because the order inside
    //    a bar is unknowable, and a gap through the stop fills at the open.
    int nOpen = array.size(opStop)
    if nOpen > 0
        for k = nOpen - 1 to 0
            bool  tradeLong = array.get(opLong, k)
            float tradeStop = array.get(opStop, k)
            float tradeTgt  = array.get(opTarget, k)
            bool  hitStop   = tradeLong ? low <= tradeStop : high >= tradeStop
            bool  hitTgt    = tradeLong ? high >= tradeTgt : low <= tradeTgt
            if hitStop or hitTgt
                float tradeEntry = array.get(opEntry, k)
                float fillPx = tradeLong ? math.min(open, tradeStop) : math.max(open, tradeStop)
                float moved = tradeLong ? fillPx - tradeEntry : tradeEntry - fillPx
                float gotR = hitStop ? moved / array.get(opRisk, k) : array.get(opPlanR, k)
                int tradeGrade = array.get(opGrade, k)
                if tradeGrade > 0
                    withWins := withWins + (hitStop ? 0 : 1)
                    withLoss := withLoss + (hitStop ? 1 : 0)
                    withSumR := withSumR + gotR
                else if tradeGrade < 0
                    contraWins := contraWins + (hitStop ? 0 : 1)
                    contraLoss := contraLoss + (hitStop ? 1 : 0)
                    contraSumR := contraSumR + gotR
                else
                    flatWins := flatWins + (hitStop ? 0 : 1)
                    flatLoss := flatLoss + (hitStop ? 1 : 0)
                    flatSumR := flatSumR + gotR
                array.remove(opEntry, k)
                array.remove(opStop, k)
                array.remove(opTarget, k)
                array.remove(opRisk, k)
                array.remove(opPlanR, k)
                array.remove(opLong, k)
                array.remove(opGrade, k)

    // 3. range activation
    if rstate == 0
        float newHi   = na
        float newLo   = na
        float newAtr  = na
        int   newLeft = bar_index
        if iMode == "Automatic"
            bool fresh = bar_index - lastRetire > iN
            bool formReady = bar_index >= iN + iAtrLen and not na(atrRef) and atrRef > 0
            if fresh and formReady and close >= llN and close <= hhN
                float tolRef = iTolAtr * atrRef
                int   tHi    = 0
                int   tLo    = 0
                int   lastHi = -1
                int   lastLo = -1
                for k = 1 to iN
                    int off = iN + 1 - k
                    if high[off] >= hhN - tolRef
                        if lastHi < 0 or math.abs(off - lastHi) >= iTouchGap
                            tHi    := tHi + 1
                            lastHi := off
                    if low[off] <= llN + tolRef
                        if lastLo < 0 or math.abs(off - lastLo) >= iTouchGap
                            tLo    := tLo + 1
                            lastLo := off
                bool narrow = hhN - llN <= iMaxWidth * atrRef
                bool both   = tHi >= iTouches and tLo >= iTouches
                if narrow and both and hhN > llN
                    newHi   := hhN
                    newLo   := llN
                    newAtr  := atrRef
                    newLeft := bar_index - iN
        else if not manualDone and iManHi > iManLo and not na(rarAtr) and rarAtr > 0 and time >= iManTs
            manualDone := true
            newHi  := iManHi
            newLo  := iManLo
            newAtr := rarAtr
        if not na(newHi)
            rHi       := newHi
            rLo       := newLo
            rAtr      := newAtr
            rTol      := iTolAtr * newAtr
            rBuf      := math.max(mintick, iBufAtr * newAtr)
            rAct      := bar_index
            rstate    := 1
            ss        := 0
            signalled := false
            accBReady := true
            accSReady := true
            outHi     := na
            outLo     := na
            upUsed    := false
            dnUsed    := false
            rangeBox := box.new(newLeft, rHi, bar_index, rLo,
                 border_color = color.new(iRangeColor, 100), border_width = 0, bgcolor = C_FILL,
                 xloc = xloc.bar_index)
            hiLine := line.new(newLeft, rHi, bar_index, rHi, color = iRangeColor, width = iRangeWidth, xloc = xloc.bar_index)
            loLine := line.new(newLeft, rLo, bar_index, rLo, color = iRangeColor, width = iRangeWidth, xloc = xloc.bar_index)
            hiLabel := na
            loLabel := na
            if iShowRangeLbl
                hiLabel := label.new(bar_index, rHi, "Range high " + fmtPrice(rHi), style = label.style_label_left, color = C_PANEL, textcolor = iRangeColor, size = txtSize)
                loLabel := label.new(bar_index, rLo, "Range low " + fmtPrice(rLo), style = label.style_label_left, color = C_PANEL, textcolor = iRangeColor, size = txtSize)
            if iShowMid
                midLine := line.new(newLeft, (rHi + rLo) / 2, bar_index,
                     (rHi + rLo) / 2, color = C_MID, style = line.style_dotted,
                     xloc = xloc.bar_index)
            else
                midLine := na
            label.new(bar_index, rLo, iMode == "Automatic" ? "range known" : "levels active",
                 style = label.style_label_up, color = C_PANEL, textcolor = C_TEXT, size = txtSize)

    // 4. extend the frozen drawings while the range is live, including the bar
    //    on which it retires
    bool wasLive = rstate == 1
    if rstate == 1 and not na(rangeBox)
        box.set_right(rangeBox, bar_index)
        line.set_x2(hiLine, bar_index)
        line.set_x2(loLine, bar_index)
        if not na(midLine)
            line.set_x2(midLine, bar_index)
        if not na(hiLabel)
            label.set_x(hiLabel, bar_index)
            label.set_x(loLabel, bar_index)

    // 5. evaluation, only after the activation bar
    if rstate == 1 and not na(rHi)
        if iMode == "Automatic" and (bar_index - rAct) > iRangeLife
            rstate        := 0
            ss            := 0
            pBar          := -1
            pExp          := -1
            lastRetire    := bar_index
            lastRetireWhy := "range life exceeded"
        else if bar_index > rAct
            bool breachUp  = high > rHi + rBuf
            bool breachDn  = low < rLo - rBuf
            bool ambiguous = breachUp and breachDn
            bool failUp    = false
            bool failDn    = false

            // excursion tracking: from the first trade beyond a boundary until a
            // bar after a close back inside, whichever setup branch is live, so
            // every rejection stop can clear all of it
            if close[1] < rHi
                outHi := na
            if close[1] > rLo
                outLo := na
            if high > rHi
                outHi := na(outHi) ? high : math.max(outHi, high)
            if low < rLo
                outLo := na(outLo) ? low : math.min(outLo, low)

            // latches: a fresh two-close sequence is required after price returns
            // to the boundary, and one excursion gets one reclaim window
            if close <= rHi + rBuf
                accBReady := true
            if close >= rLo - rBuf
                accSReady := true
            if close <= rHi
                upUsed := false
            if close >= rLo
                dnUsed := false

            // 5a. pending setup transitions, highest priority first
            if ambiguous
                if ss != 0
                    ss   := 0
                    pBar := -1
                    pExp := -1
            else if ss == 1
                exLow := outLo
                if close > rHi
                    ss := 0
                else if close >= rLo and close <= rHi and bar_index - pBar <= iReclaimWin
                    rcHigh := high
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                    ss     := 2
                else if bar_index - pBar > iReclaimWin
                    ss := 0
            else if ss == 2
                if low < exLow
                    ss := 0
                else if close > rHi
                    ss := 0
                else if close > rcHigh and close >= rLo and bar_index <= pExp
                    emit := 1
                else if bar_index > pExp
                    ss := 0
            else if ss == 3
                exHigh := outHi
                if close < rLo
                    ss := 0
                else if close >= rLo and close <= rHi and bar_index - pBar <= iReclaimWin
                    rcLow := low
                    pBar  := bar_index
                    pExp  := bar_index + iConfirmWin
                    ss    := 4
                else if bar_index - pBar > iReclaimWin
                    ss := 0
            else if ss == 4
                if high > exHigh
                    ss := 0
                else if close < rLo
                    ss := 0
                else if close < rcLow and close <= rHi and bar_index <= pExp
                    emit := 2
                else if bar_index > pExp
                    ss := 0
            else if ss == 5
                if close < rHi
                    ss := 0
                    failUp := close >= rLo
                else if low <= rHi + rTol and close >= rHi and bar_index <= pExp
                    rcHigh := high
                    exLow  := low
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                    ss     := 6
                else if bar_index > pExp
                    ss := 0
            else if ss == 6
                exLow := math.min(exLow, low)
                if close < rHi
                    ss := 0
                    failUp := close >= rLo
                else if close > rcHigh and bar_index <= pExp
                    emit := 3
                else if bar_index > pExp
                    ss := 0
            else if ss == 7
                if close > rLo
                    ss := 0
                    failDn := close <= rHi
                else if high >= rLo - rTol and close <= rLo and bar_index <= pExp
                    rcLow  := low
                    exHigh := high
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                    ss     := 8
                else if bar_index > pExp
                    ss := 0
            else if ss == 8
                exHigh := math.max(exHigh, high)
                if close > rLo
                    ss := 0
                    failDn := close <= rHi
                else if close < rcLow and bar_index <= pExp
                    emit := 4
                else if bar_index > pExp
                    ss := 0

            // 5b. a completed setup is measured, drawn and alerted once, here.
            //     It retires the range, including a structural failure.
            if emit != 0
                bool  isLong = emit == 1 or emit == 3
                bool  isAcc  = emit >= 3
                string kind  = (isLong ? "Bull " : "Bear ") + (isAcc ? "acceptance" : "rejection")
                float entryPx  = math.round_to_mintick(close)
                float stopPx   = math.round_to_mintick(isLong ? exLow - iStopTicks * mintick : exHigh + iStopTicks * mintick)
                float targetPx = math.round_to_mintick(isAcc ? nearestPivot(close, isLong) : (isLong ? rHi : rLo))
                [ok, risk, rew, rm, whyBad] = calcRisk(entryPx, stopPx, targetPx, isLong)
                nowQual := ok and rm >= iMinR
                bool  warming  = state == "Warming up"
                int   sigGrade = direction == 0 ? 0 : direction == (isLong ? 1 : -1) ? 1 : -1
                string gradeName = warming ? "warming up" : sigGrade > 0 ? "with trend" : sigGrade < 0 ? "against trend" : "no trend"
                gradePass := iAlertGrade == "All grades" or warming or sigGrade > 0 or (iAlertGrade == "Exclude against trend" and sigGrade == 0)
                lastSigGrade := gradeName
                lastSigBar  := bar_index
                lastSigKind := str.lower(kind)
                lastSigR    := rm
                lastSigQual := nowQual
                lastSigWhy  := ok and not nowQual ? "below minimum R" : whyBad
                if ok and not warming and (nowQual or not iScoreQual)
                    array.push(opEntry, entryPx)
                    array.push(opStop, stopPx)
                    array.push(opTarget, targetPx)
                    array.push(opRisk, risk)
                    array.push(opPlanR, rm)
                    array.push(opLong, isLong)
                    array.push(opGrade, sigGrade)
                if iShowLines and ok
                    int levelEnd = bar_index + iLevelBars
                    line.new(bar_index, entryPx, levelEnd, entryPx, color = C_ENTRY, width = iLevelWidth)
                    line.new(bar_index, stopPx, levelEnd, stopPx, color = C_STOP, width = iLevelWidth)
                    line.new(bar_index, targetPx, levelEnd, targetPx, color = C_TARGET, width = iLevelWidth)
                    label.new(levelEnd, entryPx, "Entry " + fmtPrice(entryPx), style = label.style_label_left, color = C_PANEL, textcolor = C_ENTRY, size = txtSize)
                    label.new(levelEnd, stopPx, "Stop " + fmtPrice(stopPx), style = label.style_label_left, color = C_PANEL, textcolor = C_STOP, size = txtSize)
                    label.new(levelEnd, targetPx, "Target " + fmtPrice(targetPx), style = label.style_label_left, color = C_PANEL, textcolor = C_TARGET, size = txtSize)
                if iProject and isAcc and risk > 0
                    float dir = isLong ? 1.0 : -1.0
                    line.new(bar_index, entryPx + dir * 2 * risk, bar_index + 12, entryPx + dir * 2 * risk, color = color.new(C_TARGET, 60), style = line.style_dashed)
                    line.new(bar_index, entryPx + dir * 3 * risk, bar_index + 12, entryPx + dir * 3 * risk, color = color.new(C_TARGET, 60), style = line.style_dashed)
                    label.new(bar_index + 12, entryPx + dir * 2 * risk, "2R projection", style = label.style_label_left, color = C_PANEL, textcolor = C_TEXT, size = txtSize)
                    label.new(bar_index + 12, entryPx + dir * 3 * risk, "3R projection", style = label.style_label_left, color = C_PANEL, textcolor = C_TEXT, size = txtSize)
                // Labels cost no plot counts, and the 64-count budget belongs to the ribbon.
                // Every signal is marked. No filter hides one.
                string mark = (isAcc ? "A" : "R") + (warming or sigGrade == 0 ? "" : sigGrade > 0 ? "+" : "-")
                label.new(bar_index, close, mark, yloc = isLong ? yloc.belowbar : yloc.abovebar,
                     style = nowQual ? (isLong ? label.style_triangleup : label.style_triangledown) : label.style_circle,
                     color = nowQual ? (isLong ? C_BULL : C_BEAR) : color.new(C_MUTED, 30),
                     textcolor = nowQual ? (isLong ? C_BULL : C_BEAR) : C_TEXT, size = nowQual ? size.normal : size.small)
                if iShowLbl
                    label.new(bar_index, isLong ? low - 0.6 * rAtr : high + 0.6 * rAtr,
                         setupText(kind, entryPx, stopPx, targetPx, rm, ok, whyBad, iShowLines) + (ok and not nowQual ? "\nbelow minimum R" : "") + "\nSpectrum: " + gradeName,
                         style = isLong ? label.style_label_up : label.style_label_down,
                         color = C_PANEL, textcolor = isLong ? C_BULL : C_BEAR, size = txtSize)
                if (nowQual or not iAlertQual) and gradePass
                    alert(kind + " | " + syminfo.ticker + " " + timeframe.period +
                         " | entry " + fmtPrice(entryPx) + " | stop " + fmtPrice(stopPx) +
                         " | target " + fmtPrice(targetPx) + " | " + fmtR(rm) +
                         " | " + (nowQual ? "qualified" : lastSigWhy) + " | Spectrum: " + gradeName,
                         alert.freq_once_per_bar_close)
                ss := 0
                signalled := true
                rstate := 0
                lastRetire := bar_index
                lastRetireWhy := "signal complete"

            // 5c. a close back inside after sustained closes beyond is the failed
            //     breakout this indicator exists to mark. It arms the opposite
            //     rejection whether or not the failing bar pokes through on its
            //     own, with the whole excursion behind the stop.
            if failUp
                ss     := 4
                exHigh := outHi
                rcLow  := low
                pBar   := bar_index
                pExp   := bar_index + iConfirmWin
            else if failDn
                ss     := 2
                exLow  := outLo
                rcHigh := high
                pBar   := bar_index
                pExp   := bar_index + iConfirmWin

            // 5d. acceptance arming outranks rejection initiation. Both closes
            //     must fall after the activation bar, and an ambiguous bar cannot arm.
            if not signalled and not ambiguous and bar_index - rAct >= 2
                if accBReady and close > rHi + rBuf and close[1] > rHi + rBuf and ss != 5 and ss != 6
                    ss     := 5
                    pBar   := bar_index
                    pExp   := bar_index + iRetestWin
                    accBReady := false
                    upUsed := true
                else if accSReady and close < rLo - rBuf and close[1] < rLo - rBuf and ss != 7 and ss != 8
                    ss     := 7
                    pBar   := bar_index
                    pExp   := bar_index + iRetestWin
                    accSReady := false
                    dnUsed := true

            // 5e. rejection initiation, only when flat and unambiguous. A same-bar
            //     reclaim is always a new event; a WAIT phase is not.
            if ss == 0 and not signalled and not ambiguous
                if breachDn and close >= rLo and close <= rHi
                    ss     := 2
                    exLow  := outLo
                    rcHigh := high
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                else if breachDn and close < rLo and not dnUsed
                    ss     := 1
                    exLow  := outLo
                    dnUsed := true
                    pBar   := bar_index
                else if breachUp and close >= rLo and close <= rHi
                    ss     := 4
                    exHigh := outHi
                    rcLow  := low
                    pBar   := bar_index
                    pExp   := bar_index + iConfirmWin
                else if breachUp and close > rHi and not upUsed
                    ss     := 3
                    exHigh := outHi
                    upUsed := true
                    pBar   := bar_index

    // 6. a retired range fades, so the live range is the one that stands out
    if wasLive and rstate == 0
        color faded = color.new(iRangeColor, 65)
        line.set_color(hiLine, faded)
        line.set_color(loLine, faded)
        line.set_width(hiLine, 1)
        line.set_width(loLine, 1)
        box.set_bgcolor(rangeBox, color.new(iRangeColor, 96))
        if not na(hiLabel)
            label.set_textcolor(hiLabel, faded)
            label.set_textcolor(loLabel, faded)

//===================================================================== plots
// Hue identifies horizon. Agreement/momentum/efficiency control brightness.
f_hue(simple int index) =>
    float x = float(index)
    x <= 4 ? color.from_gradient(x, 0, 4, #FFD166, #FF7043) : x <= 8 ? color.from_gradient(x, 4, 8, #FF7043, #EF5DA8) : x <= 12 ? color.from_gradient(x, 8, 12, #EF5DA8, #A775FF) : x <= 16 ? color.from_gradient(x, 12, 16, #A775FF, #548CFF) : color.from_gradient(x, 16, 19, #548CFF, #4DE3D0)

f_tint(simple int index, float agreement, bool filling) =>
    float support = math.max(0.0, math.sign(agreement) * momentum)
    float quality = ready ? f_clip(0.60 * math.abs(agreement) + 0.25 * support + 0.15 * efficiency, 0.0, 1.0) : 0.0
    color tint = color.from_gradient(quality, 0.0, 1.0, #737A90, f_hue(index))
    float transparency = filling ? 92.0 - 24.0 * quality : 68.0 - 58.0 * quality
    // The range and its levels come forward while a range is live.
    color.new(tint, iRecede and rstate == 1 ? math.min(95.0, transparency + 18.0) : transparency)

// BEGIN GENERATED VISUALS
p00 = plot(s00, "Spectrum 01", color=f_tint(0, a00, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p01 = plot(s01, "Spectrum 02", color=f_tint(1, a01, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p02 = plot(s02, "Spectrum 03", color=f_tint(2, a02, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p03 = plot(s03, "Spectrum 04", color=f_tint(3, a03, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p04 = plot(s04, "Spectrum 05", color=f_tint(4, a04, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p05 = plot(s05, "Spectrum 06", color=f_tint(5, a05, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p06 = plot(s06, "Spectrum 07", color=f_tint(6, a06, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p07 = plot(s07, "Spectrum 08", color=f_tint(7, a07, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p08 = plot(s08, "Spectrum 09", color=f_tint(8, a08, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p09 = plot(s09, "Spectrum 10", color=f_tint(9, a09, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p10 = plot(s10, "Spectrum 11", color=f_tint(10, a10, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p11 = plot(s11, "Spectrum 12", color=f_tint(11, a11, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p12 = plot(s12, "Spectrum 13", color=f_tint(12, a12, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p13 = plot(s13, "Spectrum 14", color=f_tint(13, a13, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p14 = plot(s14, "Spectrum 15", color=f_tint(14, a14, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p15 = plot(s15, "Spectrum 16", color=f_tint(15, a15, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p16 = plot(s16, "Spectrum 17", color=f_tint(16, a16, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p17 = plot(s17, "Spectrum 18", color=f_tint(17, a17, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p18 = plot(s18, "Spectrum 19", color=f_tint(18, a18, false), linewidth=ribbonWidth, editable=false, display=display.pane)
p19 = plot(s19, "Spectrum 20", color=f_tint(19, a19, false), linewidth=ribbonWidth, editable=false, display=display.pane)
fill(p00, p01, color=showFill ? f_tint(0, (a00 + a01) / 2.0, true) : na, title="Weave 01", editable=false)
fill(p01, p02, color=showFill ? f_tint(1, (a01 + a02) / 2.0, true) : na, title="Weave 02", editable=false)
fill(p02, p03, color=showFill ? f_tint(2, (a02 + a03) / 2.0, true) : na, title="Weave 03", editable=false)
fill(p03, p04, color=showFill ? f_tint(3, (a03 + a04) / 2.0, true) : na, title="Weave 04", editable=false)
fill(p04, p05, color=showFill ? f_tint(4, (a04 + a05) / 2.0, true) : na, title="Weave 05", editable=false)
fill(p05, p06, color=showFill ? f_tint(5, (a05 + a06) / 2.0, true) : na, title="Weave 06", editable=false)
fill(p06, p07, color=showFill ? f_tint(6, (a06 + a07) / 2.0, true) : na, title="Weave 07", editable=false)
fill(p07, p08, color=showFill ? f_tint(7, (a07 + a08) / 2.0, true) : na, title="Weave 08", editable=false)
fill(p08, p09, color=showFill ? f_tint(8, (a08 + a09) / 2.0, true) : na, title="Weave 09", editable=false)
fill(p09, p10, color=showFill ? f_tint(9, (a09 + a10) / 2.0, true) : na, title="Weave 10", editable=false)
fill(p10, p11, color=showFill ? f_tint(10, (a10 + a11) / 2.0, true) : na, title="Weave 11", editable=false)
fill(p11, p12, color=showFill ? f_tint(11, (a11 + a12) / 2.0, true) : na, title="Weave 12", editable=false)
fill(p12, p13, color=showFill ? f_tint(12, (a12 + a13) / 2.0, true) : na, title="Weave 13", editable=false)
fill(p13, p14, color=showFill ? f_tint(13, (a13 + a14) / 2.0, true) : na, title="Weave 14", editable=false)
fill(p14, p15, color=showFill ? f_tint(14, (a14 + a15) / 2.0, true) : na, title="Weave 15", editable=false)
fill(p15, p16, color=showFill ? f_tint(15, (a15 + a16) / 2.0, true) : na, title="Weave 16", editable=false)
fill(p16, p17, color=showFill ? f_tint(16, (a16 + a17) / 2.0, true) : na, title="Weave 17", editable=false)
fill(p17, p18, color=showFill ? f_tint(17, (a17 + a18) / 2.0, true) : na, title="Weave 18", editable=false)
fill(p18, p19, color=showFill ? f_tint(18, (a18 + a19) / 2.0, true) : na, title="Weave 19", editable=false)
// END GENERATED VISUALS

//==================================================================== alerts
// Four conditions keep the budget at 63 of 64. Spectrum events use the alert() stream.
bool gate = (not iAlertQual or nowQual) and gradePass
alertcondition(emit == 1 and gate, "Bullish rejection", "Bullish rejection | {{ticker}} | {{interval}} | close {{close}}")
alertcondition(emit == 2 and gate, "Bearish rejection", "Bearish rejection | {{ticker}} | {{interval}} | close {{close}}")
alertcondition(emit == 3 and gate, "Bullish acceptance", "Bullish acceptance | {{ticker}} | {{interval}} | close {{close}}")
alertcondition(emit == 4 and gate, "Bearish acceptance", "Bearish acceptance | {{ticker}} | {{interval}} | close {{close}}")
if iSpectrumAlerts
    if bullEvent
        alert("Konatus Spectrum: confirmed bullish trend | " + syminfo.ticker + " " + timeframe.period, alert.freq_once_per_bar_close)
    if bearEvent
        alert("Konatus Spectrum: confirmed bearish trend | " + syminfo.ticker + " " + timeframe.period, alert.freq_once_per_bar_close)
    if recoveryEvent
        alert("Konatus Spectrum: confirmed pullback recovery | " + syminfo.ticker + " " + timeframe.period, alert.freq_once_per_bar_close)
    if fractureEvent
        alert("Konatus Spectrum: slow trend agreement deteriorating | " + syminfo.ticker + " " + timeframe.period, alert.freq_once_per_bar_close)

//================================================================= dashboard
if iShowDash and barstate.islast
    if na(dash)
        dash := table.new(position.top_right, 2, 12, bgcolor = C_BG,
             frame_color = color.new(C_RANGE, 60), frame_width = 1,
             border_color = color.new(C_RANGE, 70), border_width = 1)
    color accent = direction > 0 ? #4DE3D0 : direction < 0 ? #EF5DA8 : #FFD166
    string bias = direction > 0 ? "BULL" : direction < 0 ? "BEAR" : "MIXED"
    string phase = state == "Warming up" ? str.tostring(math.max(0, longestLength * 3 - bar_index)) + " bars to warmup" : state
    string stat = "seeking"
    if rstate == 1
        stat := fmtPrice(rLo) + " - " + fmtPrice(rHi)
    else if iMode == "Manual"
        stat := manualDone ? "spent, set new levels" : iManHi <= iManLo ? "manual levels invalid" : "waiting for activation"
    else if lastRetire > 0 and bar_index - lastRetire <= iN
        stat := "cooldown, " + lastRetireWhy
    string pend = "bear acc, awaiting close"
    if ss == 0
        pend := "none"
    else if ss == 1
        pend := "bull rej, awaiting reclaim"
    else if ss == 2
        pend := "bull rej, awaiting close"
    else if ss == 3
        pend := "bear rej, awaiting reclaim"
    else if ss == 4
        pend := "bear rej, awaiting close"
    else if ss == 5
        pend := "bull acc, awaiting retest"
    else if ss == 6
        pend := "bull acc, awaiting close"
    else if ss == 7
        pend := "bear acc, awaiting retest"
    string sig = "none"
    string note = "-"
    if lastSigBar >= 0
        sig  := lastSigKind + " " + fmtR(lastSigR)
        note := (lastSigQual ? "qualified" : lastSigWhy) + ", " + lastSigGrade
    table.cell(dash, 0, 0, "KONATUS", text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 1, 0, "SPECTRUM + RAR", text_color = #A775FF, text_size = txtSize)
    table.cell(dash, 0, 1, "trend", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 1, bias + " · " + phase, text_color = accent, text_size = txtSize)
    table.cell(dash, 0, 2, "trend health", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 2, health, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 3, "structure", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 3, closedStructure, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 4, "range", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 4, stat, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 5, "pending", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 5, pend, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 6, "last signal", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 6, sig, text_color = C_TEXT, text_size = txtSize)
    table.cell(dash, 0, 7, "note", text_color = color.new(C_TEXT, 40), text_size = txtSize)
    table.cell(dash, 1, 7, note, text_color = C_TEXT, text_size = txtSize)
    if iShowScore
        table.cell(dash, 0, 8, "with trend", text_color = color.new(C_TEXT, 40), text_size = txtSize)
        table.cell(dash, 1, 8, fmtScore(withWins, withLoss, withSumR), text_color = C_TEXT, text_size = txtSize)
        table.cell(dash, 0, 9, "no trend", text_color = color.new(C_TEXT, 40), text_size = txtSize)
        table.cell(dash, 1, 9, fmtScore(flatWins, flatLoss, flatSumR), text_color = C_TEXT, text_size = txtSize)
        table.cell(dash, 0, 10, "against trend", text_color = color.new(C_TEXT, 40), text_size = txtSize)
        table.cell(dash, 1, 10, fmtScore(contraWins, contraLoss, contraSumR), text_color = C_TEXT, text_size = txtSize)
        table.cell(dash, 0, 11, "score scope", text_color = color.new(C_TEXT, 40), text_size = txtSize)
        table.cell(dash, 1, 11, (iScoreQual ? "qualified" : "all valid") + ", no costs, " + str.tostring(array.size(opStop)) + " open", text_color = C_TEXT, text_size = txtSize)

One chart · Two different jobs

Using both together

Read Market Anatomy for context, then Konatus Bearing for its trading-plan conditions. The purpose of pairing them is to understand what you are looking at before interpreting a setup. You can also use either one on its own.

Understand the market

Market Anatomy

What is price doing? Read direction, pace, path organization, volatility and participation. Its explanations make conflicting evidence visible. Economic causes remain hypotheses.

Explore Market Anatomy →

Review the plan

Konatus Bearing

What is the setup waiting for? Read its entry status, named restrictions and any illustrative position, frozen stop and quantity.

Explore Konatus Bearing →

Two standalone tools, shared evidence. Both analyze much of the same price history. Agreement is not two independent confirmations or a calibrated probability. The standalone scripts do not communicate: adding Anatomy does not change Bearing’s signals, stops or sizing. This manual pairing is a reading workflow, not a tested combined strategy.

Set up one readable chart

  1. Add two separate scripts. Copy the Bearing source and the Anatomy source into separate Pine Editor indicators. Save and add each to the same ordinary time-based candle chart, with the same symbol, timeframe and session.
  2. Separate the dashboards. Bearing uses the top right. In Anatomy, open Settings → Inputs → Display, set Panel position to Bottom left, and enable Detailed explanation.
  3. Give candle colors one owner. Leave Bearing’s Color confirmed candles on and Anatomy’s off. For a simpler view, switch off Anatomy’s Descriptive extension envelope and Volume-weighted reference. These are display choices.
  4. Check the inputs before reading. Review Bearing’s instrument profile and risk inputs, and Anatomy’s volume meaning. Allow both to finish warming up; Anatomy needs 82 consecutive valid bars at its default 20-bar horizon. Compare their last confirmed readings, not a developing candle.

Read in this order

  1. 1 · ContextRead Anatomy’s direction and pace, then check whether the raw path is organized, volatility has changed, and participation is available. State what you observe before inventing a cause.
  2. 2 · ConditionsRead Bearing’s plan and reason. WAIT SETUP, EXTENDED or SHOCK PAUSE still means that condition is unresolved. Anatomy’s favorable reading does not remove it.
  3. 3 · RecordIn replay or paper trading, record both readings and the decision under your written rules. If you use Bearing’s illustrative plan, inspect its frozen stop and quantity. Anatomy’s scenario boundary is a different reference, not a replacement stop.

When they disagree

Check the symbol, session, warmup and settings, then name the disagreement: direction, pace, extension or participation. Their formulas, thresholds and volume rules differ. Anatomy’s range scale is an SMA of true range; Bearing labels its distances in ATR. Their numbers are not interchangeable. Record the conflict rather than changing settings until they agree.

Keep alerts separate

For Bearing, select Any alert() function call. For Anatomy, choose the individual direction, shock, breakout or invalidation condition and Once Per Bar Close. Label alerts with the indicator name. They report separate events; neither is a broker order.

Recreate alerts after changing the script, inputs, symbol or timeframe. Existing alerts keep the configuration saved when they were created. See TradingView’s alert documentation.

Start as a research exercise. Native TradingView compilation, chart behavior and alert delivery are still unverified. Once the scripts compile and run correctly, use replay or paper trading to assess the workflow. No trading edge or benefit from combining them has been established.

Build a readable workspace

Use either standalone with other indicators.

You do not need Atlas, or both KONATUS tools, on every chart. Keep Bearing when you want its illustrative planning framework. Keep Anatomy when you want context for a method you already use. Add a companion only when it answers a specific question still missing from your workflow.

Location by price level

Bearing + Volume Profile

Bearing supplies its setup and plan conditions; Volume Profile shows how activity is distributed across price levels over a selected range. Define that range before interpreting the setup.

Activity depends on feed coverage and volume meaning. A high-volume area is not a guaranteed support level or independent proof of an entry.

About Volume Profile ↗

Context for your trend method

Anatomy + Supertrend

Use Anatomy to read pace, path organization and shocks alongside an existing ATR-based trend method. Let your written method define decisions; Anatomy explains the observed conditions.

Both depend on price. This is a manual pairing, not an automated connection. If you use Supertrend instead of Bearing, you do not inherit Bearing’s sizing or ledger.

About Supertrend ↗

A reference from a chosen event

Either + Anchored VWAP

An event-anchored reference answers a different question from a calendar reset: where is volume-weighted price since a specified point? Record an objective anchor rule, such as the first bar after a scheduled release.

Check volume semantics and avoid moving the anchor to fit the result. Hide an existing VWAP line if it only duplicates the reference.

About Anchored VWAP ↗

An alternate definition to compare

Either + RSI or Bollinger Bands

RSI provides a price-momentum oscillator. Bollinger Bands use a moving-average and standard-deviation envelope. Choose one for a defined comparison with pace or relative location.

These overlap with existing price-derived evidence. An extreme reading is not automatically a reversal, and agreement is not another independent vote.

About RSI ↗ · About Bollinger Bands ↗

Give each study one job.

  1. Write the unanswered question and the companion’s role before adding it. Start with one extra study.
  2. Keep the symbol, timeframe and session consistent. Move Anatomy’s panel if needed and allow only one study to color candles.
  3. Decide in advance how conflicts affect your written rules. Keep the tools’ alerts separately named; loading studies together does not link their logic.
  4. In replay or paper trading, compare the pairing with each tool alone. Record changed decisions, costs and failure cases before claiming an improvement.

These are research workflow suggestions, not tested combination strategies. Companion availability and data coverage depend on your TradingView access and feed. Native validation of the KONATUS research scripts remains pending.

Applies to every indicator and companion strategy

Trading & research disclaimer

All indicators, scanner readings, scripts, examples, alerts and accompanying material on this site are provided for education and independent research. They are not personalized financial, investment, tax or legal advice, a recommendation to trade, or an offer or solicitation to buy or sell an instrument. They do not assess your financial situation, objectives or ability to bear losses.

Risk belongs to the decision.

Trading and investing involve substantial risk. You can lose all capital committed; leveraged products can produce losses beyond your initial investment. False breakouts, delayed signals, volatility, gaps, liquidity constraints, fees and slippage can materially change results. Chart levels, illustrative sizing and alerts are not guaranteed prices, broker orders or substitutes for your own risk controls.

Historical and hypothetical results have limits.

Past performance does not guarantee future results. Backtests, simulations, synthetic examples and reference-model outputs are not records of actual trading. They may benefit from hindsight and may not capture liquidity, execution costs, market impact or the financial and behavioral pressures of live losses. No representation is made that an account will achieve similar gains or avoid losses.

Research software can be wrong.

The tools are experimental and may contain errors. Read the verification status beside each release: local tests do not establish native TradingView compilation, alert delivery, live reliability or a market edge. Feed availability, revisions, missing data, settings, session choices and loaded history can change readings. Confirmed calculations may lag the market.

Read scores and probabilities carefully.

Heuristic scores describe the evidence used by a script; they are not calibrated probabilities. Historical frequencies and uncertainty intervals are conditional on the sampled data and assumptions. They may not generalize to another instrument, timeframe or market regime. No predictive accuracy, profitability or superiority to another tool is promised.

Validate the code and rules, account for realistic costs, and use replay or paper testing before deciding whether a tool is appropriate. Make your own decisions and consult a suitably qualified professional when you need personalized advice. Editorial artwork is illustrative, not evidence of trading performance. These tools are not affiliated with or endorsed by TradingView.

Further reading: SEC investor bulletin on performance claims · CFTC on online trading systems.