//@version=6 indicator("Swing Levels — Breakout Map", shorttitle="Swing Levels", overlay=true, max_lines_count=500, max_labels_count=500) // ═══════════════════════════════════════════════════════════════════════════ // Raw swing highs and lows from up to three higher timeframes, read as a // BREAKOUT map rather than a rejection map. // // Rebuilt from Zeiierman's "Buyers & Sellers Profile + Dynamic S/R" after // measuring that indicator on fifteen years of NQ one-minute bars with real // volume. Three findings came out of it, and all three are built in here: // // 1. DO NOT MOVE THE LEVEL. The original pulls the pivot toward a nearby // high-volume node. Across 288 parameter combinations that made the level // WORSE: 81 cells fell outside the 95% band and every one of the 81 was // negative. A pivot is a price where the market actually turned; shifting // it half an ATR onto a volume node moves it off the price that mattered. // So there is no profile and no pull here. The level is the wick. // // 2. THE VOLUME LAYER ADDS NOTHING, so it is gone. The evidence filter came // out at 0 of 288 cells outside the band. The buyers/sellers estimate // cannot work by construction: it is (close-low)/(high-low) averaged over // hundreds of bars, and its spread collapses from 0.098 to 0.051 as the // lookback grows — 69% of readings end up between 0.45 and 0.55. A real // signal sharpens with more data; that one flattens. // // 3. THE SIGN IS BACKWARDS. Price broke these levels far more often than it // held them: 43% held against 49% broken, where a random walk gives 57.5% // held. So levels are marked as places where continuation is the better // bet, not places to fade. // // WHAT IT IS WORTH, measured, so nothing here is oversold: a real swing level // beats a randomly placed horizontal line by about 0.7 to 1.1 POINTS per // trade, and it costs about 1 point to trade. That is the whole edge. This is // a context tool, not a system, and the corner table says so on every chart. // // ARMING is the one piece of logic worth more than the lines themselves. A // level only becomes a setup once price has been armATR away from it. Without // that rule the original measurement was worthless: levels born next to the // current price counted as "retests" and inflated the result by seventeen // percentage points. It is why a level here goes from pale to solid rather // than being live from birth. // ═══════════════════════════════════════════════════════════════════════════ // ───────────────────────────── timeframes ───────────────────────────── gT = "Timeframes" use1 = input.bool(true, "", inline="t1", group=gT) tf1 = input.timeframe("15", "", inline="t1", group=gT) use2 = input.bool(true, "", inline="t2", group=gT) tf2 = input.timeframe("30", "", inline="t2", group=gT) use3 = input.bool(true, "", inline="t3", group=gT) tf3 = input.timeframe("60", "", inline="t3", group=gT) // Swing length. Tested at 5, 10 and 20 and the result was flat across all // three, so this is not a knob with an edge hidden in it — pick whatever // matches the structure you actually read on the chart. swingLen = input.int(10, "Swing length (bars each side)", minval=1, maxval=50, group=gT, tooltip="Bars required left and right of the pivot. A pivot is only drawn once the right-hand side is complete, so nothing here uses information from the future.") // ───────────────────────────── level handling ───────────────────────────── gL = "Levels" armATR = input.float(1.0, "Arm the level once price is this far away (ATR)", minval=0.0, step=0.25, group=gL, tooltip="A level is context until price leaves it and comes back. Measured: dropping this rule inflated rejection rates by 17 percentage points, because levels born next to the current price counted as retests.") mergeATR = input.float(0.25, "Merge levels closer than (ATR)", minval=0.0, step=0.05, group=gL, tooltip="Two pivots a few ticks apart are one level. Merging is across timeframes too, so a 15m and a 30m high at the same price are drawn once and labelled with whichever arrived first.") breakATR = input.float(0.1, "Mark broken after a close beyond by (ATR)", minval=0.0, step=0.05, group=gL, tooltip="A wick through is not a break. A wick through is the wick that makes the next level.") maxSide = input.int(4, "Keep this many levels per side", minval=1, maxval=20, group=gL, tooltip="Counted separately for highs and lows, so a one-sided market cannot push all the levels off one side of the chart.") atrLen = input.int(14, "ATR length (chart timeframe)", minval=2, group=gL) keepBroken = input.bool(false, "Keep broken levels on the chart", group=gL, tooltip="Off = a broken level is removed. On = it stays dotted and grey, which is useful for reading how price treated the map.") // ───────────────────────────── style ───────────────────────────── gS = "Style" colHigh = input.color(color.rgb(200, 60, 60), "Swing high", inline="c1", group=gS) colLow = input.color(color.rgb(0, 150, 90), "Swing low", inline="c1", group=gS) colArmed = input.color(color.rgb(230, 170, 40), "Armed", inline="c2", group=gS, tooltip="Price has been far enough away. A return to this level is now a genuine retest.") colBroken = input.color(color.rgb(120, 120, 120), "Broken", inline="c2", group=gS) wIdle = input.int(1, "Width before arming", minval=1, maxval=5, group=gS) wArmed = input.int(2, "Width once armed", minval=1, maxval=5, group=gS) extendRight = input.int(30, "Extend right (bars)", minval=0, maxval=300, group=gS) showLabels = input.bool(true, "Labels", group=gS) labelSizeIn = input.string("small", " └ size", options=["tiny", "small", "normal"], group=gS) showTable = input.bool(true, "Show what this is worth (corner table)", group=gS, tooltip="The measured edge, on the chart. An indicator that cannot state its own size invites you to trust it more than it earned.") labelSize = labelSizeIn == "tiny" ? size.tiny : labelSizeIn == "small" ? size.small : size.normal // ───────────────────────────── alerts ───────────────────────────── gA = "Alerts" alertArm = input.bool(true, "Alert when a level arms", group=gA) alertTouch = input.bool(true, "Alert when price returns to an armed level", group=gA) alertBreak = input.bool(true, "Alert when a level breaks", group=gA) atr = ta.atr(atrLen) // Bar duration in milliseconds, for drawing to the right of the live candle. // Taken from the timeframe rather than from time - time[1], which is na on the // first bar and wrong across a session gap. barMs = timeframe.in_seconds() * 1000 // ═══════════════════════════ higher-timeframe pivots ═══════════════════════ // request.security sits at global scope and is never wrapped in an `if`. // Inside a conditional it returns unreliable values — that cost six debug // rounds on an earlier indicator, and the fix is to always call it and ignore // the result when the timeframe is switched off. // // lookahead stays off (the default), so a pivot only appears on the chart bar // where its higher-timeframe bar has actually closed. f_piv(int k) => ph = ta.pivothigh(high, k, k) pl = ta.pivotlow(low, k, k) // time[k] is the timestamp of the pivot bar itself, not of the bar that // confirmed it. Anchoring on the confirming bar shifts a 15m level by a // quarter of an hour — wrong by exactly one candle, every time. [ph, pl, time[k]] [ph1, pl1, pt1] = request.security(syminfo.tickerid, tf1, f_piv(swingLen)) [ph2, pl2, pt2] = request.security(syminfo.tickerid, tf2, f_piv(swingLen)) [ph3, pl3, pt3] = request.security(syminfo.tickerid, tf3, f_piv(swingLen)) // ═══════════════════════════ level store ═══════════════════════════ // Parallel arrays rather than a user type, so the whole state is visible in // one place and a level is never half-updated. var array lvlPrice = array.new_float() var array lvlIsHigh = array.new_bool() var array lvlBorn = array.new_int() var array lvlArmed = array.new_bool() var array lvlBroken = array.new_bool() var array lvlTf = array.new_string() var array lvlLine = array.new_line() var array