Zones Detector
// This source code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © CaLaHe - HL

//@version=5
indicator("Zones Detector", "Zones Detector"
  , overlay = true
  , max_labels_count = 500
  , max_lines_count = 500
  , max_boxes_count = 500
  , max_bars_back = 500)
max_bars_back(time, 500)
//-----------------------------------------------------------------------------{
//Constants
//-----------------------------------------------------------------------------{
int DEMAND_ZONE = 1
int SUPPLY_ZONE = 2
int TBD_ZONE = 0

//-----------------------------------------------------------------------------{
//Settings
//-----------------------------------------------------------------------------{
// -- Indecision ----
indecision_factor = input.int(2, 'Min. Factor', minval = 1, group = "Indecision")
volume_periods = input.int(20, 'Previous Periods', minval = 1, group = "Indecision")
volume_chg = input.int(10, 'Volume Change (%)', minval = 1, group = "Indecision")
confirm_bars = input.int(5, 'Confirmation Bars', minval = 1, group = 'Indecision')
invalidation = input.string('Close', 'Invalidation Method', options = ['Close', 'Wick'], group = "Indecision")

// -- Zones ----
showlast = input.int(10, 'Show Last', minval = 1, group = 'Zones')
demand_zone_css = input.color(color.new(#66bb6a, 70), 'Demand', inline = "Zones", group = 'Zones')
supply_zone_css = input.color(color.new(#f77c80, 70), 'Supply', inline = "Zones", group = 'Zones')
indecision_zone_css = input.color(color.new(#777575, 70), 'Indecision', inline = "Zones",group = 'Zones')
text_size = input.string('Small', 'Text Size', options = ['Tiny', 'Small', 'Normal'], group = "Zones")

// -- Signals ----
use_signals = input.bool(true, 'Use Signals', group = 'Signals')
bars_below_above = input.int(2, 'Bars Below/Above', minval = 1, group = 'Signals')
//-----------------------------------------------------------------------------}
//Global variables
//-----------------------------------------------------------------------------{
var zone_boxes = array.new_box(0)
var zone_labels = array.new_label(0)
type Zone
    float top
    float bottom
    float volume
    int left
    int type = 0
    color css
    int reached = 0
    int bars_outside = 0

var zones = array.new<Zone>(0)
var last_zone_reached = Zone.new()
bool demand_zone_confirmed = false
bool supply_zone_confirmed = false
bool price_enter_supply_zone = false
bool price_enter_demand_zone = false
var label_size = text_size == 'Tiny' ? size.tiny: text_size == 'Small' ? size.small : size.normal

//------------------------------------------------------------------
// FUNCTIONS
//-----------------------------------------------------------------
get_volume(include_current_bar, bars) =>
    past_vol = array.new_float(0)
    for i = include_current_bar ? 0: 1 to bars
        array.push(past_vol, volume[i])
    array.avg(past_vol)

is_indecision_bar() =>
    //Is an indecision candle?
    float high_change = 0.
    float low_change = 0.
    float body_change = math.abs((close - open) / open)   

    if close > open
        high_change := math.abs((high - close) / close)
        low_change := math.abs((low - open) / open)
    else 
        high_change := math.abs((high - open) / open)
        low_change := math.abs((low - close) / close)

    float factor = math.floor(math.max(high_change, low_change) / body_change)
    factor := na(factor) or factor > 999999 ? 999999 : factor
    bool add_as_new_zone = false  
    if factor >= indecision_factor   
        add_as_new_zone := true     
        for zone in zones
            //if the candle its include in some previous candle don't add
            if (high < zone.top and high > zone.bottom) and (low < zone.top and low > zone.bottom)
                add_as_new_zone := false
                break
            else if zone.type == DEMAND_ZONE and (low < zone.top and low > zone.bottom) and (high >= zone.top)
                add_as_new_zone := false
                break
            else if zone.type == SUPPLY_ZONE and (high < zone.top and high > zone.bottom) and (low <= zone.bottom)
                add_as_new_zone := false
                break
    add_as_new_zone

valid_zone(zone, bclose, bhigh, blow) =>        
    if zone.type == DEMAND_ZONE 
        demand_bar_price = invalidation == 'Close' ? bclose : blow
        demand_bar_price >= zone.bottom //and bopen > zone.bottom          
    else if zone.type == SUPPLY_ZONE 
        supply_bar_price = invalidation == 'Close' ? bclose : bhigh
        supply_bar_price <= zone.top //and bopen < zone.top
    else 
        true
    
bar_reached_zone(zone, wick) =>
    (wick >= zone.bottom and wick <= zone.top) or (close >= zone.bottom and close <= zone.top) or (open >= zone.bottom and open <= zone.top)

is_lateral_movement(zone, candles) =>
    int inside = 0
    for i = 1 to candles
        if (close[i] < zone.top and close[i] > zone.bottom) and (open[i] < zone.top and open[i] > zone.bottom)
            inside += 1
    inside >= candles

confirm_zone(zone) =>
    valid_zone = true
    new_volume = get_volume(true, confirm_bars)
    zone.volume := math.round((new_volume - zone.volume) / zone.volume * 100, 0) 
    if math.max(open, close) > zone.top
        zone.type := DEMAND_ZONE
        zone.css := demand_zone_css            
    else if math.min(open, close) < zone.bottom
        zone.type := SUPPLY_ZONE
        zone.css := supply_zone_css

    if math.abs(zone.volume) >= volume_chg 
      and zone.type != TBD_ZONE 
      and not is_lateral_movement(zone, confirm_bars)                      
        for i = 0 to confirm_bars - 1
            if not valid_zone(zone, close[i], high[i], low[i])
                valid_zone := false                
                break            
    else         
        valid_zone := false                
    valid_zone
        
display_zones(boxes, labels, dzones, show_last, zsize) =>
    for i = 0 to math.min(show_last - 1, zsize - 1)
        zindex = zsize - (1 + i)
        get_box = array.get(boxes, i)
        get_label = array.get(labels, i)
        zone = array.get(dzones, zindex)

        box.set_lefttop(get_box, zone.left, zone.top)
        box.set_rightbottom(get_box, zone.left, zone.bottom)
        box.set_border_color(get_box, zone.css)
        box.set_bgcolor(get_box, zone.css)
        label.set_textcolor(get_label, color.new(zone.css, 0))
        label.set_xy(get_label, bar_index + 5, zone.bottom)

        if zone.type == TBD_ZONE        
            label.set_text(get_label, "")
        else
            label.set_text(get_label, "Volume change: " + str.tostring(zone.volume) + "%\nReached: " + str.tostring(zone.reached))            
        
//------------------------------------------------------------------
// CALCULATE ZONES
//------------------------------------------------------------------
if barstate.isfirst
    for i = 0 to showlast - 1
        array.push(zone_boxes, box.new(na,na,na,na, xloc = xloc.bar_time , extend = extend.right))   
        array.push(zone_labels, label.new(na,na,na, size = label_size, style = label.style_none))

if barstate.isconfirmed    
    if is_indecision_bar()
        period_volume = get_volume(include_current_bar = false, bars = volume_periods)
        array.push(zones, Zone.new(top = high, bottom = low, volume = period_volume, left = time, css= indecision_zone_css))

    for zone in zones     
        index = array.indexof(zones, zone)
        if zone.type == TBD_ZONE and zone.left == time[confirm_bars]
            // Confirm zone o delete it           
            if confirm_zone(zone)
                demand_zone_confirmed := zone.type == DEMAND_ZONE
                supply_zone_confirmed := zone.type == SUPPLY_ZONE
                for i = 1 to confirm_bars
                    pindex = index + 1
                    if pindex < array.size(zones)
                        array.remove(zones, pindex) 
            else 
                array.remove(zones, index)
            break

    for zone in zones      
        index = array.indexof(zones, zone)
        if not valid_zone(zone, close, high, low) 
            if last_zone_reached.left == zone.left
                last_zone_reached := Zone.new()
            array.remove(zones, index)
        else    
            // Check if price reached any zone
            if zone.type == DEMAND_ZONE and bar_reached_zone(zone, low)            
                zone.reached += 1    
                price_enter_demand_zone := true

            if zone.type == SUPPLY_ZONE and bar_reached_zone(zone, high)            
                zone.reached += 1                
                price_enter_supply_zone := true

            if price_enter_demand_zone or price_enter_supply_zone
                zone.bars_outside := 0
                last_zone_reached := zone

    if last_zone_reached.type == DEMAND_ZONE and close > last_zone_reached.top //and open > last_zone_reached.top            
        last_zone_reached.bars_outside += 1
    else if last_zone_reached.type == SUPPLY_ZONE and close < last_zone_reached.bottom //and open < last_zone_reached.bottom
        last_zone_reached.bars_outside += 1

if barstate.islast
    int zones_size = array.size(zones)
    if zones_size > 0
        display_zones(zone_boxes, zone_labels, zones, showlast, zones_size)

//------------
// SIGNALS
//------------
bool buySignal = last_zone_reached.type == DEMAND_ZONE ? last_zone_reached.bars_outside > bars_below_above : false
bool sellSignal = last_zone_reached.type == SUPPLY_ZONE ? last_zone_reached.bars_outside > bars_below_above : false

plotshape(buySignal and use_signals, title='Buy', text='Buy', style=shape.labelup, location=location.belowbar, color=color.new(color.green, 0), textcolor=color.new(color.white, 0), size=size.tiny)
plotshape(sellSignal and use_signals, title='Sell', text='Sell', style=shape.labeldown, location=location.abovebar, color=color.new(color.red, 0), textcolor=color.new(color.white, 0), size=size.tiny)

//Reset
if last_zone_reached.bars_outside > bars_below_above
    last_zone_reached := Zone.new()

//-----------------------------------------------------------------------------}
//Alerts
//-----------------------------------------------------------------------------{
alertcondition(price_enter_demand_zone or price_enter_supply_zone, 'Zone Reached', "The price reached a zone")
alertcondition(price_enter_demand_zone, 'Reached Demand Zone', 'The price reached a DEMAND zone')
alertcondition(price_enter_supply_zone, 'Reached Supply Zone', 'The price reached a SUPPLY zone')
alertcondition(demand_zone_confirmed or supply_zone_confirmed, 'New Zone Confirmed', 'There is new zone')
alertcondition(demand_zone_confirmed, 'Demand Zone Confirmed', 'There is new DEMAND zone')
alertcondition(supply_zone_confirmed, 'Supply Zone Confirmed', 'There is new SUPPLY zone')
alertcondition(buySignal and use_signals, 'Buy', 'Buy')
alertcondition(sellSignal and use_signals, 'Sell', 'Sell')