diff --git a/VisualHFT.Plugins/MarketConnectors.Binance/BinancePlugin.cs b/VisualHFT.Plugins/MarketConnectors.Binance/BinancePlugin.cs index cbaa99b7..cc10c95c 100644 --- a/VisualHFT.Plugins/MarketConnectors.Binance/BinancePlugin.cs +++ b/VisualHFT.Plugins/MarketConnectors.Binance/BinancePlugin.cs @@ -41,7 +41,7 @@ public class BinancePlugin : BasePluginDataRetriever private BinanceRestClient _restClient; // ✅ FIX: Use ConcurrentDictionary for thread safety - private readonly ConcurrentDictionary _localOrderBooks = + internal readonly ConcurrentDictionary _localOrderBooks = new ConcurrentDictionary(); private HelperCustomQueue _eventBuffers; @@ -577,7 +577,7 @@ private void eventBuffers_onErrorAction(Exception ex) Task.Run(async () => await HandleConnectionLost(_error, ex)); } - private void tradesBuffers_onReadAction(IBinanceTrade eventData) + internal void tradesBuffers_onReadAction(IBinanceTrade eventData) { var _symbol = GetNormalizedSymbol(eventData.Symbol); // Get a Trade object from the pool. @@ -589,7 +589,11 @@ private void tradesBuffers_onReadAction(IBinanceTrade eventData) trade.Timestamp = eventData.TradeTime.ToLocalTime(); trade.ProviderId = _settings.Provider.ProviderID; trade.ProviderName = _settings.Provider.ProviderName; - trade.IsBuy = eventData.BuyerIsMaker; + // IsBuy means the AGGRESSOR bought. The stream reports the opposite fact: BuyerIsMaker is + // the "m" flag, true when the buyer was the resting maker - which makes the seller the + // aggressor. Negate it, or every aggressive sell on this venue is reported as a buy and + // every study that reads trade side reads this venue backwards. + trade.IsBuy = !eventData.BuyerIsMaker; trade.MarketMidPrice = _localOrderBooks[_symbol].MidPrice; RaiseOnDataReceived(trade); diff --git a/VisualHFT.Plugins/Studies.VPIN/Model/PlugInSettings.cs b/VisualHFT.Plugins/Studies.VPIN/Model/PlugInSettings.cs index c5851bb9..857a2afc 100644 --- a/VisualHFT.Plugins/Studies.VPIN/Model/PlugInSettings.cs +++ b/VisualHFT.Plugins/Studies.VPIN/Model/PlugInSettings.cs @@ -9,6 +9,16 @@ public class PlugInSettings : ISetting { public double BucketVolSize { get; set; } public int? NumberOfBuckets { get; set; } // Rolling window size (nullable for backward compat) + + /// + /// Selects the corrected form of the metric: trades classified by the tick rule instead of + /// against the order-book mid, no value published until a full window of buckets has closed, + /// and no value published while the bucket volume is too small to hold enough prints for the + /// imbalance to describe the flow. Off by default, so a settings file written before this + /// option existed keeps the behaviour it already had. + /// + public bool UseCorrectedForm { get; set; } + public string Symbol { get; set; } public Provider Provider { get; set; } public AggregationLevel AggregationLevel { get; set; } diff --git a/VisualHFT.Plugins/Studies.VPIN/Model/VpinBucketEngine.cs b/VisualHFT.Plugins/Studies.VPIN/Model/VpinBucketEngine.cs new file mode 100644 index 00000000..4129a265 --- /dev/null +++ b/VisualHFT.Plugins/Studies.VPIN/Model/VpinBucketEngine.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; + +namespace VisualHFT.Studies.VPIN.Model +{ + /// + /// Computes VPIN over volume buckets built from a public trade tape, in the corrected form. + /// + /// THE PUBLISHED CONSTRUCTION + /// Easley, Lopez de Prado and O'Hara (2012) define the metric as the mean absolute volume + /// imbalance across the last n completed buckets of equal traded volume V: + /// + /// VPIN = (1/n) * SUM |V_buy_i - V_sell_i| / V + /// + /// The volume clock is what makes the metric a real-time one: a bucket closes when volume + /// reaches V, not when a wall-clock interval expires, so the reading advances with activity + /// rather than with time. This engine keeps that construction, including splitting a print + /// that straddles a bucket boundary and carrying the remainder into the next bucket. + /// + /// WHERE IT DEPARTS FROM THE PAPER, AND WHY + /// The paper classifies a bucket's volume in bulk, splitting it between buy and sell from the + /// bucket's price change scaled by the volatility of price changes, and never looks at an + /// individual print's side. Andersen and Bondarenko (2015) showed that this classifier is a + /// function of volatility, so a metric built on it tracks volatility mechanically; Chakrabarty, + /// Pascual and Shkilko (2015) found the tick rule produces more accurate estimates of the same + /// quantity. This engine therefore classifies with the tick rule: a print above the previous + /// print is a buy, below is a sell, and an unchanged price repeats the previous side. + /// + /// A level shift follows from that, and callers must state it. The bulk method splits a bucket's + /// volume fractionally between the two sides, while this assigns each whole print to one of them, + /// which can only produce buckets at least as one-sided. Expect this to read higher than a + /// bulk-classified figure on the same tape - expect, not measure: no such comparison has been run, + /// and the product ships no bulk classifier to compare against. The legacy form is not one either; + /// it also assigns whole prints, differing only in how it picks the side. Treat published absolute + /// thresholds as untransferable and compare ranks and percentiles instead. + /// + /// The exchange's own aggressor flag is deliberately not used. The connectors that supply it do + /// not agree on what it means, so a tile built on it would read backwards on some venues. The + /// tick rule needs nothing but the print stream. + /// + /// WHY BUCKET SIZE DECIDES THE NUMBER + /// A bucket holding B equally sized prints can only take the imbalance values 0, 2/B, 4/B and so + /// on, and on coin-flip sides its expected value is 1.000 at B = 1, 0.500 at B = 2 and 0.375 at + /// B = 4, falling toward zero only as B grows. A bucket small enough to hold one print therefore + /// reads 1 forever on random flow, by arithmetic and not by measurement. The engine samples the + /// first prints it sees, takes their median size, and refuses to publish while the configured + /// bucket volume is below times that median. + /// + /// WARM-UP + /// The paper defines the metric only over a full window, so nothing is publishable until n + /// buckets have closed. At the paper's own sizing that is roughly one trading day of volume. + /// is that cost expressed as volume; dividing it by the + /// instrument's volume rate gives it in wall time. + /// + /// THREADING + /// This class is not thread-safe and does not try to be. It is driven from the trade callback + /// alone, under the study's own lock. It is deliberately private to this plugin and shared with + /// nothing outside it. + /// + internal sealed class VpinBucketEngine + { + /// + /// Prints per bucket the configured bucket volume must be able to hold before a value is + /// published. This is the same target already used to size this metric's buckets for file + /// replay, kept identical so the two paths do not disagree about what counts as a usable + /// bucket. Twenty is far enough above the saturation point that the expected imbalance on + /// uninformative flow is a small number rather than 1. + /// + public const int DefaultMinimumPrintsPerBucket = 20; + + /// + /// Prints sampled before the median print size — and with it the bucket volume floor — is + /// settled. Fixed and small: the list is allocated once, filled once, sorted once and then + /// released, so the sizing costs nothing per print after the sample closes. + /// + public const int DefaultPrintSizeSampleCount = 100; + + private readonly decimal _bucketVolumeSize; + private readonly int _minimumPrintsPerBucket; + private readonly int _printSizeSampleCount; + + private readonly decimal[] _bucketImbalances; + private int _bufferIndex; + private int _bufferCount; + private decimal _rollingSum; + private long _completedBucketCount; + + private decimal _currentBuyVolume; + private decimal _currentSellVolume; + private decimal _currentBucketVolume; + + private decimal _previousPrice; + private bool _hasPreviousPrice; + private bool _lastSideIsBuy; + private bool _hasLastSide; + + private List? _printSizeSample; + private decimal _medianPrintSize; + private bool _isFloorResolved; + + /// Traded volume V that closes a bucket. Must be positive. + /// Number of completed buckets n averaged into the value. Must be positive. + /// + /// Prints the bucket volume must be able to hold, measured against the median print size. + /// Zero switches the floor off, which is useful when the caller has already sized V from a + /// known volume profile and wants no sampling delay. + /// + /// Prints sampled to settle the median print size. + public VpinBucketEngine( + decimal bucketVolumeSize, + int windowSize, + int minimumPrintsPerBucket = DefaultMinimumPrintsPerBucket, + int printSizeSampleCount = DefaultPrintSizeSampleCount) + { + if (bucketVolumeSize <= 0m) + throw new ArgumentOutOfRangeException(nameof(bucketVolumeSize), "Bucket volume size must be positive."); + if (windowSize <= 0) + throw new ArgumentOutOfRangeException(nameof(windowSize), "Window size must be positive."); + if (minimumPrintsPerBucket < 0) + throw new ArgumentOutOfRangeException(nameof(minimumPrintsPerBucket), "Minimum prints per bucket cannot be negative."); + if (minimumPrintsPerBucket > 0 && printSizeSampleCount <= 0) + throw new ArgumentOutOfRangeException(nameof(printSizeSampleCount), "Print size sample count must be positive when the floor is enabled."); + + _bucketVolumeSize = bucketVolumeSize; + _minimumPrintsPerBucket = minimumPrintsPerBucket; + _printSizeSampleCount = printSizeSampleCount; + _bucketImbalances = new decimal[windowSize]; + + if (minimumPrintsPerBucket > 0) + _printSizeSample = new List(printSizeSampleCount); + else + _isFloorResolved = true; + } + + /// Traded volume that closes a bucket. + public decimal BucketVolumeSize => _bucketVolumeSize; + + /// Completed buckets averaged into the value once the window is full. + public int WindowSize => _bucketImbalances.Length; + + /// Prints per bucket the configured bucket volume must hold. Zero means no floor. + public int MinimumPrintsPerBucket => _minimumPrintsPerBucket; + + /// Completed buckets so far, counted from the first print and never evicted. + public long CompletedBucketCount => _completedBucketCount; + + /// Completed buckets currently inside the rolling window. + public int BucketsInWindow => _bufferCount; + + /// True once the last closed at least one bucket. + public bool BucketJustClosed { get; private set; } + + /// True once n buckets have closed, which is the first instant the metric is defined. + public bool IsWindowFull => _bufferCount == _bucketImbalances.Length; + + /// + /// Volume that must trade before the first value can appear: one full window of buckets. + /// Divide by the instrument's volume rate to express the warm-up in wall time. + /// + public decimal WarmUpVolumeRequired => _bucketImbalances.Length * _bucketVolumeSize; + + /// + /// Median print size over the sample, or zero while the sample is still filling. Reported so + /// the caller can tell the user what bucket volume the instrument actually calls for. + /// + public decimal MedianPrintSize => _medianPrintSize; + + /// + /// Smallest bucket volume that clears the floor, or zero while the sample is still filling. + /// + public decimal RequiredBucketVolumeSize => _isFloorResolved ? _minimumPrintsPerBucket * _medianPrintSize : 0m; + + /// + /// True when the sample has settled and the configured bucket volume is too small to hold + /// prints. A bucket that small reports an artefact of + /// its own size rather than a property of the flow, so no value is published while this holds. + /// + public bool IsBucketSizeBelowFloor => _minimumPrintsPerBucket > 0 && _isFloorResolved && _bucketVolumeSize < RequiredBucketVolumeSize; + + /// + /// The window mean, defined only once . Before that it is the mean + /// of however many buckets have closed, which is NOT the published metric — read + /// before showing it to anyone. + /// + public decimal Value => _bufferCount > 0 ? _rollingSum / _bufferCount : 0m; + + /// + /// True when the value means what the tooltip says it means: a full window has closed and + /// the bucket volume is large enough for the imbalance to describe the flow. + /// + public bool HasPublishableValue => IsWindowFull && _isFloorResolved && !IsBucketSizeBelowFloor; + + /// + /// Feeds one public trade print. Prints that cannot be classified contribute no volume: the + /// first print of a session has nothing to compare against and only sets the reference, and + /// a second print at that same price has no earlier side to repeat. + /// + public void AddTrade(decimal price, decimal size) + { + BucketJustClosed = false; + + // A print with no size is not a print. It returns BEFORE the reference price is updated + // below, deliberately: the tick rule must compare the next real print against the last + // real one, so a zero-sized record must not become the thing the next print is measured + // against. Moving this below the assignment silently changes classification. + if (size <= 0m) + return; + + bool classified = TryClassify(price, out bool isBuy); + + _previousPrice = price; + _hasPreviousPrice = true; + + if (!classified) + return; + + _lastSideIsBuy = isBuy; + _hasLastSide = true; + + SamplePrintSize(size); + + if (isBuy) + _currentBuyVolume += size; + else + _currentSellVolume += size; + _currentBucketVolume += size; + + if (_currentBucketVolume < _bucketVolumeSize) + return; + + // Close the bucket this print completed, trimming the overflow off the side that received + // it and carrying that overflow into the next bucket, as the paper specifies. + decimal overflow = _currentBucketVolume - _bucketVolumeSize; + if (isBuy) + _currentBuyVolume -= overflow; + else + _currentSellVolume -= overflow; + _currentBucketVolume = _bucketVolumeSize; + CloseBucket(); + + // Whatever is left over belongs entirely to this one print, so every further whole bucket + // it fills holds one side only and has an imbalance of exactly 1. That is arithmetic, and + // it is counted rather than iterated: this runs inline on the connector's producer thread + // under the study's lock, and a bucket volume far below the print size would otherwise spin + // here for as many turns as the ratio between them, stalling every other trade subscriber. + if (overflow >= _bucketVolumeSize) + { + decimal wholeBuckets = decimal.Floor(overflow / _bucketVolumeSize); + overflow -= wholeBuckets * _bucketVolumeSize; + CloseOneSidedBuckets(wholeBuckets); + } + + _currentBuyVolume = isBuy ? overflow : 0m; + _currentSellVolume = isBuy ? 0m : overflow; + _currentBucketVolume = overflow; + } + + /// + /// The tick rule: up from the previous print is a buy, down is a sell, and an unchanged + /// price repeats the side of the last classified print. + /// + private bool TryClassify(decimal price, out bool isBuy) + { + isBuy = false; + if (!_hasPreviousPrice) + return false; + + if (price > _previousPrice) + { + isBuy = true; + return true; + } + if (price < _previousPrice) + { + isBuy = false; + return true; + } + + if (!_hasLastSide) + return false; + + isBuy = _lastSideIsBuy; + return true; + } + + private void CloseBucket() + { + decimal imbalance = Math.Abs(_currentBuyVolume - _currentSellVolume) / _bucketVolumeSize; + + if (_bufferCount == _bucketImbalances.Length) + _rollingSum -= _bucketImbalances[_bufferIndex]; + else + _bufferCount++; + + _bucketImbalances[_bufferIndex] = imbalance; + _rollingSum += imbalance; + _bufferIndex = (_bufferIndex + 1) % _bucketImbalances.Length; + + _completedBucketCount++; + BucketJustClosed = true; + } + + /// + /// Records consecutive buckets that each held one side only, and so + /// each have an imbalance of exactly 1, without walking them one at a time. Only the last + /// of them can still be inside the window - anything before that would + /// be overwritten by what follows - so at most that many slots are touched however large the + /// count is. + /// + private void CloseOneSidedBuckets(decimal count) + { + if (count <= 0m) + return; + + int slotsToWrite = count >= _bucketImbalances.Length + ? _bucketImbalances.Length + : (int)count; + + for (int i = 0; i < slotsToWrite; i++) + { + if (_bufferCount == _bucketImbalances.Length) + _rollingSum -= _bucketImbalances[_bufferIndex]; + else + _bufferCount++; + + _bucketImbalances[_bufferIndex] = 1m; + _rollingSum += 1m; + _bufferIndex = (_bufferIndex + 1) % _bucketImbalances.Length; + } + + // The running total is only ever reported, never used in the calculation, so it saturates + // rather than throwing on a count that no longer fits. + decimal headroom = long.MaxValue - _completedBucketCount; + _completedBucketCount += count >= headroom ? (long)headroom : (long)count; + + BucketJustClosed = true; + } + + private void SamplePrintSize(decimal size) + { + if (_printSizeSample == null) + return; + + _printSizeSample.Add(size); + if (_printSizeSample.Count < _printSizeSampleCount) + return; + + _printSizeSample.Sort(); + _medianPrintSize = Median(_printSizeSample); + _isFloorResolved = true; + _printSizeSample = null; + } + + /// + /// Median of a pre-sorted list: the middle value, or the mean of the two middle values when + /// the count is even. Equivalent to the linear-interpolation 50th percentile. + /// + private static decimal Median(List sortedValues) + { + int count = sortedValues.Count; + if (count == 0) + return 0m; + int middle = count / 2; + return (count % 2 != 0) + ? sortedValues[middle] + : (sortedValues[middle - 1] + sortedValues[middle]) / 2m; + } + } +} diff --git a/VisualHFT.Plugins/Studies.VPIN/UserControls/PluginSettingsView.xaml b/VisualHFT.Plugins/Studies.VPIN/UserControls/PluginSettingsView.xaml index 7ce5d1a7..bbc5ada1 100644 --- a/VisualHFT.Plugins/Studies.VPIN/UserControls/PluginSettingsView.xaml +++ b/VisualHFT.Plugins/Studies.VPIN/UserControls/PluginSettingsView.xaml @@ -98,6 +98,36 @@ + + + + + + + + Legacy form (default) + Classifies each trade against the order-book mid price, starts averaging from the first completed bucket instead of waiting for a full window, and accepts any positive bucket volume. It is the default only so that an existing settings file keeps the behaviour it already had. + + + Corrected form + Classifies each trade by the tick rule - a print above the previous print is a buy, below is a sell, unchanged repeats the previous side - which Chakrabarty, Pascual and Shkilko (2015) found more accurate than the paper's own bulk classifier. The paper splits a bucket's volume fractionally between the two sides; assigning each whole print to one side can only produce buckets at least as one-sided, so expect this to read higher than a bulk-classified figure on the same tape. That follows from the two constructions and has not been measured here. Compare ranks and percentiles rather than published absolute thresholds. + + + The tile stays blank until the metric is defined. + Nothing is shown until a full window of buckets has closed - Bucket Volume Size times Number of Buckets of traded volume, which at the paper's own sizing is roughly one trading day. Nothing is shown either while the bucket volume is too small to hold 20 trades, measured against the median trade size seen on this instrument; below that the reading measures the bucket size rather than the order flow. The log names the bucket volume the instrument calls for. + + + Switching form mid-session does not clear the tile.The buckets collected so far are discarded and the new form starts from nothing, but the study is not restarted and the last number the previous form published stays on the tile until the new form has one of its own. After switching to the corrected form that wait is a full window. Treat the displayed value as belonging to the old form until it visibly changes. + + + + + + +