Key Levels (Daily Percentages)
// This source code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © liquid-trader

// This indicator automatically identifies and progressively draws daily percentage levels, normalized to
// the first market bar. Percentages are one of the most common ways to measure price action (outside price itself).
// Being able to visually reference these levels helps contextualize price action. 
// More here: https://www.tradingview.com/script/ngtQPxWS-Key-Levels-Daily-Percentages/

//@version=5
indicator("Key Levels (Daily Percentages)", "Key Levels • Daily %", overlay = true, max_bars_back=1440, max_lines_count=500, max_boxes_count = 500, max_labels_count=500), max_bars_back(time, 1440)



// ---------------------------------------------------- SETTINGS --------------------------------------------------- //

// Base Colors
none = color.new(color.black, 100), clr1 = color.gray, clr2 = color.new(color.aqua, 50), clr3 = color.new(color.maroon, 25)

// Group Labels
g1 = "Auto Levels", g2 = "Static Zones", g3 = "General Settings", g4 = "Price Proximity", g5 = "Market Hours"

// Normalized Level Settings
zeroColor = input.color(color.new(clr1, 75), "Normalized Zero           ", tooltip="Color, width, and style of the normalization level (aka. the \"zero\").", group=g1, inline="zero", display=display.none)
zeroWidth = input.int(4, "", 1, group=g1, inline="zero", display=display.none)
zeroStyle = input.string("Solid", "", ["Solid", "Dashed", "Dotted"], group=g1, inline="zero", display=display.none)

// Whole Value Settings
wholeColor = input.color(color.new(clr1, 50), "Whole Percentages      ", "Color, width, and style of each 1 % increment.", group=g1, inline="whole", display=display.none)
wholeWidth = input.int(4, "", 1, group=g1, inline="whole", display=display.none)
wholeStyle = input.string("Solid", "", ["Solid", "Dashed", "Dotted"], group=g1, inline="whole", display=display.none)

// Fractional Value Settings
f1 = "Eighths", f2 = "Quarters", f3 = "Halves"
frac = input.bool(true, "", group=g1, inline="frac")
fracType = input.string(f2, "", [f1, f2, f3], group=g1, inline="frac", display=display.none)
fracColor = input.color(color.new(clr1, 50), "", "Color, width, and style of fractional increments.", group=g1, inline="frac", display=display.none)
fracWidth = input.int(2, "", 1, group=g1, inline="frac", display=display.none)
fracStyle = input.string("Solid", "", ["Solid", "Dashed", "Dotted"], group=g1, inline="frac", display=display.none)

// Intra Value Settings
intra = input.bool(false, "Intra Levels           ", "Color, width, and style of smaller values between the other levels.\n\nThese will not have labels or display historically.", group=g1, inline="intra", display=display.none)
intraColor = input.color(color.new(clr1, 75), "", group=g1, inline="intra", display=display.none)
intraWidth = input.int(2, "", 1, group=g1, inline="intra", display=display.none)
intraStyle = input.string("Dotted", "", ["Solid", "Dashed", "Dotted"], group=g1, inline="intra", display=display.none)

// Custom Settings
custom = input.bool(true, "Custom Range", "Line color, background color, and percentages of a custom range. The larger percentage will not display until price crosses the smaller percentage.", group=g2, inline="custom", display=display.none)
customColor = input.color(clr2, "", group=g2, inline="custom", display=display.none)
customBgColor = input.color(color.new(clr2, 95), "", group=g2, inline="custom", display=display.none)
customWidth = wholeWidth
customStyle = wholeStyle
customLower = input.float(0.25, "", 0, group=g2, inline="custom", display=display.none), var Not_Showing_LOWER_Range = false
customUpper = input.float(0.5, "", 0.001, group=g2, inline="custom", display=display.none), var Not_Showing_UPPER_Range = false

// Anomalous Settings
anomalous = input.bool(true, "Ext. Anomalies", "Color and percentage. This will change the color of levels exceeding the percentage.\n\nThis is intended to be set wherever you consider price to be extended, or an anomaly . For context, and broadly speaking, 70% of days will have 0-1% moves, 20% of days will have 1-2% moves, and 10% of days will have moves exceeding 3%.", group=g2, inline="anomalous", display=display.none)
anomalousColor = input.color(clr3, "", group=g2, inline="anomalous", display=display.none)
anomalousRange = input.float(1, "", 0, group=g2, inline="anomalous", display=display.none)

