VolSys has some nice VolumeProfile options. So does TopstepX. This indicator adds VolumeProfile for visible bars. Plus with a key modifier, volume profile between cursor and right side of the screen. I like the data structures so I share it here so others can use it as a reference. It’s completely AI coded.
#region Using declarations
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Windows.Input;
using System.Windows.Media;
using System.Xml.Serialization;
using NinjaTrader.Gui;
using NinjaTrader.Gui.Chart;
using NinjaTrader.Data;
using NinjaTrader.NinjaScript;
using NinjaTrader.NinjaScript.DrawingTools;
#endregion
namespace NinjaTrader.NinjaScript.Indicators
{
/// <summary>
/// Display Volume Profile — builds a volume profile from all completed bars
/// currently visible on the chart. Scrolling or zooming incrementally adds
/// or subtracts only the bars that entered or left the visible window;
/// a full rebuild is used when the delta exceeds half the current window span.
///
/// MODIFIER KEY (Ctrl) mode:
/// Hold Ctrl and hover over the chart. The profile is drawn from the
/// rightmost completed bar back to the bar under the cursor, letting you
/// interactively inspect any sub-range. Release Ctrl to revert to the
/// full visible window.
///
/// DESIGN — bidirectional sliding window over a permanent BarData store:
/// allBars : Dictionary keyed by bar index; written once per bar on
/// the primary series, never modified afterward.
/// displayProfile : SortedDictionary always equal to Σ allBars[windowFrom..windowTo].
/// Updated incrementally in OnRender when the visible range
/// changes; rebuilt from scratch when the delta is large.
/// Tick path : AccumulateBar into currentBar only — displayProfile is
/// never touched per tick. The live bar is intentionally
/// excluded from the display profile.
/// </summary>
public class DisplayVolumeProfile : Indicator
{
// -----------------------------------------------------------------------
// Inner types
// -----------------------------------------------------------------------
private struct PriceLevel
{
public long Buy;
public long Sell;
public long Total { get { return Buy + Sell; } }
}
/// <summary>
/// Immutable snapshot of one completed bar's volume by price level.
/// Written once when the bar closes; read-only afterward.
/// </summary>
private class BarData
{
public readonly Dictionary<double, PriceLevel> Levels =
new Dictionary<double, PriceLevel>();
public long TotalVolume;
}
// -----------------------------------------------------------------------
// State
// -----------------------------------------------------------------------
/// <summary>
/// Permanent store: every completed bar, keyed by primary-series bar index.
/// Grows monotonically; entries are never mutated or removed.
/// </summary>
private Dictionary<int, BarData> allBars;
/// <summary>Accumulates tick data for the bar currently forming.</summary>
private BarData currentBar;
private int lastBarIndex = -1;
private int lastCompletedBarIndex = -1;
// -----------------------------------------------------------------------
// displayProfile — the single source of truth for rendering and VA.
//
// Invariant: displayProfile == Σ allBars[windowFromIndex..windowToIndex]
//
// SortedDictionary: iteration in ascending price order for rendering and
// value-area traversal without an extra sort allocation.
// -----------------------------------------------------------------------
private SortedDictionary<double, PriceLevel> displayProfile;
private long displayMaxVol;
private long displayTotalVol;
private double displayPoc;
private int windowFromIndex = -1;
private int windowToIndex = -1;
private double vah, val;
// -----------------------------------------------------------------------
// Modifier-key / cursor state
//
// cursorBarIndex : bar index under the mouse, updated in the MouseMove
// handler; -1 until the mouse has entered the chart.
// -----------------------------------------------------------------------
private int cursorBarIndex = -1;
// DirectX brushes
private SharpDX.Direct2D1.Brush volBrushDX;
private SharpDX.Direct2D1.Brush buyBrushDX;
private SharpDX.Direct2D1.Brush sellBrushDX;
// -----------------------------------------------------------------------
// OnStateChange
// -----------------------------------------------------------------------
protected override void OnStateChange()
{
if (State == State.SetDefaults)
{
Name = "Display Volume Profile";
Description = "Volume profile built from all completed bars visible on the chart. "
+ "Hold Ctrl to pin the right edge and drag left to inspect any sub-range.";
IsChartOnly = true;
IsOverlay = true;
DisplayInDataBox = false;
DrawOnPricePanel = true;
EnableModifierKey = true;
BracketMinutes = 30;
ValueArea = 70;
Width = 70;
OffsetPixels = 10;
Opacity = 40;
ValueAreaOpacity = 60;
ShowBuySell = false;
ShowPoc = true;
ShowValueArea = true;
VolumeBrush = Brushes.CornflowerBlue;
BuyBrush = Brushes.DarkCyan;
SellBrush = Brushes.MediumVioletRed;
PocStroke = new Stroke(Brushes.Goldenrod, 1);
ValueAreaStroke = new Stroke(Brushes.CornflowerBlue, DashStyleHelper.Dash, 1);
}
else if (State == State.Configure)
{
Calculate = Calculate.OnEachTick;
AddDataSeries(BarsPeriodType.Tick, 1);
allBars = new Dictionary<int, BarData>();
currentBar = new BarData();
displayProfile = new SortedDictionary<double, PriceLevel>();
}
else if (State == State.Historical)
{
SetZOrder(1);
if (ChartControl != null)
{
ChartControl.PreviewMouseMove += OnChartMouseMove;
ChartControl.PreviewKeyUp += OnChartKeyUp;
}
}
else if (State == State.Terminated)
{
if (ChartControl != null)
{
ChartControl.PreviewMouseMove -= OnChartMouseMove;
ChartControl.PreviewKeyUp -= OnChartKeyUp;
}
}
}
// -----------------------------------------------------------------------
// Mouse / keyboard event handlers
// -----------------------------------------------------------------------
/// <summary>
/// Tracks the bar under the cursor. When Ctrl is held, forces a redraw
/// so the profile updates smoothly as the mouse moves.
/// </summary>
private void OnChartMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
{
if (ChartControl == null) return;
var pos = e.GetPosition(ChartControl);
cursorBarIndex = ChartBars.GetBarIdxByX(ChartControl, (int)pos.X);
if (EnableModifierKey &&
(Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl) ||
Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift)))
ChartControl.InvalidateVisual();
}
/// <summary>
/// Forces a redraw when Ctrl is released so the profile reverts to the
/// full visible window immediately.
/// </summary>
private void OnChartKeyUp(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl ||
e.Key == Key.LeftShift || e.Key == Key.RightShift || e.Key == Key.System)
ChartControl?.InvalidateVisual();
}
// -----------------------------------------------------------------------
// OnBarUpdate
// -----------------------------------------------------------------------
protected override void OnBarUpdate()
{
// ------------------------------------------------------------------
// Tick series — accumulate into currentBar only.
// displayProfile is deliberately not touched here.
// ------------------------------------------------------------------
if (BarsInProgress == 1)
{
double price = Closes[1][0];
long volume = (long)Volumes[1][0];
if (volume <= 0) return;
double ask = BarsArray[1].GetAsk(CurrentBar);
double bid = BarsArray[1].GetBid(CurrentBar);
long buyVol, sellVol;
if (ask > 0 && bid > 0 && bid < ask)
{
buyVol = price >= ask ? volume : 0;
sellVol = price <= bid ? volume : 0;
// trades inside the spread: assigned to neither
}
else
{
// bid/ask unavailable during historical replay — split equally
buyVol = volume / 2;
sellVol = volume - buyVol;
}
AccumulateBar(currentBar, price, buyVol, sellVol);
return;
}
// ------------------------------------------------------------------
// Primary series — bar boundary detected
// ------------------------------------------------------------------
if (CurrentBar == lastBarIndex) return;
// The bar at lastBarIndex just closed. Snapshot it into allBars.
if (lastBarIndex >= 0 && currentBar.TotalVolume > 0)
{
allBars[lastBarIndex] = currentBar;
lastCompletedBarIndex = lastBarIndex;
}
lastBarIndex = CurrentBar;
currentBar = new BarData();
}
// -----------------------------------------------------------------------
// Profile mutation helpers
// -----------------------------------------------------------------------
private static void AccumulateBar(BarData bar, double price, long buy, long sell)
{
PriceLevel lvl;
bar.Levels.TryGetValue(price, out lvl);
bar.Levels[price] = new PriceLevel { Buy = lvl.Buy + buy, Sell = lvl.Sell + sell };
bar.TotalVolume += buy + sell;
}
/// <summary>O(log k) — keeps displayMaxVol / displayPoc current on additions.</summary>
private void AddToDisplay(double price, long buy, long sell)
{
PriceLevel lvl;
displayProfile.TryGetValue(price, out lvl);
displayProfile[price] = new PriceLevel { Buy = lvl.Buy + buy, Sell = lvl.Sell + sell };
displayTotalVol += buy + sell;
long newTotal = displayProfile[price].Total;
if (newTotal > displayMaxVol)
{
displayMaxVol = newTotal;
displayPoc = price;
}
}
/// <summary>
/// Subtract one bar's contribution from displayProfile.
/// Does not update displayMaxVol / displayPoc — caller must RescanDisplayStats afterward.
/// </summary>
private void SubtractFromDisplay(BarData bar)
{
foreach (var kvp in bar.Levels)
{
PriceLevel lvl;
if (!displayProfile.TryGetValue(kvp.Key, out lvl)) continue;
long newBuy = Math.Max(lvl.Buy - kvp.Value.Buy, 0);
long newSell = Math.Max(lvl.Sell - kvp.Value.Sell, 0);
if (newBuy == 0 && newSell == 0)
displayProfile.Remove(kvp.Key);
else
displayProfile[kvp.Key] = new PriceLevel { Buy = newBuy, Sell = newSell };
}
}
/// <summary>O(k) full rescan. Only called after subtractions, never per tick.</summary>
private void RescanDisplayStats()
{
displayMaxVol = 0;
displayTotalVol = 0;
displayPoc = 0;
foreach (var kvp in displayProfile)
{
displayTotalVol += kvp.Value.Total;
if (kvp.Value.Total > displayMaxVol)
{
displayMaxVol = kvp.Value.Total;
displayPoc = kvp.Key;
}
}
}
// -----------------------------------------------------------------------
// Batch helpers used by UpdateWindow
// -----------------------------------------------------------------------
private void AddBarRange(int fromIdx, int toIdx)
{
for (int i = fromIdx; i <= toIdx; i++)
{
BarData bar;
if (!allBars.TryGetValue(i, out bar)) continue;
foreach (var kvp in bar.Levels)
AddToDisplay(kvp.Key, kvp.Value.Buy, kvp.Value.Sell);
}
}
private void SubtractBarRange(int fromIdx, int toIdx)
{
for (int i = fromIdx; i <= toIdx; i++)
{
BarData bar;
if (!allBars.TryGetValue(i, out bar)) continue;
SubtractFromDisplay(bar);
}
}
private void RebuildDisplay(int fromIdx, int toIdx)
{
displayProfile.Clear();
displayMaxVol = 0;
displayTotalVol = 0;
displayPoc = 0;
vah = val = 0;
AddBarRange(fromIdx, toIdx);
}
// -----------------------------------------------------------------------
// Window management — called from OnRender when visible range changes
// -----------------------------------------------------------------------
/// <summary>
/// Bring displayProfile in sync with [newFrom, newTo].
///
/// Strategy:
/// delta = bars entering + bars leaving the window
/// if delta > half the current window span → full rebuild (O(n × m))
/// else → incremental: subtract departing bars, rescan, add arriving bars
///
/// Subtractions always precede additions so that RescanDisplayStats gives
/// a correct baseline before AddToDisplay starts updating the max/poc.
/// </summary>
private void UpdateWindow(int newFrom, int newTo)
{
// First call — nothing to diff against
if (windowFromIndex < 0)
{
RebuildDisplay(newFrom, newTo);
windowFromIndex = newFrom;
windowToIndex = newTo;
return;
}
int oldSpan = windowToIndex - windowFromIndex + 1;
int delta = Math.Abs(newFrom - windowFromIndex)
+ Math.Abs(newTo - windowToIndex);
if (delta > oldSpan / 2)
{
RebuildDisplay(newFrom, newTo);
windowFromIndex = newFrom;
windowToIndex = newTo;
return;
}
// --- incremental path ---
bool needsRescan = false;
// Left side contracted (bars dropped from the left)
if (newFrom > windowFromIndex)
{
SubtractBarRange(windowFromIndex, newFrom - 1);
needsRescan = true;
}
// Right side contracted (bars dropped from the right)
if (newTo < windowToIndex)
{
SubtractBarRange(newTo + 1, windowToIndex);
needsRescan = true;
}
// Rescan once after all subtractions so the baseline is correct
// before any additions update displayMaxVol / displayPoc
if (needsRescan) RescanDisplayStats();
// Left side expanded (new bars added on the left)
if (newFrom < windowFromIndex)
AddBarRange(newFrom, windowFromIndex - 1);
// Right side expanded (new bars added on the right)
if (newTo > windowToIndex)
AddBarRange(windowToIndex + 1, newTo);
windowFromIndex = newFrom;
windowToIndex = newTo;
}
// -----------------------------------------------------------------------
// Value Area
// -----------------------------------------------------------------------
/// <summary>
/// Standard value area: start at POC, greedily expand up or down
/// (whichever adds more volume) until the target % is enclosed.
/// Runs on displayProfile.Keys which is already sorted — one ToList()
/// allocation, acceptable at bar-boundary / scroll frequency.
/// </summary>
private void CalculateValueArea()
{
if (displayProfile.Count == 0 || displayMaxVol == 0) return;
var prices = displayProfile.Keys.ToList();
int pocIdx = prices.BinarySearch(displayPoc);
if (pocIdx < 0) pocIdx = 0;
long pocVol = displayProfile.ContainsKey(displayPoc)
? displayProfile[displayPoc].Total
: displayMaxVol;
long target = (long)(displayTotalVol * (ValueArea / 100.0));
long accumulated = pocVol;
int upIdx = pocIdx + 1;
int downIdx = pocIdx - 1;
vah = displayPoc;
val = displayPoc;
while (accumulated < target)
{
long upVol = upIdx < prices.Count ? displayProfile[prices[upIdx]].Total : 0;
long downVol = downIdx >= 0 ? displayProfile[prices[downIdx]].Total : 0;
if (upVol == 0 && downVol == 0) break;
if (upVol >= downVol) { accumulated += upVol; vah = prices[upIdx++]; }
else { accumulated += downVol; val = prices[downIdx--]; }
}
}
// -----------------------------------------------------------------------
// Rendering
// -----------------------------------------------------------------------
/// <summary>
/// Returns the index of the last completed bar whose open time is ≤ endTime,
/// starting the search from fromBarIdx. Used by the Shift time-bracket mode.
/// Linear scan is intentional: bracket sizes are typically small relative to
/// bar count, so this is O(bracket bars), not O(all bars).
/// </summary>
private int FindToIndexByTime(int fromBarIdx, DateTime endTime)
{
for (int i = fromBarIdx + 1; i <= lastCompletedBarIndex; i++)
{
if (BarsArray[0].GetTime(i) > endTime)
return i - 1;
}
return lastCompletedBarIndex;
}
protected override void OnRender(ChartControl chartControl, ChartScale chartScale)
{
if (lastCompletedBarIndex < 0) return;
// ------------------------------------------------------------------
// Determine the target window.
//
// Normal mode : full visible range [FromIndex .. lastCompleted]
// Modifier mode : right edge pinned at lastCompleted,
// left edge follows the cursor — lets the user sweep
// any contiguous sub-range by hovering left or right.
// ------------------------------------------------------------------
bool ctrlDown = EnableModifierKey
&& (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl));
bool shiftDown = EnableModifierKey
&& (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift));
int newFrom, newTo;
newTo = Math.Min(ChartBars.ToIndex, lastCompletedBarIndex);
if (ctrlDown && cursorBarIndex >= 0)
{
// Ctrl mode: right edge pinned at lastCompleted, left edge follows cursor
int clampedCursor = Math.Max(0, Math.Min(cursorBarIndex, lastCompletedBarIndex));
newFrom = clampedCursor;
newTo = lastCompletedBarIndex;
}
else if (shiftDown && cursorBarIndex >= 0)
{
// Shift mode: fixed-width time bracket — cursor is the left edge,
// window extends forward BracketMinutes regardless of bar count.
newFrom = Math.Max(0, Math.Min(cursorBarIndex, lastCompletedBarIndex));
DateTime bracketEnd = BarsArray[0].GetTime(newFrom)
+ TimeSpan.FromMinutes(BracketMinutes);
newTo = FindToIndexByTime(newFrom, bracketEnd);
}
else
{
newFrom = ChartBars.FromIndex;
}
if (newFrom > newTo) return;
// Keep displayProfile in sync with the target window
if (newFrom != windowFromIndex || newTo != windowToIndex)
{
UpdateWindow(newFrom, newTo);
CalculateValueArea();
}
if (displayProfile.Count == 0 || displayMaxVol == 0) return;
RenderTarget.AntialiasMode = SharpDX.Direct2D1.AntialiasMode.Aliased;
double tickSize = chartControl.Instrument.MasterInstrument.TickSize;
// Anchor the profile to the right edge of the visible area
int barX = chartControl.GetXByBarIndex(ChartBars, ChartBars.ToIndex);
int startX = barX + OffsetPixels;
int avail = chartControl.CanvasRight - startX;
int maxW = Math.Max((int)(avail * (Width / 100f)), 10);
// SortedDictionary iterates in ascending price order — no sort needed
foreach (var kvp in displayProfile)
{
double price = kvp.Key;
float yTop = (float)chartScale.GetYByValue(price + tickSize);
float yBot = (float)chartScale.GetYByValue(price);
float barH = Math.Max(yBot - yTop, 1f);
bool inVA = price >= val && price <= vah;
float opac = (inVA ? ValueAreaOpacity : Opacity) / 100f;
if (ShowBuySell)
{
float sellW = maxW * (kvp.Value.Sell / (float)displayMaxVol);
float buyW = maxW * (kvp.Value.Buy / (float)displayMaxVol);
sellBrushDX.Opacity = opac;
buyBrushDX.Opacity = opac;
RenderTarget.FillRectangle(
new SharpDX.RectangleF(startX, yTop, sellW, barH), sellBrushDX);
RenderTarget.FillRectangle(
new SharpDX.RectangleF(startX + sellW, yTop, buyW, barH), buyBrushDX);
}
else
{
float barW = maxW * (kvp.Value.Total / (float)displayMaxVol);
volBrushDX.Opacity = opac;
RenderTarget.FillRectangle(
new SharpDX.RectangleF(startX, yTop, barW, barH), volBrushDX);
}
}
if (ShowPoc && displayPoc != 0)
{
float yPoc = (float)chartScale.GetYByValue(displayPoc + tickSize * 0.5);
RenderTarget.DrawLine(
new SharpDX.Vector2(startX, yPoc),
new SharpDX.Vector2(startX + maxW, yPoc),
PocStroke.BrushDX, PocStroke.Width, PocStroke.StrokeStyle);
}
if (ShowValueArea && vah != 0 && val != 0)
{
float yVAH = (float)chartScale.GetYByValue(vah + tickSize * 0.5);
float yVAL = (float)chartScale.GetYByValue(val + tickSize * 0.5);
RenderTarget.DrawLine(
new SharpDX.Vector2(startX, yVAH),
new SharpDX.Vector2(startX + maxW, yVAH),
ValueAreaStroke.BrushDX, ValueAreaStroke.Width, ValueAreaStroke.StrokeStyle);
RenderTarget.DrawLine(
new SharpDX.Vector2(startX, yVAL),
new SharpDX.Vector2(startX + maxW, yVAL),
ValueAreaStroke.BrushDX, ValueAreaStroke.Width, ValueAreaStroke.StrokeStyle);
}
}
public override void OnRenderTargetChanged()
{
if (volBrushDX != null) volBrushDX.Dispose();
if (buyBrushDX != null) buyBrushDX.Dispose();
if (sellBrushDX != null) sellBrushDX.Dispose();
if (RenderTarget != null)
{
volBrushDX = VolumeBrush.ToDxBrush(RenderTarget);
buyBrushDX = BuyBrush.ToDxBrush(RenderTarget);
sellBrushDX = SellBrush.ToDxBrush(RenderTarget);
PocStroke.RenderTarget = RenderTarget;
ValueAreaStroke.RenderTarget = RenderTarget;
}
}
// -----------------------------------------------------------------------
// Properties
// -----------------------------------------------------------------------
#region Setup
[Display(Name = "Enable Modifier Key (Ctrl)",
Description = "When enabled, hold Ctrl to pin the right edge and sweep the window with the cursor",
Order = 1, GroupName = "Setup")]
public bool EnableModifierKey { get; set; }
[Range(1, 1440)]
[Display(Name = "Bracket Minutes (Shift)",
Description = "Hold Shift to show a fixed-width time bracket starting at the cursor. This sets the bracket width in minutes.",
Order = 2, GroupName = "Setup")]
public int BracketMinutes { get; set; }
[NinjaScriptProperty]
[Range(10, 90)]
[Display(Name = "Value Area (%)",
Description = "Percentage of total volume enclosed by the value area",
Order = 1, GroupName = "Setup")]
public int ValueArea { get; set; }
#endregion
#region Visual
[Range(5, 100)]
[Display(Name = "Profile Width (%)", Order = 1, GroupName = "Visual")]
public int Width { get; set; }
[Range(-200, 200)]
[Display(Name = "Offset (pixels)",
Description = "Horizontal offset from the rightmost visible bar",
Order = 2, GroupName = "Visual")]
public int OffsetPixels { get; set; }
[Range(1, 100)]
[Display(Name = "Opacity (%)", Order = 3, GroupName = "Visual")]
public int Opacity { get; set; }
[Range(1, 100)]
[Display(Name = "Value Area Opacity (%)", Order = 4, GroupName = "Visual")]
public int ValueAreaOpacity { get; set; }
[Display(Name = "Show Buy / Sell Split", Order = 5, GroupName = "Visual")]
public bool ShowBuySell { get; set; }
[Display(Name = "Show POC", Order = 6, GroupName = "Visual")]
public bool ShowPoc { get; set; }
[Display(Name = "Show Value Area", Order = 7, GroupName = "Visual")]
public bool ShowValueArea { get; set; }
[XmlIgnore]
[Display(Name = "Volume Color", Order = 8, GroupName = "Visual")]
public Brush VolumeBrush { get; set; }
[Browsable(false)]
public string VolumeBrushSerialize
{
get { return Serialize.BrushToString(VolumeBrush); }
set { VolumeBrush = Serialize.StringToBrush(value); }
}
[XmlIgnore]
[Display(Name = "Buy Color", Order = 9, GroupName = "Visual")]
public Brush BuyBrush { get; set; }
[Browsable(false)]
public string BuyBrushSerialize
{
get { return Serialize.BrushToString(BuyBrush); }
set { BuyBrush = Serialize.StringToBrush(value); }
}
[XmlIgnore]
[Display(Name = "Sell Color", Order = 10, GroupName = "Visual")]
public Brush SellBrush { get; set; }
[Browsable(false)]
public string SellBrushSerialize
{
get { return Serialize.BrushToString(SellBrush); }
set { SellBrush = Serialize.StringToBrush(value); }
}
#endregion
#region Lines
[Display(Name = "POC", Order = 1, GroupName = "Lines")]
public Stroke PocStroke { get; set; }
[Display(Name = "Value Area", Order = 2, GroupName = "Lines")]
public Stroke ValueAreaStroke { get; set; }
#endregion
}
}
