Wyckoff Evidence Desk v1.0.0 · Free Pine source

One range.
Which story holds up?

Compare evidence for a rise and a fall. Follow what changes before calling it a setup.

Get the free indicator ↓

Start with Base · optional Footprint and Lab

Illustrated range with two possible explanations. Price closes above the top, then later returns inside. Both accumulation and distribution remain in view. A clue alone is not a qualified setup.
Illustrative prices · 20-second loop

What is Wyckoff Evidence Desk?

Wyckoff Evidence Desk compares accumulation and distribution hypotheses on the same chart. It shows each side’s range, confirmed events and reasons to wait or invalidate it. Contested means both paths remain plausible. The scores are independent rules, not calibrated probabilities. Start with Base; Footprint and the Lab are separate editions.

Does a higher Wyckoff score predict the next move?

No. ACC and DIST collect separate rule-based evidence; their scores need not add to 100. Contested means two active hypotheses are too close to choose a clear leader. A qualifying backup needs acceptance, a later return and the configured evidence, lead, volume and context checks. Read the evidence map

Do I need a Premium account to use the free indicator?

Base and Lab make no Footprint request. Only the separate Footprint edition needs an eligible TradingView account and feed. Base uses standard candle prices and available volume; missing volume still permits price-only hypotheses but blocks qualified setups by default. Choose the edition to install

What has actually been verified?

The producer reports native synthetic engine checks and Base chart runs on micro Nasdaq and EURUSD. Those checks do not prove trading outcomes. Its small development Lab sample lost money; Footprint native execution and held-out market validation remain unverified. Read the evidence and limits

Sideways price can tell more than one story.

Wyckoff studied price and volume together. This desk turns selected ideas into explicit, revisable rules.

Accumulation

Could selling be getting absorbed, building a case for a later rise?

Distribution

Could demand be weakening, building a case for a later fall?

Keep both in view

The tool tests both explanations. “Contested” means two active hypotheses have similar scores.

These are hypotheses about price behavior. Candles do not reveal anyone’s hidden intentions.

Where does the idea come from?

The public Wyckoff Method overview relates price, volume and the development of trading ranges. Its examples include paths without a Spring or UTAD. This desk formalizes a selected subset with its own numerical rules; it is not an official Wyckoff Analytics product or a complete implementation of the method.

The first improvement is keeping competing explanations visible. The next is recording events only when their rules can be confirmed, including failed breaks. Read the reason beside the label, rather than fitting the chart to a perfect historical drawing.

A clue comes before a qualified setup.

The letters describe what has been observed. They do not promise what comes next.

  1. A

    A possible stopping point

    Wait for the opposite boundary and a test. The first candidate is provisional.

  2. B

    A range develops

    Price works inside a known range. A balanced-range start can begin here.

  3. C

    An optional edge test

    A Spring or UTAD can recover a breached edge. This phase is not mandatory.

  4. D

    Strength is tested

    A decisive break still needs two consecutive closes beyond the boundary allowance.

  5. E

    Watch the return

    A later backup can become a setup if its checks pass. Structural failure can still end it.

There is more than one route. A valid range can move directly from B to D. A failed idea may return to range-building or reset to scanning.

How could this help my trading?

It gives you a hypothesis to test, the missing evidence and the point where that hypothesis fails. Start in Bar Replay: follow events from their confirmation candles, including failures. Then test a specific entry and risk rule on different data with realistic costs.

The indicator does not place orders. A qualified setup is not proof of an advantage. The supplied Strategy Lab’s small development sample lost money; the tool has not established profitability.

Read both columns. Then read the reason.

A higher score means more supporting rules, not a greater proven chance of winning.

Actual MNQ 5m chart. Distribution leads at 47 points versus accumulation at 14; both confirmed-setup rows show WAIT. Earlier acceptance is followed by an INVALID event.
Actual Base v1.0.0 · MNQ 5m · distribution leads; both setup rows WAIT. The captured last candle is still live. Open full size ↗
01

Compare the hypotheses

ACC and DIST have independent scores. They do not need to total 100.

02

Read the latest event

Hover for the full reason. A question mark marks a candidate. An INVALID label records a failed structure.

03

Check for a new setup

WAIT means no newly qualified setup on this bar. It is not a pending broker order.

Start with Base. Add the extras later.

Three separate Pine v6 files. Each works as a complete script.

  1. 1

    Open Pine Editor

    Use standard candles with available volume and enough history.

  2. 2

    Paste the whole Base file

    Create a new indicator, save, then Add to chart.

  3. 3

    Keep the defaults first

    Read both columns. SCAN can simply mean the tool is still waiting.

START HERE

Base indicator

Uses candle prices and volume. No Footprint subscription is required.

Base · complete Pine source

Copy the whole file. No coding needed.

View the full code
//@version=6
// Wyckoff Evidence Desk 1.0.0. Original heuristic interpretation, not a probability or profit claim.
indicator("Wyckoff Evidence Desk", "WED", overlay = true, behind_chart = false, max_labels_count = 120, max_boxes_count = 4, max_lines_count = 20, max_bars_back = 2000)
const bool footprintBuild = false
float fpDelta = na

// Causal, direction-normalized engine. The same code runs both hypotheses.
// All episode state changes occur only for committed frames. No future bars are read.
type WConfig
    int minB = 8
    int reclaimBars = 3
    int maxAge = 240
    float tolerance = 0.25
    float maxSweep = 1.5
    float minWidth = 2.5
    float trailAtr = 3.0
    bool allowBootstrap = true

type WBar
    int i = 0
    bool closed = true
    float o = na
    float h = na
    float l = na
    float c = na
    float atr = na
    float rv = na
    float vol = na
    bool volumeOk = false
    bool climax = false
    bool quiet = false
    float rangeLo = na
    float rangeHi = na
    float ph = na
    float pl = na
    float pivotVolume = na
    float pivotRv = na
    int pivotIndex = 0
    float spreadRatio = na
    bool contraction = false
    int context = 0
    float delta = na

type WEngine
    int dir = 1
    int phase = 0
    int stage = 0
    float lo = na
    float hi = na
    int born = na
    int climaxBar = na
    float climaxVolume = na
    int arBar = na
    int bStarted = na
    bool bootstrap = false
    int pending = 0
    int sweepBar = na
    float sweepExtreme = na
    float sweepRv = na
    int sweepKind = 0
    bool testPassed = false
    bool testObserved = false
    int strengthBar = na
    int breakout = na
    int outside = 0
    int invalidCloses = 0
    bool backedUp = false
    float trail = na
    float lastSwingHigh = na
    float pvSum = 0.0
    float vSum = 0.0
    float vwap = na
    float score = 0.0
    string event = ""
    string lastEvent = "Waiting"
    string why = "Waiting for exhaustion or a balanced range"
    int resetAt = -10000
    int lastTest = -10000
    int lastProcessed = -1

method tag(WEngine e, string name, string reason) =>
    e.event := name
    e.lastEvent := name
    e.why := reason

method clearEpisode(WEngine e, int i, string reason) =>
    e.phase := 0
    e.stage := 0
    e.lo := na
    e.hi := na
    e.born := na
    e.climaxBar := na
    e.climaxVolume := na
    e.arBar := na
    e.bStarted := na
    e.bootstrap := false
    e.pending := 0
    e.sweepBar := na
    e.sweepExtreme := na
    e.sweepRv := na
    e.sweepKind := 0
    e.testPassed := false
    e.testObserved := false
    e.strengthBar := na
    e.breakout := na
    e.outside := 0
    e.invalidCloses := 0
    e.backedUp := false
    e.trail := na
    e.lastSwingHigh := na
    e.pvSum := 0.0
    e.vSum := 0.0
    e.vwap := na
    e.score := 0.0
    e.resetAt := i
    e.lastTest := -10000
    e.tag("INVALID", reason)

method startEpisode(WEngine e, WBar b, bool bootstrap) =>
    e.born := b.i
    e.bootstrap := bootstrap
    e.climaxBar := b.i
    e.climaxVolume := b.volumeOk ? b.vol : na
    e.lo := bootstrap ? b.rangeLo : b.l
    e.hi := bootstrap ? b.rangeHi : na
    e.phase := bootstrap ? 2 : 1
    e.stage := bootstrap ? 3 : 1
    e.bStarted := bootstrap ? b.i : na
    e.tag(bootstrap ? "TR?" : e.dir == 1 ? "SC?" : "BC?", bootstrap ? "Balanced range bootstrap; Phase A was not observed" : "Exhaustion candidate; waiting for a confirmed automatic reaction")

method step(WEngine e, WBar b, WConfig cfg) =>
    if b.closed and b.i > e.lastProcessed
        e.lastProcessed := b.i
        e.event := ""
        float tol = b.atr * cfg.tolerance
        float width = e.hi - e.lo
        int enteredPhase = e.phase
        bool inside = not na(e.hi) and b.c >= e.lo and b.c <= e.hi
        float closeLocation = b.h > b.l ? (b.c - b.l) / (b.h - b.l) : 0.5
        bool pivotLowEffort = not na(b.pivotRv) and b.pivotRv < 1.15
        bool demand = b.c > b.o and closeLocation >= 0.65 and b.spreadRatio >= 1.05 and (not b.volumeOk or b.rv >= 1.1)
        bool expired = e.phase > 0 and e.phase < 5 and b.i - e.born > cfg.maxAge
        if e.phase > 0 and not na(e.hi)
            e.invalidCloses := b.c < e.lo - tol ? e.invalidCloses + 1 : 0
        bool invalid = e.phase > 0 and e.invalidCloses > cfg.reclaimBars
        bool deepAdverse = e.phase > 0 and not na(e.hi) and b.l < e.lo - cfg.maxSweep * b.atr
        bool bothEdges = e.phase > 0 and not na(e.hi) and b.l < e.lo - tol and b.h > e.hi + tol
        bool trendFailed = e.phase == 5 and (b.c < e.trail or b.c < e.hi - tol)
        if expired or invalid or deepAdverse
            e.clearEpisode(b.i, expired ? "Range expired before acceptance" : deepAdverse ? "Adverse excursion exceeded maximum structural depth" : "Sustained acceptance beyond the invalidation boundary")
        else if trendFailed
            e.clearEpisode(b.i, b.c < e.trail ? "Confirmed close crossed the one-way structural trail" : "Backup failed: close crossed the breakout boundary")
        else if bothEdges
            e.pending := 0
            e.testPassed := false
            e.testObserved := false
            if e.phase >= 3 and e.phase < 5
                e.phase := 2
                e.bStarted := b.i
                e.breakout := na
                e.outside := 0
            e.tag("AMBIGUOUS", "This bar crossed both range edges; OHLC cannot establish the sequence")
        else if e.phase == 0
            if b.i - e.resetAt > 3 and not na(b.atr) and b.atr > 0
                if b.climax
                    e.startEpisode(b, false)
                else if cfg.allowBootstrap and b.quiet and b.rangeHi - b.rangeLo >= cfg.minWidth * b.atr and b.c >= b.rangeLo and b.c <= b.rangeHi
                    e.startEpisode(b, true)
                else if b.volumeOk and b.rv >= 1.5 and closeLocation >= 0.65 and b.i - e.lastTest > 8
                    e.lastTest := b.i
                    e.tag(e.dir == 1 ? "PS?" : "PSY?", "Preliminary stopping activity; no range has been established")
        else if e.phase == 1
            if e.stage == 1
                // SC/BC is provisional until AR establishes the second boundary.
                if b.l < e.lo
                    e.lo := b.l
                    e.climaxBar := b.i
                    e.climaxVolume := b.volumeOk ? b.vol : na
                if not na(b.ph) and b.pivotIndex > e.climaxBar and b.ph - e.lo >= cfg.minWidth * b.atr
                    e.hi := b.ph
                    e.arBar := b.pivotIndex
                    e.stage := 2
                    e.tag("AR", "Confirmed swing establishes the other boundary; range is now frozen")
            else if e.stage == 2
                bool test = not na(b.pl) and b.pivotIndex > e.arBar and b.pl >= e.lo - tol and b.pl <= e.lo + width * 0.3 and inside
                bool comparableVolume = not na(b.pivotVolume) and b.pivotVolume > 0 and not na(e.climaxVolume) and e.climaxVolume > 0
                bool lessVolume = comparableVolume ? b.pivotVolume < e.climaxVolume : not b.volumeOk
                if test and lessVolume
                    e.phase := 2
                    e.stage := 3
                    e.bStarted := b.i
                    e.lastTest := b.i
                    e.tag("ST", b.volumeOk ? "Secondary test on lower volume; cause-building hypothesis begins" : "Price-only secondary test; volume unavailable")
        else if e.phase == 2 or e.phase == 3
            bool mature = b.i - e.bStarted >= cfg.minB
            if e.pending == 1
                e.sweepExtreme := math.min(e.sweepExtreme, b.l)
                e.sweepRv := b.volumeOk ? math.max(nz(e.sweepRv, b.rv), b.rv) : e.sweepRv
                bool tooDeep = e.lo - e.sweepExtreme > cfg.maxSweep * b.atr
                if b.i - e.sweepBar > cfg.reclaimBars or tooDeep
                    e.pending := 0
                    e.phase := 2
                    e.testPassed := false
                    e.tag("SWEEP FAILED", "Reclaim window expired or excursion exceeded the allowed depth")
                else if b.c >= e.lo and closeLocation >= 0.55
                    e.pending := 0
                    e.phase := 3
                    e.sweepKind := na(e.sweepRv) ? 0 : e.sweepRv >= 2 ? 1 : e.sweepRv >= 1 ? 2 : 3
                    e.testPassed := e.sweepKind == 3
                    e.tag(e.dir == 1 ? "SPRING" : "UTAD", b.c > e.hi ? "Recovered beyond the opposite edge; test and strength rules still apply" : e.testPassed ? "Low-volume reclaim confirmed; subsequent strength still required" : "Reclaim confirmed; waiting for a later lower-effort test")
            else if mature and b.l < e.lo - tol and b.c <= e.hi and e.lo - b.l <= cfg.maxSweep * b.atr
                e.pending := 1
                e.sweepBar := b.i
                e.sweepExtreme := b.l
                e.sweepRv := b.rv
                e.testPassed := false
                e.testObserved := false
                e.tag("SWEEP?", "Boundary breach observed; waiting for an actual close back inside")
                if inside and closeLocation >= 0.55
                    e.pending := 0
                    e.phase := 3
                    e.sweepKind := not b.volumeOk ? 0 : b.rv >= 2 ? 1 : b.rv >= 1 ? 2 : 3
                    e.testPassed := e.sweepKind == 3
                    e.tag(e.dir == 1 ? "SPRING" : "UTAD", e.testPassed ? "Same-bar low-volume reclaim confirmed; waiting for strength" : "Same-bar reclaim confirmed; later test required")
            else if e.phase == 3 and not e.testPassed
                bool higherTest = not na(b.pl) and b.pivotIndex > e.sweepBar and b.pl > e.sweepExtreme + tol and b.pl <= e.lo + width * 0.4 and inside
                bool dryTest = not na(b.pivotRv) and b.pivotRv < math.min(nz(e.sweepRv, 1), 1.15)
                bool priceOnlyTest = e.sweepKind == 0 and not b.volumeOk and na(b.pivotRv)
                if higherTest and (dryTest or priceOnlyTest)
                    e.testPassed := true
                    e.testObserved := true
                    e.lastTest := b.i
                    e.tag("TEST", priceOnlyTest ? "Structural test only; lower-effort volume evidence is unavailable" : "Confirmed higher-low / lower-high test on reduced relative volume")
            else if mature and demand and b.c > math.max(nz(e.lastSwingHigh, e.lo + width * 0.65), e.lo + width * 0.5) + tol and (e.phase == 2 or e.testPassed)
                e.phase := 4
                e.strengthBar := b.i
                e.tag(e.dir == 1 ? "SOS" : "SOW", enteredPhase == 2 ? "Directional dominance; no terminal sweep required" : "Directional dominance on expanded effort and result")
                if b.c > e.hi + tol
                    e.breakout := b.i
                    e.outside := 1
                    e.tag(e.dir == 1 ? "JAC" : "FTI", "First decisive close beyond the frozen range; acceptance is pending")
            else if e.phase == 2 and inside and b.i - e.lastTest > 5
                if not na(b.pl) and b.pivotIndex > e.bStarted and b.pl <= e.lo + width * 0.3 and b.pl >= e.lo - tol and pivotLowEffort
                    e.lastTest := b.i
                    e.tag("ST-B", "Range-edge test during cause building")
                else if b.h > e.hi + tol
                    e.lastTest := b.i
                    e.tag(e.dir == 1 ? "UTA" : "mSOW", "Opposite edge rejected back into the range; direction remains unresolved")
        else if e.phase == 4
            if na(e.breakout)
                if demand and b.c > e.hi + tol
                    e.breakout := b.i
                    e.outside := 1
                    e.tag(e.dir == 1 ? "JAC" : "FTI", "Breakout close observed; a second outside close is required")
                else if not na(b.pl) and b.pivotIndex > e.strengthBar and b.pl > nz(e.sweepExtreme, e.lo) + tol and inside and pivotLowEffort and b.i - e.lastTest > 5
                    e.lastTest := b.i
                    e.tag(e.dir == 1 ? "LPS" : "LPSY", "Confirmed pullback holds above structural origin on reduced effort")
            else
                e.outside := b.c > e.hi + tol ? e.outside + 1 : 0
                if e.outside >= 2
                    e.phase := 5
                    e.trail := math.max(e.lo, b.c - cfg.trailAtr * b.atr)
                    e.tag("ACCEPT", "Two consecutive closes accepted outside the range; awaiting backup")
                else if b.c < e.hi - tol or b.i - e.breakout > cfg.reclaimBars
                    e.breakout := na
                    e.outside := 0
                    e.phase := 2
                    e.bStarted := b.i
                    e.testPassed := false
                    e.tag("BREAKOUT FAILED", "Outside acceptance failed; cause-building hypothesis restarted")
        else if e.phase == 5
            if b.c < e.trail
                e.clearEpisode(b.i, "Confirmed close crossed the one-way structural trail")
            else if b.c < e.hi - tol
                e.clearEpisode(b.i, "Backup failed: price closed back through the breakout boundary")
            else
                e.trail := math.max(e.trail, b.c - cfg.trailAtr * b.atr)
                bool retest = b.i > e.breakout and b.l <= e.hi + tol and b.l >= e.hi - cfg.maxSweep * b.atr and b.c >= e.hi and b.c > b.o
                if retest and not e.backedUp and (not b.volumeOk or b.rv <= 1.3)
                    e.backedUp := true
                    e.tag("BU", b.volumeOk ? "Accepted breakout retested on controlled effort; one setup per episode" : "Price-only backup; volume confirmation unavailable")
        // AVWAP anchors at the observed episode start; missing volume is never invented.
        if e.phase > 0
            if b.volumeOk
                e.pvSum += (b.h + b.l + b.c) / 3 * b.vol
                e.vSum += b.vol
                e.vwap := e.pvSum / e.vSum
            if not na(b.ph) and b.pivotIndex >= e.born and b.ph < nz(e.hi, b.ph + 1)
                e.lastSwingHigh := b.ph
            float base = e.phase == 1 ? 12 : e.phase == 2 ? 25 : e.phase == 3 ? 40 : e.phase == 4 ? 55 : 65
            float support = (e.testPassed ? 8 : 0) + (b.contraction ? 5 : 0) + (not na(e.vwap) and b.c > e.vwap ? 5 : 0) + (b.context == 1 ? 7 : b.context == -1 ? -8 : 0) + (closeLocation >= 0.65 ? 5 : 0) + (not na(b.delta) and b.delta > 0.1 ? 5 : 0)
            e.score := math.max(0, math.min(100, base + support - (e.bootstrap ? 5 : 0) - (b.volumeOk ? 0 : 15) - (e.pending == 1 ? 8 : 0)))
    e.phase