// Source Settings
td = "Todays Open", yd = "Yesterdays Close", O = "open", H = "high", L = "low", C = "close", HL2 = "hl2", HLC3 = "hlc3", OHLC4 = "ohlc4", HLCC4 = "hlcc4"
srcBar = input.string(td, "Norm. Source ", [td, yd], "The value used to normalize the percent levels.\n\n\"Todays Open\" uses the first market bar of the current session.\n\n\"Yesterdays Close\" uses the last market bar of the previous session.\n\nPremarket levels inherit the current normalized source.", inline="source", group=g3), today = srcBar == td, yesterday = srcBar == yd
source = input.string(HLCC4, "", [O, H, L, C, HL2, HLC3, OHLC4, HLCC4], inline="source", group=g3, display=display.none)

// Label Settings
d1 = "Percentage ( % )", d2 = "Basis Points ( ‱ )", d3 = "Value Delta ( Δ )"
showLabel = input.bool(true, "Labels as","Label text value, and background / text colors.", inline="labels", group=g3, display=display.none)
delta = input.string(d1, "", [d1, d2, d3], inline="labels", group=g3, display=display.none)
lblClr = input.color(none, "", inline="labels", group=g3, display=display.none)
txtClr = input.color(clr1, "", inline="labels", group=g3, display=display.none)
incPrc = input.bool(false, "Include Price", inline="labels", group=g3, display=display.none)
showOldLevels = input.bool(true, "Show older levels", inline="old", group=g3, display=display.none)
oldColor = input.color(clr1, "", inline="old", group=g3, display=display.none)

// Line Settings
truncate = input.bool(false, "Max line length    ", "Truncates the lines so they do not stretch back to the first market bar.", inline="max line length", group=g3, display=display.none)
truncLen = input.int(15, "", 0, inline="max line length", group=g3, display=display.none)

// Price Proximity Settings
prxRng = input.float(0.025, "Range of Proximity ( % )", 0, tooltip="How close price needs to be for an enabled proximity setting to take effect. This is a percentage (not monetary) value.", group=g4, display=display.none)
extend = input.bool(false, "Line Length Override", "Extends the line back to the first market bar when price is proximal to a given line and \"Max Line Length\" is enabled.", inline="extend", group=g4, display=display.none)
showProx = input.bool(false, "Toggle Visibility", "Hides levels by default, and only shows a level when price is proximal to a given line.", inline="prox vis", group=g4, display=display.none)
atrZone = input.bool(false, "ATR Zones", "This will show a 5 bar ATR zone around the level when price is proximal to a given line. Half the ATR is above the line, and half the ATR is below the line.", group=g4, display=display.none)

// Market Hours Settings
mktHrs = input.session("0930-1600", "Start / End Time ", tooltip = "A 24 hour format, where 0930 is 9:30 AM, 1600 is 4:00 PM, etc.\n\nDisabling \"Mkt Hrs Only\" will extend the previous sessions percentages into the premarket of the next session.", group=g5, inline="mkt", display=display.none)
mktHrsOnly = input.bool(false, "Mkt Hrs Only", group=g5,  inline="mkt", display=display.none)
zone = input.string("America/New_York", "Time Zone          ", tooltip="Any IANA time zone ID. Ex: America/New_York\n\nYou can also use \"syminfo.timezone\", which inherits the time zone of the exchange of the chart.", group=g5, inline="zone", display=display.none)
timezone = zone == "syminfo.timezone" ? syminfo.timezone : zone


// ----------------------------------------------------- CORE ------------------------------------------------------ //

// Set common variables.
o = open, h = high, l = low, c = close, barIndex = bar_index, var scaleBarIndex = bar_index

// Set the percentage scale source, based on trader selection.
price = switch source
    O => open
    H => high
    L => low
    C => close
    HL2 => hl2
    HLC3 => hlc3
    OHLC4 => ohlc4
    HLCC4 => hlcc4

// Initialize the daily normalized zero level.
var zero = 0.0
var normalizedZero = zero

// Initialize percent increment.
increment = frac ? fracType == f1 ? 0.125 : fracType == f2 ? 0.25 : 0.5 : 1

