Supply & Demand Desk v1.0 · original open-source rules

Watch the area. Wait for the return.

See where a strong move began, follow price back to that area, then check whether an entry is ready.

Standard five-minute candles. Reference plans are not orders.

Actual Supply & Demand Desk v1.0 on BTCUSDT 5m: a ready demand zone, a new departure zone and no active paper plan.
BTCUSDT 5m · Clean view · areas to watch; no entry accepted yetOpen full-size chart ↗

What is Supply & Demand Desk?

Supply & Demand Desk is a free TradingView indicator that follows a price zone from its formation to a return and qualifying entry. Designed for standard five-minute candles, it checks departure, pullback, liquidity and rejection before fixing a paper entry, stop and target. A separate strategy script supports broker-emulator testing.

What makes a supply or demand zone ready?

A zone begins with a directional departure, then needs enough movement, a qualifying pullback and a later closing break. A newly drawn area is not ready for entry; a premature touch or expired setup can invalidate it. Read the zone-formation rules

Does every return to a zone create an entry?

No. The visit must pass front liquidity, intersect the ready zone and produce a qualifying close. Risk geometry, an existing paper plan, conflicting conditions or the optional trend gate can still block an entry. Follow the entry sequence

Is the Strategy Lab the same as the indicator's paper plan?

No. The indicator records a plan from the confirming close, while Strategy Lab uses broker-emulator orders, sizing and trading costs. Fill timing and independent position rules can differ, so compare the two models before interpreting results. Compare the two execution models

00

Where did the move begin?

When price moves sharply away, mark its starting area. The tool watches a later return and checks a sequence before proposing an entry.

One candle = five minutes up ↑ · down ↓
One candle holds five minutes.The thick body joins the opening and closing prices. The thin wick reaches the high and low. This red candle closed lower than it opened. Only 1 closed candles are known. No entry or target is shown.
Closed candle 1 · no zone yet
1 / 5

One candle holds five minutes.

The thick body joins the opening and closing prices. The thin wick reaches the high and low. This red candle closed lower than it opened.

What does this illustration assume?

Fictional prices, standard five-minute candles, supplied ATR(14) value 10 and tick 0.25. Default full-wick zones and strength filter; optional trend filter off. The move reaches 14 above the zone before pulling back, exceeding the required 8. This shows one zone, not a complete market calculation. No orders, costs or outcome are simulated here.

How does this help a trade?

The area gives you somewhere to watch. The entry checks tell you when the tool is ready; the stop defines where the plan fails. A target is a planned distance, not a promised result. Section 01 connects these parts.

Where did the idea come from?

This original automation subset draws on Mangoe’s public five-minute Liquidity Supply & Demand course. It is not his official indicator. The numerical thresholds, equality rules and accounting policies in the full rules are our explicit choices.

Supply mirrors demand. The zones and “liquidity” levels come from candle prices; they do not reveal resting orders or prove why traders bought or sold.

01

Would this candle complete the setup?

Follow the fictional candles into the area. Compare a waiting candle, an accepted entry and a losing outcome.

Earlier candles → test candle
163145.75128.5111.2594

Fictional five-minute candles. The scale stays fixed as you edit the close. A historical base anchor does not mean the zone was known before its departure close.

Building the move

Which rule is complete?

Wait for the first departure close beyond the base high.

Pullback candles before break
0
Earlier high or low broken
Waiting
Return into the zone
Waiting
Change the confirming candle
108

The teaching model supplies ATR(14) value 10 and tick 0.25; trend filtering is off. It computes the shown sequence, excluding the breakout from the two earlier retracement bars. It does not reconstruct market data, size a position, include costs, or emulate broker fills.

02

Find the area, the waiting reason and the plan.

Read these three parts in this order.

  1. 1

    The area to watch

    The rectangle marks an earlier move’s starting area. A ready zone is worth watching; a visit means price has returned. Neither is an entry by itself.

  2. 2

    Why the tool waits

    The dashboard says which check is missing. A condition alone is not an accepted entry: another plan or a conflicting setup can still block it.

  3. 3

    The entry, stop and target

    Read all three together when a plan is accepted. The default target is four times the initial entry-to-stop distance: 4R. An ended plan is history, not a current position.

Actual Supply & Demand Desk v1.0 Inspect view on BTCUSDT 5m, showing zone creation clocks and condition counts while no paper plan is active.
BTCUSDT 5m · Inspect view · creation times and loaded-history counts, not trading performanceOpen full-size chart ↗

Both charts are unmodified TradingView captures of this release. Source and image identities.

03

Indicator first. Strategy Lab separately.

Complete Pine v6 source, under MIT.

  1. 1

    Open Pine Editor

    Use a standard five-minute chart with enough history for ATR14.

  2. 2

    Paste, save, then Add to chart

    Replace the editor contents with the full indicator source. Save it and choose Add to chart. Save the separate Lab as a different script for broker testing.

  3. 3

    Check the waiting reason

    If it says “ATR14 history unavailable” or “Waiting for ATR history”, load more chart history. Keep defaults initially, then test one change at a time.

The indicator

Explains zones, confirmed conditions and frozen reference plans.

Indicator code

Copy the code as it is. No coding needed.

View the full code
//@version=6
// Supply & Demand Desk 1.0.0 · MIT · Original public-method-inspired 5m subset.
// Not Mangoe's official script or private-method parity. See docs/engine-contract.md.
// Signal-close paper references differ from next-open Strategy Lab fills.
indicator("Supply & Demand Desk · Open Source", "SDD", overlay = true, behind_chart = false, max_boxes_count = 40, max_lines_count = 100, max_labels_count = 100, max_bars_back = 2000)
const string SDD_VERSION = "1.0.0"
const bool SD_IS_LAB = false

// Original closed-bar subset of a public discretionary method. Read docs/engine-contract.md.
string sdG = "01 · Setup rules"
float sdImpulse = input.float(0.8, "Minimum departure · ATR", minval = 0.1, maxval = 5, step = 0.1, group = sdG, tooltip = "Original strength threshold, measured from proximal edge to departure extreme. Birth ATR is frozen.", display = display.none)
int sdRetrMin = input.int(2, "Minimum retracement bars", minval = 2, maxval = 8, group = sdG, tooltip = "Observed retracement bars before the breakout bar; colors may mix.", display = display.none)
float sdNear = input.float(0.5, "Front liquidity · maximum setup fraction", minval = 0.1, maxval = 1, step = 0.05, group = sdG, tooltip = "Demand: (front low − proximal) / (originating high − proximal). Supply mirrors. Our explicit near-half formalization.", display = display.none)
bool sdAccuracy = input.bool(false, "FX accuracy drawing", group = sdG, tooltip = "Optional public FX drawing variant. Excludes a protruding proximal base wick. Default full-wick drawing applies to all symbols; no automatic asset classification.", display = display.none)
int sdRetrWait = input.int(20, "Retracement deadline · bars", minval = 3, maxval = 60, group = sdG, display = display.none)
int sdZoneAge = input.int(120, "Zone lifetime · bars", minval = 10, maxval = 500, group = sdG, display = display.none)
int sdVisitAge = input.int(3, "Entry visit deadline · bars", minval = 0, maxval = 12, group = sdG, tooltip = "Age0 is first touch. Default ages0..3 can confirm; age4 expires. Opposing-color wick rejection may wait for a later directional close.", display = display.none)
bool sdTrendGate = input.bool(false, "Require chart-local HH/HL or LH/LL", group = sdG, tooltip = "Optional original gate: last two confirmed two-left/two-right swing highs and lows. No HTF request. Neutral or incomplete structure blocks when enabled.", display = display.none)
string sdP = "02 · Paper reference"
float sdStopPad = input.float(0, "Stop pad · ATR (minimum one tick)", minval = 0, maxval = 1, step = 0.05, group = sdP, display = display.none)
float sdReward = input.float(4, "Target · R", minval = 1, maxval = 10, step = 0.5, group = sdP, tooltip = "4R follows the public 2026 five-minute course. Changing this is your variant. Rounded actual R may be slightly greater.", display = display.none)
int sdHold = input.int(48, "Maximum paper hold · bars", minval = 1, maxval = 288, group = sdP, tooltip = "Ordinary stop/target checks first, then close at the observed candle close. Broker Lab uses its own next-open market exit.", display = display.none)
float sdCash = input.float(100, "Cash risk budget · symbol currency", minval = 0.01, group = sdP, display = display.none)
float sdCap = input.float(90000, "Notional cap · symbol currency", minval = 1, group = sdP, display = display.none)
float sdFee = input.float(0.05, "Estimated commission · % per side", minval = 0, maxval = 2, step = 0.01, group = sdP, display = display.none)
int sdSlip = input.int(1, "Estimated slippage · ticks per side", minval = 0, maxval = 20, group = sdP, display = display.none)
bool sdJson = input.bool(true, "JSON alert events", group = "03 · Alerts", tooltip = "Create one alert using Any alert() function call. Reference events are not broker fills. Strategy Lab can emit these paper-reference events independently from its order-fill alerts.", display = display.none)

// === TYPES AND PURE HELPERS ===
type SdConfig
    float impulse = 0.8
    int retrMin = 2
    float near = 0.5
    int retrWait = 20
    int zoneAge = 120
    int visitAge = 3
    float stopPad = 0
    float reward = 4
    int hold = 48
    float cash = 100
    float cap = 90000
    float fee = 0.05
    int slip = 1

type SdZone
    int direction = 0
    string id = ""
    string state = "INVALID"
    string reason = "Unavailable"
    int baseBar = na
    int baseTime = na
    int createdBar = na
    int createdTime = na
    int phaseBar = na
    int readyBar = na
    int readyTime = na
    int visitBar = na
    int signalBar = na
    float lower = na
    float upper = na
    float birthAtr = na
    float impulseExtreme = na
    float origin = na
    float front = na
    int retrBars = 0
    float deepest = na
    float previousHigh = na
    float previousLow = na
    float previousClose = na

type SdPlan
    int direction = 0
    string id = ""
    string state = "EMPTY"
    string reason = "No accepted reference"
    int signalBar = na
    int signalTime = na
    int endBar = na
    int endTime = na
    int hold = 48
    float entry = na
    float stop = na
    float target = na
    float risk = na
    float quantity = na
    float pointValue = na
    float tick = na
    float fee = 0
    int slip = 0
    float netAtTarget = na
    float exitPrice = na
    float netR = na
    bool ambiguous = false
    float lower = na
    float upper = na
    float front = na
    int baseTime = na
    int createdTime = na
    int readyTime = na

f_sdSign(float value) =>
    int(nz(value / math.abs(value), 0))

f_sdCompare(float lhs, float rhs) =>
    int result = na
    if not na(lhs) and not na(rhs)
        float difference = lhs - rhs
        float noise = 4 * 2.220446049250313e-16 * math.max(math.abs(lhs), math.abs(rhs))
        result := f_sdSign(math.abs(difference) - noise) <= 0 ? 0 : f_sdSign(difference)
    result

f_sdBarValid(float o, float h, float l, float c) =>
    not na(o) and not na(h) and not na(l) and not na(c) and f_sdSign(h - l) >= 0 and f_sdSign(h - math.max(o, c)) >= 0 and f_sdSign(math.min(o, c) - l) >= 0

f_sdUnits(float value) =>
    float nearest = math.round(value)
    float noise = math.min(1e-7, 8 * 2.220446049250313e-16 * math.max(1, math.abs(value)))
    f_sdSign(math.abs(value - nearest) - noise) <= 0 ? nearest : value

f_sdTick(float value, float tick, bool up) =>
    float units = f_sdUnits(value / tick)
    (up ? math.ceil(units) : math.floor(units)) * tick

f_sdDistance(float firstPrice, float secondPrice, float tick) =>
    float distance = math.abs(firstPrice - secondPrice)
    float units = distance / tick
    float nearest = math.round(units)
    float noise = math.min(1e-7, 8 * 2.220446049250313e-16 * math.max(math.abs(firstPrice), math.abs(secondPrice)) / tick)
    f_sdSign(math.abs(units - nearest) - noise) <= 0 ? nearest * tick : distance

f_sdQuantity(float entry, float distance, float cash, float cap, float pointValue, float increment) =>
    float quantity = na
    if not na(entry) and f_sdSign(distance) > 0 and f_sdSign(cash) > 0 and f_sdSign(cap) > 0 and f_sdSign(pointValue) > 0 and f_sdSign(increment) > 0
        float byRisk = cash / (distance * pointValue)
        float byCap = f_sdSign(math.abs(entry)) > 0 ? cap / (math.abs(entry) * pointValue) : byRisk
        quantity := math.floor(f_sdUnits(math.min(byRisk, byCap) / increment)) * increment
    quantity