const string G_ENGINE = "01 · Detection"
int swingBars = input.int(3, "Confirmed swing bars (latency)", minval = 2, maxval = 10, group = G_ENGINE)
int baseline = input.int(50, "Adaptive baseline", minval = 20, maxval = 200, group = G_ENGINE)
int rangeBars = input.int(30, "Balanced-range lookback", minval = 15, maxval = 100, group = G_ENGINE)
bool allowBootstrap = input.bool(true, "Allow balanced-range bootstrap", tooltip = "Recognizes continuation ranges without inventing historical SC/BC. Both directions may remain plausible.", group = G_ENGINE)
int minCause = input.int(8, "Minimum cause-building bars", minval = 3, maxval = 80, group = G_ENGINE)
int reclaimWindow = input.int(3, "Reclaim deadline (closed bars)", minval = 1, maxval = 5, group = G_ENGINE)
int maxAge = input.int(240, "Expire range after bars", minval = 40, maxval = 1500, group = G_ENGINE)
float tolerance = input.float(0.25, "Boundary tolerance / ATR", minval = 0.05, maxval = 1, step = 0.05, group = G_ENGINE)
float maxSweep = input.float(1.5, "Maximum sweep depth / ATR", minval = 0.5, maxval = 4, step = 0.1, group = G_ENGINE)
float minWidth = input.float(2.5, "Minimum range width / ATR", minval = 1, maxval = 8, step = 0.25, group = G_ENGINE)
float trailMultiple = input.float(3, "One-way trail / ATR", minval = 1, maxval = 8, step = 0.25, group = G_ENGINE)
const string G_CONTEXT = "02 · Evidence and setup gates"
bool useContext = input.bool(true, "Use confirmed higher-timeframe context", group = G_CONTEXT)
string contextInput = input.timeframe("", "Context timeframe (blank = automatic 4×)", group = G_CONTEXT)
bool requireVolume = input.bool(true, "Require volume for qualified backup", tooltip = "Price-only hypotheses remain visible. Missing/zero volume cannot qualify a setup when enabled.", group = G_CONTEXT)
float minEvidence = input.float(65, "Minimum backup evidence score", minval = 40, maxval = 95, group = G_CONTEXT)
float leadNeeded = input.float(10, "Minimum lead over opposing hypothesis", minval = 0, maxval = 40, group = G_CONTEXT)
bool enableJson = input.bool(false, "Emit JSON event alerts", group = G_CONTEXT)

float atr = ta.atr(14)
float volMedian = ta.median(volume, baseline)[1]
bool hasVolume = not na(volume) and volume > 0 and not na(volMedian) and volMedian > 0
float rvol = hasVolume ? volume / volMedian : na
float spread = high - low
float spreadMedian = ta.median(spread, baseline)[1]
float spreadRatio = spreadMedian > 0 ? spread / spreadMedian : 0
float volumeExtreme = ta.percentile_nearest_rank(volume, baseline, 90)[1]
float spreadExtreme = ta.percentile_nearest_rank(spread, baseline, 90)[1]
float ph = ta.pivothigh(high, swingBars, swingBars)
float pl = ta.pivotlow(low, swingBars, swingBars)
float recentHi = ta.highest(high, rangeBars)[1]
float recentLo = ta.lowest(low, rangeBars)[1]
float pathLength = math.sum(math.abs(ta.change(close)), rangeBars)[1]
float efficiency = pathLength > 0 ? math.abs(close[1] - close[rangeBars + 1]) / pathLength : 1
float fastVolume = ta.sma(volume, 8)
float slowVolume = ta.sma(volume, 30)
bool contraction = hasVolume and fastVolume < slowVolume * 0.9
float trend = ta.ema(close, 34)
bool priorDown = close[1] < trend[1] and trend[1] < trend[6]
bool priorUp = close[1] > trend[1] and trend[1] > trend[6]
bool quiet = efficiency < 0.3 and recentHi - recentLo <= atr * 10
bool extreme = hasVolume and volume >= volumeExtreme and rvol >= 1.8 and spread >= spreadExtreme and spreadRatio >= 1.3
float closeLocation = spread > 0 ? (close - low) / spread : 0.5
bool warm = bar_index > math.max(baseline, rangeBars) + 35 and not na(atr)
string contextTf = contextInput == "" ? timeframe.from_seconds(int(timeframe.in_seconds() * 4)) : contextInput
if barstate.isfirst and not chart.is_standard
    runtime.error("Use standard time-based candles. Synthetic prices are unsuitable for this analysis.")
if barstate.isfirst and useContext and timeframe.in_seconds(contextTf) <= timeframe.in_seconds()
    runtime.error("Context timeframe must be higher than the chart timeframe.")
[htfClose, htfMean, htfOldMean] = request.security(syminfo.tickerid, contextTf, [close[1], ta.ema(close, 34)[1], ta.ema(close, 34)[2]], lookahead = barmerge.lookahead_on)
int htfBias = not useContext ? 0 : htfClose > htfMean and htfMean > htfOldMean ? 1 : htfClose < htfMean and htfMean < htfOldMean ? -1 : 0

f_frame(int direction) =>
    WBar b = WBar.new()
    b.i := bar_index
    b.closed := barstate.isconfirmed and warm
    b.o := direction * open
    b.h := direction == 1 ? high : -low
    b.l := direction == 1 ? low : -high
    b.c := direction * close
    b.atr := atr
    b.rv := rvol
    b.vol := volume
    b.volumeOk := hasVolume
    b.climax := extreme and (direction == 1 ? priorDown and low < recentLo and closeLocation >= 0.4 : priorUp and high > recentHi and closeLocation <= 0.6)
    b.quiet := quiet
    b.rangeLo := direction == 1 ? recentLo : -recentHi
    b.rangeHi := direction == 1 ? recentHi : -recentLo
    b.ph := direction == 1 ? ph : -pl
    b.pl := direction == 1 ? pl : -ph
    b.pivotVolume := volume[swingBars]
    b.pivotRv := rvol[swingBars]
    b.pivotIndex := bar_index - swingBars
    b.spreadRatio := spreadRatio
    b.contraction := contraction
    b.context := direction * htfBias
    b.delta := direction * fpDelta
    b

var WEngine bull = WEngine.new(1)
var WEngine bear = WEngine.new(-1)
WConfig config = WConfig.new(minCause, reclaimWindow, maxAge, tolerance, maxSweep, minWidth, trailMultiple, allowBootstrap)
bull.step(f_frame(1), config)
bear.step(f_frame(-1), config)
bool eligibleLong = barstate.isconfirmed and warm and bull.event == "BU" and bull.score >= minEvidence and bull.score - bear.score >= leadNeeded and (not requireVolume or hasVolume) and (not useContext or htfBias >= 0)
bool eligibleShort = barstate.isconfirmed and warm and bear.event == "BU" and bear.score >= minEvidence and bear.score - bull.score >= leadNeeded and (not requireVolume or hasVolume) and (not useContext or htfBias <= 0)
alertcondition(eligibleLong, "Qualified bullish backup", "Wyckoff Evidence Desk: qualified bullish backup on {{ticker}} {{interval}}. Evidence is not a probability.")
alertcondition(eligibleShort, "Qualified bearish backup", "Wyckoff Evidence Desk: qualified bearish backup on {{ticker}} {{interval}}. Evidence is not a probability.")
alertcondition(barstate.isconfirmed and (bull.event == "SPRING" or bear.event == "UTAD"), "Confirmed reclaim", "Wyckoff boundary reclaim confirmed on {{ticker}} {{interval}}; inspect test and acceptance status.")
if enableJson and barstate.isconfirmed
    for side in array.from(bull, bear)
        if side.event != ""
            string payload = '{"schema":"wed.v1","symbol":"' + syminfo.tickerid + '","timeframe":"' + timeframe.period + '","barTime":' + str.tostring(time) + ',"side":' + str.tostring(side.dir) + ',"phase":' + str.tostring(side.phase) + ',"event":"' + side.event + '","evidence":' + str.tostring(side.score, "#.0") + ',"volumeAvailable":' + str.tostring(hasVolume) + '}'
            alert(payload, alert.freq_all)
// Numeric exports for Data Window and Pine Screener. Scores are uncalibrated rules.
plot(bull.phase, "Accumulation phase 0-5", display = display.data_window)
plot(bear.phase, "Distribution phase 0-5", display = display.data_window)
plot(bull.score, "Accumulation evidence 0-100", display = display.data_window)
plot(bear.score, "Distribution evidence 0-100", display = display.data_window)
plot(eligibleLong ? 1 : eligibleShort ? -1 : 0, "Qualified backup direction", display = display.data_window)



// ── Evidence desk · chart presentation ────────────────────────────────────────
string visualGroup = "05 · Evidence desk"
bool showDashboard = input.bool(true, "Evidence dashboard", group = visualGroup)
bool showTape = input.bool(true, "Last six confirmed events", group = visualGroup)
bool showLabels = input.bool(true, "Confirmed event labels", group = visualGroup)
bool showRange = input.bool(true, "Active hypothesis ranges", group = visualGroup)
bool showVwap = input.bool(true, "Event-anchored VWAP", group = visualGroup)
bool showTrail = input.bool(true, "Phase E structural trail", group = visualGroup)
bool showBars = input.bool(false, "Color bars from phase C onward", group = visualGroup)
int maxEventLabels = input.int(60, "Retained event labels", minval = 10, maxval = 100, group = visualGroup)
string dashboardPosition = input.string("Top right", "Dashboard position", options = ["Top right", "Bottom right", "Top left", "Bottom left"], group = visualGroup)

color vdInk = #0B151C
color vdPanel = #101F29
color vdGrid = #223642
color vdText = #DCE9ED
color vdMuted = #819AA5
color vdBull = #44D9C0
color vdBear = #FF8177
color vdGold = #EDC778

f_vdPhase(int phase) =>
    switch phase
        1 => "A · stopping action"
        2 => "B · range building"
        3 => "C · test / sweep"
        4 => "D · directional proof"
        5 => "E · follow-through"
        => "SCAN · no range"

f_vdRibbon(int phase) =>
    phase == 0 ? "SCAN   ·   WAIT FOR STRUCTURE" : (phase == 1 ? "[A]" : " A ") + " — " + (phase == 2 ? "[B]" : " B ") + " — " + (phase == 3 ? "[C]" : " C ") + " — " + (phase == 4 ? "[D]" : " D ") + " — " + (phase == 5 ? "[E]" : " E ")

f_vdFailed(string eventName) =>
    str.contains(str.upper(eventName), "FAIL") or str.contains(str.upper(eventName), "INVALID") or str.contains(str.upper(eventName), "EXPIRE")

f_vdPrice(float value) =>
    na(value) ? "—" : str.tostring(value, format.mintick)

f_vdScore(float value) =>
    float bounded = math.max(0.0, math.min(100.0, nz(value)))
    int blocks = int(math.round(bounded / 20.0))
    string meter = ""
    for block = 1 to 5
        meter += block <= blocks ? "■" : "·"
    str.tostring(bounded, "0") + " / 100  " + meter

f_vdBrief(string value, int limit) =>
    str.length(value) > limit ? str.substring(value, 0, limit - 1) + "…" : value

f_vdTest(WEngine engine) =>
    engine.phase == 0 ? "Awaiting range" : engine.pending == 1 ? "Reclaim pending" : engine.testPassed and engine.testObserved ? "Test confirmed" : engine.testPassed ? "Type 3 · test waived" : engine.phase >= 4 ? "Direct strength path" : "No confirmed test"

f_vdBoundaryTip(WEngine engine, bool isBull) =>
    float direction = isBull ? 1.0 : -1.0
    string side = isBull ? "below " : "above "
    string detail = "No active range."
    if engine.phase > 0 and na(engine.hi)
        detail := "Phase A candidate: awaiting the automatic reaction to establish both range edges. Observed origin: " + f_vdPrice(direction * engine.lo)
    else if engine.phase > 0
        detail := "Sustained close invalidation: " + side + f_vdPrice(direction * (engine.lo - atr * tolerance)) + ". More than " + str.tostring(reclaimWindow) + " consecutive closes are required.\nMaximum adverse wick boundary: " + f_vdPrice(direction * (engine.lo - atr * maxSweep)) + ". Crossing it invalidates on the confirmed bar."
        if engine.phase == 5
            detail += "\nPhase E structural trail: " + f_vdPrice(direction * engine.trail) + ".\nBackup failure close boundary: " + side + f_vdPrice(direction * (engine.hi - atr * tolerance)) + ".\nTrail is structural context, not a broker stop order."
    detail

f_vdCell(table panel, int column, int row, string value, color foreground, color background, string tooltipText = "") =>
    table.cell(panel, column, row, value, text_color = foreground, bgcolor = background, text_size = size.small, text_halign = column == 0 ? text.align_left : text.align_center, tooltip = tooltipText)

f_vdTape(array<string> tape, array<color> colors, bool isBull, string eventName, string reason) =>
    string stamp = str.format_time(na(time_close) ? time : time_close, "dd MMM HH:mm", syminfo.timezone)
    array.unshift(tape, stamp + "  ·  " + (isBull ? "ACC" : "DIST") + "  " + eventName + "  ·  " + f_vdBrief(reason, 28))
    array.unshift(colors, f_vdFailed(eventName) ? vdGold : isBull ? vdBull : vdBear)
    if array.size(tape) > 6
        array.pop(tape)
        array.pop(colors)

f_vdLabel(bool isBull, WEngine engine) =>
    color tone = f_vdFailed(engine.event) ? vdGold : isBull ? vdBull : vdBear
    float y = isBull ? low - nz(atr, high - low) * 0.24 : high + nz(atr, high - low) * 0.24
    string tip = (isBull ? "ACCUMULATION" : "DISTRIBUTION") + " · " + engine.event + "\nConfirmed on this bar close.\n" + f_vdPhase(engine.phase) + "\nEvidence: " + str.tostring(engine.score, "0") + " / 100 (heuristic, not probability)\n" + engine.why
    label.new(bar_index, y, engine.event, xloc = xloc.bar_index, yloc = yloc.price, style = isBull ? label.style_label_up : label.style_label_down, color = color.new(tone, 12), textcolor = vdInk, size = size.tiny, tooltip = tip)

bool vdBullActive = bull.phase > 0
bool vdBearActive = bear.phase > 0
bool vdBullRange = vdBullActive and not na(bull.lo) and not na(bull.hi)
bool vdBearRange = vdBearActive and not na(bear.lo) and not na(bear.hi)
bool vdContested = vdBullActive and vdBearActive and math.abs(bull.score - bear.score) <= 10.0
bool vdSelectBull = vdBullActive and (not vdBearActive or bull.score >= bear.score)
bool vdSelectBear = vdBearActive and not vdSelectBull
bool vdShowBull = vdBullActive and (vdSelectBull or vdContested)
bool vdShowBear = vdBearActive and (vdSelectBear or vdContested)
bool vdDrawBull = vdBullRange and vdShowBull
bool vdDrawBear = vdBearRange and vdShowBear
int vdPhase = vdSelectBull ? bull.phase : vdSelectBear ? bear.phase : 0
color vdAccent = vdContested ? vdGold : vdSelectBull ? vdBull : vdSelectBear ? vdBear : vdMuted
bool vdReset = not vdBullActive and not vdBearActive and (f_vdFailed(bull.lastEvent) or f_vdFailed(bear.lastEvent))
string vdMode = vdContested ? "CONTESTED · BOTH HYPOTHESES ACTIVE" : vdSelectBull ? "ACCUMULATION · LEADING EVIDENCE" : vdSelectBear ? "DISTRIBUTION · LEADING EVIDENCE" : vdReset ? "RESET → SCAN · AWAIT NEW STRUCTURE" : "SCANNING · AWAIT STOPPING ACTION"

// Reuse two boxes. Their left edge is the confirmation bar that started the range.
var box vdBullBox = na
var box vdBearBox = na
if showRange and vdDrawBull
    int left = math.max(nz(bull.born, bar_index), bar_index - 9999)
    if na(vdBullBox)
        vdBullBox := box.new(left, bull.hi, bar_index + 1, bull.lo, border_color = color.new(vdBull, 26), border_width = 1, bgcolor = color.new(vdBull, 94), text = "", text_color = color.new(vdBull, 12), text_size = size.tiny, text_halign = text.align_left, text_valign = text.align_top)
    box.set_lefttop(vdBullBox, left, bull.hi)
    box.set_rightbottom(vdBullBox, bar_index + 1, bull.lo)
    box.set_border_style(vdBullBox, vdContested ? line.style_dashed : line.style_solid)
    box.set_text(vdBullBox, "  ACC  ·  " + f_vdPhase(bull.phase) + "  ·  " + str.tostring(bull.score, "0") + "/100")
else if not na(vdBullBox)
    box.delete(vdBullBox)
    vdBullBox := na

if showRange and vdDrawBear
    int left = math.max(nz(bear.born, bar_index), bar_index - 9999)
    if na(vdBearBox)
        vdBearBox := box.new(left, -bear.lo, bar_index + 1, -bear.hi, border_color = color.new(vdBear, 26), border_width = 1, bgcolor = color.new(vdBear, 94), text = "", text_color = color.new(vdBear, 12), text_size = size.tiny, text_halign = text.align_left, text_valign = text.align_bottom)
    box.set_lefttop(vdBearBox, left, -bear.lo)
    box.set_rightbottom(vdBearBox, bar_index + 1, -bear.hi)
    box.set_border_style(vdBearBox, vdContested ? line.style_dashed : line.style_solid)
    box.set_text(vdBearBox, "  DIST  ·  " + f_vdPhase(bear.phase) + "  ·  " + str.tostring(bear.score, "0") + "/100")
