DEV Community

ICF Market LTD
ICF Market LTD

Posted on AI-assisted

Building a Volume Profile Indicator From Raw Market Data in C#

Building a High-Performance Volume Profile in C

Most charting platforms display volume as a single bar beneath each candle, showing how much trading activity occurred during a given time interval.

A Volume Profile asks a different question:

How much volume traded at each price?

Instead of grouping volume by time, a Volume Profile groups it by price. The result is a histogram that highlights the price levels where the market concentrated most of its activity, as well as the areas through which price moved relatively quickly.

The basic idea is simple.

Building a Volume Profile correctly from raw trade data — while keeping it efficient enough for real-time updates — is a much more interesting engineering problem.

This article presents a practical architecture for implementing one in C#, with a focus on:

  • Price indexing
  • Trade classification
  • Point of Control (POC)
  • Value Area calculation
  • Incremental updates
  • Rendering performance

Represent Prices as Integer Ticks

A first implementation might use a dictionary like this:

Dictionary<double, long> volumeByPrice;
Enter fullscreen mode Exit fullscreen mode

This is convenient, but using floating-point prices as identity keys is unnecessary and can introduce subtle problems.

Suppose an instrument has a minimum price increment, or tick size, of 0.25. Prices such as:

5234.00
5234.25
5234.50
5234.75
Enter fullscreen mode Exit fullscreen mode

already belong to a discrete integer grid.

Instead of storing the price itself, convert each price to a tick index:

static long PriceToTick(double price, double tickSize)
{
    return checked(
        (long)Math.Round(
            price / tickSize,
            MidpointRounding.AwayFromZero
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

The price is now represented by an integer.

For example, with a tick size of 0.25:

5234.00 -> 20936
5234.25 -> 20937
5234.50 -> 20938
Enter fullscreen mode Exit fullscreen mode

In production code, if the trading platform provides its own canonical price-rounding utilities, use those instead of introducing a separate rounding convention.

The important principle is that price identity should be based on ticks rather than floating-point equality.


Start with Correctness Before Optimizing Storage

Once prices are represented as integer ticks, a dictionary becomes a much safer and clearer starting point.

First, define the possible aggressor sides:

enum AggressorSide
{
    Buy,
    Sell,
    Unknown
}
Enter fullscreen mode Exit fullscreen mode

Then define the information stored at each price level:

sealed class ProfileLevel
{
    public long TotalVolume;
    public long BuyVolume;
    public long SellVolume;
    public long UnknownVolume;
    public int TradeCount;
}
Enter fullscreen mode Exit fullscreen mode

The profile can initially be stored as:

readonly Dictionary<long, ProfileLevel> levels = new();
Enter fullscreen mode Exit fullscreen mode

Updating the profile for each incoming trade is straightforward:

void OnTrade(
    double price,
    long volume,
    AggressorSide side,
    double tickSize)
{
    long tick = PriceToTick(price, tickSize);

    if (!levels.TryGetValue(tick, out ProfileLevel? level))
    {
        level = new ProfileLevel();
        levels[tick] = level;
    }

    level.TotalVolume += volume;
    level.TradeCount++;

    switch (side)
    {
        case AggressorSide.Buy:
            level.BuyVolume += volume;
            break;

        case AggressorSide.Sell:
            level.SellVolume += volume;
            break;

        default:
            level.UnknownVolume += volume;
            break;
    }
}
Enter fullscreen mode Exit fullscreen mode

A Dictionary<long, ProfileLevel> is a good correctness-first implementation.

If profiling later shows that dictionary lookup overhead is significant, the storage layer can be replaced with an offset array, chunked array, or another contiguous structure without changing the rest of the profile logic.

That decision should come from measurement rather than assumption.


Where Do BuyVolume and SellVolume Come From?

This is an important detail.

A trade does not universally arrive with a perfect buyer-initiated or seller-initiated label. Depending on the data feed and trading platform, the aggressor side may need to be inferred from market context.

A common approach is to compare the execution price with the current bid and ask.

A trade executing at or near the ask can be classified as buyer-aggressed, while a trade executing at or near the bid can be classified as seller-aggressed.

Trades occurring between the quotes may require a fallback rule or may simply remain classified as Unknown.

The exact classification method is feed- and platform-dependent.

For this reason, the Volume Profile itself and the buy/sell classification logic should be treated as separate concerns.

Total volume can still be calculated correctly even when aggressor classification is unavailable.


Point of Control

Once volume has been accumulated by price, calculating the Point of Control (POC) is conceptually simple.

The POC is the price level with the highest accumulated volume.

A full implementation could calculate it by scanning every level:

POC = level with maximum TotalVolume
Enter fullscreen mode Exit fullscreen mode

However, a live profile usually does not need to scan the entire histogram after every trade.

When a trade updates one price level, compare that level's new volume with the currently cached POC volume.

If the updated level becomes larger, update the cached POC.

This makes normal POC maintenance effectively constant-time per trade update.

Define Tie-Breaking Explicitly

One small detail matters: ties.

If two price levels contain exactly the same volume, the implementation should use a deterministic tie-breaking rule.

Possible approaches include selecting:

  • The lower price
  • The higher price
  • The level closest to another reference point

The specific choice is less important than defining the behavior explicitly.

Undefined tie behavior can cause the POC to appear to jump unpredictably between price levels.


Value Area Is More Complicated

The Value Area is typically defined as a contiguous region around the POC containing a configurable fraction of the profile's total volume — commonly around 70%.

However, there is an important implementation detail:

Different platforms may use slightly different Value Area expansion conventions.

Some implementations compare one price level at a time. Others compare groups or pairs of levels. Tie-handling rules may also differ.

If the objective is exact parity with another trading platform, its documented Value Area convention should be reproduced exactly.

For this example, we will use a simple contiguous one-level expansion rule.

The process is:

  1. Start at the POC.
  2. Look at the next available price level above and below the current Value Area.
  3. Compare their volumes.
  4. Add whichever level contains more volume.
  5. Continue until the target percentage of total volume has been reached.

First, define a helper method:

static long VolumeAt(
    IReadOnlyDictionary<long, ProfileLevel> levels,
    long tick)
{
    return levels.TryGetValue(tick, out ProfileLevel? level)
        ? level.TotalVolume
        : 0L;
}
Enter fullscreen mode Exit fullscreen mode

Then calculate the Value Area:

static (long Low, long High) ComputeValueArea(
    IReadOnlyDictionary<long, ProfileLevel> levels,
    long pocTick,
    long minTick,
    long maxTick,
    long totalVolume,
    double valueAreaFraction = 0.70)
{
    double targetVolume = totalVolume * valueAreaFraction;

    long low = pocTick;
    long high = pocTick;

    long accumulated = VolumeAt(levels, pocTick);

    while (accumulated < targetVolume &&
           (low > minTick || high < maxTick))
    {
        long volumeAbove =
            high < maxTick
                ? VolumeAt(levels, high + 1)
                : -1;

        long volumeBelow =
            low > minTick
                ? VolumeAt(levels, low - 1)
                : -1;

        if (volumeAbove >= volumeBelow)
        {
            high++;

            if (volumeAbove > 0)
                accumulated += volumeAbove;
        }
        else
        {
            low--;

            if (volumeBelow > 0)
                accumulated += volumeBelow;
        }
    }

    return (low, high);
}
Enter fullscreen mode Exit fullscreen mode

This algorithm is intentionally simple.

It produces a contiguous Value Area, but it should not be treated as a universal market-profile standard.

The important engineering decision is to define the chosen convention explicitly and test it against known profiles.


The Real Performance Problem: Value Area Updates

The POC is relatively easy to maintain incrementally.

Value Area is different.

Every trade changes the total profile volume, and any change to a price bucket can potentially affect where the Value Area boundaries should be located.

Therefore, the following optimization is unsafe:

Only recompute Value Area when the POC changes.
Enter fullscreen mode Exit fullscreen mode

The POC can remain at exactly the same price while changes in the surrounding volume distribution cause the Value Area High (VAH) or Value Area Low (VAL) to move.

A more practical architecture is to mark the Value Area as dirty whenever the profile changes.

bool valueAreaDirty = false;

void OnProfileChanged()
{
    valueAreaDirty = true;
}
Enter fullscreen mode Exit fullscreen mode

Instead of recalculating the Value Area after every individual trade, recompute it at a controlled cadence.

For example:

Trade arrives
    |
    +--> Update price bucket
    |
    +--> Update cached POC if necessary
    |
    +--> Mark Value Area dirty


Every 100–250 ms
    |
    +--> If Value Area is dirty
            |
            +--> Recompute VAH / VAL
            |
            +--> Clear dirty flag
Enter fullscreen mode Exit fullscreen mode

The exact interval depends on the application.

A research tool, historical-profile calculator, execution interface, and visual chart indicator may all have very different latency requirements.

The important distinction is between:

Data accuracy

and

Visual refresh frequency

The underlying volume buckets can update on every incoming trade, while more expensive derived values are refreshed at a controlled cadence.

A final full recomputation can also be performed when a session or profile is finalized.


Dictionary or Array?

Once the implementation is working correctly, the storage layer can be benchmarked.

A dictionary offers several advantages:

  • It handles expanding price ranges naturally.
  • It requires relatively little memory-management code.
  • It is easy to reason about.
  • It avoids allocating storage for unused price levels.

For many instruments, the number of active price levels within a single profile is modest enough that a dictionary is perfectly acceptable.

If profiling shows that dictionary lookup overhead is actually significant, an offset array can eliminate hashing entirely.

Conceptually:

arrayIndex = tickIndex - baseTick
Enter fullscreen mode Exit fullscreen mode

The main challenge is expansion in both directions.

Repeatedly reallocating and copying a large array whenever the session reaches a new high or low can eliminate much of the theoretical performance benefit.

Two possible alternatives are:

  • Over-allocating storage in blocks
  • Using a chunked or page-based layout, with each block containing a fixed number of price levels

Again, the correct choice depends on measurement.

The fastest data structure on paper is not necessarily the fastest overall system once resizing, memory allocation, cache behavior, and implementation complexity are taken into account.


Keep Computation Separate from Rendering

The profile engine should understand:

  • Price ticks
  • Volume
  • Trade count
  • Aggressor classification
  • POC
  • Value Area

It should know nothing about pixels.

Rendering is a separate problem.

A renderer needs to determine:

  • Which price levels are currently visible
  • How much horizontal space is available
  • How volume maps to bar width
  • When the viewport has changed

Keeping computation and rendering separate provides several benefits.

The same profile model can support:

  • A classic horizontal Volume Profile histogram
  • A heatmap
  • A footprint-style visualization
  • A non-visual analytics component

This separation also prevents expensive visual operations from creeping into the market-data processing path.

If market-data processing and rendering occur on different execution contexts or threads, expose a stable snapshot of the profile state or use a short, well-defined synchronization strategy.

The renderer should not iterate over mutable profile state while that state is simultaneously being updated.


Measure Before Optimizing

For a live indicator, useful performance metrics include:

  • Average processing time per trade
  • Worst-case processing time
  • Allocations per update
  • Value Area recomputations per second
  • Number of active price levels
  • Render time per frame
  • Time spent waiting on synchronization

Without measurements, it is easy to spend hours replacing a dictionary that was never the actual bottleneck.

In many real-time charting systems, the larger performance problems come from:

  • Rendering overhead
  • Unnecessary object allocation
  • Excessive synchronization
  • Repeated calculation of derived values

These can create more performance problems than the initial volume accumulation itself.

A useful engineering order is:

Correctness first.

Instrumentation second.

Optimization third.


Final Architecture

A clean implementation naturally separates into three distinct layers.

1. Ingestion Layer

The ingestion layer receives trades and is responsible for:

  • Normalizing prices to integer ticks
  • Classifying aggressor side where possible
  • Updating the appropriate volume buckets

2. Profile Layer

The profile layer manages derived state, including:

  • Point of Control
  • Total profile volume
  • Value Area High
  • Value Area Low
  • Periodic Value Area recomputation

3. Rendering Layer

The rendering layer consumes a stable representation of the profile state and converts it into pixels.

It handles visual concerns such as:

  • Visible price ranges
  • Histogram dimensions
  • Bar scaling
  • Viewport changes
  • Frame rendering

Keeping these three layers separate makes the system easier to:

  • Test
  • Benchmark
  • Maintain
  • Optimize
  • Extend

It also allows the underlying profile engine to remain independent of any particular visualization method.


Discussion

If you've built a live Volume Profile or a similar market-microstructure tool, how do you handle Value Area recomputation?

Do you periodically recompute it from the full profile, maintain a more sophisticated incremental structure, or use another approach entirely?

I'm particularly interested in implementations that keep the data model simple while still performing well under high message rates.

Top comments (0)