f_sdNetR(SdPlan plan, float exitPrice) =>
    float gross = plan.direction * (exitPrice - plan.entry) * plan.quantity * plan.pointValue
    float fees = (math.abs(plan.entry) + math.abs(exitPrice)) * plan.quantity * plan.pointValue * plan.fee / 100
    float slipCash = 2 * plan.slip * plan.tick * plan.quantity * plan.pointValue
    (gross - fees - slipCash) / (plan.risk * plan.quantity * plan.pointValue)

f_sdTerminal(string state) =>
    state == "INVALID" or state == "EXPIRED" or state == "USED"

f_sdNewZone(int direction, float bo, float bh, float bl, float bc, float o, float h, float l, float c, float atr, int baseBar, int baseTime, int nowBar, int nowTime, bool accuracy) =>
    SdZone zone = SdZone.new()
    bool packets = f_sdBarValid(bo, bh, bl, bc) and f_sdBarValid(o, h, l, c) and f_sdSign(atr) > 0
    bool colors = direction == 1 ? f_sdCompare(bo, bc) > 0 and f_sdCompare(c, o) > 0 : f_sdCompare(bc, bo) > 0 and f_sdCompare(o, c) > 0
    if packets and colors and (direction == 1 or direction == -1)
        zone.direction := direction
        zone.id := str.tostring(baseTime) + (direction == 1 ? ":D" : ":S")
        zone.baseBar := baseBar
        zone.baseTime := baseTime
        zone.createdBar := nowBar
        zone.createdTime := nowTime
        zone.phaseBar := nowBar
        zone.lower := direction == 1 ? math.min(bl, l) : accuracy and f_sdCompare(bl, l) < 0 ? bo : bl
        zone.upper := direction == -1 ? math.max(bh, h) : accuracy and f_sdCompare(bh, h) > 0 ? bo : bh
        zone.birthAtr := atr
        zone.impulseExtreme := direction == 1 ? h : l
        zone.previousHigh := h
        zone.previousLow := l
        zone.previousClose := c
        bool outside = direction == 1 ? f_sdCompare(c, zone.upper) > 0 : f_sdCompare(c, zone.lower) < 0
        if outside and f_sdCompare(zone.upper, zone.lower) > 0
            zone.state := "DEPARTURE"
            zone.reason := "Wait for front liquidity"
    zone

f_sdStep(SdZone zone, float o, float h, float l, float c, int atBar, int atTime, SdConfig cfg) =>
    bool signal = false
    bool valid = f_sdBarValid(o, h, l, c)
    if not f_sdTerminal(zone.state) and atBar > zone.createdBar
        if zone.state == "SIGNAL"
            zone.state := "USED"
            zone.reason := "Condition already used"
        else if atBar - zone.createdBar > cfg.zoneAge
            zone.state := "EXPIRED"
            zone.reason := "Zone lifetime reached"
        else if not valid
            zone.state := "INVALID"
            zone.reason := "Malformed observed candle"
        else
            bool longSide = zone.direction == 1
            float proximal = longSide ? zone.upper : zone.lower
            bool adverseClose = longSide ? f_sdCompare(c, proximal) <= 0 : f_sdCompare(c, proximal) >= 0
            bool touches = f_sdCompare(l, zone.upper) <= 0 and f_sdCompare(h, zone.lower) >= 0
            bool beforeReady = zone.state == "DEPARTURE" or zone.state == "RETRACEMENT"
            if adverseClose
                zone.state := "INVALID"
                zone.reason := "Close on/inside or through zone"
            else if beforeReady and touches
                zone.state := "INVALID"
                zone.reason := "Zone touched before readiness"
            else if zone.state == "DEPARTURE"
                bool continues = longSide ? f_sdCompare(c, o) > 0 and f_sdCompare(c, zone.previousClose) > 0 : f_sdCompare(c, o) < 0 and f_sdCompare(c, zone.previousClose) < 0
                bool counter = longSide ? f_sdCompare(c, zone.previousClose) < 0 or f_sdCompare(l, zone.previousLow) < 0 : f_sdCompare(c, zone.previousClose) > 0 or f_sdCompare(h, zone.previousHigh) > 0
                if counter
                    float move = longSide ? zone.impulseExtreme - proximal : proximal - zone.impulseExtreme
                    if f_sdCompare(move, cfg.impulse * zone.birthAtr) >= 0
                        zone.origin := zone.impulseExtreme
                        zone.front := longSide ? l : h
                        zone.retrBars := 1
                        zone.phaseBar := atBar
                        zone.state := "RETRACEMENT"
                        zone.reason := "Wait for two-bar pullback and own-origin break"
                    else
                        zone.state := "INVALID"
                        zone.reason := "Departure below strength threshold"
                else if continues
                    zone.impulseExtreme := longSide ? math.max(zone.impulseExtreme, h) : math.min(zone.impulseExtreme, l)
            else if zone.state == "RETRACEMENT"
                bool breaks = longSide ? f_sdCompare(c, zone.origin) > 0 : f_sdCompare(c, zone.origin) < 0
                if atBar - zone.phaseBar > cfg.retrWait
                    zone.state := "EXPIRED"
                    zone.reason := "Retracement deadline reached"
                else if breaks
                    float frontDistance = longSide ? zone.front - proximal : proximal - zone.front
                    float setupDistance = longSide ? zone.origin - proximal : proximal - zone.origin
                    if zone.retrBars < cfg.retrMin
                        zone.state := "INVALID"
                        zone.reason := "Origin broken before minimum retracement"
                    else if f_sdSign(setupDistance) <= 0 or f_sdSign(frontDistance) <= 0 or f_sdCompare(frontDistance / setupDistance, cfg.near) > 0
                        zone.state := "INVALID"
                        zone.reason := "Front liquidity outside near-half rule"
                    else
                        zone.state := "READY"
                        zone.readyBar := atBar
                        zone.readyTime := atTime
                        zone.reason := "Wait for liquidity raid into zone"
                else
                    zone.retrBars += 1
                    zone.front := longSide ? math.min(zone.front, l) : math.max(zone.front, h)
            else if zone.state == "READY" or zone.state == "VISIT"
                bool oldVisit = zone.state == "VISIT"
                if oldVisit and atBar - zone.visitBar > cfg.visitAge
                    zone.state := "EXPIRED"
                    zone.reason := "Entry visit deadline reached"
                else
                    bool raids = longSide ? f_sdCompare(l, zone.front) < 0 : f_sdCompare(h, zone.front) > 0
                    if not oldVisit and touches and raids and atBar > zone.readyBar
                        zone.state := "VISIT"
                        zone.visitBar := atBar
                        zone.deepest := longSide ? l : h
                        zone.reason := "Wick rejected; wait for directional close"
                    if zone.state == "VISIT"
                        zone.deepest := longSide ? math.min(zone.deepest, l) : math.max(zone.deepest, h)
                        bool directional = longSide ? f_sdCompare(c, o) > 0 : f_sdCompare(c, o) < 0
                        if directional
                            zone.state := "SIGNAL"
                            zone.signalBar := atBar
                            zone.reason := "Raid and directional close confirmed"
                            signal := true
            zone.previousHigh := h
            zone.previousLow := l
            zone.previousClose := c
    signal

f_sdPlan(SdZone zone, float entry, float atr, int atBar, int atTime, float tick, float pointValue, float increment, SdConfig cfg) =>
    SdPlan plan = SdPlan.new()
    string why = "No current condition"
    if zone.state == "SIGNAL" and zone.signalBar == atBar and f_sdSign(tick) > 0 and f_sdSign(atr) > 0
        float pad = math.max(tick, atr * cfg.stopPad)
        float stop = f_sdTick(zone.deepest - zone.direction * pad, tick, zone.direction == -1)
        float risk = f_sdDistance(entry, stop, tick)
        bool geometry = zone.direction == 1 ? f_sdCompare(entry, stop) > 0 : f_sdCompare(entry, stop) < 0
        float target = f_sdTick(entry + zone.direction * risk * cfg.reward, tick, zone.direction == 1)
        float quantity = f_sdQuantity(entry, risk, cfg.cash, cfg.cap, pointValue, increment)
        if not geometry or f_sdSign(risk) <= 0 or na(target)
            why := "Invalid rounded plan geometry"
        else if na(quantity) or f_sdSign(quantity) <= 0
            why := "Budget/cap cannot fund minimum quantity"
        else
            plan.direction := zone.direction
            plan.id := zone.id + ":" + str.tostring(atTime)
            plan.state := "ACTIVE"
            plan.reason := "Paper reference · next-bar outcomes"
            plan.signalBar := atBar
            plan.signalTime := atTime
            plan.hold := cfg.hold
            plan.entry := entry
            plan.stop := stop
            plan.target := target
            plan.risk := risk
            plan.quantity := quantity
            plan.pointValue := pointValue
            plan.tick := tick
            plan.fee := cfg.fee
            plan.slip := cfg.slip
            plan.lower := zone.lower
            plan.upper := zone.upper
            plan.front := zone.front
            plan.baseTime := zone.baseTime
            plan.createdTime := zone.createdTime
            plan.readyTime := zone.readyTime
            plan.netAtTarget := f_sdNetR(plan, target)
            why := "Accepted paper reference"
    [plan, why]

f_sdSelect(array<SdZone> candidates, float entry, float atr, int atBar, int atTime, float tick, float pointValue, float increment, SdConfig cfg) =>
    SdPlan chosen = SdPlan.new()
    bool hasLong = false
    bool hasShort = false
    string why = "No current condition"
    for zone in candidates
        hasLong := hasLong or zone.direction == 1
        hasShort := hasShort or zone.direction == -1
    bool conflict = hasLong and hasShort
    if conflict
        why := "Opposite conditions · no admission"
    else
        for zone in candidates
            [candidate, rejection] = f_sdPlan(zone, entry, atr, atBar, atTime, tick, pointValue, increment, cfg)
            if candidate.direction != 0 and (chosen.direction == 0 or f_sdCompare(candidate.risk, chosen.risk) < 0)
                chosen := candidate
                why := rejection
            else if chosen.direction == 0
                why := rejection
    [chosen, why, conflict]

f_sdOutcome(SdPlan plan, float o, float h, float l, float c, int atBar, bool confirmed) =>
    string result = ""
    float price = na
    bool ambiguous = false
    if confirmed and plan.state == "ACTIVE" and atBar > plan.signalBar
        if not f_sdBarValid(o, h, l, c)
            result := "DATA GAP"
        else
            bool longSide = plan.direction == 1
            bool gapStop = longSide ? f_sdCompare(o, plan.stop) <= 0 : f_sdCompare(o, plan.stop) >= 0
            bool gapTarget = longSide ? f_sdCompare(o, plan.target) >= 0 : f_sdCompare(o, plan.target) <= 0
            bool stopHit = longSide ? f_sdCompare(l, plan.stop) <= 0 : f_sdCompare(h, plan.stop) >= 0
            bool targetHit = longSide ? f_sdCompare(h, plan.target) >= 0 : f_sdCompare(l, plan.target) <= 0
            if gapStop or gapTarget
                result := gapStop ? "STOP" : "TARGET"
                price := o
            else if stopHit or targetHit
                result := stopHit ? "STOP" : "TARGET"
                price := stopHit ? plan.stop : plan.target
                ambiguous := stopHit and targetHit
            else if atBar - plan.signalBar >= plan.hold
                result := "TIME"
                price := c
    [result, price, ambiguous]

f_sdSettle(SdPlan plan, string result, float price, bool ambiguous, int atBar, int atTime) =>
    SdPlan settled = plan.copy()
    settled.state := result == "DATA GAP" ? "UNRESOLVED" : "ENDED"
    settled.reason := result
    settled.exitPrice := price
    settled.ambiguous := ambiguous
    settled.endBar := atBar
    settled.endTime := atTime
    settled.netR := na(price) ? na : f_sdNetR(plan, price)
    settled

f_sdNumber(float value) =>
    string rendered = "null"
    if not na(value)
        if f_sdSign(value) == 0
            rendered := "0"
        else
            int power = int(math.floor(math.log10(math.abs(value))))
            rendered := power < -6 or power > 12 ? str.tostring(value / math.pow(10, power), "0.################") + "e" + str.tostring(power) : str.tostring(value, "0.################")
    rendered

f_sdEscape(string value) =>
    string escaped = str.replace_all(value, "\\", "\\\\")
    escaped := str.replace_all(escaped, "\"", "\\\"")
    escaped := str.replace_all(escaped, "\n", "\\n")
    str.replace_all(escaped, "\t", "\\t")