else if not na(vdBearBox)
    box.delete(vdBearBox)
    vdBearBox := na

plot(showVwap and vdShowBull ? bull.vwap : na, "Accumulation event AVWAP", color = vdBull, linewidth = 2, style = plot.style_linebr)
plot(showVwap and vdShowBear ? -bear.vwap : na, "Distribution event AVWAP", color = vdBear, linewidth = 2, style = plot.style_linebr)
float vdSelectedTrail = not vdContested and vdPhase == 5 ? vdSelectBull ? bull.trail : -bear.trail : na
plot(showTrail ? vdSelectedTrail : na, "Leading Phase E structural trail", color = vdGold, linewidth = 1, style = plot.style_linebr)
barcolor(showBars and vdPhase >= 3 ? color.new(vdAccent, 24) : na, title = "Evidence phase tint")
plotshape(eligibleLong and barstate.isconfirmed, title = "Confirmed long setup", style = shape.diamond, location = location.belowbar, color = vdBull, size = size.tiny)
plotshape(eligibleShort and barstate.isconfirmed, title = "Confirmed short setup", style = shape.diamond, location = location.abovebar, color = vdBear, size = size.tiny)

var array<label> vdLabels = array.new<label>()
var array<string> vdTape = array.new<string>()
var array<color> vdTapeColors = array.new<color>()
if barstate.isconfirmed
    if bull.event != ""
        f_vdTape(vdTape, vdTapeColors, true, bull.event, bull.why)
        if showLabels
            array.push(vdLabels, f_vdLabel(true, bull))
    if bear.event != ""
        f_vdTape(vdTape, vdTapeColors, false, bear.event, bear.why)
        if showLabels
            array.push(vdLabels, f_vdLabel(false, bear))
    while array.size(vdLabels) > maxEventLabels
        label.delete(array.shift(vdLabels))

string vdPosition = switch dashboardPosition
    "Bottom right" => position.bottom_right
    "Top left" => position.top_left
    "Bottom left" => position.bottom_left
    => position.top_right
var table vdDesk = table.new(vdPosition, 3, 21, bgcolor = vdInk, frame_color = vdGrid, frame_width = 1, border_color = vdInk, border_width = 1)
if barstate.isfirst
    table.merge_cells(vdDesk, 0, 0, 2, 0)
    table.merge_cells(vdDesk, 0, 1, 2, 1)
    table.merge_cells(vdDesk, 0, 2, 2, 2)
    table.merge_cells(vdDesk, 0, 13, 2, 13)
    table.merge_cells(vdDesk, 0, 14, 2, 14)
    for row = 15 to 20
        table.merge_cells(vdDesk, 0, row, 2, row)

if barstate.islast or barstate.islastconfirmedhistory
    if showDashboard
        f_vdCell(vdDesk, 0, 0, "WYCKOFF  /  EVIDENCE DESK", vdText, vdPanel, "Two independent, sequential hypotheses. Evidence scores are rule-based support, not calibrated probabilities.")
        f_vdCell(vdDesk, 0, 1, vdMode, vdAccent, color.new(vdAccent, 91))
        f_vdCell(vdDesk, 0, 2, vdContested ? "TWO ACTIVE PATHS · NO SINGLE PHASE RIBBON" : f_vdRibbon(vdPhase), vdAccent, vdInk)
        f_vdCell(vdDesk, 0, 3, "HYPOTHESIS", vdMuted, vdPanel)
        f_vdCell(vdDesk, 1, 3, "ACCUMULATION", vdBull, vdPanel)
        f_vdCell(vdDesk, 2, 3, "DISTRIBUTION", vdBear, vdPanel)
        f_vdCell(vdDesk, 0, 4, "Phase", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 4, f_vdPhase(bull.phase), vdBullActive ? vdBull : vdMuted, vdInk)
        f_vdCell(vdDesk, 2, 4, f_vdPhase(bear.phase), vdBearActive ? vdBear : vdMuted, vdInk)
        f_vdCell(vdDesk, 0, 5, "Evidence", vdMuted, vdPanel, "Heuristic support from the configured rules; 80/100 does not mean an 80% chance of success.")
        f_vdCell(vdDesk, 1, 5, f_vdScore(bull.score), vdBull, vdPanel)
        f_vdCell(vdDesk, 2, 5, f_vdScore(bear.score), vdBear, vdPanel)
        f_vdCell(vdDesk, 0, 6, "Range / limits ⓘ", vdMuted, vdInk, "Hover either hypothesis for its invalidation boundaries and, in Phase E, its structural trail. Live ATR-based boundaries can move until bar close.")
        f_vdCell(vdDesk, 1, 6, vdBullRange ? f_vdPrice(bull.lo) + " – " + f_vdPrice(bull.hi) : vdBullActive ? "Awaiting AR" : "—", vdText, vdInk, f_vdBoundaryTip(bull, true))
        f_vdCell(vdDesk, 2, 6, vdBearRange ? f_vdPrice(-bear.hi) + " – " + f_vdPrice(-bear.lo) : vdBearActive ? "Awaiting AR" : "—", vdText, vdInk, f_vdBoundaryTip(bear, false))
        f_vdCell(vdDesk, 0, 7, "Test", vdMuted, vdPanel)
        f_vdCell(vdDesk, 1, 7, f_vdTest(bull), bull.pending == 1 ? vdGold : vdText, vdPanel)
        f_vdCell(vdDesk, 2, 7, f_vdTest(bear), bear.pending == 1 ? vdGold : vdText, vdPanel)
        f_vdCell(vdDesk, 0, 8, "Latest event", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 8, bull.lastEvent == "" ? "—" : bull.lastEvent, f_vdFailed(bull.lastEvent) ? vdGold : vdBull, vdInk)
        f_vdCell(vdDesk, 2, 8, bear.lastEvent == "" ? "—" : bear.lastEvent, f_vdFailed(bear.lastEvent) ? vdGold : vdBear, vdInk)
        string contextName = not useContext ? "OFF" : htfBias == 1 ? "BULLISH" : htfBias == -1 ? "BEARISH" : "NEUTRAL"
        f_vdCell(vdDesk, 0, 9, "Context · " + contextTf, vdMuted, vdPanel, "Context is read from confirmed higher-timeframe data.")
        f_vdCell(vdDesk, 1, 9, contextName, not useContext ? vdMuted : htfBias == 1 ? vdBull : htfBias == -1 ? vdBear : vdGold, vdPanel)
        f_vdCell(vdDesk, 2, 9, useContext ? "CLOSED HTF" : "FILTER OFF", vdMuted, vdPanel)
        f_vdCell(vdDesk, 0, 10, "Volume / flow", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 10, hasVolume ? "RVOL  " + str.tostring(rvol, "0.00") + "×" : "VOLUME UNAVAILABLE", hasVolume ? vdText : vdGold, vdInk)
        f_vdCell(vdDesk, 2, 10, footprintBuild ? na(fpDelta) ? "FOOTPRINT N/A" : "Δ VOL  " + (fpDelta > 0 ? "+" : "") + str.tostring(fpDelta * 100.0, "0.0") + "%" : "OHLCV", footprintBuild and na(fpDelta) ? vdGold : vdMuted, vdInk, "Footprint delta divided by total classified volume. +25.0% means a +0.25 ratio, not 25 contracts or a probability.")
        f_vdCell(vdDesk, 0, 11, "Confirmed setup", vdMuted, vdPanel, "This row is true only on a confirmed eligible setup event. It is not a standing instruction or a profitability estimate.")
        f_vdCell(vdDesk, 1, 11, eligibleLong and barstate.isconfirmed ? "LONG · NEW EVENT" : "WAIT", eligibleLong and barstate.isconfirmed ? vdBull : vdMuted, vdPanel)
        f_vdCell(vdDesk, 2, 11, eligibleShort and barstate.isconfirmed ? "SHORT · NEW EVENT" : "WAIT", eligibleShort and barstate.isconfirmed ? vdBear : vdMuted, vdPanel)
        f_vdCell(vdDesk, 0, 12, "Evidence note", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 12, f_vdBrief(bull.why, 26), vdMuted, vdInk, bull.why)
        f_vdCell(vdDesk, 2, 12, f_vdBrief(bear.why, 26), vdMuted, vdInk, bear.why)
        f_vdCell(vdDesk, 0, 13, barstate.isconfirmed ? "BAR CLOSED  ·  SCORE ≠ PROBABILITY" : "LIVE BAR  ·  EVENTS WAIT FOR CLOSE", barstate.isconfirmed ? vdMuted : vdGold, vdPanel)
        f_vdCell(vdDesk, 0, 14, showTape ? "EVENT TAPE  ·  NEWEST FIRST  ·  EXCHANGE TIME" : "", vdMuted, showTape ? vdPanel : color.new(vdInk, 100))
        for index = 0 to 5
            bool hasEntry = showTape and index < array.size(vdTape)
            string entry = hasEntry ? array.get(vdTape, index) : showTape and index == 0 ? "No confirmed events in loaded history." : ""
            color entryColor = hasEntry ? array.get(vdTapeColors, index) : vdMuted
            f_vdCell(vdDesk, 0, 15 + index, entry, entryColor, showTape ? index % 2 == 0 ? vdInk : vdPanel : color.new(vdInk, 100))
    else
        table.clear(vdDesk, 0, 0, 2, 20)
Download Base .pine
Optional · Footprint edition

Uses classified buy/sell volume as one extra clue. Requires TradingView Premium or Ultimate and available Footprint data. Base works separately.

Unavailable data means no extra evidence. It is not zero delta or verified exchange aggressor flow. See TradingView’s Footprint documentation.

Footprint · complete Pine source

Copy the whole file. No coding needed.

View the full code
//@version=6
// Wyckoff Evidence Desk 1.0.0. Original heuristic interpretation, not a probability or profit claim.
indicator("Wyckoff Evidence Desk · Footprint", "WED FP", overlay = true, behind_chart = false, max_labels_count = 120, max_boxes_count = 4, max_lines_count = 20, max_bars_back = 2000)
const bool footprintBuild = true
int fpTicks = input.int(100, "Footprint row ticks", minval = 1, group = "00 · Footprint · Premium / Ultimate")
footprint fp = request.footprint(fpTicks)
float fpTotal = not na(fp) ? fp.total_volume() : na
float fpDelta = not na(fpTotal) and fpTotal > 0 ? fp.delta() / fpTotal : na
// Direction-classified lower-timeframe volume; not verified exchange aggressor flow.

// Causal, direction-normalized engine. The same code runs both hypotheses.
// All episode state changes occur only for committed frames. No future bars are read.
type WConfig
    int minB = 8
    int reclaimBars = 3
    int maxAge = 240
    float tolerance = 0.25
    float maxSweep = 1.5
    float minWidth = 2.5
    float trailAtr = 3.0
    bool allowBootstrap = true

type WBar
    int i = 0
    bool closed = true
    float o = na
    float h = na
    float l = na
    float c = na
    float atr = na
    float rv = na
    float vol = na
    bool volumeOk = false
    bool climax = false
    bool quiet = false
    float rangeLo = na
    float rangeHi = na
    float ph = na
    float pl = na
    float pivotVolume = na
    float pivotRv = na
    int pivotIndex = 0
    float spreadRatio = na
    bool contraction = false
    int context = 0
    float delta = na

type WEngine
    int dir = 1
    int phase = 0
    int stage = 0
    float lo = na
    float hi = na
    int born = na
    int climaxBar = na
    float climaxVolume = na
    int arBar = na
    int bStarted = na
    bool bootstrap = false
    int pending = 0
    int sweepBar = na
    float sweepExtreme = na
    float sweepRv = na
    int sweepKind = 0
    bool testPassed = false
    bool testObserved = false
    int strengthBar = na
    int breakout = na
    int outside = 0
    int invalidCloses = 0
    bool backedUp = false
    float trail = na
    float lastSwingHigh = na
    float pvSum = 0.0
    float vSum = 0.0
    float vwap = na
    float score = 0.0
    string event = ""
    string lastEvent = "Waiting"
    string why = "Waiting for exhaustion or a balanced range"
    int resetAt = -10000
    int lastTest = -10000
    int lastProcessed = -1

method tag(WEngine e, string name, string reason) =>
    e.event := name
    e.lastEvent := name
    e.why := reason

method clearEpisode(WEngine e, int i, string reason) =>
    e.phase := 0
    e.stage := 0
    e.lo := na
    e.hi := na
    e.born := na
    e.climaxBar := na
    e.climaxVolume := na
    e.arBar := na
    e.bStarted := na
    e.bootstrap := false
    e.pending := 0
    e.sweepBar := na
    e.sweepExtreme := na
    e.sweepRv := na
    e.sweepKind := 0
    e.testPassed := false
    e.testObserved := false
    e.strengthBar := na
    e.breakout := na
    e.outside := 0
    e.invalidCloses := 0
    e.backedUp := false
    e.trail := na
    e.lastSwingHigh := na
    e.pvSum := 0.0
    e.vSum := 0.0
    e.vwap := na
    e.score := 0.0
    e.resetAt := i
    e.lastTest := -10000
    e.tag("INVALID", reason)

method startEpisode(WEngine e, WBar b, bool bootstrap) =>
    e.born := b.i
    e.bootstrap := bootstrap
    e.climaxBar := b.i
    e.climaxVolume := b.volumeOk ? b.vol : na
    e.lo := bootstrap ? b.rangeLo : b.l
    e.hi := bootstrap ? b.rangeHi : na
    e.phase := bootstrap ? 2 : 1
    e.stage := bootstrap ? 3 : 1
    e.bStarted := bootstrap ? b.i : na
    e.tag(bootstrap ? "TR?" : e.dir == 1 ? "SC?" : "BC?", bootstrap ? "Balanced range bootstrap; Phase A was not observed" : "Exhaustion candidate; waiting for a confirmed automatic reaction")

