volume indicators

This commit is contained in:
Miha Kralj
2026-01-30 12:47:25 -08:00
parent 76d2b50cbb
commit 7b3a6520d2
99 changed files with 9539 additions and 283 deletions
+230
View File
@@ -0,0 +1,230 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class VaIndicatorTests
{
[Fact]
public void VaIndicator_Constructor_SetsDefaults()
{
var indicator = new VaIndicator();
Assert.Equal("VA - Volume Accumulation", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(1, indicator.MinHistoryDepths);
}
[Fact]
public void VaIndicator_ShortName_IsConstant()
{
var indicator = new VaIndicator();
Assert.Equal("VA", indicator.ShortName);
}
[Fact]
public void VaIndicator_MinHistoryDepths_EqualsOne()
{
var indicator = new VaIndicator();
Assert.Equal(1, indicator.MinHistoryDepths);
Assert.Equal(1, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void VaIndicator_Initialize_CreatesInternalVa()
{
var indicator = new VaIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void VaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new VaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double close = 100 + i * 0.5;
indicator.HistoricalData.AddBar(now.AddMinutes(i), close - 2, close + 2, close - 3, close, 100000);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
[Fact]
public void VaIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new VaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 105, 100000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar
indicator.HistoricalData.AddBar(now.AddMinutes(30), 105, 115, 100, 112, 80000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void VaIndicator_CloseAboveMidpoint_PositiveAccumulation()
{
var indicator = new VaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// Bar: H=110, L=90, C=105, V=1000
// midpoint = (110 + 90) / 2 = 100
// va_period = 1000 * (105 - 100) = 5000
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(5000, val, 1);
}
[Fact]
public void VaIndicator_CloseBelowMidpoint_NegativeAccumulation()
{
var indicator = new VaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// Bar: H=110, L=90, C=95, V=1000
// midpoint = (110 + 90) / 2 = 100
// va_period = 1000 * (95 - 100) = -5000
indicator.HistoricalData.AddBar(now, 100, 110, 90, 95, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(-5000, val, 1);
}
[Fact]
public void VaIndicator_CloseAtMidpoint_ZeroAccumulation()
{
var indicator = new VaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// Bar: H=110, L=90, C=100, V=1000
// midpoint = (110 + 90) / 2 = 100
// va_period = 1000 * (100 - 100) = 0
indicator.HistoricalData.AddBar(now, 100, 110, 90, 100, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(0, val, 1);
}
[Fact]
public void VaIndicator_MultipleBarAccumulation_CorrectSum()
{
var indicator = new VaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// Bar 1: midpoint=100, close=105, vol=1000 -> va=5000
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Bar 2: midpoint=100, close=95, vol=500 -> va_period=-2500, total=2500
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 110, 90, 95, 500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(2500, val, 1);
}
[Fact]
public void VaIndicator_LargeVolume_LargerImpact()
{
var indicator1 = new VaIndicator();
indicator1.Initialize();
var indicator2 = new VaIndicator();
indicator2.Initialize();
var now = DateTime.UtcNow;
// Same price action, different volume
indicator1.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator2.HistoricalData.AddBar(now, 100, 110, 90, 105, 10000);
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double val1 = indicator1.LinesSeries[0].GetValue(0);
double val2 = indicator2.LinesSeries[0].GetValue(0);
// 10x volume should produce 10x VA
Assert.Equal(val1 * 10, val2, 1);
}
[Fact]
public void VaIndicator_CumulativeNature_AlwaysAccumulates()
{
var indicator = new VaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
double lastVa = 0;
// Add multiple positive bars - VA should keep increasing
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 108, 1000);
var args = i == 0
? new UpdateArgs(UpdateReason.HistoricalBar)
: new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
double currentVa = indicator.LinesSeries[0].GetValue(0);
Assert.True(currentVa > lastVa, $"VA should increase: {currentVa} > {lastVa}");
lastVa = currentVa;
}
}
[Fact]
public void VaIndicator_MixedPressure_CorrectNetEffect()
{
var indicator = new VaIndicator();
indicator.Initialize();
var now = DateTime.UtcNow;
// Equal positive and negative with same volume should net to zero
// Bar 1: +5000 (close above midpoint)
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Bar 2: -5000 (close below midpoint by same amount)
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 110, 90, 95, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double val = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(0, val, 1);
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class VaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Va _va = null!;
private readonly LineSeries _series;
#pragma warning disable S2325 // Instance property required by Quantower indicator interface
public int MinHistoryDepths => 1;
#pragma warning restore S2325
int IWatchlistIndicator.MinHistoryDepths => 1;
public override string ShortName => "VA";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volume/va/Va.Quantower.cs";
public VaIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "VA - Volume Accumulation";
Description = "Cumulative volume indicator that measures volume flow relative to the midpoint of each bar's range.";
_series = new LineSeries(name: "VA", color: Color.Cyan, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_va = new Va();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _va.Update(bar, args.IsNewBar());
_series.SetValue(result.Value, _va.IsHot, ShowColdValues);
}
}
+346
View File
@@ -0,0 +1,346 @@
using Xunit;
namespace QuanTAlib.Tests;
public class VaTests
{
[Fact]
public void Constructor_CreatesValidIndicator()
{
var va = new Va();
Assert.Equal("Va", va.Name);
Assert.Equal(1, Va.WarmupPeriod);
Assert.False(va.IsHot);
}
[Fact]
public void Update_WithTBar_ReturnsValidValue()
{
var va = new Va();
// Bar: H=110, L=90, C=105, V=1000
// midpoint = (110 + 90) / 2 = 100
// va_period = 1000 * (105 - 100) = 5000
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result = va.Update(bar);
Assert.Equal(5000, result.Value, 10);
}
[Fact]
public void Update_CloseAboveMidpoint_PositiveValue()
{
var va = new Va();
// Close above midpoint = buying pressure = positive
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 108, 1000);
// midpoint = 100, va = 1000 * (108 - 100) = 8000
var result = va.Update(bar);
Assert.True(result.Value > 0);
Assert.Equal(8000, result.Value, 10);
}
[Fact]
public void Update_CloseBelowMidpoint_NegativeValue()
{
var va = new Va();
// Close below midpoint = selling pressure = negative
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 92, 1000);
// midpoint = 100, va = 1000 * (92 - 100) = -8000
var result = va.Update(bar);
Assert.True(result.Value < 0);
Assert.Equal(-8000, result.Value, 10);
}
[Fact]
public void Update_CloseAtMidpoint_ZeroValue()
{
var va = new Va();
// Close at midpoint = neutral
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000);
// midpoint = 100, va = 1000 * (100 - 100) = 0
var result = va.Update(bar);
Assert.Equal(0, result.Value, 10);
}
[Fact]
public void Update_MultipleValues_Accumulates()
{
var va = new Va();
var time = DateTime.UtcNow;
// Bar 1: midpoint=100, close=105, vol=1000 -> va=5000
va.Update(new TBar(time, 100, 110, 90, 105, 1000));
Assert.Equal(5000, va.Last.Value, 10);
// Bar 2: midpoint=100, close=95, vol=500 -> va_period=-2500, total=2500
va.Update(new TBar(time.AddMinutes(1), 100, 110, 90, 95, 500));
Assert.Equal(2500, va.Last.Value, 10);
// Bar 3: midpoint=100, close=100, vol=2000 -> va_period=0, total=2500
va.Update(new TBar(time.AddMinutes(2), 100, 110, 90, 100, 2000));
Assert.Equal(2500, va.Last.Value, 10);
}
[Fact]
public void Update_IsNewTrue_AdvancesState()
{
var va = new Va();
var bar1 = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
var result1 = va.Update(bar1, isNew: true);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 105, 115, 95, 110, 800);
var result2 = va.Update(bar2, isNew: true);
Assert.NotEqual(result1.Time, result2.Time);
Assert.NotEqual(result1.Value, result2.Value);
}
[Fact]
public void Update_IsNewFalse_UpdatesCurrentBar()
{
var va = new Va();
var gbm = new GBM(seed: 42);
// Build up history
for (int i = 0; i < 20; i++)
{
va.Update(gbm.Next(), isNew: true);
}
// New bar
var bar1 = gbm.Next();
va.Update(bar1, isNew: true);
// Correction - restore previous state
va.Update(bar1, isNew: false);
// Value should change based on bar correction
Assert.True(double.IsFinite(va.Last.Value));
}
[Fact]
public void Update_IterativeCorrections_RestoresState()
{
var va = new Va();
var gbm = new GBM(seed: 123);
// Build up history
for (int i = 0; i < 20; i++)
{
va.Update(gbm.Next(), isNew: true);
}
// New bar
var originalBar = gbm.Next();
va.Update(originalBar, isNew: true);
// Correction with same values using isNew=false should restore
va.Update(originalBar, isNew: false);
Assert.True(double.IsFinite(va.Last.Value));
}
[Fact]
public void Update_WarmupPeriod_IsHotAfterFirstBar()
{
var va = new Va();
Assert.False(va.IsHot);
va.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000), isNew: true);
Assert.True(va.IsHot);
}
[Fact]
public void Update_WithNaN_UsesLastValidValue()
{
var va = new Va();
var time = DateTime.UtcNow;
// Process valid bar first
va.Update(new TBar(time, 100, 110, 90, 105, 1000));
// Process bar with NaN close
var nanBar = new TBar(time.AddMinutes(1), 100, 110, 90, double.NaN, 500);
var result = va.Update(nanBar);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Reset_ClearsState()
{
var va = new Va();
var gbm = new GBM(seed: 42);
for (int i = 0; i < 10; i++)
{
va.Update(gbm.Next(), isNew: true);
}
Assert.True(va.IsHot);
Assert.NotEqual(0, va.Last.Value);
va.Reset();
Assert.False(va.IsHot);
Assert.Equal(default, va.Last);
}
[Fact]
public void BatchCalculate_MatchesStreaming()
{
var bars = new TBarSeries();
var gbm = new GBM(seed: 42);
for (int i = 0; i < 100; i++)
{
bars.Add(gbm.Next());
}
// Streaming
var va = new Va();
var streamingValues = new List<double>();
foreach (var bar in bars)
{
streamingValues.Add(va.Update(bar).Value);
}
// Batch
var batchResult = Va.Calculate(bars);
Assert.Equal(bars.Count, batchResult.Count);
for (int i = 0; i < bars.Count; i++)
{
Assert.Equal(streamingValues[i], batchResult[i].Value, 10);
}
}
[Fact]
public void SpanCalculate_MatchesStreaming()
{
var gbm = new GBM(seed: 42);
int count = 100;
var high = new double[count];
var low = new double[count];
var close = new double[count];
var volume = new double[count];
for (int i = 0; i < count; i++)
{
var bar = gbm.Next();
high[i] = bar.High;
low[i] = bar.Low;
close[i] = bar.Close;
volume[i] = bar.Volume;
}
// Streaming
var va = new Va();
var streamingValues = new List<double>();
var time = DateTime.UtcNow;
for (int i = 0; i < count; i++)
{
streamingValues.Add(va.Update(new TBar(time.AddMinutes(i), 0, high[i], low[i], close[i], volume[i])).Value);
}
// Span
var output = new double[count];
Va.Calculate(high, low, close, volume, output);
for (int i = 0; i < count; i++)
{
Assert.Equal(streamingValues[i], output[i], 10);
}
}
[Fact]
public void SpanCalculate_InvalidLengths_ThrowsArgumentException()
{
var high = new double[100];
var low = new double[100];
var close = new double[100];
var volume = new double[99]; // Different length
var output = new double[100];
Assert.Throws<ArgumentException>(() => Va.Calculate(high, low, close, volume, output));
}
[Fact]
public void SpanCalculate_EmptyInput_HandlesGracefully()
{
var high = Array.Empty<double>();
var low = Array.Empty<double>();
var close = Array.Empty<double>();
var volume = Array.Empty<double>();
var output = Array.Empty<double>();
Va.Calculate(high, low, close, volume, output);
Assert.Empty(output);
}
[Fact]
public void Event_PubFiresOnUpdate()
{
var va = new Va();
TValue? receivedValue = null;
bool receivedIsNew = false;
va.Pub += (object? sender, in TValueEventArgs args) =>
{
receivedValue = args.Value;
receivedIsNew = args.IsNew;
};
va.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000), isNew: true);
Assert.NotNull(receivedValue);
Assert.True(receivedIsNew);
}
[Fact]
public void LargeDataset_HandlesWithoutError()
{
var bars = new TBarSeries();
var gbm = new GBM(seed: 42);
for (int i = 0; i < 10000; i++)
{
bars.Add(gbm.Next());
}
var va = new Va();
foreach (var bar in bars)
{
var result = va.Update(bar);
Assert.True(double.IsFinite(result.Value));
}
Assert.True(va.IsHot);
}
[Fact]
public void FormulaVerification_ManualCalculation()
{
var va = new Va();
var time = DateTime.UtcNow;
// Bar 1: H=110, L=90, C=105, V=1000
// midpoint = (110+90)/2 = 100
// va_period = 1000 * (105 - 100) = 5000
va.Update(new TBar(time, 100, 110, 90, 105, 1000));
Assert.Equal(5000, va.Last.Value, 10);
// Bar 2: H=120, L=100, C=115, V=2000
// midpoint = (120+100)/2 = 110
// va_period = 2000 * (115 - 110) = 10000
// total = 5000 + 10000 = 15000
va.Update(new TBar(time.AddMinutes(1), 100, 120, 100, 115, 2000));
Assert.Equal(15000, va.Last.Value, 10);
// Bar 3: H=115, L=95, C=98, V=1500
// midpoint = (115+95)/2 = 105
// va_period = 1500 * (98 - 105) = -10500
// total = 15000 - 10500 = 4500
va.Update(new TBar(time.AddMinutes(2), 100, 115, 95, 98, 1500));
Assert.Equal(4500, va.Last.Value, 10);
}
}
+269
View File
@@ -0,0 +1,269 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// VA: Volume Accumulation
/// A cumulative volume indicator that measures volume flow relative to the midpoint of
/// each bar's range. Volume is multiplied by the difference between close and midpoint.
/// </summary>
/// <remarks>
/// VA Formula:
/// midpoint = (High + Low) / 2
/// va_period = Volume × (Close - midpoint)
/// VA = cumulative sum of va_period
///
/// Key characteristics:
/// - Positive when close is above the midpoint (buying pressure)
/// - Negative when close is below the midpoint (selling pressure)
/// - Cumulative measure of volume-weighted price position
/// - Similar to ADL but uses range midpoint instead of full range
///
/// Sources:
/// PineScript reference: va.pine
/// </remarks>
[SkipLocalsInit]
public sealed class Va : ITValuePublisher
{
[StructLayout(LayoutKind.Auto)]
private record struct State(
double VaValue,
double LastValidHigh,
double LastValidLow,
double LastValidClose,
double LastValidVolume,
int Index);
private State _s;
private State _ps;
/// <inheritdoc/>
public TValue Last { get; private set; }
/// <inheritdoc/>
public bool IsHot => _s.Index >= 1;
/// <inheritdoc/>
public static int WarmupPeriod => 1;
/// <inheritdoc/>
public string Name { get; }
/// <inheritdoc/>
public event TValuePublishedHandler? Pub;
/// <summary>
/// Initializes a new instance of the VA indicator.
/// </summary>
public Va()
{
Name = "Va";
Reset();
}
/// <summary>
/// Resets the indicator to its initial state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_s = new State(VaValue: 0, LastValidHigh: 0, LastValidLow: 0, LastValidClose: 0, LastValidVolume: 0, Index: 0);
_ps = _s;
Last = default;
}
/// <summary>
/// Updates the VA with a new bar.
/// </summary>
/// <param name="input">The bar data.</param>
/// <param name="isNew">True if this is a new bar, false if updating current bar.</param>
/// <returns>The current VA value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
// Handle NaN/Infinity - substitute with last valid values
double high = double.IsFinite(input.High) ? input.High : s.LastValidHigh;
double low = double.IsFinite(input.Low) ? input.Low : s.LastValidLow;
double close = double.IsFinite(input.Close) ? input.Close : s.LastValidClose;
double volume = double.IsFinite(input.Volume) ? input.Volume : s.LastValidVolume;
// Update last valid values
if (double.IsFinite(input.High) && input.High > 0)
{
s.LastValidHigh = input.High;
}
if (double.IsFinite(input.Low) && input.Low > 0)
{
s.LastValidLow = input.Low;
}
if (double.IsFinite(input.Close) && input.Close > 0)
{
s.LastValidClose = input.Close;
}
if (double.IsFinite(input.Volume) && input.Volume >= 0)
{
s.LastValidVolume = input.Volume;
}
// Calculate VA for this period
double midpoint = (high + low) / 2.0;
double vaPeriod = volume * (close - midpoint);
// Accumulate
s.VaValue += vaPeriod;
if (isNew)
{
s.Index++;
}
_s = s;
Last = new TValue(input.Time, s.VaValue);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// Updates the VA with a TValue input.
/// </summary>
/// <remarks>
/// VA requires OHLCV data for proper calculation. Using TValue without full bar data
/// will keep VA unchanged.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
// VA requires OHLCV; without it, we can't compute
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
Last = new TValue(input.Time, _s.VaValue);
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
return Last;
}
/// <summary>
/// Updates the VA with a series of bars (batch mode).
/// </summary>
/// <param name="source">The bar series.</param>
/// <returns>The result series.</returns>
public TSeries Update(TBarSeries source)
{
var t = new List<long>(source.Count);
var v = new List<double>(source.Count);
Reset();
for (int i = 0; i < source.Count; i++)
{
var val = Update(source[i], isNew: true);
t.Add(val.Time);
v.Add(val.Value);
}
return new TSeries(t, v);
}
/// <summary>
/// Calculates VA for a series of bars (static batch mode).
/// </summary>
/// <param name="source">The bar series.</param>
/// <returns>The result series.</returns>
public static TSeries Calculate(TBarSeries source)
{
if (source.Count == 0)
{
return [];
}
var t = source.Open.Times.ToArray();
var v = new double[source.Count];
Calculate(source.High.Values, source.Low.Values, source.Close.Values, source.Volume.Values, v);
return new TSeries(t, v);
}
/// <summary>
/// Calculates VA for spans of OHLCV data (high-performance span mode).
/// </summary>
/// <param name="high">The high price span.</param>
/// <param name="low">The low price span.</param>
/// <param name="close">The close price span.</param>
/// <param name="volume">The volume span.</param>
/// <param name="output">The output VA span.</param>
/// <exception cref="ArgumentException">Thrown when span lengths don't match.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> high, ReadOnlySpan<double> low, ReadOnlySpan<double> close, ReadOnlySpan<double> volume, Span<double> output)
{
if (high.Length != low.Length || high.Length != close.Length || high.Length != volume.Length)
{
throw new ArgumentException("All input spans must be of the same length", nameof(volume));
}
if (high.Length != output.Length)
{
throw new ArgumentException("Output span must be of the same length as input", nameof(output));
}
int len = high.Length;
if (len == 0)
{
return;
}
double va = 0;
double lastValidHigh = high[0];
double lastValidLow = low[0];
double lastValidClose = close[0];
double lastValidVolume = volume[0];
for (int i = 0; i < len; i++)
{
// Get valid values
double h = double.IsFinite(high[i]) ? high[i] : lastValidHigh;
double l = double.IsFinite(low[i]) ? low[i] : lastValidLow;
double c = double.IsFinite(close[i]) ? close[i] : lastValidClose;
double v = double.IsFinite(volume[i]) ? volume[i] : lastValidVolume;
// Update last valid values
if (double.IsFinite(high[i]) && high[i] > 0)
{
lastValidHigh = high[i];
}
if (double.IsFinite(low[i]) && low[i] > 0)
{
lastValidLow = low[i];
}
if (double.IsFinite(close[i]) && close[i] > 0)
{
lastValidClose = close[i];
}
if (double.IsFinite(volume[i]) && volume[i] >= 0)
{
lastValidVolume = volume[i];
}
// Calculate VA
double midpoint = (h + l) / 2.0;
double vaPeriod = v * (c - midpoint);
va += vaPeriod;
output[i] = va;
}
}
}
+233
View File
@@ -0,0 +1,233 @@
# VA: Volume Accumulation
> "Volume tells you who's winning the argument between bulls and bears—VA keeps a running tally of the score." — Anonymous Trader
Volume Accumulation (VA) measures the cumulative flow of volume weighted by where price closes relative to the bar's midpoint. When price closes above the midpoint, volume is considered buying pressure; when below, selling pressure. The cumulative sum reveals the net directional conviction of market participants over time.
Unlike the Accumulation/Distribution Line (ADL) which uses the full bar range, VA simplifies to the midpoint—a cleaner measure that's less sensitive to extreme wicks. This makes VA particularly useful in markets prone to liquidity spikes that create artificial range extensions.
## Historical Context
Volume Accumulation emerged from the Williams Accumulation/Distribution line developed by Larry Williams in the 1970s. While Williams' original formula used the relationship between close and true range, VA simplifies this to the midpoint relationship:
- **ADL approach**: Uses (Close - Low) / (High - Low) as the multiplier
- **VA approach**: Uses (Close - Midpoint) where Midpoint = (High + Low) / 2
The midpoint simplification offers several advantages:
1. **Symmetric treatment**: Above and below midpoint are treated equally
2. **Reduced sensitivity**: Extreme wicks have less impact than in ADL
3. **Computational simplicity**: One subtraction instead of division
4. **No divide-by-zero**: ADL can produce NaN when High = Low; VA cannot
VA gained popularity in technical analysis software during the 1990s as a cleaner alternative to the more complex ADL formula. It appears in various trading platforms under names like "Volume Accumulation Oscillator" or simply "VA."
## Architecture & Physics
VA operates as a simple cumulative indicator with no lookback period or decay. Each bar contributes a signed volume amount based on price position relative to midpoint.
### Component Breakdown
1. **Midpoint Calculation**: Average of high and low prices
2. **Volume Attribution**: Multiply volume by (close - midpoint)
3. **Cumulation**: Running sum of attributed volume
### State Requirements
| Component | Type | Purpose |
| :--- | :--- | :--- |
| VaValue | double | Cumulative volume accumulation |
| LastValidHigh | double | Fallback for NaN handling |
| LastValidLow | double | Fallback for NaN handling |
| LastValidClose | double | Fallback for NaN handling |
| LastValidVolume | double | Fallback for NaN handling |
| Index | int | Bar counter for warmup |
### Volume Attribution Logic
$$
VA_{contribution} = Volume \times (Close - Midpoint)
$$
- **Close > Midpoint**: Positive contribution (buying pressure)
- **Close < Midpoint**: Negative contribution (selling pressure)
- **Close = Midpoint**: Zero contribution (neutral)
The magnitude scales with volume—high volume bars contribute more to the cumulative total, reflecting the intensity of conviction.
## Mathematical Foundation
### Core Formula
$$
Midpoint_t = \frac{High_t + Low_t}{2}
$$
$$
VA\_Period_t = Volume_t \times (Close_t - Midpoint_t)
$$
$$
VA_t = VA_{t-1} + VA\_Period_t
$$
### Expanded Form
$$
VA_t = \sum_{i=1}^{t} Volume_i \times \left( Close_i - \frac{High_i + Low_i}{2} \right)
$$
### Boundary Cases
| Condition | Midpoint | VA Contribution |
| :--- | :--- | :--- |
| Close = High | (H + L) / 2 | Vol × (H - (H+L)/2) = Vol × (H-L)/2 > 0 |
| Close = Low | (H + L) / 2 | Vol × (L - (H+L)/2) = -Vol × (H-L)/2 < 0 |
| Close = Midpoint | (H + L) / 2 | Vol × 0 = 0 |
| High = Low = Close | Close | Vol × 0 = 0 (doji) |
### Comparison with ADL
| Indicator | Formula | Range |
| :--- | :--- | :--- |
| VA | Vol × (C - (H+L)/2) | Unbounded |
| ADL | Vol × ((C-L) - (H-C)) / (H-L) | ±Volume |
VA produces values in volume units (shares, contracts), while ADL's multiplier is bounded to [-1, +1].
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Notes |
| :--- | :---: | :--- |
| ADD | 3 | H+L, cumulative sum, midpoint sub |
| MUL | 1 | Volume × price difference |
| DIV | 1 | Midpoint calculation |
| **Total** | 5 | Per bar, O(1) |
### Batch Mode (SIMD)
| Operation | Vectorizable | Notes |
| :--- | :---: | :--- |
| Midpoint calculation | ✅ | Fully parallel: (H + L) / 2 |
| Volume attribution | ✅ | Fully parallel: Vol × diff |
| Cumulative sum | ❌ | Sequential prefix sum |
The cumulative sum can be parallelized using prefix scan algorithms, but the benefit is marginal for typical series lengths (< 10K bars). Sequential implementation is preferred for simplicity.
### Memory Footprint
| Scope | Size |
| :--- | :--- |
| Per instance | ~104 bytes (State record struct × 2) |
| Buffer requirements | None (O(1) state) |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 10/10 | Exact arithmetic computation |
| **Timeliness** | 10/10 | First bar valid; no warmup |
| **Trend Detection** | 7/10 | Good for sustained moves |
| **Noise Filtering** | 4/10 | None; responds to every bar |
| **Memory** | 10/10 | O(1) constant |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | Uses different AD formula |
| **Skender** | N/A | Uses Chaikin ADL |
| **Tulip** | N/A | Not implemented |
| **Ooples** | N/A | Not implemented |
| **PineScript** | ✅ | Reference implementation (va.pine) |
VA validation focuses on internal consistency between streaming, batch, and span modes (verified with 1e-10 tolerance) and formula correctness against manual calculations.
## Common Pitfalls
1. **Unbounded Values**: VA accumulates indefinitely with no reset mechanism. After thousands of bars, values can become extremely large (millions in volume units). Consider normalizing or using VA change rather than absolute level.
2. **No Mean Reversion**: Unlike oscillators, VA has no center point. The indicator trends; it doesn't oscillate. Divergence analysis works, but overbought/oversold levels don't apply.
3. **Volume Scale Dependency**: VA values depend entirely on volume magnitude. A 100M share day in a liquid stock produces larger contributions than a 10K share day. Cross-instrument comparison requires normalization.
4. **Zero Volume Bars**: Bars with zero volume contribute nothing to VA regardless of price position. This is mathematically correct but can cause visual gaps in the indicator for illiquid instruments.
5. **Range Compression**: Very small bars (High ≈ Low) produce near-zero VA contributions even with significant volume. This differs from ADL which can produce large values from small ranges.
6. **Cumulative Drift**: Any floating-point error accumulates over time. While individual errors are minuscule (~1e-15), millions of bars can accumulate measurable drift. The implementation maintains last-valid tracking for NaN recovery.
7. **Session Considerations**: VA does not reset across sessions. For intraday analysis, consider comparing VA change within a session rather than absolute levels that include prior day's accumulation.
8. **isNew Parameter**: Bar correction (isNew = false) properly restores the previous VA state. Incorrect usage causes cumulative errors that propagate forward indefinitely.
## Interpretation Guide
### Trend Confirmation
| VA Behavior | Price Behavior | Interpretation |
| :--- | :--- | :--- |
| Rising VA | Rising price | Confirmed uptrend (accumulation) |
| Falling VA | Falling price | Confirmed downtrend (distribution) |
| Rising VA | Falling price | Bullish divergence (accumulation despite price drop) |
| Falling VA | Rising price | Bearish divergence (distribution despite price rise) |
### Volume-Weighted Pressure
Since VA weights by volume, large volume days dominate the calculation:
- **Big green bar**: Large positive VA contribution
- **Big red bar**: Large negative VA contribution
- **Low volume day**: Minimal impact on VA regardless of price action
This makes VA particularly useful for identifying whether institutional players (high volume) support the price move.
### Divergence Trading
VA divergences often precede trend reversals:
1. **Bullish divergence**: Price makes lower lows, VA makes higher lows
2. **Bearish divergence**: Price makes higher highs, VA makes lower highs
The divergence signals that volume conviction doesn't support the price extreme—a potential reversal setup.
### Rate of Change Analysis
Rather than absolute VA level, consider VA change:
$$
VA\_ROC_n = VA_t - VA_{t-n}
$$
This removes the unbounded accumulation issue and focuses on recent volume pressure.
## Parameter Selection Guide
VA has no parameters—it's a pure cumulative indicator. Usage variations include:
| Technique | Description |
| :--- | :--- |
| Raw VA | Cumulative value (unbounded) |
| VA change | Difference over N periods |
| VA rate | Percentage change of VA |
| Smoothed VA | EMA/SMA of VA for noise reduction |
| VA divergence | Compare VA slope vs price slope |
### Suggested Smoothing
For noisy instruments, apply a short moving average:
```csharp
var va = new Va();
var smoothedVa = new Ema(5); // 5-period smoothing
// Chain: va.Pub += (_, args) => smoothedVa.Update(args.Value);
```
## References
- Williams, L. (1979). "How I Made One Million Dollars Last Year Trading Commodities." Windsor Books.
- Granville, J. (1976). "Granville's New Strategy of Daily Stock Market Timing." Prentice-Hall.
- Achelis, S. (2000). "Technical Analysis from A to Z." McGraw-Hill.
- TradingView. "PineScript Volume Accumulation." Community Reference.