f_sdJson(SdPlan plan, string event, string symbol, string tf) =>
    "{\"schema\":\"sdd1\",\"event\":\"" + event + "\",\"kind\":\"paper_reference\",\"symbol\":\"" + f_sdEscape(symbol) + "\",\"tf\":\"" + tf + "\",\"id\":\"" + plan.id + "\",\"side\":\"" + (plan.direction == 1 ? "long" : "short") + "\",\"signal_time\":" + str.tostring(plan.signalTime) + ",\"entry\":" + f_sdNumber(plan.entry) + ",\"stop\":" + f_sdNumber(plan.stop) + ",\"target\":" + f_sdNumber(plan.target) + ",\"quantity\":" + f_sdNumber(plan.quantity) + ",\"net_at_target_r\":" + f_sdNumber(plan.netAtTarget) + ",\"exit\":" + f_sdNumber(plan.exitPrice) + ",\"net_r\":" + f_sdNumber(plan.netR) + ",\"ambiguous\":" + (plan.ambiguous ? "true" : "false") + "}"

// === CHART ENGINE ===
if barstate.isfirst and (not chart.is_standard or not timeframe.isminutes or timeframe.multiplier != 5)
    runtime.error("Supply & Demand Desk requires standard 5-minute candles. Switch chart type to Candles and timeframe to 5m.")

SdConfig sdCfg = SdConfig.new(sdImpulse, sdRetrMin, sdNear, sdRetrWait, sdZoneAge, sdVisitAge, sdStopPad, sdReward, sdHold, sdCash, sdCap, sdFee, sdSlip)
float sdAtr = ta.atr(14)
float sdPh = ta.pivothigh(high, 2, 2)
float sdPl = ta.pivotlow(low, 2, 2)
var float sdLastHigh = na
var float sdPriorHigh = na
var float sdLastLow = na
var float sdPriorLow = na
var int sdTrend = 0
var array<SdZone> sdZones = array.new<SdZone>()
var array<SdZone> sdCandidates = array.new<SdZone>()
var array<SdPlan> sdHistory = array.new<SdPlan>()
var SdPlan sdPaper = SdPlan.new()
var string sdWhy = "Waiting for ATR history"
var int sdLastClosedTime = na
var int sdRetired = 0
var int sdConditions = 0
var int sdBlocked = 0
var int sdLastExit = na
bool sdEntered = false
bool sdExited = false
bool sdCondition = false
bool sdConflict = false
bool sdValid = f_sdBarValid(open, high, low, close)
bool sdReady = not na(sdAtr) and f_sdSign(sdAtr) > 0

if barstate.isconfirmed
    sdLastClosedTime := time_close
    array.clear(sdCandidates)
    if not na(sdPh)
        sdPriorHigh := sdLastHigh
        sdLastHigh := sdPh
    if not na(sdPl)
        sdPriorLow := sdLastLow
        sdLastLow := sdPl
    sdTrend := f_sdCompare(sdLastHigh, sdPriorHigh) > 0 and f_sdCompare(sdLastLow, sdPriorLow) > 0 ? 1 : f_sdCompare(sdLastHigh, sdPriorHigh) < 0 and f_sdCompare(sdLastLow, sdPriorLow) < 0 ? -1 : 0
    [sdResult, sdExitPrice, sdAmbiguous] = f_sdOutcome(sdPaper, open, high, low, close, bar_index, true)
    if sdResult != ""
        sdPaper := f_sdSettle(sdPaper, sdResult, sdExitPrice, sdAmbiguous, bar_index, time_close)
        array.push(sdHistory, sdPaper.copy())
        if array.size(sdHistory) > 6
            array.shift(sdHistory)
        sdExited := true
        sdLastExit := bar_index
        if sdJson
            alert(f_sdJson(sdPaper, sdResult, syminfo.tickerid, timeframe.period), alert.freq_all)
    for zone in sdZones
        bool candidate = f_sdStep(zone, open, high, low, close, bar_index, time_close, sdCfg)
        if candidate
            sdConditions += 1
            sdCondition := true
            if not sdTrendGate or sdTrend == zone.direction
                array.push(sdCandidates, zone)
            else
                zone.reason := "Condition blocked by chart-local trend"
                sdBlocked += 1
    if array.size(sdCandidates) > 0
        [sdSelected, sdSelectionWhy, sdHasConflict] = f_sdSelect(sdCandidates, close, sdAtr, bar_index, time_close, syminfo.mintick, syminfo.pointvalue, syminfo.mincontract, sdCfg)
        sdConflict := sdHasConflict
        if sdPaper.state == "ACTIVE"
            sdWhy := "Condition seen · paper reference already active"
            sdBlocked += 1
        else if sdExited
            sdWhy := "Condition seen · no exit-bar replacement"
            sdBlocked += 1
        else if sdSelected.direction == 0
            sdWhy := sdSelectionWhy
            sdBlocked += 1
        else
            sdPaper := sdSelected
            sdEntered := true
            sdWhy := sdSelectionWhy
            if sdJson
                alert(f_sdJson(sdPaper, "ENTRY", syminfo.tickerid, timeframe.period), alert.freq_all)
    else if not sdValid
        sdWhy := "Malformed observed candle · wait for clean data"
    else if not sdReady
        sdWhy := "ATR14 history unavailable"
    else if sdCondition
        sdWhy := "Condition blocked by chart-local trend"
    else if sdPaper.state == "ACTIVE"
        sdWhy := "Fixed paper reference active · no new entry"
    else
        sdWhy := "No current accepted entry"
    bool previousValid = bar_index > 0 and f_sdBarValid(open[1], high[1], low[1], close[1])
    if sdValid and previousValid and sdReady
        int sdBirthDirection = f_sdCompare(close, open) > 0 and f_sdCompare(close[1], open[1]) < 0 ? 1 : f_sdCompare(close, open) < 0 and f_sdCompare(close[1], open[1]) > 0 ? -1 : 0
        if sdBirthDirection != 0
            SdZone birth = f_sdNewZone(sdBirthDirection, open[1], high[1], low[1], close[1], open, high, low, close, sdAtr, bar_index - 1, time[1], bar_index, time_close, sdAccuracy)
            if birth.state == "DEPARTURE"
                if array.size(sdZones) >= 32
                    int removeIndex = 0
                    bool foundTerminal = false
                    for i = 0 to array.size(sdZones) - 1
                        if f_sdTerminal(array.get(sdZones, i).state) and not foundTerminal
                            removeIndex := i
                            foundTerminal := true
                    array.remove(sdZones, removeIndex)
                    sdRetired += 1
                array.push(sdZones, birth)

plot(sdPaper.state == "ACTIVE" ? sdPaper.entry : na, "Paper entry reference", display = display.none, editable = false)
plot(sdPaper.state == "ACTIVE" ? sdPaper.stop : na, "Paper stop", display = display.none, editable = false)
plot(sdPaper.state == "ACTIVE" ? sdPaper.target : na, "Paper target", display = display.none, editable = false)
plotshape(sdEntered and sdPaper.direction == 1, "Paper long reference", shape.triangleup, location.belowbar, color = color.teal, size = size.tiny, text = "REF L", textcolor = color.teal, display = display.pane)
plotshape(sdEntered and sdPaper.direction == -1, "Paper short reference", shape.triangledown, location.abovebar, color = color.orange, size = size.tiny, text = "REF S", textcolor = color.orange, display = display.pane)
alertcondition(sdEntered, "Paper entry reference", "SDD paper reference {{ticker}} {{interval}} entry={{plot(\"Paper entry reference\")}} stop={{plot(\"Paper stop\")}} target={{plot(\"Paper target\")}}. This is a confirmed reference, not a broker fill.")
alertcondition(sdExited, "Paper reference ended", "SDD paper reference ended on {{ticker}} {{interval}}. Use JSON for exact reason, prices, costs and ambiguity.")
alertcondition(sdCondition, "Zone condition confirmed", "SDD zone condition on {{ticker}} {{interval}}. A condition may be blocked by trend, opposite conditions or plan occupancy; it is not an accepted entry.")

// Rendering consumes committed fields; no price or admission logic uses display inputs.
string vGroup = "05 · Presentation"
string vMode = input.string("Clean", "Chart view", options = ["Off", "Clean", "Inspect"], group = vGroup, display = display.none)
bool vDesk = input.bool(true, "Show WHY / NEXT desk", group = vGroup, display = display.none)
int vZoneCount = input.int(4, "Visible live zones", minval = 1, maxval = 8, group = vGroup, active = vMode != "Off", display = display.none)
bool vHistory = input.bool(true, "Ended paper markers", group = vGroup, active = vMode != "Off", display = display.none)
string vSize = input.string("Normal", "Text size", options = ["Small", "Normal", "Large"], group = vGroup, display = display.none)
color vBull = color.rgb(33, 191, 160)
color vBear = color.rgb(244, 158, 85)
color vInk = color.rgb(17, 29, 43)
color vMuted = color.rgb(158, 177, 196)
string vBodySize = vSize == "Large" ? size.normal : vSize == "Small" ? size.tiny : size.small
string vSmallSize = vSize == "Large" ? size.small : size.tiny
var array<box> vBoxes = array.new<box>()
var array<line> vLines = array.new<line>()
var array<label> vLabels = array.new<label>()
var table vTable = table.new(position.top_right, 2, 11, bgcolor = na, frame_width = 0)
float vRange = ta.highest(high, 80) - ta.lowest(low, 80)

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

f_vClock(int stamp) =>
    na(stamp) ? "—" : str.format_time(stamp, "MM-dd HH:mm", "UTC")

f_vRank(string state) =>
    state == "VISIT" ? 4 : state == "READY" ? 3 : state == "RETRACEMENT" ? 2 : state == "DEPARTURE" ? 1 : 0

f_vNext(SdZone zone) =>
    zone.state == "DEPARTURE" ? "Wait for a pullback above/below this untouched zone" : zone.state == "RETRACEMENT" ? "Need " + str.tostring(sdRetrMin) + " pullback bars then a close through " + f_vPrice(zone.origin) : zone.state == "READY" ? "Raid " + f_vPrice(zone.front) + " into zone; directional close outside" : zone.state == "VISIT" ? "Need " + (zone.direction == 1 ? "bullish close > " + f_vPrice(zone.upper) : "bearish close < " + f_vPrice(zone.lower)) : zone.reason

f_vLevel(float price, string captionText, color ink, int startBar, int endBar, float tagPrice, string tip) =>
    array.push(vLines, line.new(startBar, price, endBar, price, xloc = xloc.bar_index, color = color.new(ink, 15), width = 1))
    if f_sdCompare(price, tagPrice) != 0
        array.push(vLines, line.new(endBar, price, endBar + 1, tagPrice, xloc = xloc.bar_index, color = color.new(ink, 35), style = line.style_dotted))
    array.push(vLabels, label.new(endBar + 1, tagPrice, captionText, xloc = xloc.bar_index, style = label.style_label_left, color = color.new(vInk, 5), textcolor = ink, size = vBodySize, tooltip = tip))