method step(WEngine e, WBar b, WConfig cfg) =>
    if b.closed and b.i > e.lastProcessed
        e.lastProcessed := b.i
        e.event := ""
        float tol = b.atr * cfg.tolerance
        float width = e.hi - e.lo
        int enteredPhase = e.phase
        bool inside = not na(e.hi) and b.c >= e.lo and b.c <= e.hi
        float closeLocation = b.h > b.l ? (b.c - b.l) / (b.h - b.l) : 0.5
        bool pivotLowEffort = not na(b.pivotRv) and b.pivotRv < 1.15
        bool demand = b.c > b.o and closeLocation >= 0.65 and b.spreadRatio >= 1.05 and (not b.volumeOk or b.rv >= 1.1)
        bool expired = e.phase > 0 and e.phase < 5 and b.i - e.born > cfg.maxAge
        if e.phase > 0 and not na(e.hi)
            e.invalidCloses := b.c < e.lo - tol ? e.invalidCloses + 1 : 0
        bool invalid = e.phase > 0 and e.invalidCloses > cfg.reclaimBars
        bool deepAdverse = e.phase > 0 and not na(e.hi) and b.l < e.lo - cfg.maxSweep * b.atr
        bool bothEdges = e.phase > 0 and not na(e.hi) and b.l < e.lo - tol and b.h > e.hi + tol
        bool trendFailed = e.phase == 5 and (b.c < e.trail or b.c < e.hi - tol)
        if expired or invalid or deepAdverse
            e.clearEpisode(b.i, expired ? "Range expired before acceptance" : deepAdverse ? "Adverse excursion exceeded maximum structural depth" : "Sustained acceptance beyond the invalidation boundary")
        else if trendFailed
            e.clearEpisode(b.i, b.c < e.trail ? "Confirmed close crossed the one-way structural trail" : "Backup failed: close crossed the breakout boundary")
        else if bothEdges
            e.pending := 0
            e.testPassed := false
            e.testObserved := false
            if e.phase >= 3 and e.phase < 5
                e.phase := 2
                e.bStarted := b.i
                e.breakout := na
                e.outside := 0
            e.tag("AMBIGUOUS", "This bar crossed both range edges; OHLC cannot establish the sequence")
        else if e.phase == 0
            if b.i - e.resetAt > 3 and not na(b.atr) and b.atr > 0
                if b.climax
                    e.startEpisode(b, false)
                else if cfg.allowBootstrap and b.quiet and b.rangeHi - b.rangeLo >= cfg.minWidth * b.atr and b.c >= b.rangeLo and b.c <= b.rangeHi
                    e.startEpisode(b, true)
                else if b.volumeOk and b.rv >= 1.5 and closeLocation >= 0.65 and b.i - e.lastTest > 8
                    e.lastTest := b.i
                    e.tag(e.dir == 1 ? "PS?" : "PSY?", "Preliminary stopping activity; no range has been established")
        else if e.phase == 1
            if e.stage == 1
                // SC/BC is provisional until AR establishes the second boundary.
                if b.l < e.lo
                    e.lo := b.l
                    e.climaxBar := b.i
                    e.climaxVolume := b.volumeOk ? b.vol : na
                if not na(b.ph) and b.pivotIndex > e.climaxBar and b.ph - e.lo >= cfg.minWidth * b.atr
                    e.hi := b.ph
                    e.arBar := b.pivotIndex
                    e.stage := 2
                    e.tag("AR", "Confirmed swing establishes the other boundary; range is now frozen")
            else if e.stage == 2
                bool test = not na(b.pl) and b.pivotIndex > e.arBar and b.pl >= e.lo - tol and b.pl <= e.lo + width * 0.3 and inside
                bool comparableVolume = not na(b.pivotVolume) and b.pivotVolume > 0 and not na(e.climaxVolume) and e.climaxVolume > 0
                bool lessVolume = comparableVolume ? b.pivotVolume < e.climaxVolume : not b.volumeOk
                if test and lessVolume
                    e.phase := 2
                    e.stage := 3
                    e.bStarted := b.i
                    e.lastTest := b.i
                    e.tag("ST", b.volumeOk ? "Secondary test on lower volume; cause-building hypothesis begins" : "Price-only secondary test; volume unavailable")
        else if e.phase == 2 or e.phase == 3
            bool mature = b.i - e.bStarted >= cfg.minB
            if e.pending == 1
                e.sweepExtreme := math.min(e.sweepExtreme, b.l)
                e.sweepRv := b.volumeOk ? math.max(nz(e.sweepRv, b.rv), b.rv) : e.sweepRv
                bool tooDeep = e.lo - e.sweepExtreme > cfg.maxSweep * b.atr
                if b.i - e.sweepBar > cfg.reclaimBars or tooDeep
                    e.pending := 0
                    e.phase := 2
                    e.testPassed := false
                    e.tag("SWEEP FAILED", "Reclaim window expired or excursion exceeded the allowed depth")
                else if b.c >= e.lo and closeLocation >= 0.55
                    e.pending := 0
                    e.phase := 3
                    e.sweepKind := na(e.sweepRv) ? 0 : e.sweepRv >= 2 ? 1 : e.sweepRv >= 1 ? 2 : 3
                    e.testPassed := e.sweepKind == 3
                    e.tag(e.dir == 1 ? "SPRING" : "UTAD", b.c > e.hi ? "Recovered beyond the opposite edge; test and strength rules still apply" : e.testPassed ? "Low-volume reclaim confirmed; subsequent strength still required" : "Reclaim confirmed; waiting for a later lower-effort test")
            else if mature and b.l < e.lo - tol and b.c <= e.hi and e.lo - b.l <= cfg.maxSweep * b.atr
                e.pending := 1
                e.sweepBar := b.i
                e.sweepExtreme := b.l
                e.sweepRv := b.rv
                e.testPassed := false
                e.testObserved := false
                e.tag("SWEEP?", "Boundary breach observed; waiting for an actual close back inside")
                if inside and closeLocation >= 0.55
                    e.pending := 0
                    e.phase := 3
                    e.sweepKind := not b.volumeOk ? 0 : b.rv >= 2 ? 1 : b.rv >= 1 ? 2 : 3
                    e.testPassed := e.sweepKind == 3
                    e.tag(e.dir == 1 ? "SPRING" : "UTAD", e.testPassed ? "Same-bar low-volume reclaim confirmed; waiting for strength" : "Same-bar reclaim confirmed; later test required")
            else if e.phase == 3 and not e.testPassed
                bool higherTest = not na(b.pl) and b.pivotIndex > e.sweepBar and b.pl > e.sweepExtreme + tol and b.pl <= e.lo + width * 0.4 and inside
                bool dryTest = not na(b.pivotRv) and b.pivotRv < math.min(nz(e.sweepRv, 1), 1.15)
                bool priceOnlyTest = e.sweepKind == 0 and not b.volumeOk and na(b.pivotRv)
                if higherTest and (dryTest or priceOnlyTest)
                    e.testPassed := true
                    e.testObserved := true
                    e.lastTest := b.i
                    e.tag("TEST", priceOnlyTest ? "Structural test only; lower-effort volume evidence is unavailable" : "Confirmed higher-low / lower-high test on reduced relative volume")
            else if mature and demand and b.c > math.max(nz(e.lastSwingHigh, e.lo + width * 0.65), e.lo + width * 0.5) + tol and (e.phase == 2 or e.testPassed)
                e.phase := 4
                e.strengthBar := b.i
                e.tag(e.dir == 1 ? "SOS" : "SOW", enteredPhase == 2 ? "Directional dominance; no terminal sweep required" : "Directional dominance on expanded effort and result")
                if b.c > e.hi + tol
                    e.breakout := b.i
                    e.outside := 1
                    e.tag(e.dir == 1 ? "JAC" : "FTI", "First decisive close beyond the frozen range; acceptance is pending")
            else if e.phase == 2 and inside and b.i - e.lastTest > 5
                if not na(b.pl) and b.pivotIndex > e.bStarted and b.pl <= e.lo + width * 0.3 and b.pl >= e.lo - tol and pivotLowEffort
                    e.lastTest := b.i
                    e.tag("ST-B", "Range-edge test during cause building")
                else if b.h > e.hi + tol
                    e.lastTest := b.i
                    e.tag(e.dir == 1 ? "UTA" : "mSOW", "Opposite edge rejected back into the range; direction remains unresolved")
        else if e.phase == 4
            if na(e.breakout)
                if demand and b.c > e.hi + tol
                    e.breakout := b.i
                    e.outside := 1
                    e.tag(e.dir == 1 ? "JAC" : "FTI", "Breakout close observed; a second outside close is required")
                else if not na(b.pl) and b.pivotIndex > e.strengthBar and b.pl > nz(e.sweepExtreme, e.lo) + tol and inside and pivotLowEffort and b.i - e.lastTest > 5
                    e.lastTest := b.i
                    e.tag(e.dir == 1 ? "LPS" : "LPSY", "Confirmed pullback holds above structural origin on reduced effort")
            else
                e.outside := b.c > e.hi + tol ? e.outside + 1 : 0
                if e.outside >= 2
                    e.phase := 5
                    e.trail := math.max(e.lo, b.c - cfg.trailAtr * b.atr)
                    e.tag("ACCEPT", "Two consecutive closes accepted outside the range; awaiting backup")
                else if b.c < e.hi - tol or b.i - e.breakout > cfg.reclaimBars
                    e.breakout := na
                    e.outside := 0
                    e.phase := 2
                    e.bStarted := b.i
                    e.testPassed := false
                    e.tag("BREAKOUT FAILED", "Outside acceptance failed; cause-building hypothesis restarted")
        else if e.phase == 5
            if b.c < e.trail
                e.clearEpisode(b.i, "Confirmed close crossed the one-way structural trail")
            else if b.c < e.hi - tol
                e.clearEpisode(b.i, "Backup failed: price closed back through the breakout boundary")
            else
                e.trail := math.max(e.trail, b.c - cfg.trailAtr * b.atr)
                bool retest = b.i > e.breakout and b.l <= e.hi + tol and b.l >= e.hi - cfg.maxSweep * b.atr and b.c >= e.hi and b.c > b.o
                if retest and not e.backedUp and (not b.volumeOk or b.rv <= 1.3)
                    e.backedUp := true
                    e.tag("BU", b.volumeOk ? "Accepted breakout retested on controlled effort; one setup per episode" : "Price-only backup; volume confirmation unavailable")
        // AVWAP anchors at the observed episode start; missing volume is never invented.
        if e.phase > 0
            if b.volumeOk
                e.pvSum += (b.h + b.l + b.c) / 3 * b.vol
                e.vSum += b.vol
                e.vwap := e.pvSum / e.vSum
            if not na(b.ph) and b.pivotIndex >= e.born and b.ph < nz(e.hi, b.ph + 1)
                e.lastSwingHigh := b.ph
            float base = e.phase == 1 ? 12 : e.phase == 2 ? 25 : e.phase == 3 ? 40 : e.phase == 4 ? 55 : 65
            float support = (e.testPassed ? 8 : 0) + (b.contraction ? 5 : 0) + (not na(e.vwap) and b.c > e.vwap ? 5 : 0) + (b.context == 1 ? 7 : b.context == -1 ? -8 : 0) + (closeLocation >= 0.65 ? 5 : 0) + (not na(b.delta) and b.delta > 0.1 ? 5 : 0)
            e.score := math.max(0, math.min(100, base + support - (e.bootstrap ? 5 : 0) - (b.volumeOk ? 0 : 15) - (e.pending == 1 ? 8 : 0)))
    e.phase

const string G_ENGINE = "01 · Detection"
int swingBars = input.int(3, "Confirmed swing bars (latency)", minval = 2, maxval = 10, group = G_ENGINE)
int baseline = input.int(50, "Adaptive baseline", minval = 20, maxval = 200, group = G_ENGINE)
int rangeBars = input.int(30, "Balanced-range lookback", minval = 15, maxval = 100, group = G_ENGINE)
bool allowBootstrap = input.bool(true, "Allow balanced-range bootstrap", tooltip = "Recognizes continuation ranges without inventing historical SC/BC. Both directions may remain plausible.", group = G_ENGINE)
int minCause = input.int(8, "Minimum cause-building bars", minval = 3, maxval = 80, group = G_ENGINE)
int reclaimWindow = input.int(3, "Reclaim deadline (closed bars)", minval = 1, maxval = 5, group = G_ENGINE)
int maxAge = input.int(240, "Expire range after bars", minval = 40, maxval = 1500, group = G_ENGINE)
float tolerance = input.float(0.25, "Boundary tolerance / ATR", minval = 0.05, maxval = 1, step = 0.05, group = G_ENGINE)
float maxSweep = input.float(1.5, "Maximum sweep depth / ATR", minval = 0.5, maxval = 4, step = 0.1, group = G_ENGINE)
float minWidth = input.float(2.5, "Minimum range width / ATR", minval = 1, maxval = 8, step = 0.25, group = G_ENGINE)
float trailMultiple = input.float(3, "One-way trail / ATR", minval = 1, maxval = 8, step = 0.25, group = G_ENGINE)
const string G_CONTEXT = "02 · Evidence and setup gates"
bool useContext = input.bool(true, "Use confirmed higher-timeframe context", group = G_CONTEXT)
string contextInput = input.timeframe("", "Context timeframe (blank = automatic 4×)", group = G_CONTEXT)
bool requireVolume = input.bool(true, "Require volume for qualified backup", tooltip = "Price-only hypotheses remain visible. Missing/zero volume cannot qualify a setup when enabled.", group = G_CONTEXT)
float minEvidence = input.float(65, "Minimum backup evidence score", minval = 40, maxval = 95, group = G_CONTEXT)
float leadNeeded = input.float(10, "Minimum lead over opposing hypothesis", minval = 0, maxval = 40, group = G_CONTEXT)
bool enableJson = input.bool(false, "Emit JSON event alerts", group = G_CONTEXT)

float atr = ta.atr(14)
float volMedian = ta.median(volume, baseline)[1]
bool hasVolume = not na(volume) and volume > 0 and not na(volMedian) and volMedian > 0
float rvol = hasVolume ? volume / volMedian : na
float spread = high - low
float spreadMedian = ta.median(spread, baseline)[1]
float spreadRatio = spreadMedian > 0 ? spread / spreadMedian : 0
float volumeExtreme = ta.percentile_nearest_rank(volume, baseline, 90)[1]
float spreadExtreme = ta.percentile_nearest_rank(spread, baseline, 90)[1]
float ph = ta.pivothigh(high, swingBars, swingBars)
float pl = ta.pivotlow(low, swingBars, swingBars)
float recentHi = ta.highest(high, rangeBars)[1]
float recentLo = ta.lowest(low, rangeBars)[1]
float pathLength = math.sum(math.abs(ta.change(close)), rangeBars)[1]
float efficiency = pathLength > 0 ? math.abs(close[1] - close[rangeBars + 1]) / pathLength : 1
float fastVolume = ta.sma(volume, 8)
float slowVolume = ta.sma(volume, 30)
bool contraction = hasVolume and fastVolume < slowVolume * 0.9
float trend = ta.ema(close, 34)
bool priorDown = close[1] < trend[1] and trend[1] < trend[6]
bool priorUp = close[1] > trend[1] and trend[1] > trend[6]
bool quiet = efficiency < 0.3 and recentHi - recentLo <= atr * 10
bool extreme = hasVolume and volume >= volumeExtreme and rvol >= 1.8 and spread >= spreadExtreme and spreadRatio >= 1.3
float closeLocation = spread > 0 ? (close - low) / spread : 0.5
bool warm = bar_index > math.max(baseline, rangeBars) + 35 and not na(atr)
string contextTf = contextInput == "" ? timeframe.from_seconds(int(timeframe.in_seconds() * 4)) : contextInput
if barstate.isfirst and not chart.is_standard
    runtime.error("Use standard time-based candles. Synthetic prices are unsuitable for this analysis.")
if barstate.isfirst and useContext and timeframe.in_seconds(contextTf) <= timeframe.in_seconds()
    runtime.error("Context timeframe must be higher than the chart timeframe.")
[htfClose, htfMean, htfOldMean] = request.security(syminfo.tickerid, contextTf, [close[1], ta.ema(close, 34)[1], ta.ema(close, 34)[2]], lookahead = barmerge.lookahead_on)
int htfBias = not useContext ? 0 : htfClose > htfMean and htfMean > htfOldMean ? 1 : htfClose < htfMean and htfMean < htfOldMean ? -1 : 0

f_frame(int direction) =>
    WBar b = WBar.new()
    b.i := bar_index
    b.closed := barstate.isconfirmed and warm
    b.o := direction * open
    b.h := direction == 1 ? high : -low
    b.l := direction == 1 ? low : -high
    b.c := direction * close
    b.atr := atr
    b.rv := rvol
    b.vol := volume
    b.volumeOk := hasVolume
    b.climax := extreme and (direction == 1 ? priorDown and low < recentLo and closeLocation >= 0.4 : priorUp and high > recentHi and closeLocation <= 0.6)
    b.quiet := quiet
    b.rangeLo := direction == 1 ? recentLo : -recentHi
    b.rangeHi := direction == 1 ? recentHi : -recentLo
    b.ph := direction == 1 ? ph : -pl
    b.pl := direction == 1 ? pl : -ph
    b.pivotVolume := volume[swingBars]
    b.pivotRv := rvol[swingBars]
    b.pivotIndex := bar_index - swingBars
    b.spreadRatio := spreadRatio
    b.contraction := contraction
    b.context := direction * htfBias
    b.delta := direction * fpDelta
    b

var WEngine bull = WEngine.new(1)
var WEngine bear = WEngine.new(-1)
WConfig config = WConfig.new(minCause, reclaimWindow, maxAge, tolerance, maxSweep, minWidth, trailMultiple, allowBootstrap)
bull.step(f_frame(1), config)
bear.step(f_frame(-1), config)
bool eligibleLong = barstate.isconfirmed and warm and bull.event == "BU" and bull.score >= minEvidence and bull.score - bear.score >= leadNeeded and (not requireVolume or hasVolume) and (not useContext or htfBias >= 0)
bool eligibleShort = barstate.isconfirmed and warm and bear.event == "BU" and bear.score >= minEvidence and bear.score - bull.score >= leadNeeded and (not requireVolume or hasVolume) and (not useContext or htfBias <= 0)
alertcondition(eligibleLong, "Qualified bullish backup", "Wyckoff Evidence Desk: qualified bullish backup on {{ticker}} {{interval}}. Evidence is not a probability.")
alertcondition(eligibleShort, "Qualified bearish backup", "Wyckoff Evidence Desk: qualified bearish backup on {{ticker}} {{interval}}. Evidence is not a probability.")
alertcondition(barstate.isconfirmed and (bull.event == "SPRING" or bear.event == "UTAD"), "Confirmed reclaim", "Wyckoff boundary reclaim confirmed on {{ticker}} {{interval}}; inspect test and acceptance status.")
if enableJson and barstate.isconfirmed
    for side in array.from(bull, bear)
        if side.event != ""
            string payload = '{"schema":"wed.v1","symbol":"' + syminfo.tickerid + '","timeframe":"' + timeframe.period + '","barTime":' + str.tostring(time) + ',"side":' + str.tostring(side.dir) + ',"phase":' + str.tostring(side.phase) + ',"event":"' + side.event + '","evidence":' + str.tostring(side.score, "#.0") + ',"volumeAvailable":' + str.tostring(hasVolume) + '}'
            alert(payload, alert.freq_all)
// Numeric exports for Data Window and Pine Screener. Scores are uncalibrated rules.
plot(bull.phase, "Accumulation phase 0-5", display = display.data_window)
plot(bear.phase, "Distribution phase 0-5", display = display.data_window)
plot(bull.score, "Accumulation evidence 0-100", display = display.data_window)
plot(bear.score, "Distribution evidence 0-100", display = display.data_window)
plot(eligibleLong ? 1 : eligibleShort ? -1 : 0, "Qualified backup direction", display = display.data_window)



// ── Evidence desk · chart presentation ────────────────────────────────────────
string visualGroup = "05 · Evidence desk"
bool showDashboard = input.bool(true, "Evidence dashboard", group = visualGroup)
bool showTape = input.bool(true, "Last six confirmed events", group = visualGroup)
bool showLabels = input.bool(true, "Confirmed event labels", group = visualGroup)
bool showRange = input.bool(true, "Active hypothesis ranges", group = visualGroup)
bool showVwap = input.bool(true, "Event-anchored VWAP", group = visualGroup)
bool showTrail = input.bool(true, "Phase E structural trail", group = visualGroup)
bool showBars = input.bool(false, "Color bars from phase C onward", group = visualGroup)
int maxEventLabels = input.int(60, "Retained event labels", minval = 10, maxval = 100, group = visualGroup)
string dashboardPosition = input.string("Top right", "Dashboard position", options = ["Top right", "Bottom right", "Top left", "Bottom left"], group = visualGroup)

color vdInk = #0B151C
color vdPanel = #101F29
color vdGrid = #223642
color vdText = #DCE9ED
color vdMuted = #819AA5
color vdBull = #44D9C0
color vdBear = #FF8177
color vdGold = #EDC778

f_vdPhase(int phase) =>
    switch phase
        1 => "A · stopping action"
        2 => "B · range building"
        3 => "C · test / sweep"
        4 => "D · directional proof"
        5 => "E · follow-through"
        => "SCAN · no range"

f_vdRibbon(int phase) =>
    phase == 0 ? "SCAN   ·   WAIT FOR STRUCTURE" : (phase == 1 ? "[A]" : " A ") + " — " + (phase == 2 ? "[B]" : " B ") + " — " + (phase == 3 ? "[C]" : " C ") + " — " + (phase == 4 ? "[D]" : " D ") + " — " + (phase == 5 ? "[E]" : " E ")

f_vdFailed(string eventName) =>
    str.contains(str.upper(eventName), "FAIL") or str.contains(str.upper(eventName), "INVALID") or str.contains(str.upper(eventName), "EXPIRE")

f_vdPrice(float value) =>
    na(value) ? "—" : str.tostring(value, format.mintick)

f_vdScore(float value) =>
    float bounded = math.max(0.0, math.min(100.0, nz(value)))
    int blocks = int(math.round(bounded / 20.0))
    string meter = ""
    for block = 1 to 5
        meter += block <= blocks ? "■" : "·"
    str.tostring(bounded, "0") + " / 100  " + meter

f_vdBrief(string value, int limit) =>
    str.length(value) > limit ? str.substring(value, 0, limit - 1) + "…" : value

f_vdTest(WEngine engine) =>
    engine.phase == 0 ? "Awaiting range" : engine.pending == 1 ? "Reclaim pending" : engine.testPassed and engine.testObserved ? "Test confirmed" : engine.testPassed ? "Type 3 · test waived" : engine.phase >= 4 ? "Direct strength path" : "No confirmed test"

