Volume/Market Profile
// This source code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © SamRecio

//@version=5
indicator("Volume/Market Profile", shorttitle = "VMP", overlay = true, max_lines_count = 500, max_boxes_count = 500)

//Profile Settings
tf = input.timeframe("D", title = "Timeframe", inline = "0", group = "PROFILE SETTINGS")
lb_calc = input.bool(false, title = "Use Lookback Calc?", inline = "0", group = "PROFILE SETTINGS", tooltip = "Lookback Calculation Historically compiles a profile to display the data from the requested timeframe amount of time.\nProgressive Calculation (Default) restarts the profile calculation on each change of the requested timeframe(New Bar).\n\nExamples:\nLookback = '1D' will display a profile based on 1 Day of historical data.\nProgressive = '1D' will display a profile base on data since the start of the Day.\n\nTip: There is a manual control for lookback bars at the bottom if you prefer to see a specific number.")
vap = input.float(70, title = "Value Area %", inline = "1_1", group = "PROFILE SETTINGS")/100
mp = input.bool(false, title = "Calculate As Market Profile", inline = "2", group = "PROFILE SETTINGS", tooltip = "Calculations will distribue a 1 instead of the candle's volume.")
//Display Settings
disp_size = input.int(-50, minval = -500,maxval = 500,title  = "Display Size   ", inline = "3", group = "DISPLAY SETTINGS", tooltip = "The entire range of your profile will scale to fit inside this range.\nNotes:\n-This value is # bars away from your profile's axis.\n-The larger this value is, the more granular your (horizontal) view will be. This does not change the Profiles' value; because of this, sometimes the POC looks tied with other values widely different. The POC CAN be tied to values close to it, but if the value is far away it is likely to just be a visual constraint.\n-This Value CAN be negative")
prof_offset = input.int(50, minval = -500,maxval = 500, title  = "Display Offset", inline = "4", group = "DISPLAY SETTINGS", tooltip = "Offset your profile's axis (Left/Right) to customize your display to fit your style.\nNotes:\n-Zero = Current Bar\n-This Value CAN be negative")
//Additional Data Displays
extend_day = input.bool(false, title = "Extend POC/VAH/VAL", inline = "1", group = "Additional Data Displays")
lab_tog = input.bool(true, title = "Label POC/VAH/VAL", inline = "2", group = "Additional Data Displays")
dev_poc = input.bool(false, title = "Rolling POC/TPO", inline = "3", group = "Additional Data Displays", tooltip = "Displays Value as it Develops!\nNote: Will Cause Longer Load Time. If not needed, it is reccomended to turn these off.")
dev_va = input.bool(false, title = "Rolling VAH/VAL", inline = "4", group = "Additional Data Displays")
//High/Low Volume Nodes
node_tog = input.bool(true, title = "Highlight Nodes", group = "High/Low Volume Nodes")
hi_width = input.int(10, maxval = 100, minval = 1,title = "[HVN] Analysis Width %      ↕", group = "High/Low Volume Nodes", tooltip = "[HVN] = High Volume Node\nAnalysis Width % = % of profile to take into account when determining what is a High Volume Node and what is Not.")*0.01
lo_width = input.int(10, maxval = 100, minval = 1, title = "[LVN]  Analysis Width %      ↕", group = "High/Low Volume Nodes", tooltip = "[LVN] = Low Volume Node\nAnalysis Width % = % of profile to take into account when determining what is a Low Volume Node and what is Not.")*0.01
//Colors
poc_color = input.color(#ff033e, title = "POC/TPO Color", group = "Colors")
var_color = input.color(color.white, title = "Value High/Low Color", group = "Colors")
vaz_color = input.color(color.new(#555555,50), title = "Value Zone Color", group = "Colors")
ov_color = input.color(#555555, title = "Profile Color", group = "Colors")
lv_color = input.color(#801922, title  = "Low Volume Color", group = "Colors")
hv_color = input.color(#2295BF, title = "High Volume Color", group = "Colors")
//⭕Manual Lookback Override⭕
lb_override = input.bool(false, title = "[Check to Enable]", group = "⭕Manual Lookback Override⭕", inline = "1")
lb_over_bars = input.int(500, title = "   Lookback", group = "⭕Manual Lookback Override⭕", inline = "1", tooltip = "Lookback Calc must be enabled for this feature.")

///_________________________________________
///Misc Stuff
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
decimal_places = str.contains(str.tostring(syminfo.mintick),".")?str.length(array.get(str.split(str.tostring(syminfo.mintick),"."),1)):0

round_to(_round,_to) =>
    math.round(_round/_to)*_to
///_________________________________________
///Setup for Length
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
tf_change = timeframe.change(tf)
var int bi_nd = na // BI_ND = Bar Index _ New Day
var int bi_lnd = na //BI_LND = Bar Index Last New Day

bi_lnd := tf_change?bi_nd:bi_lnd
bi_nd := tf_change?bar_index:bi_nd

tf_len = (bi_nd - bi_lnd)+1
bs_newtf = (bar_index - bi_nd)+1
id_lb_bars = timeframe.in_seconds(tf)/timeframe.in_seconds(timeframe.period)
dwm_lb_bars = (math.max(tf_len,bs_newtf)>1?math.max(tf_len,bs_newtf)-1:1)
auto_lb_bars = timeframe.in_seconds(tf) >= timeframe.in_seconds("D")?dwm_lb_bars:id_lb_bars
lb_bars = (lb_calc?(na(tf_len)?bar_index:(lb_override?lb_over_bars:auto_lb_bars)):na(bs_newtf)?bar_index:bs_newtf)
///_________________________________________
///Warning for Large Timeframe
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
var d = dayofmonth
var m = switch
    month == 1 => "Jan"
    month == 2 => "Feb"
    month == 3 => "Mar"
    month == 4 => "Apr"
    month == 5 => "May"
    month == 6 => "Jun"
    month == 7 => "Jul"
    month == 8 => "Aug"
    month == 9 => "Sep"
    month == 10 => "Oct"
    month == 11 => "Nov"
    month == 12 => "Dec"
var y = year - (int(year/100)*100)
send_warning = (lb_calc and na(tf_len)) or (not lb_calc and na(bs_newtf))
warning = "[WARNING ⚠]"+" ["+tf+"]"+"\nData Start: " + str.tostring(d,"00")+" "+m + " '" + str.tostring(y) + "\n\n"
if na(volume) and (mp == false)
    runtime.error("No Volume Data. Please Use a Ticker with Volume Data or Switch to Market Profile Calculation.")

///_________________________________________
///Start Data Accumulation
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
//By storing the data, I can use it later without the need for historical reference. 
//Additionally, I can control the amount of data kept in memory to only hold the lookback amount of bars.
//This is all of the advantages of lookback calcs, with all the advantages of progressive calculations.

src_high = math.round_to_mintick(high)
src_low = math.round_to_mintick(low)

var highs = array.new_float(na)
var lows = array.new_float(na)
var vol = array.new_float(na)

highs.push(src_high)
lows.push(src_low)
vol.push(volume)

if highs.size() >= lb_bars
    for i = highs.size()-1 to lb_bars
        if highs.size() == lb_bars
            break
        else
            highs.shift()
            lows.shift()
            vol.shift()
//Granularity Gets more corse farther from current bar for speed. Replay mode can get a full gran profile if needed.
v_gran = bar_index>=(last_bar_index-1000)?999: bar_index>=(last_bar_index-2000)?249: bar_index>=(last_bar_index-4000)?124:64

tick_size = math.max(syminfo.mintick,(highs.max()-lows.min())/v_gran)
full_hist_calc = (dev_poc or dev_va)

///_________________________________________
///Profile Calcs
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
data_map = map.new<float,float>()

if full_hist_calc and highs.size() > 0
    for i = 0 to array.size(highs)-1
        hi = round_to(highs.get(i),tick_size)
        lo = round_to(lows.get(i),tick_size)
        candle_index = ((hi-lo)/tick_size)
        tick_vol = mp?1:math.round((vol.get(i)/(candle_index+1)),3)
        for e = 0 to candle_index
            val = round_to(lo+(e*tick_size),tick_size)
            data_map.put(val, math.round(nz(data_map.get(val)),3)+tick_vol)


if (full_hist_calc == false) and barstate.islast
    for i = 0 to array.size(highs)-1
        hi = round_to(highs.get(i),tick_size)
        lo = round_to(lows.get(i),tick_size)
        candle_index = ((hi-lo)/tick_size)
        tick_vol = mp?1:math.round((vol.get(i)/(candle_index+1)),3)
        for e = 0 to candle_index
            val = round_to(lo+(e*tick_size),tick_size)
            data_map.put(val, math.round(nz(data_map.get(val)),3)+tick_vol)

///_________________________________________
////Other Profile Values
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
keys = map.keys(data_map)
values = map.values(data_map)
array.sort(keys, order.ascending)
///_________________________________________
///AQUIRE POC
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
//POC = Largest Volume Closest to Price Average of Profile
//
float poc = 0
float poc_vol = 0
prof_avg = array.avg(keys)
for [key, value] in data_map
    if (value > poc_vol) or (value == poc_vol and math.abs(key-prof_avg)<math.abs(poc-prof_avg))
        poc := key
        poc_vol := value
///_________________________________________
///VALUE ZONES
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
max_vol = array.sum(values)*vap
max_index = poc
vol_count = poc_vol
up_count = max_index
down_count = max_index
for i = 0 to array.size(keys)
    if vol_count >= max_vol
        break
    upper_vol = nz(data_map.get(round_to(up_count+tick_size,tick_size))) + nz(data_map.get(round_to(up_count+(tick_size*2),tick_size)))
    lower_vol = nz(data_map.get(round_to(down_count-tick_size,tick_size))) + nz(data_map.get(round_to(down_count-(tick_size*2),tick_size)))
    if (((upper_vol >= lower_vol) and upper_vol > 0) or (lower_vol>0 == false)) and up_count < array.max(keys)-tick_size
        vol_count += upper_vol                                                    
        up_count := round_to(up_count+(tick_size*2),tick_size)                                                       
    else if down_count > array.min(keys)+tick_size
        vol_count += lower_vol
        down_count := round_to(down_count-(tick_size*2),tick_size)  
val = down_count
vah = up_count
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//END PROFILE CALCs//
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////


////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//Cluster ID for Volume Nodes//
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//   Cluster ID analyzes a local group (cluster) of volume profile indexes (rows) to determine if the current index is higher(or lower) than those around it.
//   The Analysis width is a % of the entire # of rows in the profile to pull into a cluster, by increasing or decreasing this value, we can tune the profile to our individual preference.

vgroup_pull(_var,_array,_num1,_num2) =>
    _var == 1 and _num1>=_num2?data_map.get(array.get(_array,_num1-_num2)):                               //Pulls Index Value from Below
     _var == 2 and array.size(_array)-1 >= (_num1 + _num2)?data_map.get(array.get(_array,_num1+_num2))    //Pulls Index Value from Above 
     :0

var hvn_points = array.new_int(na)  
if array.size(keys) > 0 and barstate.islast and node_tog
    array.clear(hvn_points)
    for i = 0 to array.size(keys)-1      
        _val = data_map.get(array.get(keys,i))
        ary = array.new_float(na)                               
        for e = 0 to int(array.size(keys)*hi_width) 
            array.push(ary,vgroup_pull(1,keys,i,e))
            array.push(ary,vgroup_pull(2,keys,i,e))       
        max = array.max(ary)  
        avg = array.avg(ary)
        if _val >= math.avg(max,avg)
            array.push(hvn_points,i)

var lvn_points = array.new_int(na)
if array.size(keys) > 0 and barstate.islast and node_tog
    array.clear(lvn_points)
    for i = 0 to array.size(keys)-1
        _val = data_map.get(array.get(keys,i)) 
        ary = array.new_float(na) 
        for e = 0 to int(array.size(keys)*lo_width)  
            array.push(ary,vgroup_pull(1,keys,i,e))
            array.push(ary,vgroup_pull(2,keys,i,e))
        min = array.min(ary)
        avg = array.avg(ary)
        if _val <= math.avg(min,avg)  
            array.push(lvn_points,i)

///_________________________________________
///Cluster Merging
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾

merge_clusters(_array) =>
    if array.size(_array)>0
        for i = 0 to array.size(_array)-1
            hi_found = false
            lo_found = false
            _val = array.get(_array,i)
            for e = int(array.size(keys)*0.02) to 0
                if hi_found
                    array.push(_array,_val+e)
                else if array.includes(_array,_val+e)
                    hi_found := true

                if lo_found
                    array.push(_array,_val-e)
                else if array.includes(_array,_val-e) 
                    lo_found := true
            
                dummy = "This is here to make the loop return 'void'. "
                 
// run it
if barstate.islast and node_tog
    merge_clusters(hvn_points)
    merge_clusters(lvn_points)
            
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//END Cluster ID Calcs//
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////


/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//Display//
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

var profile = array.new_line(na)	
var box_profile = array.new_box(na)	 

if barstate.islast and array.size(profile) > 0
	for [index,line] in profile         
		line.delete(line)       
    array.clear(profile)
if barstate.islast and array.size(box_profile) > 0        
	for [index,box] in box_profile      
		box.delete(box)     
    array.clear(box_profile)

prof_color(_num,_key) => //Function for determining what color each profile index gets
    switch
        _key==poc => poc_color                                               //If its the max Value, give it poc color
        (_key==up_count or _key==down_count) => var_color                    //If its the value high or low, give it those colors
        array.includes(hvn_points,_num) and array.includes(lvn_points,_num) => ((_key>up_count or _key<down_count)?ov_color:vaz_color) /// If its BOTH a HVN and LVN only display color based on valuezone (in value or out) I plan to imporove this in the future.
        array.includes(lvn_points,_num) => lv_color     //If it is < lv% of the max volume, give it the low volume color 
        array.includes(hvn_points,_num) => hv_color     //If its > hv%  of the max volume, give it the high volume color 
        (_key>up_count or _key<down_count) => ov_color                       //If its above or below our value zone, give it the out of value color
        => vaz_color // Everything else is inside the value zone so it gets the value zone color

dash(_i) => (_i/2 - math.floor(_i/2)) > 0 //only true when the number is odd, Making it oscillate on/off, I can make use of this to evenly distribute boxes and lines throughout the display to make it cleaner.

roof = keys.max()
///_________________________________________
///Drawing the Profile
///‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
if barstate.islast and array.size(keys) > 0
    for [i,get_key] in keys
        scale = disp_size/array.max(values)
        get_vol = data_map.get(get_key)
        scaled = math.round(get_vol*scale)
        too_far_back = (lb_calc?bar_index-lb_bars:bi_nd)<bar_index-4999
        p1 = extend_day ? (too_far_back?bar_index-4999:(lb_calc?bar_index-lb_bars:bi_nd)) : (bar_index+prof_offset)
        p2 = extend_day ? (disp_size<0?(bar_index+prof_offset):((bar_index+scaled)+prof_offset)) : ((bar_index+scaled)+prof_offset)
        if get_key == poc 
            array.push(box_profile,box.new(p1,poc,p2,poc, border_color = poc_color, border_style = line.style_solid, border_width = 1, extend = (extend_day and too_far_back?extend.left:extend.none)))
        else if get_key == vah
            array.push(box_profile,box.new(p1,vah,p2,vah, border_color = var_color, border_style = line.style_solid, border_width = 1, extend = (extend_day and too_far_back?extend.left:extend.none)))
        else if get_key == val
            array.push(box_profile,box.new(p1,val,p2,val, border_color = var_color, border_style = line.style_solid, border_width = 1, extend = (extend_day and too_far_back?extend.left:extend.none)))
        else if dash(i) and (array.size(profile) <= 499) and (math.abs(scaled)>=1)
            array.push(profile,line.new(bar_index+prof_offset,get_key,(bar_index+scaled)+prof_offset,get_key, color = prof_color(i,get_key), style = (get_key<down_count or get_key>up_count?line.style_dotted:line.style_solid),width = 1))
        else if (array.size(box_profile) <= 499) and (math.abs(scaled)>=1)
            array.push(box_profile,box.new(bar_index+prof_offset,get_key,(bar_index+scaled)+prof_offset,get_key, border_color = prof_color(i,get_key), border_style = (get_key<down_count or get_key>up_count?line.style_dotted:line.style_solid), border_width = 1))

//Drawing labels for the profile
lab = label.new(bar_index+prof_offset,roof, text = (send_warning?warning:"")+(mp?"Market Profile [":"Volume Profile [") + (send_warning?"MAX":(lb_override?"Manual":tf)) + "] " + (lb_calc?"\nLookback [" + str.tostring(lb_bars) + " Bars]":"") + "\nGranularity: " + str.tostring(math.round(tick_size,decimal_places),"$#.################"), style = (disp_size<0?label.style_label_lower_right:label.style_label_lower_left), color = color.rgb(0,0,0,100), textcolor = send_warning?color.rgb(255, 136, 0):chart.fg_color, textalign = (disp_size<0?text.align_left:text.align_right), text_font_family = font.family_monospace)
label.delete(lab[1])
if lab_tog 
    poc_lab = label.new(bar_index+prof_offset,poc, text = mp?"TPO":"POC", tooltip = (mp?"TPO: ":"POC: ") + str.tostring(math.round(poc,decimal_places),"$#.################"),size = size.small, style = (disp_size<0?label.style_label_left:label.style_label_right), color = color.rgb(0,0,0,100), textcolor = poc_color, textalign = (disp_size<0?text.align_right:text.align_left), text_font_family = font.family_monospace)
    vah_lab = label.new(bar_index+prof_offset,vah, text = "VAH",size = size.small,tooltip = "VAH: " + str.tostring(math.round(vah,decimal_places),"$#.################"), style = (disp_size<0?label.style_label_left:label.style_label_right), color = color.rgb(0,0,0,100), textcolor = var_color, textalign = (disp_size<0?text.align_right:text.align_left), text_font_family = font.family_monospace)
    val_lab = label.new(bar_index+prof_offset,val, text = "VAL",size = size.small,tooltip = "VAL: " + str.tostring(math.round(val,decimal_places),"$#.################"), style = (disp_size<0?label.style_label_left:label.style_label_right), color = color.rgb(0,0,0,100), textcolor = var_color, textalign = (disp_size<0?text.align_right:text.align_left), text_font_family = font.family_monospace)
    label.delete(poc_lab[1])
    label.delete(vah_lab[1])
    label.delete(val_lab[1])
// Alerts
alertcondition(ta.crossover(close,poc), "Cross-Over Point of Control")
alertcondition(ta.crossunder(close,poc), "Cross-Under Point of Control")
alertcondition(ta.crossover(close,vah), "Cross-Over Value High")
alertcondition(ta.crossunder(close,vah), "Cross-Under Value High")
alertcondition(ta.crossover(close,val), "Cross-Over Value Low")
alertcondition(ta.crossunder(close,val),"Cross-Under Value Low")
//Plotting Developing Lines
plot(dev_poc?(poc==0?na:poc):na, color = poc_color, title = "Developing POC/TOP")
plot(dev_va?vah:na, color = var_color, title = "Developing VAH")
plot(dev_va?val:na, color = var_color, title = "Developing VAL")