if barstate.islastconfirmedhistory or barstate.isrealtime
    for item in vBoxes
        box.delete(item)
    for item in vLines
        line.delete(item)
    for item in vLabels
        label.delete(item)
    array.clear(vBoxes)
    array.clear(vLines)
    array.clear(vLabels)
    table.clear(vTable, 0, 0, 1, 10)
    table.set_bgcolor(vTable, na)
    table.set_frame_color(vTable, vDesk ? color.new(vMuted, 65) : na)
    table.set_frame_width(vTable, 0)
    int vReadyCount = 0
    int vPendingCount = 0
    int vDrawn = 0
    SdZone vFocus = SdZone.new()
    if array.size(sdZones) > 0
        for i = array.size(sdZones) - 1 to 0
            SdZone zone = array.get(sdZones, i)
            bool live = not f_sdTerminal(zone.state) and zone.state != "SIGNAL"
            if live
                vPendingCount += 1
                if zone.state == "READY" or zone.state == "VISIT"
                    vReadyCount += 1
                if f_vRank(zone.state) > f_vRank(vFocus.state)
                    vFocus := zone
                if vMode != "Off" and vDrawn < vZoneCount
                    color ink = zone.direction == 1 ? vBull : vBear
                    int start = vMode == "Inspect" ? zone.baseBar : zone.createdBar
                    array.push(vBoxes, box.new(start, zone.upper, bar_index + 2, zone.lower, border_color = color.new(ink, zone.state == "VISIT" ? 5 : 45), bgcolor = color.new(ink, zone.state == "VISIT" ? 84 : 93), text = (zone.direction == 1 ? "DEMAND · " : "SUPPLY · ") + zone.state, text_color = color.new(ink, 5), text_size = vSmallSize, text_halign = text.align_left, text_valign = text.align_center))
                    if not na(zone.front)
                        array.push(vLines, line.new(zone.phaseBar, zone.front, bar_index + 2, zone.front, color = color.new(ink, 25), style = line.style_dashed))
                    if vMode == "Inspect"
                        array.push(vLabels, label.new(zone.createdBar, zone.direction == 1 ? zone.lower : zone.upper, "KNOWN " + f_vClock(zone.createdTime), style = zone.direction == 1 ? label.style_label_up : label.style_label_down, color = color.new(vInk, 20), textcolor = ink, size = size.tiny, tooltip = "Base " + f_vClock(zone.baseTime) + " UTC\nCreated/known " + f_vClock(zone.createdTime) + " UTC\n" + zone.reason + "\n" + f_vNext(zone)))
                    vDrawn += 1
    bool vHasPaper = sdPaper.direction != 0
    bool vActive = sdPaper.state == "ACTIVE"
    bool vRecent = vHasPaper and not na(sdPaper.endBar) and bar_index - sdPaper.endBar <= 6
    if vMode != "Off" and vHasPaper and (vActive or vRecent)
        float span = math.max(vRange, math.abs(sdPaper.target - sdPaper.stop))
        float gap = math.max(4 * syminfo.mintick, span * (vSize == "Large" ? 0.07 : 0.045))
        int end = bar_index + 3
        color sideColor = sdPaper.direction == 1 ? vBull : vBear
        string frozenTip = "Frozen at " + f_vClock(sdPaper.signalTime) + " UTC\nBase " + f_vClock(sdPaper.baseTime) + " · known " + f_vClock(sdPaper.createdTime) + "\nQuantity " + f_sdNumber(sdPaper.quantity) + " · estimated net at target " + f_sdNumber(sdPaper.netAtTarget) + "R\nPaper reference; this is not a broker fill."
        array.push(vBoxes, box.new(sdPaper.signalBar, math.max(sdPaper.entry, sdPaper.target), end, math.min(sdPaper.entry, sdPaper.target), border_color = na, bgcolor = color.new(sideColor, vActive ? 92 : 97)))
        array.push(vBoxes, box.new(sdPaper.signalBar, math.max(sdPaper.entry, sdPaper.stop), end, math.min(sdPaper.entry, sdPaper.stop), border_color = na, bgcolor = color.new(color.red, vActive ? 93 : 98)))
        float entryTag = sdPaper.entry
        float stopTag = sdPaper.direction == 1 ? math.min(sdPaper.stop, entryTag - gap) : math.max(sdPaper.stop, entryTag + gap)
        float targetTag = sdPaper.direction == 1 ? math.max(sdPaper.target, entryTag + gap) : math.min(sdPaper.target, entryTag - gap)
        f_vLevel(sdPaper.entry, (vActive ? "ENTRY REF  " : "ENDED REF  ") + f_vPrice(sdPaper.entry), color.white, sdPaper.signalBar, end, entryTag, frozenTip)
        f_vLevel(sdPaper.stop, "STOP  " + f_vPrice(sdPaper.stop), color.rgb(246, 117, 117), sdPaper.signalBar, end, stopTag, frozenTip)
        f_vLevel(sdPaper.target, str.tostring(math.abs(sdPaper.target - sdPaper.entry) / sdPaper.risk, "0.##") + "R TARGET  " + f_vPrice(sdPaper.target), sideColor, sdPaper.signalBar, end, targetTag, frozenTip)
    if vMode != "Off" and vHistory
        for ended in sdHistory
            if bar_index - ended.endBar <= 200
                float marker = na(ended.exitPrice) ? ended.entry : ended.exitPrice
                string caption = ended.reason + (ended.ambiguous ? " · BOTH TOUCHED" : "") + (na(ended.netR) ? "\nUNPRICED" : "\n" + str.tostring(ended.netR, "0.00") + "R est. net")
                array.push(vLabels, label.new(ended.endBar, marker, caption, style = ended.direction == 1 ? label.style_label_down : label.style_label_up, color = color.new(vInk, 15), textcolor = ended.reason == "TARGET" ? vBull : vMuted, size = vSmallSize, tooltip = na(ended.exitPrice) ? "Unpriced data gap; marker uses frozen entry for orientation. No exit price or fill inferred." : "Historical paper outcome " + f_vClock(ended.endTime) + " UTC. Price " + f_vPrice(ended.exitPrice) + ". Not a currently tracked broker position."))
    if vDesk
        // Transparent chart-header inset; body rows retain their own background.
        table.cell(vTable, 0, 0, "", height = 3, bgcolor = na)
        table.cell(vTable, 1, 0, "", height = 3, bgcolor = na)
        string nowText = vActive ? "ACTIVE PAPER " + (sdPaper.direction == 1 ? "LONG" : "SHORT") : sdEntered ? "NEW PAPER REFERENCE" : "NO ACTIVE PAPER PLAN"
        table.cell(vTable, 0, 1, "SUPPLY & DEMAND", text_color = color.white, text_size = vBodySize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 1, "DESK " + SDD_VERSION + " · 5m", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 2, nowText, text_color = vActive ? sdPaper.direction == 1 ? vBull : vBear : vMuted, text_size = vBodySize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 2, "Closed " + f_vClock(sdLastClosedTime) + " UTC", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 3, "WHY", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 3, sdWhy, text_color = color.white, text_size = vBodySize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 4, "NEXT", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 4, vActive ? "Fixed stop/target · later candles only" : vFocus.direction != 0 ? f_vNext(vFocus) : "Wait for an untouched base and strong departure", text_color = color.white, text_size = vBodySize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 5, "READY / BUILDING", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 5, str.tostring(vReadyCount) + " / " + str.tostring(vPendingCount - vReadyCount) + " · shown " + str.tostring(vDrawn), text_color = vMuted, text_size = vSmallSize, tooltip = "All stored live zones are counted; chart draws at most the configured number, latest first.", bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 6, "TREND", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 6, (sdTrend == 1 ? "HH / HL" : sdTrend == -1 ? "LH / LL" : "MIXED / WARMUP") + (sdTrendGate ? " · gate ON" : " · context only"), text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 7, "PLAN / COSTS", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 7, vActive ? "Qty " + f_sdNumber(sdPaper.quantity) + " · net at TP " + str.tostring(sdPaper.netAtTarget, "0.00") + "R" : "Signal-close reference · no entry pending", text_color = vActive and f_sdSign(sdPaper.netAtTarget) <= 0 ? color.orange : vMuted, text_size = vSmallSize, tooltip = "Indicator cash quantities use symbol currency. Estimated net includes the declared fee/slippage allowance. Nonpositive net-at-TP remains disclosed; it is not profitability.", bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 8, "ALERT", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 8, SD_IS_LAB ? "Any alert() call = paper JSON; order fills = broker" : "Create alert → Any alert() function call", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        if vMode == "Inspect"
            table.cell(vTable, 0, 9, "CONDITIONS / BLOCKS", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
            table.cell(vTable, 1, 9, str.tostring(sdConditions) + " / " + str.tostring(sdBlocked) + " · retired " + str.tostring(sdRetired), text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
            table.cell(vTable, 0, 10, "ORIGINAL SUBSET", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
            table.cell(vTable, 1, 10, "Closed bars · no flip-entry or private parity", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
Download indicator .pine

The separate Strategy Lab

Tests independent orders, fills, sizing and declared costs.

Strategy code

Copy the code as it is. No coding needed.

View the full code
//@version=6
// Supply & Demand Desk 1.0.0 · MIT · Original public-method-inspired 5m subset.
// Not Mangoe's official script or private-method parity. See docs/engine-contract.md.
// Signal-close paper references differ from next-open Strategy Lab fills.
strategy("Supply & Demand Desk · Strategy Lab", "SDD Lab", overlay = true, behind_chart = false, max_boxes_count = 40, max_lines_count = 100, max_labels_count = 100, max_bars_back = 2000, initial_capital = 100000, commission_type = strategy.commission.percent, commission_value = 0.05, slippage = 1, pyramiding = 0, margin_long = 100, margin_short = 100, process_orders_on_close = false, calc_on_every_tick = false, calc_on_order_fills = false)
const string SDD_VERSION = "1.0.0"
const bool SD_IS_LAB = true

// Original closed-bar subset of a public discretionary method. Read docs/engine-contract.md.
string sdG = "01 · Setup rules"
float sdImpulse = input.float(0.8, "Minimum departure · ATR", minval = 0.1, maxval = 5, step = 0.1, group = sdG, tooltip = "Original strength threshold, measured from proximal edge to departure extreme. Birth ATR is frozen.", display = display.none)
int sdRetrMin = input.int(2, "Minimum retracement bars", minval = 2, maxval = 8, group = sdG, tooltip = "Observed retracement bars before the breakout bar; colors may mix.", display = display.none)
float sdNear = input.float(0.5, "Front liquidity · maximum setup fraction", minval = 0.1, maxval = 1, step = 0.05, group = sdG, tooltip = "Demand: (front low − proximal) / (originating high − proximal). Supply mirrors. Our explicit near-half formalization.", display = display.none)
bool sdAccuracy = input.bool(false, "FX accuracy drawing", group = sdG, tooltip = "Optional public FX drawing variant. Excludes a protruding proximal base wick. Default full-wick drawing applies to all symbols; no automatic asset classification.", display = display.none)
int sdRetrWait = input.int(20, "Retracement deadline · bars", minval = 3, maxval = 60, group = sdG, display = display.none)
int sdZoneAge = input.int(120, "Zone lifetime · bars", minval = 10, maxval = 500, group = sdG, display = display.none)
int sdVisitAge = input.int(3, "Entry visit deadline · bars", minval = 0, maxval = 12, group = sdG, tooltip = "Age0 is first touch. Default ages0..3 can confirm; age4 expires. Opposing-color wick rejection may wait for a later directional close.", display = display.none)
bool sdTrendGate = input.bool(false, "Require chart-local HH/HL or LH/LL", group = sdG, tooltip = "Optional original gate: last two confirmed two-left/two-right swing highs and lows. No HTF request. Neutral or incomplete structure blocks when enabled.", display = display.none)
string sdP = "02 · Paper reference"
float sdStopPad = input.float(0, "Stop pad · ATR (minimum one tick)", minval = 0, maxval = 1, step = 0.05, group = sdP, display = display.none)
float sdReward = input.float(4, "Target · R", minval = 1, maxval = 10, step = 0.5, group = sdP, tooltip = "4R follows the public 2026 five-minute course. Changing this is your variant. Rounded actual R may be slightly greater.", display = display.none)
int sdHold = input.int(48, "Maximum paper hold · bars", minval = 1, maxval = 288, group = sdP, tooltip = "Ordinary stop/target checks first, then close at the observed candle close. Broker Lab uses its own next-open market exit.", display = display.none)
float sdCash = input.float(100, "Cash risk budget · symbol currency", minval = 0.01, group = sdP, display = display.none)
float sdCap = input.float(90000, "Notional cap · symbol currency", minval = 1, group = sdP, display = display.none)
float sdFee = input.float(0.05, "Estimated commission · % per side", minval = 0, maxval = 2, step = 0.01, group = sdP, display = display.none)
int sdSlip = input.int(1, "Estimated slippage · ticks per side", minval = 0, maxval = 20, group = sdP, display = display.none)
bool sdJson = input.bool(true, "JSON alert events", group = "03 · Alerts", tooltip = "Create one alert using Any alert() function call. Reference events are not broker fills. Strategy Lab can emit these paper-reference events independently from its order-fill alerts.", display = display.none)

// === TYPES AND PURE HELPERS ===
type SdConfig
    float impulse = 0.8
    int retrMin = 2
    float near = 0.5
    int retrWait = 20
    int zoneAge = 120
    int visitAge = 3
    float stopPad = 0
    float reward = 4
    int hold = 48
    float cash = 100
    float cap = 90000
    float fee = 0.05
    int slip = 1

type SdZone
    int direction = 0
    string id = ""
    string state = "INVALID"
    string reason = "Unavailable"
    int baseBar = na
    int baseTime = na
    int createdBar = na
    int createdTime = na
    int phaseBar = na
    int readyBar = na
    int readyTime = na
    int visitBar = na
    int signalBar = na
    float lower = na
    float upper = na
    float birthAtr = na
    float impulseExtreme = na
    float origin = na
    float front = na
    int retrBars = 0
    float deepest = na
    float previousHigh = na
    float previousLow = na
    float previousClose = na

type SdPlan
    int direction = 0
    string id = ""
    string state = "EMPTY"
    string reason = "No accepted reference"
    int signalBar = na
    int signalTime = na
    int endBar = na
    int endTime = na
    int hold = 48
    float entry = na
    float stop = na
    float target = na
    float risk = na
    float quantity = na
    float pointValue = na
    float tick = na
    float fee = 0
    int slip = 0
    float netAtTarget = na
    float exitPrice = na
    float netR = na
    bool ambiguous = false
    float lower = na
    float upper = na
    float front = na
    int baseTime = na
    int createdTime = na
    int readyTime = na

f_sdSign(float value) =>
    int(nz(value / math.abs(value), 0))

f_sdCompare(float lhs, float rhs) =>
    int result = na
    if not na(lhs) and not na(rhs)
        float difference = lhs - rhs
        float noise = 4 * 2.220446049250313e-16 * math.max(math.abs(lhs), math.abs(rhs))
        result := f_sdSign(math.abs(difference) - noise) <= 0 ? 0 : f_sdSign(difference)
    result

f_sdBarValid(float o, float h, float l, float c) =>
    not na(o) and not na(h) and not na(l) and not na(c) and f_sdSign(h - l) >= 0 and f_sdSign(h - math.max(o, c)) >= 0 and f_sdSign(math.min(o, c) - l) >= 0

f_sdUnits(float value) =>
    float nearest = math.round(value)
    float noise = math.min(1e-7, 8 * 2.220446049250313e-16 * math.max(1, math.abs(value)))
    f_sdSign(math.abs(value - nearest) - noise) <= 0 ? nearest : value

f_sdTick(float value, float tick, bool up) =>
    float units = f_sdUnits(value / tick)
    (up ? math.ceil(units) : math.floor(units)) * tick

f_sdDistance(float firstPrice, float secondPrice, float tick) =>
    float distance = math.abs(firstPrice - secondPrice)
    float units = distance / tick
    float nearest = math.round(units)
    float noise = math.min(1e-7, 8 * 2.220446049250313e-16 * math.max(math.abs(firstPrice), math.abs(secondPrice)) / tick)
    f_sdSign(math.abs(units - nearest) - noise) <= 0 ? nearest * tick : distance

f_sdQuantity(float entry, float distance, float cash, float cap, float pointValue, float increment) =>
    float quantity = na
    if not na(entry) and f_sdSign(distance) > 0 and f_sdSign(cash) > 0 and f_sdSign(cap) > 0 and f_sdSign(pointValue) > 0 and f_sdSign(increment) > 0
        float byRisk = cash / (distance * pointValue)
        float byCap = f_sdSign(math.abs(entry)) > 0 ? cap / (math.abs(entry) * pointValue) : byRisk
        quantity := math.floor(f_sdUnits(math.min(byRisk, byCap) / increment)) * increment
    quantity

f_sdNetR(SdPlan plan, float exitPrice) =>
    float gross = plan.direction * (exitPrice - plan.entry) * plan.quantity * plan.pointValue
    float fees = (math.abs(plan.entry) + math.abs(exitPrice)) * plan.quantity * plan.pointValue * plan.fee / 100
    float slipCash = 2 * plan.slip * plan.tick * plan.quantity * plan.pointValue
    (gross - fees - slipCash) / (plan.risk * plan.quantity * plan.pointValue)

f_sdTerminal(string state) =>
    state == "INVALID" or state == "EXPIRED" or state == "USED"

f_sdNewZone(int direction, float bo, float bh, float bl, float bc, float o, float h, float l, float c, float atr, int baseBar, int baseTime, int nowBar, int nowTime, bool accuracy) =>
    SdZone zone = SdZone.new()
    bool packets = f_sdBarValid(bo, bh, bl, bc) and f_sdBarValid(o, h, l, c) and f_sdSign(atr) > 0
    bool colors = direction == 1 ? f_sdCompare(bo, bc) > 0 and f_sdCompare(c, o) > 0 : f_sdCompare(bc, bo) > 0 and f_sdCompare(o, c) > 0
    if packets and colors and (direction == 1 or direction == -1)
        zone.direction := direction
        zone.id := str.tostring(baseTime) + (direction == 1 ? ":D" : ":S")
        zone.baseBar := baseBar
        zone.baseTime := baseTime
        zone.createdBar := nowBar
        zone.createdTime := nowTime
        zone.phaseBar := nowBar
        zone.lower := direction == 1 ? math.min(bl, l) : accuracy and f_sdCompare(bl, l) < 0 ? bo : bl
        zone.upper := direction == -1 ? math.max(bh, h) : accuracy and f_sdCompare(bh, h) > 0 ? bo : bh
        zone.birthAtr := atr
        zone.impulseExtreme := direction == 1 ? h : l
        zone.previousHigh := h
        zone.previousLow := l
        zone.previousClose := c
        bool outside = direction == 1 ? f_sdCompare(c, zone.upper) > 0 : f_sdCompare(c, zone.lower) < 0
        if outside and f_sdCompare(zone.upper, zone.lower) > 0
            zone.state := "DEPARTURE"
            zone.reason := "Wait for front liquidity"
    zone

f_sdStep(SdZone zone, float o, float h, float l, float c, int atBar, int atTime, SdConfig cfg) =>
    bool signal = false
    bool valid = f_sdBarValid(o, h, l, c)
    if not f_sdTerminal(zone.state) and atBar > zone.createdBar
        if zone.state == "SIGNAL"
            zone.state := "USED"
            zone.reason := "Condition already used"
        else if atBar - zone.createdBar > cfg.zoneAge
            zone.state := "EXPIRED"
            zone.reason := "Zone lifetime reached"
        else if not valid
            zone.state := "INVALID"
            zone.reason := "Malformed observed candle"
        else
            bool longSide = zone.direction == 1
            float proximal = longSide ? zone.upper : zone.lower
            bool adverseClose = longSide ? f_sdCompare(c, proximal) <= 0 : f_sdCompare(c, proximal) >= 0
            bool touches = f_sdCompare(l, zone.upper) <= 0 and f_sdCompare(h, zone.lower) >= 0
            bool beforeReady = zone.state == "DEPARTURE" or zone.state == "RETRACEMENT"
            if adverseClose
                zone.state := "INVALID"
                zone.reason := "Close on/inside or through zone"
            else if beforeReady and touches
                zone.state := "INVALID"
                zone.reason := "Zone touched before readiness"
            else if zone.state == "DEPARTURE"
                bool continues = longSide ? f_sdCompare(c, o) > 0 and f_sdCompare(c, zone.previousClose) > 0 : f_sdCompare(c, o) < 0 and f_sdCompare(c, zone.previousClose) < 0
                bool counter = longSide ? f_sdCompare(c, zone.previousClose) < 0 or f_sdCompare(l, zone.previousLow) < 0 : f_sdCompare(c, zone.previousClose) > 0 or f_sdCompare(h, zone.previousHigh) > 0
                if counter
                    float move = longSide ? zone.impulseExtreme - proximal : proximal - zone.impulseExtreme
                    if f_sdCompare(move, cfg.impulse * zone.birthAtr) >= 0
                        zone.origin := zone.impulseExtreme
                        zone.front := longSide ? l : h
                        zone.retrBars := 1
                        zone.phaseBar := atBar
                        zone.state := "RETRACEMENT"
                        zone.reason := "Wait for two-bar pullback and own-origin break"
                    else
                        zone.state := "INVALID"
                        zone.reason := "Departure below strength threshold"
                else if continues
                    zone.impulseExtreme := longSide ? math.max(zone.impulseExtreme, h) : math.min(zone.impulseExtreme, l)
            else if zone.state == "RETRACEMENT"
                bool breaks = longSide ? f_sdCompare(c, zone.origin) > 0 : f_sdCompare(c, zone.origin) < 0
                if atBar - zone.phaseBar > cfg.retrWait
                    zone.state := "EXPIRED"
                    zone.reason := "Retracement deadline reached"
                else if breaks
                    float frontDistance = longSide ? zone.front - proximal : proximal - zone.front
                    float setupDistance = longSide ? zone.origin - proximal : proximal - zone.origin
                    if zone.retrBars < cfg.retrMin
                        zone.state := "INVALID"
                        zone.reason := "Origin broken before minimum retracement"
                    else if f_sdSign(setupDistance) <= 0 or f_sdSign(frontDistance) <= 0 or f_sdCompare(frontDistance / setupDistance, cfg.near) > 0
                        zone.state := "INVALID"
                        zone.reason := "Front liquidity outside near-half rule"
                    else
                        zone.state := "READY"
                        zone.readyBar := atBar
                        zone.readyTime := atTime
                        zone.reason := "Wait for liquidity raid into zone"
                else
                    zone.retrBars += 1
                    zone.front := longSide ? math.min(zone.front, l) : math.max(zone.front, h)
            else if zone.state == "READY" or zone.state == "VISIT"
                bool oldVisit = zone.state == "VISIT"
                if oldVisit and atBar - zone.visitBar > cfg.visitAge
                    zone.state := "EXPIRED"
                    zone.reason := "Entry visit deadline reached"
                else
                    bool raids = longSide ? f_sdCompare(l, zone.front) < 0 : f_sdCompare(h, zone.front) > 0
                    if not oldVisit and touches and raids and atBar > zone.readyBar
                        zone.state := "VISIT"
                        zone.visitBar := atBar
                        zone.deepest := longSide ? l : h
                        zone.reason := "Wick rejected; wait for directional close"
                    if zone.state == "VISIT"
                        zone.deepest := longSide ? math.min(zone.deepest, l) : math.max(zone.deepest, h)
                        bool directional = longSide ? f_sdCompare(c, o) > 0 : f_sdCompare(c, o) < 0
                        if directional
                            zone.state := "SIGNAL"
                            zone.signalBar := atBar
                            zone.reason := "Raid and directional close confirmed"
                            signal := true
            zone.previousHigh := h
            zone.previousLow := l
            zone.previousClose := c
    signal

f_sdPlan(SdZone zone, float entry, float atr, int atBar, int atTime, float tick, float pointValue, float increment, SdConfig cfg) =>
    SdPlan plan = SdPlan.new()
    string why = "No current condition"
    if zone.state == "SIGNAL" and zone.signalBar == atBar and f_sdSign(tick) > 0 and f_sdSign(atr) > 0
        float pad = math.max(tick, atr * cfg.stopPad)
        float stop = f_sdTick(zone.deepest - zone.direction * pad, tick, zone.direction == -1)
        float risk = f_sdDistance(entry, stop, tick)
        bool geometry = zone.direction == 1 ? f_sdCompare(entry, stop) > 0 : f_sdCompare(entry, stop) < 0
        float target = f_sdTick(entry + zone.direction * risk * cfg.reward, tick, zone.direction == 1)
        float quantity = f_sdQuantity(entry, risk, cfg.cash, cfg.cap, pointValue, increment)
        if not geometry or f_sdSign(risk) <= 0 or na(target)
            why := "Invalid rounded plan geometry"
        else if na(quantity) or f_sdSign(quantity) <= 0
            why := "Budget/cap cannot fund minimum quantity"
        else
            plan.direction := zone.direction
            plan.id := zone.id + ":" + str.tostring(atTime)
            plan.state := "ACTIVE"
            plan.reason := "Paper reference · next-bar outcomes"
            plan.signalBar := atBar
            plan.signalTime := atTime
            plan.hold := cfg.hold
            plan.entry := entry
            plan.stop := stop
            plan.target := target
            plan.risk := risk
            plan.quantity := quantity
            plan.pointValue := pointValue
            plan.tick := tick
            plan.fee := cfg.fee
            plan.slip := cfg.slip
            plan.lower := zone.lower
            plan.upper := zone.upper
            plan.front := zone.front
            plan.baseTime := zone.baseTime
            plan.createdTime := zone.createdTime
            plan.readyTime := zone.readyTime
            plan.netAtTarget := f_sdNetR(plan, target)
            why := "Accepted paper reference"
    [plan, why]

f_sdSelect(array<SdZone> candidates, float entry, float atr, int atBar, int atTime, float tick, float pointValue, float increment, SdConfig cfg) =>
    SdPlan chosen = SdPlan.new()
    bool hasLong = false
    bool hasShort = false
    string why = "No current condition"
    for zone in candidates
        hasLong := hasLong or zone.direction == 1
        hasShort := hasShort or zone.direction == -1
    bool conflict = hasLong and hasShort
    if conflict
        why := "Opposite conditions · no admission"
    else
        for zone in candidates
            [candidate, rejection] = f_sdPlan(zone, entry, atr, atBar, atTime, tick, pointValue, increment, cfg)
            if candidate.direction != 0 and (chosen.direction == 0 or f_sdCompare(candidate.risk, chosen.risk) < 0)
                chosen := candidate
                why := rejection
            else if chosen.direction == 0
                why := rejection
    [chosen, why, conflict]

f_sdOutcome(SdPlan plan, float o, float h, float l, float c, int atBar, bool confirmed) =>
    string result = ""
    float price = na
    bool ambiguous = false
    if confirmed and plan.state == "ACTIVE" and atBar > plan.signalBar
        if not f_sdBarValid(o, h, l, c)
            result := "DATA GAP"
        else
            bool longSide = plan.direction == 1
            bool gapStop = longSide ? f_sdCompare(o, plan.stop) <= 0 : f_sdCompare(o, plan.stop) >= 0
            bool gapTarget = longSide ? f_sdCompare(o, plan.target) >= 0 : f_sdCompare(o, plan.target) <= 0
            bool stopHit = longSide ? f_sdCompare(l, plan.stop) <= 0 : f_sdCompare(h, plan.stop) >= 0
            bool targetHit = longSide ? f_sdCompare(h, plan.target) >= 0 : f_sdCompare(l, plan.target) <= 0
            if gapStop or gapTarget
                result := gapStop ? "STOP" : "TARGET"
                price := o
            else if stopHit or targetHit
                result := stopHit ? "STOP" : "TARGET"
                price := stopHit ? plan.stop : plan.target
                ambiguous := stopHit and targetHit
            else if atBar - plan.signalBar >= plan.hold
                result := "TIME"
                price := c
    [result, price, ambiguous]

f_sdSettle(SdPlan plan, string result, float price, bool ambiguous, int atBar, int atTime) =>
    SdPlan settled = plan.copy()
    settled.state := result == "DATA GAP" ? "UNRESOLVED" : "ENDED"
    settled.reason := result
    settled.exitPrice := price
    settled.ambiguous := ambiguous
    settled.endBar := atBar
    settled.endTime := atTime
    settled.netR := na(price) ? na : f_sdNetR(plan, price)
    settled

f_sdNumber(float value) =>
    string rendered = "null"
    if not na(value)
        if f_sdSign(value) == 0
            rendered := "0"
        else
            int power = int(math.floor(math.log10(math.abs(value))))
            rendered := power < -6 or power > 12 ? str.tostring(value / math.pow(10, power), "0.################") + "e" + str.tostring(power) : str.tostring(value, "0.################")
    rendered

f_sdEscape(string value) =>
    string escaped = str.replace_all(value, "\\", "\\\\")
    escaped := str.replace_all(escaped, "\"", "\\\"")
    escaped := str.replace_all(escaped, "\n", "\\n")
    str.replace_all(escaped, "\t", "\\t")

f_sdJson(SdPlan plan, string event, string symbol, string tf) =>
    "{\"schema\":\"sdd1\",\"event\":\"" + event + "\",\"kind\":\"paper_reference\",\"symbol\":\"" + f_sdEscape(symbol) + "\",\"tf\":\"" + tf + "\",\"id\":\"" + plan.id + "\",\"side\":\"" + (plan.direction == 1 ? "long" : "short") + "\",\"signal_time\":" + str.tostring(plan.signalTime) + ",\"entry\":" + f_sdNumber(plan.entry) + ",\"stop\":" + f_sdNumber(plan.stop) + ",\"target\":" + f_sdNumber(plan.target) + ",\"quantity\":" + f_sdNumber(plan.quantity) + ",\"net_at_target_r\":" + f_sdNumber(plan.netAtTarget) + ",\"exit\":" + f_sdNumber(plan.exitPrice) + ",\"net_r\":" + f_sdNumber(plan.netR) + ",\"ambiguous\":" + (plan.ambiguous ? "true" : "false") + "}"

// === CHART ENGINE ===
if barstate.isfirst and (not chart.is_standard or not timeframe.isminutes or timeframe.multiplier != 5)
    runtime.error("Supply & Demand Desk requires standard 5-minute candles. Switch chart type to Candles and timeframe to 5m.")

SdConfig sdCfg = SdConfig.new(sdImpulse, sdRetrMin, sdNear, sdRetrWait, sdZoneAge, sdVisitAge, sdStopPad, sdReward, sdHold, sdCash, sdCap, sdFee, sdSlip)
float sdAtr = ta.atr(14)
float sdPh = ta.pivothigh(high, 2, 2)
float sdPl = ta.pivotlow(low, 2, 2)
var float sdLastHigh = na
var float sdPriorHigh = na
var float sdLastLow = na
var float sdPriorLow = na
var int sdTrend = 0
var array<SdZone> sdZones = array.new<SdZone>()
var array<SdZone> sdCandidates = array.new<SdZone>()
var array<SdPlan> sdHistory = array.new<SdPlan>()
var SdPlan sdPaper = SdPlan.new()
var string sdWhy = "Waiting for ATR history"
var int sdLastClosedTime = na
var int sdRetired = 0
var int sdConditions = 0
var int sdBlocked = 0
var int sdLastExit = na
bool sdEntered = false
bool sdExited = false
bool sdCondition = false
bool sdConflict = false
bool sdValid = f_sdBarValid(open, high, low, close)
bool sdReady = not na(sdAtr) and f_sdSign(sdAtr) > 0

if barstate.isconfirmed
    sdLastClosedTime := time_close
    array.clear(sdCandidates)
    if not na(sdPh)
        sdPriorHigh := sdLastHigh
        sdLastHigh := sdPh
    if not na(sdPl)
        sdPriorLow := sdLastLow
        sdLastLow := sdPl
    sdTrend := f_sdCompare(sdLastHigh, sdPriorHigh) > 0 and f_sdCompare(sdLastLow, sdPriorLow) > 0 ? 1 : f_sdCompare(sdLastHigh, sdPriorHigh) < 0 and f_sdCompare(sdLastLow, sdPriorLow) < 0 ? -1 : 0
    [sdResult, sdExitPrice, sdAmbiguous] = f_sdOutcome(sdPaper, open, high, low, close, bar_index, true)
    if sdResult != ""
        sdPaper := f_sdSettle(sdPaper, sdResult, sdExitPrice, sdAmbiguous, bar_index, time_close)
        array.push(sdHistory, sdPaper.copy())
        if array.size(sdHistory) > 6
            array.shift(sdHistory)
        sdExited := true
        sdLastExit := bar_index
        if sdJson
            alert(f_sdJson(sdPaper, sdResult, syminfo.tickerid, timeframe.period), alert.freq_all)
    for zone in sdZones
        bool candidate = f_sdStep(zone, open, high, low, close, bar_index, time_close, sdCfg)
        if candidate
            sdConditions += 1
            sdCondition := true
            if not sdTrendGate or sdTrend == zone.direction
                array.push(sdCandidates, zone)
            else
                zone.reason := "Condition blocked by chart-local trend"
                sdBlocked += 1
    if array.size(sdCandidates) > 0
        [sdSelected, sdSelectionWhy, sdHasConflict] = f_sdSelect(sdCandidates, close, sdAtr, bar_index, time_close, syminfo.mintick, syminfo.pointvalue, syminfo.mincontract, sdCfg)
        sdConflict := sdHasConflict
        if sdPaper.state == "ACTIVE"
            sdWhy := "Condition seen · paper reference already active"
            sdBlocked += 1
        else if sdExited
            sdWhy := "Condition seen · no exit-bar replacement"
            sdBlocked += 1
        else if sdSelected.direction == 0
            sdWhy := sdSelectionWhy
            sdBlocked += 1
        else
            sdPaper := sdSelected
            sdEntered := true
            sdWhy := sdSelectionWhy
            if sdJson
                alert(f_sdJson(sdPaper, "ENTRY", syminfo.tickerid, timeframe.period), alert.freq_all)
    else if not sdValid
        sdWhy := "Malformed observed candle · wait for clean data"
    else if not sdReady
        sdWhy := "ATR14 history unavailable"
    else if sdCondition
        sdWhy := "Condition blocked by chart-local trend"
    else if sdPaper.state == "ACTIVE"
        sdWhy := "Fixed paper reference active · no new entry"
    else
        sdWhy := "No current accepted entry"
    bool previousValid = bar_index > 0 and f_sdBarValid(open[1], high[1], low[1], close[1])
    if sdValid and previousValid and sdReady
        int sdBirthDirection = f_sdCompare(close, open) > 0 and f_sdCompare(close[1], open[1]) < 0 ? 1 : f_sdCompare(close, open) < 0 and f_sdCompare(close[1], open[1]) > 0 ? -1 : 0
        if sdBirthDirection != 0
            SdZone birth = f_sdNewZone(sdBirthDirection, open[1], high[1], low[1], close[1], open, high, low, close, sdAtr, bar_index - 1, time[1], bar_index, time_close, sdAccuracy)
            if birth.state == "DEPARTURE"
                if array.size(sdZones) >= 32
                    int removeIndex = 0
                    bool foundTerminal = false
                    for i = 0 to array.size(sdZones) - 1
                        if f_sdTerminal(array.get(sdZones, i).state) and not foundTerminal
                            removeIndex := i
                            foundTerminal := true
                    array.remove(sdZones, removeIndex)
                    sdRetired += 1
                array.push(sdZones, birth)

plot(sdPaper.state == "ACTIVE" ? sdPaper.entry : na, "Paper entry reference", display = display.none, editable = false)
plot(sdPaper.state == "ACTIVE" ? sdPaper.stop : na, "Paper stop", display = display.none, editable = false)
plot(sdPaper.state == "ACTIVE" ? sdPaper.target : na, "Paper target", display = display.none, editable = false)
plotshape(sdEntered and sdPaper.direction == 1, "Paper long reference", shape.triangleup, location.belowbar, color = color.teal, size = size.tiny, text = "REF L", textcolor = color.teal, display = display.pane)
plotshape(sdEntered and sdPaper.direction == -1, "Paper short reference", shape.triangledown, location.abovebar, color = color.orange, size = size.tiny, text = "REF S", textcolor = color.orange, display = display.pane)


// Independent broker owner. Paper reference exits never route broker closes.
string labGroup = "04 · Strategy Lab"
float labRisk = input.float(100, "Lab risk budget · account currency", minval = 0.01, group = labGroup, display = display.none)
float labExposure = input.float(90, "Lab maximum equity notional · %", minval = 1, maxval = 100, group = labGroup, tooltip = "90% reserves some room for gaps/fees; margin calls can still occur. Quantity rounds down to symbol minimum contract.", display = display.none)
var SdPlan labOwner = SdPlan.new()
var int labQueuedBar = na
var int labFillBar = na
var int labExitBar = na
var int labSeenClosed = 0
var int labMarginClosures = 0
var int labPartialClosures = 0
var string labWhy = "No queued broker entry"
var int labQueuedCount = 0

if barstate.isconfirmed
    bool labFlat = f_sdSign(strategy.position_size) == 0
    if strategy.closedtrades > labSeenClosed
        for labTrade = labSeenClosed to strategy.closedtrades - 1
            if str.contains(strategy.closedtrades.exit_comment(labTrade), "Margin")
                labMarginClosures += 1
        if labFlat
            labOwner := SdPlan.new()
            labQueuedBar := na
            labFillBar := na
            labExitBar := bar_index
            labWhy := "Broker flat · no replacement on this bar"
        else
            labPartialClosures += strategy.closedtrades - labSeenClosed
            labWhy := "Partial broker close · frozen bracket retained"
        labSeenClosed := strategy.closedtrades
    if not labFlat and labOwner.direction != 0
        if na(labFillBar)
            labFillBar := strategy.opentrades.entry_bar_index(0)
        strategy.exit("SD bracket", "SD entry", stop = labOwner.stop, limit = labOwner.target, comment_loss = "SD stop", comment_profit = "SD target", alert_message = "SupplyDemandDesk broker bracket fill; broker prices may differ from paper reference.")
        if bar_index - labFillBar >= labOwner.hold or not sdValid
            strategy.close("SD entry", comment = not sdValid ? "SD data gap" : "SD time", alert_message = "SupplyDemandDesk independent broker lifecycle exit.")
            labWhy := not sdValid ? "Broker data-gap close requested" : "Broker hold deadline · market close requested"
        else if strategy.closedtrades == labSeenClosed and labWhy != "Partial broker close · frozen bracket retained"
            labWhy := "Broker live · fixed bracket; own hold clock"
    if labFlat and labOwner.direction != 0 and not na(labQueuedBar) and bar_index > labQueuedBar + 1
        strategy.cancel("SD entry")
        strategy.cancel("SD bracket")
        labOwner := SdPlan.new()
        labQueuedBar := na
        labWhy := "Entry not filled · ownership cleared"
    if labFlat and labOwner.direction == 0 and (na(labExitBar) or bar_index > labExitBar) and array.size(sdCandidates) > 0
        SdConfig labCfg = sdCfg.copy()
        labCfg.cash := strategy.convert_to_symbol(labRisk)
        labCfg.cap := strategy.convert_to_symbol(math.max(strategy.equity, 0) * labExposure / 100)
        [labCandidate, labReason, labConflict] = f_sdSelect(sdCandidates, close, sdAtr, bar_index, time_close, syminfo.mintick, syminfo.pointvalue, syminfo.mincontract, labCfg)
        if labCandidate.direction != 0
            labOwner := labCandidate
            labQueuedBar := bar_index
            labFillBar := na
            labQueuedCount += 1
            labWhy := "Entry queued · next available open"
            strategy.entry("SD entry", labCandidate.direction == 1 ? strategy.long : strategy.short, qty = labCandidate.quantity, alert_message = "SupplyDemandDesk broker entry. Frozen signal-close bracket; actual fill follows the emulator.")
            strategy.exit("SD bracket", "SD entry", stop = labCandidate.stop, limit = labCandidate.target, comment_loss = "SD stop", comment_profit = "SD target", alert_message = "SupplyDemandDesk broker bracket fill.")
        else
            labWhy := labReason

var table labTable = table.new(position.bottom_left, 2, 5, bgcolor = color.new(color.rgb(13, 23, 35), 5), frame_color = color.new(color.white, 80), frame_width = 1)
if barstate.islastconfirmedhistory or barstate.isrealtime
    table.cell(labTable, 0, 0, "STRATEGY LAB", text_color = color.white, text_size = size.small)
    table.cell(labTable, 1, 0, "Next-open broker fills", text_color = color.silver, text_size = size.small)
    table.cell(labTable, 0, 1, labWhy, text_color = color.white, text_size = size.small)
    table.cell(labTable, 1, 1, "Queued " + str.tostring(labQueuedCount) + " · closed " + str.tostring(strategy.closedtrades), text_color = color.silver, text_size = size.small)
    table.cell(labTable, 0, 2, "Partial closes " + str.tostring(labPartialClosures), text_color = color.silver, text_size = size.small)
    table.cell(labTable, 1, 2, "Margin events " + str.tostring(labMarginClosures), text_color = color.silver, text_size = size.small)
    table.cell(labTable, 0, 3, "Paper ≠ broker position", text_color = color.orange, text_size = size.small)
    table.cell(labTable, 1, 3, "Costs: strategy Properties", text_color = color.silver, text_size = size.small)
    // Display-only footer clearance for the native TradingView watermark.
    table.cell(labTable, 0, 4, "", height = 4, bgcolor = color.new(color.rgb(13, 23, 35), 100))
    table.cell(labTable, 1, 4, "", height = 4, bgcolor = color.new(color.rgb(13, 23, 35), 100))

// Rendering consumes committed fields; no price or admission logic uses display inputs.
string vGroup = "05 · Presentation"
string vMode = input.string("Clean", "Chart view", options = ["Off", "Clean", "Inspect"], group = vGroup, display = display.none)
bool vDesk = input.bool(true, "Show WHY / NEXT desk", group = vGroup, display = display.none)
int vZoneCount = input.int(4, "Visible live zones", minval = 1, maxval = 8, group = vGroup, active = vMode != "Off", display = display.none)
bool vHistory = input.bool(true, "Ended paper markers", group = vGroup, active = vMode != "Off", display = display.none)
string vSize = input.string("Normal", "Text size", options = ["Small", "Normal", "Large"], group = vGroup, display = display.none)
color vBull = color.rgb(33, 191, 160)
color vBear = color.rgb(244, 158, 85)
color vInk = color.rgb(17, 29, 43)
color vMuted = color.rgb(158, 177, 196)
string vBodySize = vSize == "Large" ? size.normal : vSize == "Small" ? size.tiny : size.small
string vSmallSize = vSize == "Large" ? size.small : size.tiny
var array<box> vBoxes = array.new<box>()
var array<line> vLines = array.new<line>()
var array<label> vLabels = array.new<label>()
var table vTable = table.new(position.top_right, 2, 11, bgcolor = na, frame_width = 0)
float vRange = ta.highest(high, 80) - ta.lowest(low, 80)

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

f_vClock(int stamp) =>
    na(stamp) ? "—" : str.format_time(stamp, "MM-dd HH:mm", "UTC")

f_vRank(string state) =>
    state == "VISIT" ? 4 : state == "READY" ? 3 : state == "RETRACEMENT" ? 2 : state == "DEPARTURE" ? 1 : 0

f_vNext(SdZone zone) =>
    zone.state == "DEPARTURE" ? "Wait for a pullback above/below this untouched zone" : zone.state == "RETRACEMENT" ? "Need " + str.tostring(sdRetrMin) + " pullback bars then a close through " + f_vPrice(zone.origin) : zone.state == "READY" ? "Raid " + f_vPrice(zone.front) + " into zone; directional close outside" : zone.state == "VISIT" ? "Need " + (zone.direction == 1 ? "bullish close > " + f_vPrice(zone.upper) : "bearish close < " + f_vPrice(zone.lower)) : zone.reason

f_vLevel(float price, string captionText, color ink, int startBar, int endBar, float tagPrice, string tip) =>
    array.push(vLines, line.new(startBar, price, endBar, price, xloc = xloc.bar_index, color = color.new(ink, 15), width = 1))
    if f_sdCompare(price, tagPrice) != 0
        array.push(vLines, line.new(endBar, price, endBar + 1, tagPrice, xloc = xloc.bar_index, color = color.new(ink, 35), style = line.style_dotted))
    array.push(vLabels, label.new(endBar + 1, tagPrice, captionText, xloc = xloc.bar_index, style = label.style_label_left, color = color.new(vInk, 5), textcolor = ink, size = vBodySize, tooltip = tip))

if barstate.islastconfirmedhistory or barstate.isrealtime
    for item in vBoxes
        box.delete(item)
    for item in vLines
        line.delete(item)
    for item in vLabels
        label.delete(item)
    array.clear(vBoxes)
    array.clear(vLines)
    array.clear(vLabels)
    table.clear(vTable, 0, 0, 1, 10)
    table.set_bgcolor(vTable, na)
    table.set_frame_color(vTable, vDesk ? color.new(vMuted, 65) : na)
    table.set_frame_width(vTable, 0)
    int vReadyCount = 0
    int vPendingCount = 0
    int vDrawn = 0
    SdZone vFocus = SdZone.new()
    if array.size(sdZones) > 0
        for i = array.size(sdZones) - 1 to 0
            SdZone zone = array.get(sdZones, i)
            bool live = not f_sdTerminal(zone.state) and zone.state != "SIGNAL"
            if live
                vPendingCount += 1
                if zone.state == "READY" or zone.state == "VISIT"
                    vReadyCount += 1
                if f_vRank(zone.state) > f_vRank(vFocus.state)
                    vFocus := zone
                if vMode != "Off" and vDrawn < vZoneCount
                    color ink = zone.direction == 1 ? vBull : vBear
                    int start = vMode == "Inspect" ? zone.baseBar : zone.createdBar
                    array.push(vBoxes, box.new(start, zone.upper, bar_index + 2, zone.lower, border_color = color.new(ink, zone.state == "VISIT" ? 5 : 45), bgcolor = color.new(ink, zone.state == "VISIT" ? 84 : 93), text = (zone.direction == 1 ? "DEMAND · " : "SUPPLY · ") + zone.state, text_color = color.new(ink, 5), text_size = vSmallSize, text_halign = text.align_left, text_valign = text.align_center))
                    if not na(zone.front)
                        array.push(vLines, line.new(zone.phaseBar, zone.front, bar_index + 2, zone.front, color = color.new(ink, 25), style = line.style_dashed))
                    if vMode == "Inspect"
                        array.push(vLabels, label.new(zone.createdBar, zone.direction == 1 ? zone.lower : zone.upper, "KNOWN " + f_vClock(zone.createdTime), style = zone.direction == 1 ? label.style_label_up : label.style_label_down, color = color.new(vInk, 20), textcolor = ink, size = size.tiny, tooltip = "Base " + f_vClock(zone.baseTime) + " UTC\nCreated/known " + f_vClock(zone.createdTime) + " UTC\n" + zone.reason + "\n" + f_vNext(zone)))
                    vDrawn += 1
    bool vHasPaper = sdPaper.direction != 0
    bool vActive = sdPaper.state == "ACTIVE"
    bool vRecent = vHasPaper and not na(sdPaper.endBar) and bar_index - sdPaper.endBar <= 6
    if vMode != "Off" and vHasPaper and (vActive or vRecent)
        float span = math.max(vRange, math.abs(sdPaper.target - sdPaper.stop))
        float gap = math.max(4 * syminfo.mintick, span * (vSize == "Large" ? 0.07 : 0.045))
        int end = bar_index + 3
        color sideColor = sdPaper.direction == 1 ? vBull : vBear
        string frozenTip = "Frozen at " + f_vClock(sdPaper.signalTime) + " UTC\nBase " + f_vClock(sdPaper.baseTime) + " · known " + f_vClock(sdPaper.createdTime) + "\nQuantity " + f_sdNumber(sdPaper.quantity) + " · estimated net at target " + f_sdNumber(sdPaper.netAtTarget) + "R\nPaper reference; this is not a broker fill."
        array.push(vBoxes, box.new(sdPaper.signalBar, math.max(sdPaper.entry, sdPaper.target), end, math.min(sdPaper.entry, sdPaper.target), border_color = na, bgcolor = color.new(sideColor, vActive ? 92 : 97)))
        array.push(vBoxes, box.new(sdPaper.signalBar, math.max(sdPaper.entry, sdPaper.stop), end, math.min(sdPaper.entry, sdPaper.stop), border_color = na, bgcolor = color.new(color.red, vActive ? 93 : 98)))
        float entryTag = sdPaper.entry
        float stopTag = sdPaper.direction == 1 ? math.min(sdPaper.stop, entryTag - gap) : math.max(sdPaper.stop, entryTag + gap)
        float targetTag = sdPaper.direction == 1 ? math.max(sdPaper.target, entryTag + gap) : math.min(sdPaper.target, entryTag - gap)
        f_vLevel(sdPaper.entry, (vActive ? "ENTRY REF  " : "ENDED REF  ") + f_vPrice(sdPaper.entry), color.white, sdPaper.signalBar, end, entryTag, frozenTip)
        f_vLevel(sdPaper.stop, "STOP  " + f_vPrice(sdPaper.stop), color.rgb(246, 117, 117), sdPaper.signalBar, end, stopTag, frozenTip)
        f_vLevel(sdPaper.target, str.tostring(math.abs(sdPaper.target - sdPaper.entry) / sdPaper.risk, "0.##") + "R TARGET  " + f_vPrice(sdPaper.target), sideColor, sdPaper.signalBar, end, targetTag, frozenTip)
    if vMode != "Off" and vHistory
        for ended in sdHistory
            if bar_index - ended.endBar <= 200
                float marker = na(ended.exitPrice) ? ended.entry : ended.exitPrice
                string caption = ended.reason + (ended.ambiguous ? " · BOTH TOUCHED" : "") + (na(ended.netR) ? "\nUNPRICED" : "\n" + str.tostring(ended.netR, "0.00") + "R est. net")
                array.push(vLabels, label.new(ended.endBar, marker, caption, style = ended.direction == 1 ? label.style_label_down : label.style_label_up, color = color.new(vInk, 15), textcolor = ended.reason == "TARGET" ? vBull : vMuted, size = vSmallSize, tooltip = na(ended.exitPrice) ? "Unpriced data gap; marker uses frozen entry for orientation. No exit price or fill inferred." : "Historical paper outcome " + f_vClock(ended.endTime) + " UTC. Price " + f_vPrice(ended.exitPrice) + ". Not a currently tracked broker position."))
    if vDesk
        // Transparent chart-header inset; body rows retain their own background.
        table.cell(vTable, 0, 0, "", height = 3, bgcolor = na)
        table.cell(vTable, 1, 0, "", height = 3, bgcolor = na)
        string nowText = vActive ? "ACTIVE PAPER " + (sdPaper.direction == 1 ? "LONG" : "SHORT") : sdEntered ? "NEW PAPER REFERENCE" : "NO ACTIVE PAPER PLAN"
        table.cell(vTable, 0, 1, "SUPPLY & DEMAND", text_color = color.white, text_size = vBodySize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 1, "DESK " + SDD_VERSION + " · 5m", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 2, nowText, text_color = vActive ? sdPaper.direction == 1 ? vBull : vBear : vMuted, text_size = vBodySize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 2, "Closed " + f_vClock(sdLastClosedTime) + " UTC", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 3, "WHY", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 3, sdWhy, text_color = color.white, text_size = vBodySize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 4, "NEXT", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 4, vActive ? "Fixed stop/target · later candles only" : vFocus.direction != 0 ? f_vNext(vFocus) : "Wait for an untouched base and strong departure", text_color = color.white, text_size = vBodySize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 5, "READY / BUILDING", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 5, str.tostring(vReadyCount) + " / " + str.tostring(vPendingCount - vReadyCount) + " · shown " + str.tostring(vDrawn), text_color = vMuted, text_size = vSmallSize, tooltip = "All stored live zones are counted; chart draws at most the configured number, latest first.", bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 6, "TREND", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 6, (sdTrend == 1 ? "HH / HL" : sdTrend == -1 ? "LH / LL" : "MIXED / WARMUP") + (sdTrendGate ? " · gate ON" : " · context only"), text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 7, "PLAN / COSTS", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 7, vActive ? "Qty " + f_sdNumber(sdPaper.quantity) + " · net at TP " + str.tostring(sdPaper.netAtTarget, "0.00") + "R" : "Signal-close reference · no entry pending", text_color = vActive and f_sdSign(sdPaper.netAtTarget) <= 0 ? color.orange : vMuted, text_size = vSmallSize, tooltip = "Indicator cash quantities use symbol currency. Estimated net includes the declared fee/slippage allowance. Nonpositive net-at-TP remains disclosed; it is not profitability.", bgcolor = color.new(vInk, 4))
        table.cell(vTable, 0, 8, "ALERT", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        table.cell(vTable, 1, 8, SD_IS_LAB ? "Any alert() call = paper JSON; order fills = broker" : "Create alert → Any alert() function call", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
        if vMode == "Inspect"
            table.cell(vTable, 0, 9, "CONDITIONS / BLOCKS", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
            table.cell(vTable, 1, 9, str.tostring(sdConditions) + " / " + str.tostring(sdBlocked) + " · retired " + str.tostring(sdRetired), text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
            table.cell(vTable, 0, 10, "ORIGINAL SUBSET", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
            table.cell(vTable, 1, 10, "Closed bars · no flip-entry or private parity", text_color = vMuted, text_size = vSmallSize, bgcolor = color.new(vInk, 4))
Download Strategy Lab .pine

Release identity and file hashes · MIT licence

Pine Editor installation walkthrough
04

The rules, one question at a time.

Open the detail you need.

How does a zone become ready?

A directional departure after an opposite candle creates the zone at its first close. Default bounds use the base's full range, extended only by a lower departure wick for demand or a higher one for supply. The edge facing the departed price is proximal; the far edge is distal. At the first pullback candle, the move must have reached at least 0.8 ATR beyond proximal, using ATR frozen at the zone's birth.

Its pre-retracement extreme becomes a fixed break level. At least two retracement bars must finish before a later strict close breaks that level. The breakout candle cannot count as the second retracement. Front liquidity must remain outside the zone and in the nearer half of the departure.

These thresholds and the optional FX wick adjustment are original formalizations. Five-minute standard candles are required; other chart types do not produce valid conditions.

When does the visit become an entry?

After readiness, price must pass front liquidity and actually intersect the zone. Demand then needs a bullish close strictly above proximal; supply needs a bearish close strictly below. An opposite-color rejecting candle can wait, but a close on the adverse edge invalidates first.

The deepest wick is tracked from first visit through confirmation. Visit age zero through three is allowed; age four expires. A zone has one condition, even if plan admission is blocked. There is no automatic re-entry.

Where do the fixed stop and 4R come from?

In the lesson, entry 108 minus stop 101.75 gives initial risk 6.25, called 1R. Four times that distance is 25, so the 4R target is 133. This is distance before costs, not a chance of winning.

Entry is the confirming close reference. Stop sits beyond the deepest visit wick by at least one tick; optional ATR padding increases that distance. Stop and target round outward to the instrument's tick grid. Default target is four initial risk distances away.

A reference plan cannot exit on its entry candle. On later bars, an open already beyond a reference uses that open. Otherwise touching both stop and target records an ambiguous STOP. A 48-bar hold deadline checks ordinary outcomes before a close-based time exit. No trailing, breakeven move or partial profit is assumed.

Projected net-at-target includes estimated costs. Nonpositive projection does not automatically block admission in this version. It is a scenario calculation, not an expectation or probability.

Why can a condition be blocked?

Only one paper plan is active. The optional chart-local trend filter runs first: long needs HH+HL, short LH+LL, from two-left/two-right confirmed swings. It defaults off; neutral or unavailable structure blocks when enabled. Opposite trend-eligible conditions then conflict before geometry selection. Same-side conditions choose the smallest valid risk distance, then the oldest zone.

Bad metadata, zero rounded quantity or unusable risk also blocks admission. A plan that exits on this bar cannot be replaced on the same bar. No condition is recycled after it is consumed.

What keeps old or incomplete zones out?

A zone touched after departure but before readiness is invalidated. Total age 120 is allowed; age 121 expires. Retracement age 20 is allowed; age 21 expires before a breakout, measured from the first retracement bar. Malformed observed OHLC clears pending setups and ends an active reference as unpriced DATA GAP.

Ages count observed bars. Missing timestamps do not create invented candles. The desk retains at most 32 zones: it removes the oldest ended zone first, or the oldest live zone if none has ended. It keeps six ended paper plans. Inputs and loaded history can change a recalculated result; display controls do not change the engine.

What does every setting change?

All 21 indicator inputs are also in the Lab, with the same defaults. The Lab adds two sizing inputs. The TradingView names below help you find each setting.

Build and time the setup

These rules use completed, standard five-minute candles. ATR describes recent price movement; the departure test freezes its value when the zone is born.

How far price must leave the zone
Default: 0.8 × birth ATR
Checked when the first pullback starts. A weaker departure is rejected.
TradingView: Minimum departure · ATR
Pullback candles needed before the break
Default: 2 candles
The breakout candle does not count. Pullback candle colors can mix.
TradingView: Minimum retracement bars
How close the pullback stays to the zone
Default: 0.5 · nearer half
The pullback extreme must stay outside the zone, within half the distance to the fixed departure extreme.
TradingView: Front liquidity · maximum setup fraction
Use the optional zone-edge variant
Default: Off
When the base wick protrudes past the departure candle, this uses the base open for the near edge. It never detects asset type automatically.
TradingView: FX accuracy drawing
How long the pullback may wait for a break
Default: 20 candles
Count from the first pullback candle: age 20 is allowed; age 21 expires before the break check.
TradingView: Retracement deadline · bars
How long a setup can remain available
Default: 120 candles
Count from zone creation: age 120 is allowed; age 121 expires before a condition can confirm.
TradingView: Zone lifetime · bars
How long a visit may wait for confirmation
Default: Ages 0–3
First touch is age 0. A directional close can confirm through age 3; age 4 expires.
TradingView: Entry visit deadline · bars
Also require matching swing direction
Default: Off
Uses confirmed two-left/two-right swings on this chart. When enabled, mixed or incomplete structure blocks plan admission.
TradingView: Require chart-local HH/HL or LH/LL

Set the paper reference

Paper sizing uses the symbol’s currency and rounds quantity down. These estimates describe the indicator’s reference plan; the Lab owns separate broker sizing and costs.

Extra space beyond the deepest visit wick
Default: 0 × ATR · minimum 1 tick
Uses ATR at confirmation. Even at zero, the stop sits at least one tick beyond the wick and rounds outward.
TradingView: Stop pad · ATR (minimum one tick)
Target distance compared with initial risk
Default: 4R
Four times the entry-to-stop distance. Outward rounding can make actual R slightly larger. This distance is not a success rate.
TradingView: Target · R
How long a reference may stay active
Default: 48 candles
At the deadline, ordinary stop/target checks run first, then a close-based time exit. The Lab starts its own clock at the fill.
TradingView: Maximum paper hold · bars
Paper risk budget before costs
Default: 100 symbol-currency units
Quantity depends on the rounded stop distance and instrument metadata. Costs or price gaps can increase the final loss.
TradingView: Cash risk budget · symbol currency
Limit the paper position’s entry value
Default: 90,000 symbol-currency units
Can reduce quantity below the risk budget. If minimum quantity cannot be funded, no plan is admitted.
TradingView: Notional cap · symbol currency
Paper commission estimate
Default: 0.05% each side
Applied to entry and exit notionals in reference accounting. Set broker-test commission separately in Strategy Properties.
TradingView: Estimated commission · % per side
Paper slippage estimate
Default: 1 tick each side
Subtracts an estimated cost without moving paper price lines. The Lab’s fill slippage is a separate Property.
TradingView: Estimated slippage · ticks per side

Choose the alert message

Creating or changing an input does not create an alert. Reference messages and Lab order-fill messages are different streams.

Enable structured reference messages
Default: On
Choose Any alert() function call when creating the alert. Both scripts can emit paper entry and final-outcome messages.
TradingView: JSON alert events

Lab-only sizing inputs

The Lab keeps the shared paper inputs, then adds these two broker sizing controls. It converts account-currency amounts to the symbol’s currency before rounding quantity.

Broker-test risk budget before costs
Default: 100 account-currency units
Replaces the paper cash budget for broker sizing. It does not change the indicator’s paper quantity.
TradingView: Lab risk budget · account currency
Limit entry value using current Lab equity
Default: 90% of equity
Replaces the paper notional cap for broker sizing. The remaining buffer does not rule out margin calls.
TradingView: Lab maximum equity notional · %

Choose what the chart shows

Display controls do not change the engine. The desk can remain visible when Chart view is Off.

Amount of chart detail
Default: Clean
Off hides drawings. Clean starts boxes when known. Inspect also shows historical base anchors, creation clocks and extra counts.
TradingView: Chart view
Show the explanation panel
Default: On
Lists the current waiting reason and next requirement. Its focus favors Visit, then Ready, Pullback and Departure.
TradingView: Show WHY / NEXT desk
Maximum live boxes drawn
Default: 4 zones
Draws the newest live zones first; counts include stored live zones that are not drawn. Available when Chart view is not Off.
TradingView: Visible live zones
Show historical outcome markers
Default: On
Uses up to six retained outcomes, each within 200 candles. The latest ended price lines can remain for six candles separately.
TradingView: Ended paper markers
Size of labels and desk text
Default: Normal
Choose Small, Normal or Large. It changes readability, not setup qualification.
TradingView: Text size

Lab Strategy Properties

These are the shipped broker-test defaults, separate from script Inputs. Saved Properties can override them. Changing paper fee, slippage or sizing inputs does not replace the Lab settings below.

Initial capital
Default: 100,000
Starting balance for the broker test. Check the account currency in Properties.
Commission method
Default: Percentage
Broker commission is a percentage of order value.
Broker commission
Default: 0.05% each side
Separate from the paper commission input.
Broker slippage
Default: 1 tick
Used by TradingView’s fill emulator; separate from paper cost accounting.
Add entries to an existing position
Default: 0
No stacking of entries in the same direction.
Long-position margin
Default: 100%
Full collateral is required by this default broker model.
Short-position margin
Default: 100%
Full collateral is required by this default broker model.
Process orders on the closing tick
Default: Off
A confirmed market entry normally fills at the next available open.
Recalculate on every tick
Default: Off
The supplied broker test calculates on completed candles.
Extra calculation after an order fill
Default: Off
The supplied strategy does not request a separate recalculation after fills.
How is the Strategy Lab different?

The Lab submits its bracket with the signal and normally fills at the next available open. It owns its position and deadline independently of paper outcomes. A partial margin exit keeps the remaining position's bracket.

Defaults include 100,000 initial capital, fixed risk 100, 90-percent equity notional cap, 0.05-percent commission per side and one tick of slippage. Actual broker fills, currency conversion, gaps and costs can differ from chart references. Configure Properties for the instrument and inspect complete outcomes.

Which alert should I use?

Choose Paper entry reference when you want an accepted entry, rather than Zone condition confirmed for every qualifying condition. Paper reference ended is the separate final-outcome condition. Any alert() function call provides the structured lifecycle message. Recreate alerts after source, symbol, timeframe or input changes.

Indicator reference alerts are not broker fills. Lab order-fill notifications are a different stream. This page creates no alert and proves no notification delivery.

What has this established?

The interactive lesson explains a stated hypothesis using fictional candles. It does not measure a trading edge. The download record pins the exact source and actual chart images. Compilation, synthetic cases, real chart checks and broker reconciliation are distinct software checks; none establishes an economic advantage.

The untouched MNQ Strategy Lab sample in the release checks lost money after declared costs. That sample does not establish future performance; it is a reason to inspect losing trades as well as the entry logic. No private indicator parity, success probability or automatic profitability is claimed.

  • SMC Basics

    Learn the basic gap and order-block price patterns before following a zone through readiness and return.

  • Location Desk

    Compare a zone return with an entry sequence that also checks price location within broader ranges.

Built by ProEA Lab. Compare all free indicators.

05 · THE LOG

Every change, dated

What shipped and what changed in Supply & Demand 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. Supply & Demand Desk v1.0.0 Shipped

    Checks a return to a price area before planning entry.

BUILT WITH TRADERS

Something to change in Supply & Demand 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.

Explore the other free tools · Compare Sweep Desk's inversion-close model

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