// Initialize color, and width arrays.
colors = array.from(zeroColor, wholeColor, fracColor, intraColor, customColor, anomalousColor)
width = array.from(zeroWidth, wholeWidth, fracWidth, intraWidth, customWidth)

// Initialize style array.
var style = array.from(zeroStyle, wholeStyle, fracStyle, intraStyle, customStyle), var Style_Arr_Not_Initialized = true
if Style_Arr_Not_Initialized
    for i = 0 to style.size() - 1
        s = switch style.get(i)
            "Solid"  => line.style_solid
            "Dashed" => line.style_dashed
            "Dotted" => line.style_dotted
        style.set(i, s)
    Style_Arr_Not_Initialized := false

// Initialize arrays used to manage objects.
var percent = array.new_float(na), var level = array.new_float(na), var lines = array.new_line(na), var labels = array.new_label(na), var atrBox = array.new_box(na), var intraLevel = array.new_float(na), var intraLines = array.new_line(na), var crLevel = array.new_float(na), var crBox = array.new_box(na), var signal = array.new_float(4, na)

// Set relative direction of price action.
dir = price >= normalizedZero ? 1 : -1



// --------------------------------------------------- TIME LOGIC -------------------------------------------------- //

// Define session segments.
minutesInDay = "1440"
market = time(minutesInDay, mktHrs, timezone)
newSession = session.isfirstbar
firstMarketBar = market and (not market[1] or newSession)
lastMarketBar  = market[1] and (not market or newSession)



// -------------------------------------------- OBJECT UPDATE FUNCTIONS -------------------------------------------- //

convertToLevel(a) =>
    math.round_to_mintick(normalizedZero + (normalizedZero * a) / 100)

labelString(pct, lvl) =>
    float value = pct
    symbol = " %"
    prefix = pct > 0 ? " + " : pct < 0 ? " - " : "   "
    suffix = incPrc ? " • " + str.tostring(lvl) + " " : " "
    switch delta
        d2 => value *= 100, symbol := " ‱"
        d3 => value := level.get(0) - lvl, symbol := " Δ"
    prefix + str.tostring(math.abs(value)) + symbol + suffix

setSignal(pct) =>
    if pct == 0
        signal.set(0, math.avg(math.avg(convertToLevel(increment), normalizedZero), normalizedZero))
        signal.set(1, increment)
        signal.set(2, math.avg(math.avg(convertToLevel(-1 * increment), normalizedZero), normalizedZero))
        signal.set(3, -1 * increment)
    else 
        currLevel = convertToLevel(pct)
        p = dir * (math.abs(pct) + increment)
        signal.set(p > 0 ? 0 : 2, math.avg(math.avg(convertToLevel(p), currLevel), currLevel))
        signal.set(p > 0 ? 1 : 3, p)

getIndexAndColor(pct, lvl) =>
    decimal = pct - math.floor(pct)
    decInc  = (decimal / increment) - math.floor(decimal / increment)
    n = decimal == 0 ? 1 : decInc == 0 ? 2 : 3
    i = crLevel.includes(lvl) ? 4 : pct != 0 ? n : 0
    clr = anomalous and math.abs(pct) >= anomalousRange ? anomalousColor : colors.get(i)
    [i, clr]

addLevel(pct) =>
    lvl = convertToLevel(pct)
    [i, linClr] =  getIndexAndColor(pct, lvl)
    if (i < 2 or frac or crLevel.includes(lvl)) and not level.includes(lvl)
        percent.push(pct)
        level.push(lvl)
        lines.push(line.new(scaleBarIndex, lvl, scaleBarIndex + 1, lvl, color=linClr, style=style.get(i), width=width.get(i), extend=extend.none))
        if atrZone
            atrBox.push(box.new(scaleBarIndex, lvl, scaleBarIndex + 1, lvl, color.new(linClr, 80), 1, bgcolor=color.new(linClr, 90)))
        if showLabel and i != 3
            labels.push(label.new(barIndex, lvl, labelString(pct, lvl), color=lblClr, textcolor=txtClr, style=label.style_label_left))