f_vdBoundaryTip(WEngine engine, bool isBull) =>
    float direction = isBull ? 1.0 : -1.0
    string side = isBull ? "below " : "above "
    string detail = "No active range."
    if engine.phase > 0 and na(engine.hi)
        detail := "Phase A candidate: awaiting the automatic reaction to establish both range edges. Observed origin: " + f_vdPrice(direction * engine.lo)
    else if engine.phase > 0
        detail := "Sustained close invalidation: " + side + f_vdPrice(direction * (engine.lo - atr * tolerance)) + ". More than " + str.tostring(reclaimWindow) + " consecutive closes are required.\nMaximum adverse wick boundary: " + f_vdPrice(direction * (engine.lo - atr * maxSweep)) + ". Crossing it invalidates on the confirmed bar."
        if engine.phase == 5
            detail += "\nPhase E structural trail: " + f_vdPrice(direction * engine.trail) + ".\nBackup failure close boundary: " + side + f_vdPrice(direction * (engine.hi - atr * tolerance)) + ".\nTrail is structural context, not a broker stop order."
    detail

f_vdCell(table panel, int column, int row, string value, color foreground, color background, string tooltipText = "") =>
    table.cell(panel, column, row, value, text_color = foreground, bgcolor = background, text_size = size.small, text_halign = column == 0 ? text.align_left : text.align_center, tooltip = tooltipText)

f_vdTape(array<string> tape, array<color> colors, bool isBull, string eventName, string reason) =>
    string stamp = str.format_time(na(time_close) ? time : time_close, "dd MMM HH:mm", syminfo.timezone)
    array.unshift(tape, stamp + "  ·  " + (isBull ? "ACC" : "DIST") + "  " + eventName + "  ·  " + f_vdBrief(reason, 28))
    array.unshift(colors, f_vdFailed(eventName) ? vdGold : isBull ? vdBull : vdBear)
    if array.size(tape) > 6
        array.pop(tape)
        array.pop(colors)

f_vdLabel(bool isBull, WEngine engine) =>
    color tone = f_vdFailed(engine.event) ? vdGold : isBull ? vdBull : vdBear
    float y = isBull ? low - nz(atr, high - low) * 0.24 : high + nz(atr, high - low) * 0.24
    string tip = (isBull ? "ACCUMULATION" : "DISTRIBUTION") + " · " + engine.event + "\nConfirmed on this bar close.\n" + f_vdPhase(engine.phase) + "\nEvidence: " + str.tostring(engine.score, "0") + " / 100 (heuristic, not probability)\n" + engine.why
    label.new(bar_index, y, engine.event, xloc = xloc.bar_index, yloc = yloc.price, style = isBull ? label.style_label_up : label.style_label_down, color = color.new(tone, 12), textcolor = vdInk, size = size.tiny, tooltip = tip)

bool vdBullActive = bull.phase > 0
bool vdBearActive = bear.phase > 0
bool vdBullRange = vdBullActive and not na(bull.lo) and not na(bull.hi)
bool vdBearRange = vdBearActive and not na(bear.lo) and not na(bear.hi)
bool vdContested = vdBullActive and vdBearActive and math.abs(bull.score - bear.score) <= 10.0
bool vdSelectBull = vdBullActive and (not vdBearActive or bull.score >= bear.score)
bool vdSelectBear = vdBearActive and not vdSelectBull
bool vdShowBull = vdBullActive and (vdSelectBull or vdContested)
bool vdShowBear = vdBearActive and (vdSelectBear or vdContested)
bool vdDrawBull = vdBullRange and vdShowBull
bool vdDrawBear = vdBearRange and vdShowBear
int vdPhase = vdSelectBull ? bull.phase : vdSelectBear ? bear.phase : 0
color vdAccent = vdContested ? vdGold : vdSelectBull ? vdBull : vdSelectBear ? vdBear : vdMuted
bool vdReset = not vdBullActive and not vdBearActive and (f_vdFailed(bull.lastEvent) or f_vdFailed(bear.lastEvent))
string vdMode = vdContested ? "CONTESTED · BOTH HYPOTHESES ACTIVE" : vdSelectBull ? "ACCUMULATION · LEADING EVIDENCE" : vdSelectBear ? "DISTRIBUTION · LEADING EVIDENCE" : vdReset ? "RESET → SCAN · AWAIT NEW STRUCTURE" : "SCANNING · AWAIT STOPPING ACTION"

// Reuse two boxes. Their left edge is the confirmation bar that started the range.
var box vdBullBox = na
var box vdBearBox = na
if showRange and vdDrawBull
    int left = math.max(nz(bull.born, bar_index), bar_index - 9999)
    if na(vdBullBox)
        vdBullBox := box.new(left, bull.hi, bar_index + 1, bull.lo, border_color = color.new(vdBull, 26), border_width = 1, bgcolor = color.new(vdBull, 94), text = "", text_color = color.new(vdBull, 12), text_size = size.tiny, text_halign = text.align_left, text_valign = text.align_top)
    box.set_lefttop(vdBullBox, left, bull.hi)
    box.set_rightbottom(vdBullBox, bar_index + 1, bull.lo)
    box.set_border_style(vdBullBox, vdContested ? line.style_dashed : line.style_solid)
    box.set_text(vdBullBox, "  ACC  ·  " + f_vdPhase(bull.phase) + "  ·  " + str.tostring(bull.score, "0") + "/100")
else if not na(vdBullBox)
    box.delete(vdBullBox)
    vdBullBox := na

if showRange and vdDrawBear
    int left = math.max(nz(bear.born, bar_index), bar_index - 9999)
    if na(vdBearBox)
        vdBearBox := box.new(left, -bear.lo, bar_index + 1, -bear.hi, border_color = color.new(vdBear, 26), border_width = 1, bgcolor = color.new(vdBear, 94), text = "", text_color = color.new(vdBear, 12), text_size = size.tiny, text_halign = text.align_left, text_valign = text.align_bottom)
    box.set_lefttop(vdBearBox, left, -bear.lo)
    box.set_rightbottom(vdBearBox, bar_index + 1, -bear.hi)
    box.set_border_style(vdBearBox, vdContested ? line.style_dashed : line.style_solid)
    box.set_text(vdBearBox, "  DIST  ·  " + f_vdPhase(bear.phase) + "  ·  " + str.tostring(bear.score, "0") + "/100")
else if not na(vdBearBox)
    box.delete(vdBearBox)
    vdBearBox := na

plot(showVwap and vdShowBull ? bull.vwap : na, "Accumulation event AVWAP", color = vdBull, linewidth = 2, style = plot.style_linebr)
plot(showVwap and vdShowBear ? -bear.vwap : na, "Distribution event AVWAP", color = vdBear, linewidth = 2, style = plot.style_linebr)
float vdSelectedTrail = not vdContested and vdPhase == 5 ? vdSelectBull ? bull.trail : -bear.trail : na
plot(showTrail ? vdSelectedTrail : na, "Leading Phase E structural trail", color = vdGold, linewidth = 1, style = plot.style_linebr)
barcolor(showBars and vdPhase >= 3 ? color.new(vdAccent, 24) : na, title = "Evidence phase tint")
plotshape(eligibleLong and barstate.isconfirmed, title = "Confirmed long setup", style = shape.diamond, location = location.belowbar, color = vdBull, size = size.tiny)
plotshape(eligibleShort and barstate.isconfirmed, title = "Confirmed short setup", style = shape.diamond, location = location.abovebar, color = vdBear, size = size.tiny)

var array<label> vdLabels = array.new<label>()
var array<string> vdTape = array.new<string>()
var array<color> vdTapeColors = array.new<color>()
if barstate.isconfirmed
    if bull.event != ""
        f_vdTape(vdTape, vdTapeColors, true, bull.event, bull.why)
        if showLabels
            array.push(vdLabels, f_vdLabel(true, bull))
    if bear.event != ""
        f_vdTape(vdTape, vdTapeColors, false, bear.event, bear.why)
        if showLabels
            array.push(vdLabels, f_vdLabel(false, bear))
    while array.size(vdLabels) > maxEventLabels
        label.delete(array.shift(vdLabels))

string vdPosition = switch dashboardPosition
    "Bottom right" => position.bottom_right
    "Top left" => position.top_left
    "Bottom left" => position.bottom_left
    => position.top_right
var table vdDesk = table.new(vdPosition, 3, 21, bgcolor = vdInk, frame_color = vdGrid, frame_width = 1, border_color = vdInk, border_width = 1)
if barstate.isfirst
    table.merge_cells(vdDesk, 0, 0, 2, 0)
    table.merge_cells(vdDesk, 0, 1, 2, 1)
    table.merge_cells(vdDesk, 0, 2, 2, 2)
    table.merge_cells(vdDesk, 0, 13, 2, 13)
    table.merge_cells(vdDesk, 0, 14, 2, 14)
    for row = 15 to 20
        table.merge_cells(vdDesk, 0, row, 2, row)

if barstate.islast or barstate.islastconfirmedhistory
    if showDashboard
        f_vdCell(vdDesk, 0, 0, "WYCKOFF  /  EVIDENCE DESK", vdText, vdPanel, "Two independent, sequential hypotheses. Evidence scores are rule-based support, not calibrated probabilities.")
        f_vdCell(vdDesk, 0, 1, vdMode, vdAccent, color.new(vdAccent, 91))
        f_vdCell(vdDesk, 0, 2, vdContested ? "TWO ACTIVE PATHS · NO SINGLE PHASE RIBBON" : f_vdRibbon(vdPhase), vdAccent, vdInk)
        f_vdCell(vdDesk, 0, 3, "HYPOTHESIS", vdMuted, vdPanel)
        f_vdCell(vdDesk, 1, 3, "ACCUMULATION", vdBull, vdPanel)
        f_vdCell(vdDesk, 2, 3, "DISTRIBUTION", vdBear, vdPanel)
        f_vdCell(vdDesk, 0, 4, "Phase", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 4, f_vdPhase(bull.phase), vdBullActive ? vdBull : vdMuted, vdInk)
        f_vdCell(vdDesk, 2, 4, f_vdPhase(bear.phase), vdBearActive ? vdBear : vdMuted, vdInk)
        f_vdCell(vdDesk, 0, 5, "Evidence", vdMuted, vdPanel, "Heuristic support from the configured rules; 80/100 does not mean an 80% chance of success.")
        f_vdCell(vdDesk, 1, 5, f_vdScore(bull.score), vdBull, vdPanel)
        f_vdCell(vdDesk, 2, 5, f_vdScore(bear.score), vdBear, vdPanel)
        f_vdCell(vdDesk, 0, 6, "Range / limits ⓘ", vdMuted, vdInk, "Hover either hypothesis for its invalidation boundaries and, in Phase E, its structural trail. Live ATR-based boundaries can move until bar close.")
        f_vdCell(vdDesk, 1, 6, vdBullRange ? f_vdPrice(bull.lo) + " – " + f_vdPrice(bull.hi) : vdBullActive ? "Awaiting AR" : "—", vdText, vdInk, f_vdBoundaryTip(bull, true))
        f_vdCell(vdDesk, 2, 6, vdBearRange ? f_vdPrice(-bear.hi) + " – " + f_vdPrice(-bear.lo) : vdBearActive ? "Awaiting AR" : "—", vdText, vdInk, f_vdBoundaryTip(bear, false))
        f_vdCell(vdDesk, 0, 7, "Test", vdMuted, vdPanel)
        f_vdCell(vdDesk, 1, 7, f_vdTest(bull), bull.pending == 1 ? vdGold : vdText, vdPanel)
        f_vdCell(vdDesk, 2, 7, f_vdTest(bear), bear.pending == 1 ? vdGold : vdText, vdPanel)
        f_vdCell(vdDesk, 0, 8, "Latest event", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 8, bull.lastEvent == "" ? "—" : bull.lastEvent, f_vdFailed(bull.lastEvent) ? vdGold : vdBull, vdInk)
        f_vdCell(vdDesk, 2, 8, bear.lastEvent == "" ? "—" : bear.lastEvent, f_vdFailed(bear.lastEvent) ? vdGold : vdBear, vdInk)
        string contextName = not useContext ? "OFF" : htfBias == 1 ? "BULLISH" : htfBias == -1 ? "BEARISH" : "NEUTRAL"
        f_vdCell(vdDesk, 0, 9, "Context · " + contextTf, vdMuted, vdPanel, "Context is read from confirmed higher-timeframe data.")
        f_vdCell(vdDesk, 1, 9, contextName, not useContext ? vdMuted : htfBias == 1 ? vdBull : htfBias == -1 ? vdBear : vdGold, vdPanel)
        f_vdCell(vdDesk, 2, 9, useContext ? "CLOSED HTF" : "FILTER OFF", vdMuted, vdPanel)
        f_vdCell(vdDesk, 0, 10, "Volume / flow", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 10, hasVolume ? "RVOL  " + str.tostring(rvol, "0.00") + "×" : "VOLUME UNAVAILABLE", hasVolume ? vdText : vdGold, vdInk)
        f_vdCell(vdDesk, 2, 10, footprintBuild ? na(fpDelta) ? "FOOTPRINT N/A" : "Δ VOL  " + (fpDelta > 0 ? "+" : "") + str.tostring(fpDelta * 100.0, "0.0") + "%" : "OHLCV", footprintBuild and na(fpDelta) ? vdGold : vdMuted, vdInk, "Footprint delta divided by total classified volume. +25.0% means a +0.25 ratio, not 25 contracts or a probability.")
        f_vdCell(vdDesk, 0, 11, "Confirmed setup", vdMuted, vdPanel, "This row is true only on a confirmed eligible setup event. It is not a standing instruction or a profitability estimate.")
        f_vdCell(vdDesk, 1, 11, eligibleLong and barstate.isconfirmed ? "LONG · NEW EVENT" : "WAIT", eligibleLong and barstate.isconfirmed ? vdBull : vdMuted, vdPanel)
        f_vdCell(vdDesk, 2, 11, eligibleShort and barstate.isconfirmed ? "SHORT · NEW EVENT" : "WAIT", eligibleShort and barstate.isconfirmed ? vdBear : vdMuted, vdPanel)
        f_vdCell(vdDesk, 0, 12, "Evidence note", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 12, f_vdBrief(bull.why, 26), vdMuted, vdInk, bull.why)
        f_vdCell(vdDesk, 2, 12, f_vdBrief(bear.why, 26), vdMuted, vdInk, bear.why)
        f_vdCell(vdDesk, 0, 13, barstate.isconfirmed ? "BAR CLOSED  ·  SCORE ≠ PROBABILITY" : "LIVE BAR  ·  EVENTS WAIT FOR CLOSE", barstate.isconfirmed ? vdMuted : vdGold, vdPanel)
        f_vdCell(vdDesk, 0, 14, showTape ? "EVENT TAPE  ·  NEWEST FIRST  ·  EXCHANGE TIME" : "", vdMuted, showTape ? vdPanel : color.new(vdInk, 100))
        for index = 0 to 5
            bool hasEntry = showTape and index < array.size(vdTape)
            string entry = hasEntry ? array.get(vdTape, index) : showTape and index == 0 ? "No confirmed events in loaded history." : ""
            color entryColor = hasEntry ? array.get(vdTapeColors, index) : vdMuted
            f_vdCell(vdDesk, 0, 15 + index, entry, entryColor, showTape ? index % 2 == 0 ? vdInk : vdPanel : color.new(vdInk, 100))
    else
        table.clear(vdDesk, 0, 0, 2, 20)
Download Footprint .pine
Optional · Strategy Lab

A separate strategy tests qualified backups with simulated orders and costs. It uses fixed quantity, not fixed cash risk. Its initial development sample lost money.

Lab · complete Pine source

Copy the whole file. No coding needed.

View the full code
//@version=6
// Wyckoff Evidence Desk 1.0.0. Original heuristic interpretation, not a probability or profit claim.
strategy("Wyckoff Evidence Desk · Validation Lab", "WED Lab", overlay = true, behind_chart = false, max_labels_count = 120, max_boxes_count = 4, max_lines_count = 20, max_bars_back = 2000, initial_capital = 100000, pyramiding = 0, default_qty_type = strategy.fixed, default_qty_value = 1, commission_type = strategy.commission.percent, commission_value = 0.05, slippage = 2, process_orders_on_close = false, calc_on_every_tick = false, calc_on_order_fills = false, calc_on_every_history_tick = false, margin_long = 100, margin_short = 100)
const bool footprintBuild = false
float fpDelta = na

// Causal, direction-normalized engine. The same code runs both hypotheses.
// All episode state changes occur only for committed frames. No future bars are read.
type WConfig
    int minB = 8
    int reclaimBars = 3
    int maxAge = 240
    float tolerance = 0.25
    float maxSweep = 1.5
    float minWidth = 2.5
    float trailAtr = 3.0
    bool allowBootstrap = true

type WBar
    int i = 0
    bool closed = true
    float o = na
    float h = na
    float l = na
    float c = na
    float atr = na
    float rv = na
    float vol = na
    bool volumeOk = false
    bool climax = false
    bool quiet = false
    float rangeLo = na
    float rangeHi = na
    float ph = na
    float pl = na
    float pivotVolume = na
    float pivotRv = na
    int pivotIndex = 0
    float spreadRatio = na
    bool contraction = false
    int context = 0
    float delta = na

type WEngine
    int dir = 1
    int phase = 0
    int stage = 0
    float lo = na
    float hi = na
    int born = na
    int climaxBar = na
    float climaxVolume = na
    int arBar = na
    int bStarted = na
    bool bootstrap = false
    int pending = 0
    int sweepBar = na
    float sweepExtreme = na
    float sweepRv = na
    int sweepKind = 0
    bool testPassed = false
    bool testObserved = false
    int strengthBar = na
    int breakout = na
    int outside = 0
    int invalidCloses = 0
    bool backedUp = false
    float trail = na
    float lastSwingHigh = na
    float pvSum = 0.0
    float vSum = 0.0
    float vwap = na
    float score = 0.0
    string event = ""
    string lastEvent = "Waiting"
    string why = "Waiting for exhaustion or a balanced range"
    int resetAt = -10000
    int lastTest = -10000
    int lastProcessed = -1

method tag(WEngine e, string name, string reason) =>
    e.event := name
    e.lastEvent := name
    e.why := reason

method clearEpisode(WEngine e, int i, string reason) =>
    e.phase := 0
    e.stage := 0
    e.lo := na
    e.hi := na
    e.born := na
    e.climaxBar := na
    e.climaxVolume := na
    e.arBar := na
    e.bStarted := na
    e.bootstrap := false
    e.pending := 0
    e.sweepBar := na
    e.sweepExtreme := na
    e.sweepRv := na
    e.sweepKind := 0
    e.testPassed := false
    e.testObserved := false
    e.strengthBar := na
    e.breakout := na
    e.outside := 0
    e.invalidCloses := 0
    e.backedUp := false
    e.trail := na
    e.lastSwingHigh := na
    e.pvSum := 0.0
    e.vSum := 0.0
    e.vwap := na
    e.score := 0.0
    e.resetAt := i
    e.lastTest := -10000
    e.tag("INVALID", reason)

