Skip to content

Commit 19ebb88

Browse files
committed
Candles: bound volume-profile levels by snapping prices to the instrument price step
A volume profile keyed each level by the exact level price. Built from a near-continuous source (e.g. a Level1 SpreadMiddle feed on a wide-range instrument), every distinct fractional price spawned its own level, so an hour candle accumulated thousands of levels and could no longer be stored or transmitted as deep history. VolumeProfileBuilder gains an optional price-step constructor: when set, each level price is snapped to the instrument grid (ShrinkPrice) before it keys a level, so the count stays bounded; volumes for prices in the same bucket merge through the existing Join path. The parameterless constructor keeps the old per-price behaviour. CandleBuilder now passes Message.PriceStep at all three profile-creation sites (the value/time-frame path, the PnF CreateCandle path and the Renko path). Tests cover both the bucketed and the back-compat no-step modes.
1 parent 660f2a2 commit 19ebb88

3 files changed

Lines changed: 73 additions & 3 deletions

File tree

Algo/Candles/Compression/CandleBuilder.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ protected virtual IEnumerable<TCandleMessage> OnProcess(ICandleBuilderSubscripti
137137

138138
if (subscription.Message.IsCalcVolumeProfile)
139139
{
140-
subscription.VolumeProfile = volumeProfile = new();
140+
subscription.VolumeProfile = volumeProfile = new(subscription.Message.PriceStep);
141141
volumeProfile.Update(transform);
142142

143143
candle.PriceLevels = subscription.VolumeProfile.PriceLevels;
@@ -768,7 +768,7 @@ private PnFCandleMessage CreateCandle(ICandleBuilderSubscription subscription, D
768768

769769
if (subscription.Message.IsCalcVolumeProfile)
770770
{
771-
subscription.VolumeProfile = new();
771+
subscription.VolumeProfile = new(subscription.Message.PriceStep);
772772
candle.PriceLevels = subscription.VolumeProfile.PriceLevels;
773773
}
774774

@@ -818,7 +818,7 @@ RenkoCandleMessage GenerateNewCandle(decimal openPrice, decimal closePrice)
818818

819819
if (subscription.Message.IsCalcVolumeProfile)
820820
{
821-
subscription.VolumeProfile = new();
821+
subscription.VolumeProfile = new(subscription.Message.PriceStep);
822822
candle.PriceLevels = subscription.VolumeProfile.PriceLevels;
823823
}
824824

Algo/Candles/Compression/VolumeProfileBuilder.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ public class VolumeProfileBuilder
77
{
88
private readonly Dictionary<decimal, int> _volumeProfileInfo = [];
99

10+
// Optional price-step bucketing for the level price. When set (> 0), every level price is
11+
// snapped to the instrument price step before it keys a profile level. Without it a profile
12+
// built from a near-continuous source (e.g. Level1 SpreadMiddle on a wide-range instrument
13+
// such as a crypto/RUB pair) accumulates an unbounded number of distinct levels, bloating the
14+
// candle so much that it can no longer be stored/transmitted as deep history.
15+
private readonly decimal? _priceStep;
16+
1017
/// <summary>
1118
/// The upper price level.
1219
/// </summary>
@@ -46,6 +53,16 @@ public VolumeProfileBuilder()
4653
{
4754
}
4855

56+
/// <summary>
57+
/// Initializes a new instance of the <see cref="VolumeProfileBuilder"/> that snaps every level
58+
/// price to <paramref name="priceStep"/> so the number of distinct levels stays bounded.
59+
/// </summary>
60+
/// <param name="priceStep">Instrument price step used to bucket level prices; <see langword="null"/> or non-positive disables bucketing.</param>
61+
public VolumeProfileBuilder(decimal? priceStep)
62+
{
63+
_priceStep = priceStep;
64+
}
65+
4966
private readonly List<CandlePriceLevel> _levels = [];
5067

5168
/// <summary>
@@ -93,6 +110,15 @@ public void Update(CandlePriceLevel level)
93110
if (price == 0)
94111
throw new ArgumentOutOfRangeException(nameof(level));
95112

113+
if (_priceStep is decimal step && step > 0m)
114+
{
115+
// Snap the level price to the instrument grid so a near-continuous source cannot
116+
// spawn a new level per tick. Volumes for prices that fall into the same bucket are
117+
// merged by the existing Join path below.
118+
price = price.ShrinkPrice(step, step.GetCachedDecimals());
119+
level.Price = price;
120+
}
121+
96122
if (!_volumeProfileInfo.TryGetValue(price, out var idx))
97123
{
98124
idx = _levels.Count;

Tests/VolumeProfileBuilderTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
namespace StockSharp.Tests;
2+
3+
using StockSharp.Algo.Candles.Compression;
4+
using StockSharp.Messages;
5+
6+
[TestClass]
7+
public class VolumeProfileBuilderTests : BaseTestClass
8+
{
9+
[TestMethod]
10+
public void PriceStepBucketsLevels()
11+
{
12+
// Regression: a profile built from a near-continuous source (Level1 SpreadMiddle on a
13+
// wide-range instrument) used to spawn one level per distinct fractional price, so an
14+
// hour candle ballooned to thousands of levels and could no longer be served as deep
15+
// history. With a price step every level must snap to the grid, keeping the count bounded.
16+
const decimal step = 1m;
17+
var builder = new VolumeProfileBuilder(step);
18+
19+
for (var i = 0; i < 1000; i++)
20+
{
21+
// 100.000, 100.001 ... 100.999 — all collapse onto the integer grid.
22+
var price = 100m + i / 1000m;
23+
builder.Update(price, 1m, Sides.Buy);
24+
}
25+
26+
var levels = builder.PriceLevels.ToArray();
27+
28+
(levels.Length <= 2).AssertTrue($"Expected <=2 grid-bucketed levels, got {levels.Length}");
29+
levels.All(l => l.Price == decimal.Round(l.Price / step) * step).AssertTrue("Every level price must be snapped to the step grid");
30+
AreEqual(1000m, levels.Sum(l => l.TotalVolume), "All volume must be preserved across buckets");
31+
}
32+
33+
[TestMethod]
34+
public void NoPriceStepKeepsRawLevels()
35+
{
36+
// Back-compat: without a step each distinct price stays its own level.
37+
var builder = new VolumeProfileBuilder();
38+
39+
for (var i = 0; i < 100; i++)
40+
builder.Update(100m + i / 1000m, 1m, Sides.Buy);
41+
42+
AreEqual(100, builder.PriceLevels.Count(), "Without bucketing each distinct price is its own level");
43+
}
44+
}

0 commit comments

Comments
 (0)