addIntraLevels(pct) =>
    a = math.abs(pct)
    b = math.abs(pct) - increment
    for i = 1 to 3
        lvl = convertToLevel((a - ((a - b) / 4) * i) * dir)
        [j, linClr] = getIndexAndColor(b, lvl)
        intraLevel.push(lvl)
        intraLines.push(line.new(scaleBarIndex, intraLevel.last(), scaleBarIndex + 1, intraLevel.last(), color=linClr, style=style.get(3), width=width.get(3), extend=extend.none))

nextLevel(next) =>
    addLevel(next)
    if intra and not showProx
        addIntraLevels(percent.last())
        addIntraLevels(next)
    setSignal(next)

showCustomRange(a, b) =>
    if not level.includes(a)
        addLevel(customLower * dir)
    if not level.includes(b)
        addLevel(customUpper * dir)
    if not showProx
        avg = math.round_to_mintick(math.avg(a, b))
        intraLines.push(line.new(lines.get(0).get_x1(), avg, lines.get(0).get_x2(), avg, color=colors.get(4), style=line.style_dotted, width=2, extend=extend.none))
        crBox.push(box.new(lines.get(0).get_x1(), a, lines.get(0).get_x2(), b, customBgColor, 1, bgcolor=customBgColor))
    if atrZone
        atrBox.push(box.new(lines.get(0).get_x1(), level.last(), lines.get(0).get_x2(), level.last(), none, 1, bgcolor=none))



// ------------------------------------------------- SESSION LOGIC ------------------------------------------------- //

deleteArray(arr) =>
    for object in arr
        object.delete()

setNewZero = ((today and firstMarketBar) or (yesterday and lastMarketBar)) and barstate.isconfirmed
terminateScale = (today and firstMarketBar) or (yesterday and newSession) or (mktHrsOnly and lastMarketBar)
createNewScale = ((today and firstMarketBar) or (yesterday and ((not mktHrsOnly and newSession) or (mktHrsOnly and firstMarketBar)))) and barstate.isconfirmed

// When it's time to terminate the old scale…
if terminateScale
    // Delete old custom range lines, intra lines, and ATR zones.
    deleteArray(intraLines), deleteArray(atrBox)

    // If the trader wants levels from the previous day to remain visible…
    if showOldLevels
        // Reformat lines.
        for [i, lineObject] in lines
            [j, linClr] =  getIndexAndColor(percent.get(i), level.get(i))
            lineObject.set_x1(scaleBarIndex)
            lineObject.set_x2(barIndex[1])
            lineObject.set_width(1)
            lineObject.set_color(color.new(linClr, 50))
            lineObject.set_style(style.get(j))
        // Reformat labels.
        for labelObject in labels
            labelObject.set_x(barIndex[1])
            labelObject.set_color(none)
            labelObject.set_textcolor(oldColor)
            labelObject.set_style(label.style_label_lower_right)
        // Reformat custom ranges.
        for boxObject in crBox
            boxObject.set_left(scaleBarIndex)
            boxObject.set_right(barIndex[1])
    else
        // Otherwise, delete the old stuff.
        deleteArray(lines), deleteArray(labels), deleteArray(crBox)
    
    // Remove objects from the management arrays.
    percent.clear(), level.clear(), lines.clear(), labels.clear(), atrBox.clear(), intraLevel.clear(), intraLines.clear(), crLevel.clear(), crBox.clear()

// Set the normalized zero value.
if setNewZero
    zero := today ? price : price[1]

if createNewScale
    // Set scale bar metrics.
    scaleBarIndex := barIndex
    normalizedZero := zero
    dir := price >= normalizedZero ? 1 : -1

    // If the custom range is enabled…
    if custom
        // Reset custom range switches.
        Not_Showing_UPPER_Range := custom
        Not_Showing_LOWER_Range := custom

        // Set custom range.
        crLevel := array.from(customLower, customUpper)
        for i = 0 to crLevel.size() - 1
            p = crLevel.shift()
            crLevel.push(convertToLevel(p))
            crLevel.push(convertToLevel(-1 * p))

    // Reset the daily percent scale.
    addLevel(0), setSignal(0)



// ----------------------------------------------- BAR BY BAR LOGIC ------------------------------------------------ //