method startEpisode(WEngine e, WBar b, bool bootstrap) =>
    e.born := b.i
    e.bootstrap := bootstrap
    e.climaxBar := b.i
    e.climaxVolume := b.volumeOk ? b.vol : na
    e.lo := bootstrap ? b.rangeLo : b.l
    e.hi := bootstrap ? b.rangeHi : na
    e.phase := bootstrap ? 2 : 1
    e.stage := bootstrap ? 3 : 1
    e.bStarted := bootstrap ? b.i : na
    e.tag(bootstrap ? "TR?" : e.dir == 1 ? "SC?" : "BC?", bootstrap ? "Balanced range bootstrap; Phase A was not observed" : "Exhaustion candidate; waiting for a confirmed automatic reaction")

method step(WEngine e, WBar b, WConfig cfg) =>
    if b.closed and b.i > e.lastProcessed
        e.lastProcessed := b.i
        e.event := ""
        float tol = b.atr * cfg.tolerance
        float width = e.hi - e.lo
        int enteredPhase = e.phase
        bool inside = not na(e.hi) and b.c >= e.lo and b.c <= e.hi
        float closeLocation = b.h > b.l ? (b.c - b.l) / (b.h - b.l) : 0.5
        bool pivotLowEffort = not na(b.pivotRv) and b.pivotRv < 1.15
        bool demand = b.c > b.o and closeLocation >= 0.65 and b.spreadRatio >= 1.05 and (not b.volumeOk or b.rv >= 1.1)
        bool expired = e.phase > 0 and e.phase < 5 and b.i - e.born > cfg.maxAge
        if e.phase > 0 and not na(e.hi)
            e.invalidCloses := b.c < e.lo - tol ? e.invalidCloses + 1 : 0
        bool invalid = e.phase > 0 and e.invalidCloses > cfg.reclaimBars
        bool deepAdverse = e.phase > 0 and not na(e.hi) and b.l < e.lo - cfg.maxSweep * b.atr
        bool bothEdges = e.phase > 0 and not na(e.hi) and b.l < e.lo - tol and b.h > e.hi + tol
        bool trendFailed = e.phase == 5 and (b.c < e.trail or b.c < e.hi - tol)
        if expired or invalid or deepAdverse
            e.clearEpisode(b.i, expired ? "Range expired before acceptance" : deepAdverse ? "Adverse excursion exceeded maximum structural depth" : "Sustained acceptance beyond the invalidation boundary")
        else if trendFailed
            e.clearEpisode(b.i, b.c < e.trail ? "Confirmed close crossed the one-way structural trail" : "Backup failed: close crossed the breakout boundary")
        else if bothEdges
            e.pending := 0
            e.testPassed := false
            e.testObserved := false
            if e.phase >= 3 and e.phase < 5
                e.phase := 2
                e.bStarted := b.i
                e.breakout := na
                e.outside := 0
            e.tag("AMBIGUOUS", "This bar crossed both range edges; OHLC cannot establish the sequence")
        else if e.phase == 0
            if b.i - e.resetAt > 3 and not na(b.atr) and b.atr > 0
                if b.climax
                    e.startEpisode(b, false)
                else if cfg.allowBootstrap and b.quiet and b.rangeHi - b.rangeLo >= cfg.minWidth * b.atr and b.c >= b.rangeLo and b.c <= b.rangeHi
                    e.startEpisode(b, true)
                else if b.volumeOk and b.rv >= 1.5 and closeLocation >= 0.65 and b.i - e.lastTest > 8
                    e.lastTest := b.i
                    e.tag(e.dir == 1 ? "PS?" : "PSY?", "Preliminary stopping activity; no range has been established")
        else if e.phase == 1
            if e.stage == 1
                // SC/BC is provisional until AR establishes the second boundary.
                if b.l < e.lo
                    e.lo := b.l
                    e.climaxBar := b.i
                    e.climaxVolume := b.volumeOk ? b.vol : na
                if not na(b.ph) and b.pivotIndex > e.climaxBar and b.ph - e.lo >= cfg.minWidth * b.atr
                    e.hi := b.ph
                    e.arBar := b.pivotIndex
                    e.stage := 2
                    e.tag("AR", "Confirmed swing establishes the other boundary; range is now frozen")
            else if e.stage == 2
                bool test = not na(b.pl) and b.pivotIndex > e.arBar and b.pl >= e.lo - tol and b.pl <= e.lo + width * 0.3 and inside
                bool comparableVolume = not na(b.pivotVolume) and b.pivotVolume > 0 and not na(e.climaxVolume) and e.climaxVolume > 0
                bool lessVolume = comparableVolume ? b.pivotVolume < e.climaxVolume : not b.volumeOk
                if test and lessVolume
                    e.phase := 2
                    e.stage := 3
                    e.bStarted := b.i
                    e.lastTest := b.i
                    e.tag("ST", b.volumeOk ? "Secondary test on lower volume; cause-building hypothesis begins" : "Price-only secondary test; volume unavailable")
        else if e.phase == 2 or e.phase == 3
            bool mature = b.i - e.bStarted >= cfg.minB
            if e.pending == 1
                e.sweepExtreme := math.min(e.sweepExtreme, b.l)
                e.sweepRv := b.volumeOk ? math.max(nz(e.sweepRv, b.rv), b.rv) : e.sweepRv
                bool tooDeep = e.lo - e.sweepExtreme > cfg.maxSweep * b.atr
                if b.i - e.sweepBar > cfg.reclaimBars or tooDeep
                    e.pending := 0
                    e.phase := 2
                    e.testPassed := false
                    e.tag("SWEEP FAILED", "Reclaim window expired or excursion exceeded the allowed depth")
                else if b.c >= e.lo and closeLocation >= 0.55
                    e.pending := 0
                    e.phase := 3
                    e.sweepKind := na(e.sweepRv) ? 0 : e.sweepRv >= 2 ? 1 : e.sweepRv >= 1 ? 2 : 3
                    e.testPassed := e.sweepKind == 3
                    e.tag(e.dir == 1 ? "SPRING" : "UTAD", b.c > e.hi ? "Recovered beyond the opposite edge; test and strength rules still apply" : e.testPassed ? "Low-volume reclaim confirmed; subsequent strength still required" : "Reclaim confirmed; waiting for a later lower-effort test")
            else if mature and b.l < e.lo - tol and b.c <= e.hi and e.lo - b.l <= cfg.maxSweep * b.atr
                e.pending := 1
                e.sweepBar := b.i
                e.sweepExtreme := b.l
                e.sweepRv := b.rv
                e.testPassed := false
                e.testObserved := false
                e.tag("SWEEP?", "Boundary breach observed; waiting for an actual close back inside")
                if inside and closeLocation >= 0.55
                    e.pending := 0
                    e.phase := 3
                    e.sweepKind := not b.volumeOk ? 0 : b.rv >= 2 ? 1 : b.rv >= 1 ? 2 : 3
                    e.testPassed := e.sweepKind == 3
                    e.tag(e.dir == 1 ? "SPRING" : "UTAD", e.testPassed ? "Same-bar low-volume reclaim confirmed; waiting for strength" : "Same-bar reclaim confirmed; later test required")
            else if e.phase == 3 and not e.testPassed
                bool higherTest = not na(b.pl) and b.pivotIndex > e.sweepBar and b.pl > e.sweepExtreme + tol and b.pl <= e.lo + width * 0.4 and inside
                bool dryTest = not na(b.pivotRv) and b.pivotRv < math.min(nz(e.sweepRv, 1), 1.15)
                bool priceOnlyTest = e.sweepKind == 0 and not b.volumeOk and na(b.pivotRv)
                if higherTest and (dryTest or priceOnlyTest)
                    e.testPassed := true
                    e.testObserved := true
                    e.lastTest := b.i
                    e.tag("TEST", priceOnlyTest ? "Structural test only; lower-effort volume evidence is unavailable" : "Confirmed higher-low / lower-high test on reduced relative volume")
            else if mature and demand and b.c > math.max(nz(e.lastSwingHigh, e.lo + width * 0.65), e.lo + width * 0.5) + tol and (e.phase == 2 or e.testPassed)
                e.phase := 4
                e.strengthBar := b.i
                e.tag(e.dir == 1 ? "SOS" : "SOW", enteredPhase == 2 ? "Directional dominance; no terminal sweep required" : "Directional dominance on expanded effort and result")
                if b.c > e.hi + tol
                    e.breakout := b.i
                    e.outside := 1
                    e.tag(e.dir == 1 ? "JAC" : "FTI", "First decisive close beyond the frozen range; acceptance is pending")
            else if e.phase == 2 and inside and b.i - e.lastTest > 5
                if not na(b.pl) and b.pivotIndex > e.bStarted and b.pl <= e.lo + width * 0.3 and b.pl >= e.lo - tol and pivotLowEffort
                    e.lastTest := b.i
                    e.tag("ST-B", "Range-edge test during cause building")
                else if b.h > e.hi + tol
                    e.lastTest := b.i
                    e.tag(e.dir == 1 ? "UTA" : "mSOW", "Opposite edge rejected back into the range; direction remains unresolved")
        else if e.phase == 4
            if na(e.breakout)
                if demand and b.c > e.hi + tol
                    e.breakout := b.i
                    e.outside := 1
                    e.tag(e.dir == 1 ? "JAC" : "FTI", "Breakout close observed; a second outside close is required")
                else if not na(b.pl) and b.pivotIndex > e.strengthBar and b.pl > nz(e.sweepExtreme, e.lo) + tol and inside and pivotLowEffort and b.i - e.lastTest > 5
                    e.lastTest := b.i
                    e.tag(e.dir == 1 ? "LPS" : "LPSY", "Confirmed pullback holds above structural origin on reduced effort")
            else
                e.outside := b.c > e.hi + tol ? e.outside + 1 : 0
                if e.outside >= 2
                    e.phase := 5
                    e.trail := math.max(e.lo, b.c - cfg.trailAtr * b.atr)
                    e.tag("ACCEPT", "Two consecutive closes accepted outside the range; awaiting backup")
                else if b.c < e.hi - tol or b.i - e.breakout > cfg.reclaimBars
                    e.breakout := na
                    e.outside := 0
                    e.phase := 2
                    e.bStarted := b.i
                    e.testPassed := false
                    e.tag("BREAKOUT FAILED", "Outside acceptance failed; cause-building hypothesis restarted")
        else if e.phase == 5
            if b.c < e.trail
                e.clearEpisode(b.i, "Confirmed close crossed the one-way structural trail")
            else if b.c < e.hi - tol
                e.clearEpisode(b.i, "Backup failed: price closed back through the breakout boundary")
            else
                e.trail := math.max(e.trail, b.c - cfg.trailAtr * b.atr)
                bool retest = b.i > e.breakout and b.l <= e.hi + tol and b.l >= e.hi - cfg.maxSweep * b.atr and b.c >= e.hi and b.c > b.o
                if retest and not e.backedUp and (not b.volumeOk or b.rv <= 1.3)
                    e.backedUp := true
                    e.tag("BU", b.volumeOk ? "Accepted breakout retested on controlled effort; one setup per episode" : "Price-only backup; volume confirmation unavailable")
        // AVWAP anchors at the observed episode start; missing volume is never invented.
        if e.phase > 0
            if b.volumeOk
                e.pvSum += (b.h + b.l + b.c) / 3 * b.vol
                e.vSum += b.vol
                e.vwap := e.pvSum / e.vSum
            if not na(b.ph) and b.pivotIndex >= e.born and b.ph < nz(e.hi, b.ph + 1)
                e.lastSwingHigh := b.ph
            float base = e.phase == 1 ? 12 : e.phase == 2 ? 25 : e.phase == 3 ? 40 : e.phase == 4 ? 55 : 65
            float support = (e.testPassed ? 8 : 0) + (b.contraction ? 5 : 0) + (not na(e.vwap) and b.c > e.vwap ? 5 : 0) + (b.context == 1 ? 7 : b.context == -1 ? -8 : 0) + (closeLocation >= 0.65 ? 5 : 0) + (not na(b.delta) and b.delta > 0.1 ? 5 : 0)
            e.score := math.max(0, math.min(100, base + support - (e.bootstrap ? 5 : 0) - (b.volumeOk ? 0 : 15) - (e.pending == 1 ? 8 : 0)))
    e.phase

const string G_ENGINE = "01 · Detection"
int swingBars = input.int(3, "Confirmed swing bars (latency)", minval = 2, maxval = 10, group = G_ENGINE)
int baseline = input.int(50, "Adaptive baseline", minval = 20, maxval = 200, group = G_ENGINE)
int rangeBars = input.int(30, "Balanced-range lookback", minval = 15, maxval = 100, group = G_ENGINE)
bool allowBootstrap = input.bool(true, "Allow balanced-range bootstrap", tooltip = "Recognizes continuation ranges without inventing historical SC/BC. Both directions may remain plausible.", group = G_ENGINE)
int minCause = input.int(8, "Minimum cause-building bars", minval = 3, maxval = 80, group = G_ENGINE)
int reclaimWindow = input.int(3, "Reclaim deadline (closed bars)", minval = 1, maxval = 5, group = G_ENGINE)
int maxAge = input.int(240, "Expire range after bars", minval = 40, maxval = 1500, group = G_ENGINE)
float tolerance = input.float(0.25, "Boundary tolerance / ATR", minval = 0.05, maxval = 1, step = 0.05, group = G_ENGINE)
float maxSweep = input.float(1.5, "Maximum sweep depth / ATR", minval = 0.5, maxval = 4, step = 0.1, group = G_ENGINE)
float minWidth = input.float(2.5, "Minimum range width / ATR", minval = 1, maxval = 8, step = 0.25, group = G_ENGINE)
float trailMultiple = input.float(3, "One-way trail / ATR", minval = 1, maxval = 8, step = 0.25, group = G_ENGINE)
const string G_CONTEXT = "02 · Evidence and setup gates"
bool useContext = input.bool(true, "Use confirmed higher-timeframe context", group = G_CONTEXT)
string contextInput = input.timeframe("", "Context timeframe (blank = automatic 4×)", group = G_CONTEXT)
bool requireVolume = input.bool(true, "Require volume for qualified backup", tooltip = "Price-only hypotheses remain visible. Missing/zero volume cannot qualify a setup when enabled.", group = G_CONTEXT)
float minEvidence = input.float(65, "Minimum backup evidence score", minval = 40, maxval = 95, group = G_CONTEXT)
float leadNeeded = input.float(10, "Minimum lead over opposing hypothesis", minval = 0, maxval = 40, group = G_CONTEXT)
bool enableJson = input.bool(false, "Emit JSON event alerts", group = G_CONTEXT)

float atr = ta.atr(14)
float volMedian = ta.median(volume, baseline)[1]
bool hasVolume = not na(volume) and volume > 0 and not na(volMedian) and volMedian > 0
float rvol = hasVolume ? volume / volMedian : na
float spread = high - low
float spreadMedian = ta.median(spread, baseline)[1]
float spreadRatio = spreadMedian > 0 ? spread / spreadMedian : 0
float volumeExtreme = ta.percentile_nearest_rank(volume, baseline, 90)[1]
float spreadExtreme = ta.percentile_nearest_rank(spread, baseline, 90)[1]
float ph = ta.pivothigh(high, swingBars, swingBars)
float pl = ta.pivotlow(low, swingBars, swingBars)
float recentHi = ta.highest(high, rangeBars)[1]
float recentLo = ta.lowest(low, rangeBars)[1]
float pathLength = math.sum(math.abs(ta.change(close)), rangeBars)[1]
float efficiency = pathLength > 0 ? math.abs(close[1] - close[rangeBars + 1]) / pathLength : 1
float fastVolume = ta.sma(volume, 8)
float slowVolume = ta.sma(volume, 30)
bool contraction = hasVolume and fastVolume < slowVolume * 0.9
float trend = ta.ema(close, 34)
bool priorDown = close[1] < trend[1] and trend[1] < trend[6]
bool priorUp = close[1] > trend[1] and trend[1] > trend[6]
bool quiet = efficiency < 0.3 and recentHi - recentLo <= atr * 10
bool extreme = hasVolume and volume >= volumeExtreme and rvol >= 1.8 and spread >= spreadExtreme and spreadRatio >= 1.3
float closeLocation = spread > 0 ? (close - low) / spread : 0.5
bool warm = bar_index > math.max(baseline, rangeBars) + 35 and not na(atr)
string contextTf = contextInput == "" ? timeframe.from_seconds(int(timeframe.in_seconds() * 4)) : contextInput
if barstate.isfirst and not chart.is_standard
    runtime.error("Use standard time-based candles. Synthetic prices are unsuitable for this analysis.")
if barstate.isfirst and useContext and timeframe.in_seconds(contextTf) <= timeframe.in_seconds()
    runtime.error("Context timeframe must be higher than the chart timeframe.")
[htfClose, htfMean, htfOldMean] = request.security(syminfo.tickerid, contextTf, [close[1], ta.ema(close, 34)[1], ta.ema(close, 34)[2]], lookahead = barmerge.lookahead_on)
int htfBias = not useContext ? 0 : htfClose > htfMean and htfMean > htfOldMean ? 1 : htfClose < htfMean and htfMean < htfOldMean ? -1 : 0

f_frame(int direction) =>
    WBar b = WBar.new()
    b.i := bar_index
    b.closed := barstate.isconfirmed and warm
    b.o := direction * open
    b.h := direction == 1 ? high : -low
    b.l := direction == 1 ? low : -high
    b.c := direction * close
    b.atr := atr
    b.rv := rvol
    b.vol := volume
    b.volumeOk := hasVolume
    b.climax := extreme and (direction == 1 ? priorDown and low < recentLo and closeLocation >= 0.4 : priorUp and high > recentHi and closeLocation <= 0.6)
    b.quiet := quiet
    b.rangeLo := direction == 1 ? recentLo : -recentHi
    b.rangeHi := direction == 1 ? recentHi : -recentLo
    b.ph := direction == 1 ? ph : -pl
    b.pl := direction == 1 ? pl : -ph
    b.pivotVolume := volume[swingBars]
    b.pivotRv := rvol[swingBars]
    b.pivotIndex := bar_index - swingBars
    b.spreadRatio := spreadRatio
    b.contraction := contraction
    b.context := direction * htfBias
    b.delta := direction * fpDelta
    b