if not mktHrsOnly or (market and mktHrsOnly)
    // Show the next meaningful level.
    if price > signal.get(0)
        nextLevel(signal.get(1))
    if price < signal.get(2)
        nextLevel(signal.get(3))

    // Show the custom range.
    if custom
        if price > crLevel.max(1) and Not_Showing_UPPER_Range
            showCustomRange(crLevel.max(1), crLevel.max())
            Not_Showing_UPPER_Range := false
        if price < crLevel.min(1) and Not_Showing_LOWER_Range
            showCustomRange(crLevel.min(1), crLevel.min())
            Not_Showing_LOWER_Range := false

    // Adjust labels.
    for labelObject in labels
        labelObject.set_x(barIndex + 10)

    // Adjust percentage lines.
    for lineObject in lines
        lineObject.set_x2(barIndex + 10)
        if truncate
            lineObject.set_x1(barIndex - 15)

    // Adjust intra lines.
    for lineObject in intraLines
        lvl = lineObject.get_y1()
        avg1 = math.round_to_mintick(math.avg(crLevel.max(), crLevel.max(1)))
        avg2 = math.round_to_mintick(math.avg(crLevel.min(), crLevel.min(1)))
        n = lvl == avg1 or lvl == avg2 ? 0 : 5
        lineObject.set_x2(lines.get(0).get_x2() - n)
        if truncate
            lineObject.set_x1(lines.get(0).get_x1() + n)

    // Adjust custom range box.
    for boxObject in crBox
        boxObject.set_right(barIndex + 10)
        if truncate
            boxObject.set_left(barIndex - 15)



// ----------------------------------------- PRICE & LEVEL PROXIMITY LOGIC ----------------------------------------- //

// Proximity functions.
getLevelRange(lineObject) =>
    lvl = lineObject.get_price(barIndex)
    lvlRng = lvl * (prxRng / 100)
    lvlRngHi = lvl + lvlRng
    lvlRngLo = lvl - lvlRng
    [lvl, lvlRng, lvlRngHi, lvlRngLo]

priceWithinLevelProximity(lvlRngHi, lvlRngLo) =>
    (h < lvlRngHi and h > lvlRngLo)
     or (l < lvlRngHi and l > lvlRngLo)
     or (h > lvlRngHi and l < lvlRngLo)
     or (h[1] < lvlRngHi and h[1] > lvlRngLo)
     or (l[1] < lvlRngHi and l[1] > lvlRngLo)
     or (h[1] > lvlRngHi and l[1] < lvlRngLo)

proximal(lineObject) =>
    [lvl, lvlRng, lvlRngHi, lvlRngLo] = getLevelRange(lineObject)
    proximal = priceWithinLevelProximity(lvlRngHi, lvlRngLo)

// If proximity settings are enabled…
if (truncate and extend) or showProx or atrZone
    // Proximity specific variables.
    prxAtr = ta.atr(5) / 2
    for [i, lineObject] in lines
        if proximal(lineObject)
            if truncate and extend
                // Override truncated line length.
                lineObject.set_x1(scaleBarIndex)
            if showProx
                // Show level and label.
                [j, linClr] = getIndexAndColor(percent.get(i), level.get(i))
                lineObject.set_color(linClr)
                if showLabel
                    labels.get(i).set_color(lblClr)
                    labels.get(i).set_textcolor(txtClr)
            if atrZone
                // // Show average true range zone around level.
                [lvl, lvlRng, lvlRngHi, lvlRngLo] = getLevelRange(lineObject)
                [j, clr] = getIndexAndColor(percent.get(i), level.get(i))
                atrBox.get(i).set_lefttop(lineObject.get_x1(), lvl + prxAtr)
                atrBox.get(i).set_rightbottom(lineObject.get_x2(),  lvl - prxAtr)
                atrBox.get(i).set_bgcolor(color.new(clr, 90))
                atrBox.get(i).set_border_color(color.new(clr, 80))
        else
            if truncate and extend
                // Re-truncate lines.
                lineObject.set_x1(barIndex - truncLen)
            if showProx
                // Hide level and label.
                lineObject.set_color(none)
                if showLabel
                    labels.get(i).set_color(none)
                    labels.get(i).set_textcolor(none)
            if atrZone
                // Hide ATR zone.
                atrBox.get(i).set_lefttop(lineObject.get_x1(), lineObject.get_y1())
                atrBox.get(i).set_rightbottom(lineObject.get_x2(),  lineObject.get_y2())
                atrBox.get(i).set_bgcolor(none)
                atrBox.get(i).set_border_color(none)