var WEngine bull = WEngine.new(1)
var WEngine bear = WEngine.new(-1)
WConfig config = WConfig.new(minCause, reclaimWindow, maxAge, tolerance, maxSweep, minWidth, trailMultiple, allowBootstrap)
bull.step(f_frame(1), config)
bear.step(f_frame(-1), config)
bool eligibleLong = barstate.isconfirmed and warm and bull.event == "BU" and bull.score >= minEvidence and bull.score - bear.score >= leadNeeded and (not requireVolume or hasVolume) and (not useContext or htfBias >= 0)
bool eligibleShort = barstate.isconfirmed and warm and bear.event == "BU" and bear.score >= minEvidence and bear.score - bull.score >= leadNeeded and (not requireVolume or hasVolume) and (not useContext or htfBias <= 0)
alertcondition(eligibleLong, "Qualified bullish backup", "Wyckoff Evidence Desk: qualified bullish backup on {{ticker}} {{interval}}. Evidence is not a probability.")
alertcondition(eligibleShort, "Qualified bearish backup", "Wyckoff Evidence Desk: qualified bearish backup on {{ticker}} {{interval}}. Evidence is not a probability.")
alertcondition(barstate.isconfirmed and (bull.event == "SPRING" or bear.event == "UTAD"), "Confirmed reclaim", "Wyckoff boundary reclaim confirmed on {{ticker}} {{interval}}; inspect test and acceptance status.")
if enableJson and barstate.isconfirmed
    for side in array.from(bull, bear)
        if side.event != ""
            string payload = '{"schema":"wed.v1","symbol":"' + syminfo.tickerid + '","timeframe":"' + timeframe.period + '","barTime":' + str.tostring(time) + ',"side":' + str.tostring(side.dir) + ',"phase":' + str.tostring(side.phase) + ',"event":"' + side.event + '","evidence":' + str.tostring(side.score, "#.0") + ',"volumeAvailable":' + str.tostring(hasVolume) + '}'
            alert(payload, alert.freq_all)
// Numeric exports for Data Window and Pine Screener. Scores are uncalibrated rules.
plot(bull.phase, "Accumulation phase 0-5", display = display.data_window)
plot(bear.phase, "Distribution phase 0-5", display = display.data_window)
plot(bull.score, "Accumulation evidence 0-100", display = display.data_window)
plot(bear.score, "Distribution evidence 0-100", display = display.data_window)
plot(eligibleLong ? 1 : eligibleShort ? -1 : 0, "Qualified backup direction", display = display.data_window)

const string G_LAB = "04 · Validation Lab"
int startDate = input.time(timestamp("01 Jan 2024 00:00 +0000"), "Test start", group = G_LAB)
int endDate = input.time(timestamp("01 Jan 2030 00:00 +0000"), "Test end", group = G_LAB)
float orderQty = input.float(1, "Fixed contracts / units", minval = 0.000001, group = G_LAB)
float targetR = input.float(2, "Target / signal-planned risk", minval = 0.5, step = 0.25, tooltip = "Bracket is fixed at signal close and active with the next-open entry. Gaps and slippage change the realized entry risk/reward.", group = G_LAB)
int maxHold = input.int(100, "Maximum holding bars", minval = 5, maxval = 1000, group = G_LAB)
var float labStop = na
var float labTarget = na
var int labEntryBar = na
bool testWindow = time >= startDate and time <= endDate
if barstate.isconfirmed
    if strategy.position_size == 0 and strategy.position_size[1] != 0
        labStop := na
        labTarget := na
        labEntryBar := na
    if testWindow and strategy.position_size == 0
        if eligibleLong and not eligibleShort
            labStop := math.min(low - atr * tolerance, close - atr)
            labTarget := close + (close - labStop) * targetR
            labEntryBar := bar_index
            strategy.entry("Long BU", strategy.long, qty = orderQty)
            strategy.exit("Long bracket", "Long BU", stop = labStop, limit = labTarget)
        else if eligibleShort and not eligibleLong
            labStop := math.max(high + atr * tolerance, close + atr)
            labTarget := close - (labStop - close) * targetR
            labEntryBar := bar_index
            strategy.entry("Short BU", strategy.short, qty = orderQty)
            strategy.exit("Short bracket", "Short BU", stop = labStop, limit = labTarget)
    if strategy.position_size > 0
        strategy.exit("Long bracket", "Long BU", stop = labStop, limit = labTarget)
    if strategy.position_size < 0
        strategy.exit("Short bracket", "Short BU", stop = labStop, limit = labTarget)
    if strategy.position_size != 0 and (not testWindow or bar_index - strategy.opentrades.entry_bar_index(0) >= maxHold)
        strategy.close_all(comment = "Time exit")
plot(strategy.position_size != 0 ? labStop : na, "Lab frozen stop", color = #ff7d78, style = plot.style_linebr)
plot(strategy.position_size != 0 ? labTarget : na, "Lab frozen target", color = #51d9be, style = plot.style_linebr)

// ── Evidence desk · chart presentation ────────────────────────────────────────
string visualGroup = "05 · Evidence desk"
bool showDashboard = input.bool(true, "Evidence dashboard", group = visualGroup)
bool showTape = input.bool(true, "Last six confirmed events", group = visualGroup)
bool showLabels = input.bool(true, "Confirmed event labels", group = visualGroup)
bool showRange = input.bool(true, "Active hypothesis ranges", group = visualGroup)
bool showVwap = input.bool(true, "Event-anchored VWAP", group = visualGroup)
bool showTrail = input.bool(true, "Phase E structural trail", group = visualGroup)
bool showBars = input.bool(false, "Color bars from phase C onward", group = visualGroup)
int maxEventLabels = input.int(60, "Retained event labels", minval = 10, maxval = 100, group = visualGroup)
string dashboardPosition = input.string("Top right", "Dashboard position", options = ["Top right", "Bottom right", "Top left", "Bottom left"], group = visualGroup)

color vdInk = #0B151C
color vdPanel = #101F29
color vdGrid = #223642
color vdText = #DCE9ED
color vdMuted = #819AA5
color vdBull = #44D9C0
color vdBear = #FF8177
color vdGold = #EDC778

f_vdPhase(int phase) =>
    switch phase
        1 => "A · stopping action"
        2 => "B · range building"
        3 => "C · test / sweep"
        4 => "D · directional proof"
        5 => "E · follow-through"
        => "SCAN · no range"

f_vdRibbon(int phase) =>
    phase == 0 ? "SCAN   ·   WAIT FOR STRUCTURE" : (phase == 1 ? "[A]" : " A ") + " — " + (phase == 2 ? "[B]" : " B ") + " — " + (phase == 3 ? "[C]" : " C ") + " — " + (phase == 4 ? "[D]" : " D ") + " — " + (phase == 5 ? "[E]" : " E ")

f_vdFailed(string eventName) =>
    str.contains(str.upper(eventName), "FAIL") or str.contains(str.upper(eventName), "INVALID") or str.contains(str.upper(eventName), "EXPIRE")

f_vdPrice(float value) =>
    na(value) ? "—" : str.tostring(value, format.mintick)

f_vdScore(float value) =>
    float bounded = math.max(0.0, math.min(100.0, nz(value)))
    int blocks = int(math.round(bounded / 20.0))
    string meter = ""
    for block = 1 to 5
        meter += block <= blocks ? "■" : "·"
    str.tostring(bounded, "0") + " / 100  " + meter

f_vdBrief(string value, int limit) =>
    str.length(value) > limit ? str.substring(value, 0, limit - 1) + "…" : value

f_vdTest(WEngine engine) =>
    engine.phase == 0 ? "Awaiting range" : engine.pending == 1 ? "Reclaim pending" : engine.testPassed and engine.testObserved ? "Test confirmed" : engine.testPassed ? "Type 3 · test waived" : engine.phase >= 4 ? "Direct strength path" : "No confirmed test"

f_vdBoundaryTip(WEngine engine, bool isBull) =>
    float direction = isBull ? 1.0 : -1.0
    string side = isBull ? "below " : "above "
    string detail = "No active range."
    if engine.phase > 0 and na(engine.hi)
        detail := "Phase A candidate: awaiting the automatic reaction to establish both range edges. Observed origin: " + f_vdPrice(direction * engine.lo)
    else if engine.phase > 0
        detail := "Sustained close invalidation: " + side + f_vdPrice(direction * (engine.lo - atr * tolerance)) + ". More than " + str.tostring(reclaimWindow) + " consecutive closes are required.\nMaximum adverse wick boundary: " + f_vdPrice(direction * (engine.lo - atr * maxSweep)) + ". Crossing it invalidates on the confirmed bar."
        if engine.phase == 5
            detail += "\nPhase E structural trail: " + f_vdPrice(direction * engine.trail) + ".\nBackup failure close boundary: " + side + f_vdPrice(direction * (engine.hi - atr * tolerance)) + ".\nTrail is structural context, not a broker stop order."
    detail

f_vdCell(table panel, int column, int row, string value, color foreground, color background, string tooltipText = "") =>
    table.cell(panel, column, row, value, text_color = foreground, bgcolor = background, text_size = size.small, text_halign = column == 0 ? text.align_left : text.align_center, tooltip = tooltipText)

f_vdTape(array<string> tape, array<color> colors, bool isBull, string eventName, string reason) =>
    string stamp = str.format_time(na(time_close) ? time : time_close, "dd MMM HH:mm", syminfo.timezone)
    array.unshift(tape, stamp + "  ·  " + (isBull ? "ACC" : "DIST") + "  " + eventName + "  ·  " + f_vdBrief(reason, 28))
    array.unshift(colors, f_vdFailed(eventName) ? vdGold : isBull ? vdBull : vdBear)
    if array.size(tape) > 6
        array.pop(tape)
        array.pop(colors)

f_vdLabel(bool isBull, WEngine engine) =>
    color tone = f_vdFailed(engine.event) ? vdGold : isBull ? vdBull : vdBear
    float y = isBull ? low - nz(atr, high - low) * 0.24 : high + nz(atr, high - low) * 0.24
    string tip = (isBull ? "ACCUMULATION" : "DISTRIBUTION") + " · " + engine.event + "\nConfirmed on this bar close.\n" + f_vdPhase(engine.phase) + "\nEvidence: " + str.tostring(engine.score, "0") + " / 100 (heuristic, not probability)\n" + engine.why
    label.new(bar_index, y, engine.event, xloc = xloc.bar_index, yloc = yloc.price, style = isBull ? label.style_label_up : label.style_label_down, color = color.new(tone, 12), textcolor = vdInk, size = size.tiny, tooltip = tip)

bool vdBullActive = bull.phase > 0
bool vdBearActive = bear.phase > 0
bool vdBullRange = vdBullActive and not na(bull.lo) and not na(bull.hi)
bool vdBearRange = vdBearActive and not na(bear.lo) and not na(bear.hi)
bool vdContested = vdBullActive and vdBearActive and math.abs(bull.score - bear.score) <= 10.0
bool vdSelectBull = vdBullActive and (not vdBearActive or bull.score >= bear.score)
bool vdSelectBear = vdBearActive and not vdSelectBull
bool vdShowBull = vdBullActive and (vdSelectBull or vdContested)
bool vdShowBear = vdBearActive and (vdSelectBear or vdContested)
bool vdDrawBull = vdBullRange and vdShowBull
bool vdDrawBear = vdBearRange and vdShowBear
int vdPhase = vdSelectBull ? bull.phase : vdSelectBear ? bear.phase : 0
color vdAccent = vdContested ? vdGold : vdSelectBull ? vdBull : vdSelectBear ? vdBear : vdMuted
bool vdReset = not vdBullActive and not vdBearActive and (f_vdFailed(bull.lastEvent) or f_vdFailed(bear.lastEvent))
string vdMode = vdContested ? "CONTESTED · BOTH HYPOTHESES ACTIVE" : vdSelectBull ? "ACCUMULATION · LEADING EVIDENCE" : vdSelectBear ? "DISTRIBUTION · LEADING EVIDENCE" : vdReset ? "RESET → SCAN · AWAIT NEW STRUCTURE" : "SCANNING · AWAIT STOPPING ACTION"

// Reuse two boxes. Their left edge is the confirmation bar that started the range.
var box vdBullBox = na
var box vdBearBox = na
if showRange and vdDrawBull
    int left = math.max(nz(bull.born, bar_index), bar_index - 9999)
    if na(vdBullBox)
        vdBullBox := box.new(left, bull.hi, bar_index + 1, bull.lo, border_color = color.new(vdBull, 26), border_width = 1, bgcolor = color.new(vdBull, 94), text = "", text_color = color.new(vdBull, 12), text_size = size.tiny, text_halign = text.align_left, text_valign = text.align_top)
    box.set_lefttop(vdBullBox, left, bull.hi)
    box.set_rightbottom(vdBullBox, bar_index + 1, bull.lo)
    box.set_border_style(vdBullBox, vdContested ? line.style_dashed : line.style_solid)
    box.set_text(vdBullBox, "  ACC  ·  " + f_vdPhase(bull.phase) + "  ·  " + str.tostring(bull.score, "0") + "/100")
else if not na(vdBullBox)
    box.delete(vdBullBox)
    vdBullBox := na

if showRange and vdDrawBear
    int left = math.max(nz(bear.born, bar_index), bar_index - 9999)
    if na(vdBearBox)
        vdBearBox := box.new(left, -bear.lo, bar_index + 1, -bear.hi, border_color = color.new(vdBear, 26), border_width = 1, bgcolor = color.new(vdBear, 94), text = "", text_color = color.new(vdBear, 12), text_size = size.tiny, text_halign = text.align_left, text_valign = text.align_bottom)
    box.set_lefttop(vdBearBox, left, -bear.lo)
    box.set_rightbottom(vdBearBox, bar_index + 1, -bear.hi)
    box.set_border_style(vdBearBox, vdContested ? line.style_dashed : line.style_solid)
    box.set_text(vdBearBox, "  DIST  ·  " + f_vdPhase(bear.phase) + "  ·  " + str.tostring(bear.score, "0") + "/100")
else if not na(vdBearBox)
    box.delete(vdBearBox)
    vdBearBox := na

plot(showVwap and vdShowBull ? bull.vwap : na, "Accumulation event AVWAP", color = vdBull, linewidth = 2, style = plot.style_linebr)
plot(showVwap and vdShowBear ? -bear.vwap : na, "Distribution event AVWAP", color = vdBear, linewidth = 2, style = plot.style_linebr)
float vdSelectedTrail = not vdContested and vdPhase == 5 ? vdSelectBull ? bull.trail : -bear.trail : na
plot(showTrail ? vdSelectedTrail : na, "Leading Phase E structural trail", color = vdGold, linewidth = 1, style = plot.style_linebr)
barcolor(showBars and vdPhase >= 3 ? color.new(vdAccent, 24) : na, title = "Evidence phase tint")
plotshape(eligibleLong and barstate.isconfirmed, title = "Confirmed long setup", style = shape.diamond, location = location.belowbar, color = vdBull, size = size.tiny)
plotshape(eligibleShort and barstate.isconfirmed, title = "Confirmed short setup", style = shape.diamond, location = location.abovebar, color = vdBear, size = size.tiny)

var array<label> vdLabels = array.new<label>()
var array<string> vdTape = array.new<string>()
var array<color> vdTapeColors = array.new<color>()
if barstate.isconfirmed
    if bull.event != ""
        f_vdTape(vdTape, vdTapeColors, true, bull.event, bull.why)
        if showLabels
            array.push(vdLabels, f_vdLabel(true, bull))
    if bear.event != ""
        f_vdTape(vdTape, vdTapeColors, false, bear.event, bear.why)
        if showLabels
            array.push(vdLabels, f_vdLabel(false, bear))
    while array.size(vdLabels) > maxEventLabels
        label.delete(array.shift(vdLabels))

string vdPosition = switch dashboardPosition
    "Bottom right" => position.bottom_right
    "Top left" => position.top_left
    "Bottom left" => position.bottom_left
    => position.top_right
var table vdDesk = table.new(vdPosition, 3, 21, bgcolor = vdInk, frame_color = vdGrid, frame_width = 1, border_color = vdInk, border_width = 1)
if barstate.isfirst
    table.merge_cells(vdDesk, 0, 0, 2, 0)
    table.merge_cells(vdDesk, 0, 1, 2, 1)
    table.merge_cells(vdDesk, 0, 2, 2, 2)
    table.merge_cells(vdDesk, 0, 13, 2, 13)
    table.merge_cells(vdDesk, 0, 14, 2, 14)
    for row = 15 to 20
        table.merge_cells(vdDesk, 0, row, 2, row)

if barstate.islast or barstate.islastconfirmedhistory
    if showDashboard
        f_vdCell(vdDesk, 0, 0, "WYCKOFF  /  EVIDENCE DESK", vdText, vdPanel, "Two independent, sequential hypotheses. Evidence scores are rule-based support, not calibrated probabilities.")
        f_vdCell(vdDesk, 0, 1, vdMode, vdAccent, color.new(vdAccent, 91))
        f_vdCell(vdDesk, 0, 2, vdContested ? "TWO ACTIVE PATHS · NO SINGLE PHASE RIBBON" : f_vdRibbon(vdPhase), vdAccent, vdInk)
        f_vdCell(vdDesk, 0, 3, "HYPOTHESIS", vdMuted, vdPanel)
        f_vdCell(vdDesk, 1, 3, "ACCUMULATION", vdBull, vdPanel)
        f_vdCell(vdDesk, 2, 3, "DISTRIBUTION", vdBear, vdPanel)
        f_vdCell(vdDesk, 0, 4, "Phase", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 4, f_vdPhase(bull.phase), vdBullActive ? vdBull : vdMuted, vdInk)
        f_vdCell(vdDesk, 2, 4, f_vdPhase(bear.phase), vdBearActive ? vdBear : vdMuted, vdInk)
        f_vdCell(vdDesk, 0, 5, "Evidence", vdMuted, vdPanel, "Heuristic support from the configured rules; 80/100 does not mean an 80% chance of success.")
        f_vdCell(vdDesk, 1, 5, f_vdScore(bull.score), vdBull, vdPanel)
        f_vdCell(vdDesk, 2, 5, f_vdScore(bear.score), vdBear, vdPanel)
        f_vdCell(vdDesk, 0, 6, "Range / limits ⓘ", vdMuted, vdInk, "Hover either hypothesis for its invalidation boundaries and, in Phase E, its structural trail. Live ATR-based boundaries can move until bar close.")
        f_vdCell(vdDesk, 1, 6, vdBullRange ? f_vdPrice(bull.lo) + " – " + f_vdPrice(bull.hi) : vdBullActive ? "Awaiting AR" : "—", vdText, vdInk, f_vdBoundaryTip(bull, true))
        f_vdCell(vdDesk, 2, 6, vdBearRange ? f_vdPrice(-bear.hi) + " – " + f_vdPrice(-bear.lo) : vdBearActive ? "Awaiting AR" : "—", vdText, vdInk, f_vdBoundaryTip(bear, false))
        f_vdCell(vdDesk, 0, 7, "Test", vdMuted, vdPanel)
        f_vdCell(vdDesk, 1, 7, f_vdTest(bull), bull.pending == 1 ? vdGold : vdText, vdPanel)
        f_vdCell(vdDesk, 2, 7, f_vdTest(bear), bear.pending == 1 ? vdGold : vdText, vdPanel)
        f_vdCell(vdDesk, 0, 8, "Latest event", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 8, bull.lastEvent == "" ? "—" : bull.lastEvent, f_vdFailed(bull.lastEvent) ? vdGold : vdBull, vdInk)
        f_vdCell(vdDesk, 2, 8, bear.lastEvent == "" ? "—" : bear.lastEvent, f_vdFailed(bear.lastEvent) ? vdGold : vdBear, vdInk)
        string contextName = not useContext ? "OFF" : htfBias == 1 ? "BULLISH" : htfBias == -1 ? "BEARISH" : "NEUTRAL"
        f_vdCell(vdDesk, 0, 9, "Context · " + contextTf, vdMuted, vdPanel, "Context is read from confirmed higher-timeframe data.")
        f_vdCell(vdDesk, 1, 9, contextName, not useContext ? vdMuted : htfBias == 1 ? vdBull : htfBias == -1 ? vdBear : vdGold, vdPanel)
        f_vdCell(vdDesk, 2, 9, useContext ? "CLOSED HTF" : "FILTER OFF", vdMuted, vdPanel)
        f_vdCell(vdDesk, 0, 10, "Volume / flow", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 10, hasVolume ? "RVOL  " + str.tostring(rvol, "0.00") + "×" : "VOLUME UNAVAILABLE", hasVolume ? vdText : vdGold, vdInk)
        f_vdCell(vdDesk, 2, 10, footprintBuild ? na(fpDelta) ? "FOOTPRINT N/A" : "Δ VOL  " + (fpDelta > 0 ? "+" : "") + str.tostring(fpDelta * 100.0, "0.0") + "%" : "OHLCV", footprintBuild and na(fpDelta) ? vdGold : vdMuted, vdInk, "Footprint delta divided by total classified volume. +25.0% means a +0.25 ratio, not 25 contracts or a probability.")
        f_vdCell(vdDesk, 0, 11, "Confirmed setup", vdMuted, vdPanel, "This row is true only on a confirmed eligible setup event. It is not a standing instruction or a profitability estimate.")
        f_vdCell(vdDesk, 1, 11, eligibleLong and barstate.isconfirmed ? "LONG · NEW EVENT" : "WAIT", eligibleLong and barstate.isconfirmed ? vdBull : vdMuted, vdPanel)
        f_vdCell(vdDesk, 2, 11, eligibleShort and barstate.isconfirmed ? "SHORT · NEW EVENT" : "WAIT", eligibleShort and barstate.isconfirmed ? vdBear : vdMuted, vdPanel)
        f_vdCell(vdDesk, 0, 12, "Evidence note", vdMuted, vdInk)
        f_vdCell(vdDesk, 1, 12, f_vdBrief(bull.why, 26), vdMuted, vdInk, bull.why)
        f_vdCell(vdDesk, 2, 12, f_vdBrief(bear.why, 26), vdMuted, vdInk, bear.why)
        f_vdCell(vdDesk, 0, 13, barstate.isconfirmed ? "BAR CLOSED  ·  SCORE ≠ PROBABILITY" : "LIVE BAR  ·  EVENTS WAIT FOR CLOSE", barstate.isconfirmed ? vdMuted : vdGold, vdPanel)
        f_vdCell(vdDesk, 0, 14, showTape ? "EVENT TAPE  ·  NEWEST FIRST  ·  EXCHANGE TIME" : "", vdMuted, showTape ? vdPanel : color.new(vdInk, 100))
        for index = 0 to 5
            bool hasEntry = showTape and index < array.size(vdTape)
            string entry = hasEntry ? array.get(vdTape, index) : showTape and index == 0 ? "No confirmed events in loaded history." : ""
            color entryColor = hasEntry ? array.get(vdTapeColors, index) : vdMuted
            f_vdCell(vdDesk, 0, 15 + index, entry, entryColor, showTape ? index % 2 == 0 ? vdInk : vdPanel : color.new(vdInk, 100))
    else
        table.clear(vdDesk, 0, 0, 2, 20)
Download Strategy Lab .pine

Pine Editor installation walkthrough · Recreate alerts after changing the script or its inputs.

The details, when you need them.

Exact rules, settings and test limits.

How does a range become known?

The classical route starts with a provisional stopping candidate (SC? or BC?). Its extreme may change until a confirmed automatic reaction (AR) supplies the other boundary. The default pivot needs three bars on each side; its label appears when confirmed, not at the earlier turning point. A subsequent qualifying lower-effort secondary test starts B.

The optional balanced-range start uses the previous 30 bars, movement efficiency below 0.30, and width between 2.5 and 10 current ATR by default. It begins in B as TR? and explicitly says Phase A was not observed. Boundaries freeze once AR or bootstrap establishes them. B must develop for at least eight bars before the relevant sweep or strength transition.

A–E are the engine’s states, not proof of accumulation, distribution or institutional intent. SCAN means no active episode; a preliminary PS?/PSY? observation does not establish a range.

Must there be a Spring or UTAD?

No. A mature B range can move directly to D on the engine’s directional-strength conditions. The optional C route starts with a breach beyond the boundary allowance and within the maximum sweep depth. A pending SWEEP? must recover by the configured deadline; this is a real later close, not future information attached to the past.

A confirmed Spring or UTAD is classified by relative volume: type 1 at least 2, type 2 at least 1, type 3 below 1. Those are this release’s thresholds. Type 1/2 require a subsequent qualifying lower-effort test. Type 3 waives that test but still needs strength, acceptance and backup. “Test confirmed” and “Type 3 · test waived” are different observations.

A candle crossing both boundaries is ambiguous because OHLC cannot reveal its intrabar order. More severe invalidation takes priority. Distribution mirrors the price-direction conditions.

When does a backup actually qualify?

A decisive breakout meeting the directional-strength rule starts the acceptance check. Two consecutive closes beyond the boundary allowance establish ACCEPT and phase E; the original breakout candle counts as the first when the next close follows through. A close back inside the allowance resets that count; later acceptance closes do not recheck the original strength rule. ACCEPT still waits for a later return, called a backup (BU).

For the accumulation side, the later candle reaches the breakout area without exceeding allowed depth, closes at or above the range top with a bullish body, and has RVOL at most 1.3 when volume is available. Existing invalidation rules run first. Distribution reverses the price conditions.

A fresh BU is checked once against the configured gates: default score at least 65, lead over the opposing score at least 10, volume available, and higher-timeframe context not opposing. A blocked BU is consumed; later improvement does not retry it. It is one opportunity per episode, not a queued order.

CONTESTED is a display rule: both hypotheses are active and their score difference is at most 10. The entry lead input is separate. At exactly 10 points, a fresh BU can satisfy the default lead gate while the display still says CONTESTED.

What goes into the evidence scores?

Each hypothesis starts from its current phase: A 12, B 25, C 40, D 55, E 65. Additions are: test requirement satisfied +8 (including type 3 waiver), volume contraction +5, close on the supportive AVWAP side +5, aligned higher timeframe +7, supportive close location +5. Opposing higher-timeframe context subtracts 8.

Bootstrap subtracts 5; unavailable volume subtracts 15; a pending reclaim subtracts 8. Footprint can add 5 when directional delta divided by total volume exceeds 0.10. Scores are bounded to 0–100 independently. They are not calibrated probabilities, and need not sum to 100.

Adaptive RVOL compares volume with the median of earlier bars. The engine also uses prior 90th-percentile volume/spread references. This is a local baseline, not an adjustment for typical activity at each time of day. Volume can mean different things across feeds, including tick volume.

What do AVWAP and the larger timeframe mean?

AVWAP starts at the bar when an episode is observed and accumulates typical price times available volume. It is a weighted price reference, not a volume profile or POC. A failed breakout that returns to B keeps the episode’s anchor; a full reset clears it.

The context uses completed higher-timeframe bars, with a one-bar offset and lookahead enabled. Automatic context is approximately four times the chart timeframe, mapped to a supported interval. With the context switch on, the interval must be larger than the chart. Neutral context is permitted; opposing context blocks the default setup. Turning it off removes its score adjustment and gate.

Core events commit on closed chart bars. Pivot confirmation is delayed. Revised feeds, changed inputs or different loaded history can change a recalculated path. Footprint has additional historical-data limitations; absolute non-repainting is not claimed.

What happens when the idea fails?

Before acceptance, an episode expires when its age exceeds the configured maximum. Phase E does not expire from that age setting; it ends through structural failure. The original Thai manual’s general phase-E expiry wording should be read with this source distinction.

Too many consecutive adverse closes or a wick deeper than the maximum allowed excursion fully reset an episode, even if a deep wick recovers. A failed phase-D breakout can return to B and restart its development clock within the same episode. Phase E can fail on a close through its breakout boundary or its previously established structural trail.

The trail only tightens. Its plot is not a broker stop. ATR-based boundaries can move as ATR changes; decisions use the closed bar. Ended events remain in the short event tape, not in an unlimited permanent history.

Which alerts mean a new setup?

The three named conditions are Qualified bullish backup, Qualified bearish backup and Confirmed reclaim. The first two mean a new setup passed its gates; a reclaim alone does not. Create alerts with closed-bar frequency and recreate them after changing source or inputs.

Emit JSON event alerts starts off. With it enabled, create Any alert() function call to receive raw events, including INVALID. The payload includes side, phase, event, evidence and volume availability. Its barTime is the candle’s opening timestamp; the event is confirmed at its close. Only the final event per side on that bar is emitted.

This version has no Setup ID export, frozen trade-plan journal or full-history guarantee. Labels are bounded; the event tape keeps six latest events across both sides, in exchange time. Strategy order-fill alerts are a separate stream.

What does every setting change?

Base has 26 inputs, Footprint 27 and Lab 31. Shared defaults are identical. The edition label identifies the extra controls.

Optional Footprint edition · Footprint only

Requires an eligible TradingView account and feed. Base and Lab make no Footprint request. Classified volume is not verified exchange aggressor flow.

Choose the price-row sizeFootprint row ticks

Minimum 1. Directional delta/total above 10% adds five evidence points; unavailable data adds none.

100 ticks

How a range becomes known

Uses current chart ATR14 and previous-bar volume/spread baselines. With defaults, detection can begin on candle 87 if ATR is available. Change one rule at a time.

Wait for a confirmed turning pointConfirmed swing bars (latency)

Range 2–10. Confirmation arrives three bars after the pivot; labels appear at confirmation.

3 bars each side
Compare activity with earlier candlesAdaptive baseline

Range 20–200. Prior median and 90th percentile of volume/spread; no session-seasonality adjustment.

50 bars
Look for sideways movementBalanced-range lookback

Range 15–100. Bootstrap also needs path efficiency below 0.30 and width at most 10 ATR.

30 prior bars
Allow a range without a climaxAllow balanced-range bootstrap

Starts TR? in phase B when observed; never invents an earlier stopping event.

On
Give the range time to developMinimum cause-building bars

Range 3–80. Counts from phase-B start or restart before a sweep or strength transition.

8 bars
Limit time to recover a boundaryReclaim deadline (closed bars)

Range 1–5. Also limits breakout waiting; sustained invalidation needs more than this many consecutive adverse closes.

3 bars
End an unaccepted old rangeExpire range after bars

Range 40–1500. Expires when age exceeds this limit, before phase E only.

240 bars
Allow space around range edgesBoundary tolerance / ATR

Range 0.05–1. Shared edge allowance; also pads the Lab’s signal-candle stop.

0.25 ATR
Reject an excessively deep moveMaximum sweep depth / ATR

Range 0.5–4. A deeper adverse wick ends the episode even if its candle recovers.

1.5 ATR
Require enough room in the rangeMinimum range width / ATR

Range 1–8. Applies when AR fixes the opposite edge and when bootstrap begins.

2.5 ATR
Follow accepted movementOne-way trail / ATR

Range 1–8. Phase-E trail only tightens. A later close beyond the prior trail ends the episode; it is not a broker stop.

3 ATR

When a return passes the checks

ACC and DIST scores are independent rule-based evidence, not calibrated probabilities. A BU event is checked once; failing a gate does not queue a later entry.

Check the larger chart’s directionUse confirmed higher-timeframe context

Opposing context blocks qualification; neutral is allowed. Off removes its score adjustment and gate.

On
Choose the larger timeframeContext timeframe (blank = automatic 4×)

TradingView maps 4× to a supported interval. With context On, it must exceed the chart timeframe.

Blank · automatic 4×
Require usable volume evidenceRequire volume for qualified backup

Off allows price-only qualification. Missing volume still lowers the evidence score.

On
Require enough supporting evidenceMinimum backup evidence score

Range 40–95. Applies on the backup event, alongside the other gates.

65 points
Require a lead over the other sideMinimum lead over opposing hypothesis

Range 0–40. Qualification uses this input; the display’s Contested threshold stays fixed at a 10-point gap.

10 points
Send closed-bar event detailsEmit JSON event alerts

Create Any alert() function call. Raw events include side, phase and score; they are not all qualified setups.

Off

Separate Lab experiment · Lab only

Qualified backups only. Brackets freeze at signal close and submit with next-open entries. Gaps/slippage change realized risk. The producer’s small development sample lost money.

Start the signal-date windowTest start

Inclusive signal candle’s opening time; the later fill may fall outside the window.

1 Jan 2024 · 00:00 UTC
End the signal-date windowTest end

Inclusive. An open position receives a market-close instruction once outside the window.

1 Jan 2030 · 00:00 UTC
Choose the order quantityFixed contracts / units

Minimum 0.000001. Explicit entry quantity overrides Properties’ default size; it is not fixed cash risk.

1 unit
Place the fixed targetTarget / signal-planned risk

Minimum 0.5. Stop distance is at least one current ATR from signal close.

2× signal-planned risk
Limit how long a fill is heldMaximum holding bars

Range 5–1000. Counts from the filled entry bar; market-close instruction processes on the next tick.

100 chart bars

Choose what to see

Display switches do not change the engine. Earlier plotted history is bounded; changing inputs rebuilds it from loaded data.

Show both hypotheses and reasonsEvidence dashboard

Hover table cells for full reasons and boundaries.

On
Show the newest event notesLast six confirmed events

Six events across both sides, newest first in exchange time. Needs the dashboard visible.

On
Put observed events on the chartConfirmed event labels

Labels sit on confirmation candles, including delayed pivots.

On
Draw the current price rangesActive hypothesis ranges

Dashed when contested. Boundaries freeze after AR or bootstrap; preliminary climax extremes can move.

On
Show the episode’s volume-weighted priceEvent-anchored VWAP

Starts when the episode is observed; uses available volume. It is not volume profile or POC.

On
Draw the leading structural trailPhase E structural trail

Visible only for an uncontested leading phase-E hypothesis; separate from the Lab bracket.

On
Tint candles by displayed phaseColor bars from phase C onward

Optional chart tint; setup diamonds remain separate.

Off
Limit old chart labelsRetained event labels

Range 10–100, shared by both hypotheses. Oldest labels are removed; event tape retains six.

60 labels
Move the evidence tableDashboard position

Top or bottom, left or right.

Top right
How does the separate Strategy Lab trade?

The Lab submits a market entry and fixed bracket after a qualifying BU while flat. The order normally fills at the next available open. Default quantity is one unit, not fixed cash risk. Stop distance is at least one signal-bar ATR; the target is two times the risk measured from the signal close.

Its bracket remains tied to that signal and does not follow a new episode. Gaps and slippage can change actual risk. The 100-bar holding limit counts from the filled entry. The date window filters the signal candle’s opening time, so a later fill can fall outside it. Market-close instructions execute on a later tick.

The default experiment includes 0.05% commission per order and two ticks of slippage. Set costs, quantity and Properties for the instrument. Keep the shipped recalculation switches off when comparing with the supplied results.

Initial capitalStarting account balance.
100,000
Additional same-side entriesNo additions; Lab enters only while flat.
0
Default sizing methodThe explicit Fixed contracts / units input controls each entry.
Fixed
Default order sizeOverridden by the entry’s quantity input.
1
Commission methodPercentage of order value.
Percentage
CommissionGeneric development assumption; adjust to the instrument.
0.05% per order
SlippageGeneric emulator adjustment, not observed execution cost.
2 ticks
Fill on the closing tickMarket instructions normally process at the next candle’s open.
Off
Recalculate every realtime tickKeep the shipped closed-bar experiment.
Off
Recalculate after a fillNo extra calculation immediately after fills.
Off
Recalculate historical ticksEnabling intrabar calculation changes the experiment.
Off
Long margin requirementFull position value required as margin.
100%
Short margin requirementFull position value required as margin.
100%
What passed, and what still has not been proved?

The producer’s three native Pine probe suites report 889 assertions over 25 synthetic scenarios and 129 frames. These exercise the embedded engine, not 889 trades or independent market samples. The website import checks the supplied evidence and rebuilds sources/probes; it does not claim a new run of those 889 assertions.

All three served scripts were freshly compiled with TradingView: zero errors and warnings. Compilation does not establish runtime data availability. The producer’s Basic account could not run Footprint, so its runtime remains unverified in that account despite successful server compilation.

The initial Strategy Lab sample lost money. Eight closed trades totalled −$446.03 under its stated development settings and costs. It is a small functional smoke check, not evidence of a profitable strategy or performance across markets.

Website verification and limits · Original verification record · Fresh compiler evidence

  • Sweep Desk

    Compare a boundary reclaim with a separate sweep-and-confirmation entry model.

  • Value Envelope

    Compare the price range with an estimated area of trading volume.

Built by ProEA Lab. Compare all free indicators.

05 · THE LOG

Every change, dated

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

  1. Wyckoff Evidence Desk v1.0.0 Shipped

    Compares two possible market stories and shows what supports or weakens each.

  2. Engine checks and separate market limits Lab note Wyckoff Evidence Desk

    Producer engine probes passed; the small development Lab run lost money. Evidence scores remain uncalibrated.

    • Producer reports 889 native assertions across 25 synthetic scenarios; feature extraction and broker fills are outside that fixture scope
    • Base rendered on micro Nasdaq and EURUSD; Footprint native execution remains unverified on the available Basic account
    • The small development Lab sample was negative; no calibrated probabilities or trading advantage established
BUILT WITH TRADERS

Something to change in Wyckoff Evidence Desk?

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

What would help?

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

Add chart details Optional

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

Guides & new tools Optional

Add an email above to choose this.

Show us on your chart Optional

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

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

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

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