Add VWAPSD (Volume Weighted Average Price with Standard Deviation Bands) implementation and validation tests

- Implemented Vwapsd class for calculating VWAP with configurable standard deviation bands.
- Added methods for updating the indicator with new bars and calculating VWAPSD using both bar series and span arrays.
- Created comprehensive validation tests for VWAPSD, including checks for consistency between streaming and batch modes, mathematical correctness, and handling of edge cases such as NaN values and zero volume bars.
- Ensured that the implementation adheres to performance standards with tests for large datasets and fractional numDevs values.
This commit is contained in:
Miha Kralj
2026-01-24 19:07:52 -08:00
parent fd6c80e8db
commit 744d680435
32 changed files with 9090 additions and 538 deletions
@@ -0,0 +1,261 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class VwapsdIndicatorTests
{
[Fact]
public void VwapsdIndicator_Constructor_SetsDefaults()
{
var indicator = new VwapsdIndicator();
Assert.Equal(2.0, indicator.NumDevs);
Assert.True(indicator.ShowColdValues);
Assert.Equal("VWAPSD - Volume Weighted Average Price with Configurable Standard Deviation Bands", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void VwapsdIndicator_MinHistoryDepths_EqualsTwo()
{
var indicator = new VwapsdIndicator();
Assert.Equal(2, indicator.MinHistoryDepths);
Assert.Equal(2, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void VwapsdIndicator_ShortName_IncludesNumDevs()
{
var indicator = new VwapsdIndicator { NumDevs = 2.5 };
Assert.Contains("VWAPSD", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("2.5", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void VwapsdIndicator_Initialize_CreatesFourLineSeries()
{
var indicator = new VwapsdIndicator { NumDevs = 2.0 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (VWAP, Upper, Lower, Width)
Assert.Equal(4, indicator.LinesSeries.Count);
}
[Fact]
public void VwapsdIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new VwapsdIndicator { NumDevs = 2.0 };
indicator.Initialize();
// Add historical data with volume
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000);
// Process update
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
// Line series should have values
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void VwapsdIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new VwapsdIndicator { NumDevs = 2.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void VwapsdIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new VwapsdIndicator { NumDevs = 2.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void VwapsdIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new VwapsdIndicator { NumDevs = 2.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 102, 104, 103, 105 };
double[] volumes = { 1000, 1500, 2000, 1200, 1800 };
for (int i = 0; i < closes.Length; i++)
{
double close = closes[i];
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close, volumes[i]);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
now = now.AddMinutes(1);
}
// All values should be finite
for (int i = 0; i < closes.Length; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
}
// VWAP should be within price range
double lastVwap = indicator.LinesSeries[0].GetValue(0);
Assert.True(lastVwap >= 95 && lastVwap <= 110);
}
[Fact]
public void VwapsdIndicator_Parameters_CanBeChanged()
{
var indicator = new VwapsdIndicator { NumDevs = 1.5 };
Assert.Equal(1.5, indicator.NumDevs);
indicator.NumDevs = 2.5;
Assert.Equal(2.5, indicator.NumDevs);
}
[Fact]
public void VwapsdIndicator_AllBandsUpdate_Correctly()
{
var indicator = new VwapsdIndicator { NumDevs = 2.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i, 1000 + i * 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Verify all 4 line series have values (VWAP, Upper, Lower, Width)
Assert.Equal(4, indicator.LinesSeries.Count);
foreach (var series in indicator.LinesSeries)
{
Assert.Equal(5, series.Count);
Assert.True(double.IsFinite(series.GetValue(0)));
}
}
[Fact]
public void VwapsdIndicator_BandRelationships_AreCorrect()
{
var indicator = new VwapsdIndicator { NumDevs = 2.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add varied data to generate band width
double[] closes = { 100, 105, 95, 110, 90, 105, 100, 108, 92, 103 };
double[] volumes = { 1000, 1500, 2000, 1200, 1800, 1100, 1600, 1300, 1900, 1400 };
for (int i = 0; i < closes.Length; i++)
{
double close = closes[i];
indicator.HistoricalData.AddBar(now, close, close + 3, close - 3, close, volumes[i]);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
now = now.AddMinutes(1);
}
// Get last values: VWAP=0, Upper=1, Lower=2, Width=3
double vwap = indicator.LinesSeries[0].GetValue(0);
double upper = indicator.LinesSeries[1].GetValue(0);
double lower = indicator.LinesSeries[2].GetValue(0);
double width = indicator.LinesSeries[3].GetValue(0);
// Band relationships: Upper > VWAP > Lower
Assert.True(upper >= vwap, $"Upper ({upper}) should be >= VWAP ({vwap})");
Assert.True(vwap >= lower, $"VWAP ({vwap}) should be >= Lower ({lower})");
// Width = Upper - Lower (2 × numDevs × StdDev)
Assert.True(Math.Abs(width - (upper - lower)) < 0.0001,
$"Width ({width}) should equal Upper - Lower ({upper - lower})");
}
[Fact]
public void VwapsdIndicator_VolumeWeighting_AffectsVwap()
{
var indicator1 = new VwapsdIndicator { NumDevs = 2.0 };
var indicator2 = new VwapsdIndicator { NumDevs = 2.0 };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
// Same prices but different volume distributions
// Process both bars for each indicator
// Indicator1: high volume on low price, low volume on high price
indicator1.HistoricalData.AddBar(now, 100, 102, 98, 100, 10000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator1.HistoricalData.AddBar(now.AddMinutes(1), 110, 112, 108, 110, 100);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Indicator2: low volume on low price, high volume on high price
indicator2.HistoricalData.AddBar(now, 100, 102, 98, 100, 100);
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator2.HistoricalData.AddBar(now.AddMinutes(1), 110, 112, 108, 110, 10000);
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double vwap1 = indicator1.LinesSeries[0].GetValue(0);
double vwap2 = indicator2.LinesSeries[0].GetValue(0);
// VWAP1 should be lower (weighted toward 100 due to high volume at low price)
// VWAP2 should be higher (weighted toward 110 due to high volume at high price)
Assert.True(vwap1 < vwap2, $"VWAP1 ({vwap1}) should be less than VWAP2 ({vwap2}) due to volume weighting");
}
[Fact]
public void VwapsdIndicator_NumDevs_AffectsBandWidth()
{
var indicator1 = new VwapsdIndicator { NumDevs = 1.0 };
var indicator2 = new VwapsdIndicator { NumDevs = 2.0 };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 105, 95, 110, 90 };
double[] volumes = { 1000, 1500, 2000, 1200, 1800 };
for (int i = 0; i < closes.Length; i++)
{
double close = closes[i];
indicator1.HistoricalData.AddBar(now, close, close + 3, close - 3, close, volumes[i]);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator2.HistoricalData.AddBar(now, close, close + 3, close - 3, close, volumes[i]);
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
now = now.AddMinutes(1);
}
// Width should be proportional to numDevs
double width1 = indicator1.LinesSeries[3].GetValue(0);
double width2 = indicator2.LinesSeries[3].GetValue(0);
// Width2 should be approximately 2x Width1
Assert.True(Math.Abs(width2 - 2 * width1) < 0.0001,
$"Width2 ({width2}) should be ~2x Width1 ({width1})");
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class VwapsdIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Number of Deviations", sortIndex: 1, minimum: 0.1, maximum: 5.0, increment: 0.1, decimalPlaces: 1)]
public double NumDevs { get; set; } = 2.0;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Vwapsd? vwapsd;
protected LineSeries? VwapSeries;
protected LineSeries? UpperSeries;
protected LineSeries? LowerSeries;
protected LineSeries? WidthSeries;
#pragma warning disable S2325 // Methods and properties that don't access instance data should be static
public int MinHistoryDepths => 2;
#pragma warning restore S2325
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"VWAPSD ({NumDevs:F1})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/channels/vwapsd/Vwapsd.cs";
public VwapsdIndicator()
{
Name = "VWAPSD - Volume Weighted Average Price with Configurable Standard Deviation Bands";
Description = "Volume weighted average price with configurable standard deviation bands";
VwapSeries = new("VWAP", Color.Blue, 2, LineStyle.Solid);
UpperSeries = new($"Upper (+{NumDevs}σ)", Color.Red, 1, LineStyle.Solid);
LowerSeries = new($"Lower (-{NumDevs}σ)", Color.Green, 1, LineStyle.Solid);
WidthSeries = new("Width", Color.Gray, 1, LineStyle.Dot);
AddLineSeries(VwapSeries);
AddLineSeries(UpperSeries);
AddLineSeries(LowerSeries);
AddLineSeries(WidthSeries);
SeparateWindow = false;
OnBackGround = true;
}
protected override void OnInit()
{
vwapsd = new(NumDevs);
base.OnInit();
}
protected override void OnUpdate(UpdateArgs args)
{
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
var time = HistoricalData.Time();
// VWAP requires OHLCV data - using HLC3 for price
double high = item[PriceType.High];
double low = item[PriceType.Low];
double close = item[PriceType.Close];
double volume = item[PriceType.Volume];
TBar bar = new(time, item[PriceType.Open], high, low, close, volume);
TValue result = vwapsd!.Update(bar, args.IsNewBar());
VwapSeries!.SetValue(result.Value, vwapsd.IsHot, ShowColdValues);
UpperSeries!.SetValue(vwapsd.Upper.Value, vwapsd.IsHot, ShowColdValues);
LowerSeries!.SetValue(vwapsd.Lower.Value, vwapsd.IsHot, ShowColdValues);
WidthSeries!.SetValue(vwapsd.Width.Value, vwapsd.IsHot, ShowColdValues);
}
}
+666
View File
@@ -0,0 +1,666 @@
namespace QuanTAlib.Tests;
public class VwapsdTests
{
[Fact]
public void Vwapsd_Constructor_ValidatesNumDevs()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Vwapsd(0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Vwapsd(-1));
Assert.Throws<ArgumentOutOfRangeException>(() => new Vwapsd(0.05)); // Below MinNumDevs (0.1)
Assert.Throws<ArgumentOutOfRangeException>(() => new Vwapsd(5.1)); // Above MaxNumDevs (5.0)
Assert.Throws<ArgumentOutOfRangeException>(() => new Vwapsd(10)); // Above MaxNumDevs (5.0)
var vwapsd = new Vwapsd(1.0);
Assert.NotNull(vwapsd);
}
[Fact]
public void Vwapsd_Constructor_AcceptsValidRange()
{
// Test boundary values
var vwapsdMin = new Vwapsd(0.1);
Assert.NotNull(vwapsdMin);
var vwapsdMax = new Vwapsd(5.0);
Assert.NotNull(vwapsdMax);
var vwapsdMid = new Vwapsd(2.5);
Assert.NotNull(vwapsdMid);
}
[Fact]
public void Vwapsd_DefaultConstructor_UsesDefaultNumDevs()
{
var vwapsd = new Vwapsd();
Assert.NotNull(vwapsd);
Assert.Contains("Vwapsd", vwapsd.Name, StringComparison.Ordinal);
Assert.Contains("2.0", vwapsd.Name, StringComparison.Ordinal); // Default is 2.0
}
[Fact]
public void Vwapsd_Update_ReturnsValue()
{
var vwapsd = new Vwapsd(1.0);
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000);
var result = vwapsd.Update(bar);
Assert.True(double.IsFinite(result.Value));
Assert.True(double.IsFinite(vwapsd.Upper.Value));
Assert.True(double.IsFinite(vwapsd.Lower.Value));
Assert.True(double.IsFinite(vwapsd.Vwap.Value));
Assert.True(double.IsFinite(vwapsd.StdDev.Value));
Assert.True(double.IsFinite(vwapsd.Width.Value));
}
[Fact]
public void Vwapsd_FirstBar_InitializesCorrectly()
{
var vwapsd = new Vwapsd(1.0);
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000);
_ = vwapsd.Update(bar);
// First bar: VWAP = HLC3 = (105+95+100)/3 = 100
double expectedVwap = (105 + 95 + 100) / 3.0;
Assert.Equal(expectedVwap, vwapsd.Vwap.Value, precision: 10);
// First bar has zero variance (only 1 point)
Assert.Equal(0, vwapsd.StdDev.Value, precision: 10);
Assert.Equal(expectedVwap, vwapsd.Upper.Value, precision: 10);
Assert.Equal(expectedVwap, vwapsd.Lower.Value, precision: 10);
}
[Fact]
public void Vwapsd_Properties_Accessible()
{
var vwapsd = new Vwapsd(2.0);
Assert.False(vwapsd.IsHot);
Assert.Contains("Vwapsd", vwapsd.Name, StringComparison.Ordinal);
Assert.Equal(2, vwapsd.WarmupPeriod);
}
[Fact]
public void Vwapsd_Update_IsNew_AcceptsParameter()
{
var vwapsd = new Vwapsd(1.0);
var bar1 = new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000);
var bar2 = new TBar(DateTime.UtcNow, 100, 106, 94, 101, 1100);
var result1 = vwapsd.Update(bar1, isNew: true);
var result2 = vwapsd.Update(bar2, isNew: false);
Assert.True(double.IsFinite(result1.Value));
Assert.True(double.IsFinite(result2.Value));
}
[Fact]
public void Vwapsd_Update_IsNew_False_UpdatesValue()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(15, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Process several bars
for (int i = 0; i < 15; i++)
{
vwapsd.Update(bars[i], isNew: true);
}
double beforeCorrection = vwapsd.Vwap.Value;
// Correct last bar with different value
var correctionBar = new TBar(DateTime.UtcNow, 200, 210, 190, 200, 5000);
vwapsd.Update(correctionBar, isNew: false);
double afterCorrection = vwapsd.Vwap.Value;
Assert.NotEqual(beforeCorrection, afterCorrection);
}
[Fact]
public void Vwapsd_IterativeCorrections_RestoreToOriginalState()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Process all bars
for (int i = 0; i < bars.Count; i++)
{
vwapsd.Update(bars[i]);
}
double originalVwap = vwapsd.Vwap.Value;
double originalUpper = vwapsd.Upper.Value;
double originalLower = vwapsd.Lower.Value;
// Make multiple corrections
for (int i = 0; i < 10; i++)
{
var correctionBar = new TBar(DateTime.UtcNow, 150 + i, 160 + i, 140 + i, 155 + i, 2000 + i * 100);
vwapsd.Update(correctionBar, isNew: false);
}
// Restore original
vwapsd.Update(bars[^1], isNew: false);
double restoredVwap = vwapsd.Vwap.Value;
double restoredUpper = vwapsd.Upper.Value;
double restoredLower = vwapsd.Lower.Value;
Assert.Equal(originalVwap, restoredVwap, precision: 8);
Assert.Equal(originalUpper, restoredUpper, precision: 8);
Assert.Equal(originalLower, restoredLower, precision: 8);
}
[Fact]
public void Vwapsd_Reset_ClearsState()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < 20; i++)
{
vwapsd.Update(bars[i]);
}
Assert.True(vwapsd.IsHot);
vwapsd.Reset();
Assert.False(vwapsd.IsHot);
}
[Fact]
public void Vwapsd_IsHot_BecomesTrueAfterWarmup()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// WarmupPeriod is 2
vwapsd.Update(bars[0]);
Assert.False(vwapsd.IsHot);
vwapsd.Update(bars[1]);
Assert.True(vwapsd.IsHot);
}
[Fact]
public void Vwapsd_WarmupPeriod_IsSetCorrectly()
{
var vwapsd = new Vwapsd(1.0);
Assert.Equal(2, vwapsd.WarmupPeriod);
}
[Fact]
public void Vwapsd_NaN_Price_UsesLastValidValue()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < 10; i++)
{
vwapsd.Update(bars[i]);
}
vwapsd.Update(new TValue(DateTime.UtcNow, double.NaN), 1000, isNew: true);
double afterNaN = vwapsd.Vwap.Value;
Assert.True(double.IsFinite(afterNaN));
}
[Fact]
public void Vwapsd_NaN_Volume_UsesLastValidValue()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < 10; i++)
{
vwapsd.Update(bars[i]);
}
vwapsd.Update(new TValue(DateTime.UtcNow, 100), double.NaN, isNew: true);
double afterNaN = vwapsd.Vwap.Value;
Assert.True(double.IsFinite(afterNaN));
}
[Fact]
public void Vwapsd_Infinity_Input_UsesLastValidValue()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(10, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < 10; i++)
{
vwapsd.Update(bars[i]);
}
vwapsd.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity), 1000, isNew: true);
Assert.True(double.IsFinite(vwapsd.Vwap.Value));
vwapsd.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity), 1000, isNew: true);
Assert.True(double.IsFinite(vwapsd.Vwap.Value));
}
[Fact]
public void Vwapsd_BandRelationship_UpperGreaterThanVwapGreaterThanLower()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
vwapsd.Update(bars[i]);
// Skip first bar where StdDev is 0
if (i > 0)
{
Assert.True(vwapsd.Upper.Value >= vwapsd.Vwap.Value,
$"Upper ({vwapsd.Upper.Value}) should be >= Vwap ({vwapsd.Vwap.Value})");
Assert.True(vwapsd.Vwap.Value >= vwapsd.Lower.Value,
$"Vwap ({vwapsd.Vwap.Value}) should be >= Lower ({vwapsd.Lower.Value})");
}
}
}
[Fact]
public void Vwapsd_VwapBetweenBands()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
vwapsd.Update(bars[i]);
Assert.True(vwapsd.Vwap.Value <= vwapsd.Upper.Value,
$"Vwap ({vwapsd.Vwap.Value}) should be <= Upper ({vwapsd.Upper.Value})");
Assert.True(vwapsd.Vwap.Value >= vwapsd.Lower.Value,
$"Vwap ({vwapsd.Vwap.Value}) should be >= Lower ({vwapsd.Lower.Value})");
}
}
[Fact]
public void Vwapsd_Width_EqualsUpperMinusLower()
{
var vwapsd = new Vwapsd(1.5);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
vwapsd.Update(bars[i]);
double expectedWidth = vwapsd.Upper.Value - vwapsd.Lower.Value;
Assert.Equal(expectedWidth, vwapsd.Width.Value, precision: 10);
}
}
[Fact]
public void Vwapsd_SessionReset_ResetsVwapCalculation()
{
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Process first 10 bars
for (int i = 0; i < 10; i++)
{
vwapsd.Update(bars[i]);
}
double vwapBeforeReset = vwapsd.Vwap.Value;
// Reset and process next bar - should start fresh
var resetBar = new TBar(DateTime.UtcNow, 200, 210, 190, 200, 1000);
vwapsd.Update(resetBar, isNew: true, reset: true);
// After reset, VWAP should be just the new bar's HLC3
double expectedVwap = (210 + 190 + 200) / 3.0;
Assert.Equal(expectedVwap, vwapsd.Vwap.Value, precision: 10);
Assert.NotEqual(vwapBeforeReset, vwapsd.Vwap.Value);
}
[Fact]
public void Vwapsd_VwapFormula_MatchesExpected()
{
var vwapsd = new Vwapsd(1.0);
// Bar 1: price=100, volume=1000
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1000);
vwapsd.Update(bar1);
Assert.Equal(100.0, vwapsd.Vwap.Value, precision: 10);
// Bar 2: price=110, volume=2000
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 110, 110, 110, 110, 2000);
vwapsd.Update(bar2);
// VWAP = (100*1000 + 110*2000) / (1000+2000) = 320000/3000 = 106.666...
double expectedVwap = (100.0 * 1000 + 110.0 * 2000) / (1000 + 2000);
Assert.Equal(expectedVwap, vwapsd.Vwap.Value, precision: 10);
}
[Fact]
public void Vwapsd_StdDevFormula_MatchesExpected()
{
var vwapsd = new Vwapsd(1.0);
// Bar 1: price=100, volume=1
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1);
vwapsd.Update(bar1);
// Bar 2: price=200, volume=1
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 200, 200, 200, 200, 1);
vwapsd.Update(bar2);
// VWAP = (100*1 + 200*1) / 2 = 150
// MeanP2 = (100²*1 + 200²*1) / 2 = (10000 + 40000) / 2 = 25000
// Variance = MeanP2 - VWAP² = 25000 - 22500 = 2500
// StdDev = sqrt(2500) = 50
Assert.Equal(150.0, vwapsd.Vwap.Value, precision: 10);
Assert.Equal(50.0, vwapsd.StdDev.Value, precision: 10);
Assert.Equal(200.0, vwapsd.Upper.Value, precision: 10); // 150 + 1*50
Assert.Equal(100.0, vwapsd.Lower.Value, precision: 10); // 150 - 1*50
}
[Fact]
public void Vwapsd_NumDevs_AffectsBands()
{
// Test with 2 standard deviations
var vwapsd2 = new Vwapsd(2.0);
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1);
vwapsd2.Update(bar1);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 200, 200, 200, 200, 1);
vwapsd2.Update(bar2);
// VWAP = 150, StdDev = 50
// With numDevs=2: Upper = 150 + 2*50 = 250, Lower = 150 - 2*50 = 50
Assert.Equal(150.0, vwapsd2.Vwap.Value, precision: 10);
Assert.Equal(50.0, vwapsd2.StdDev.Value, precision: 10);
Assert.Equal(250.0, vwapsd2.Upper.Value, precision: 10);
Assert.Equal(50.0, vwapsd2.Lower.Value, precision: 10);
}
[Fact]
public void Vwapsd_BatchCalc_MatchesIterativeCalc()
{
var vwapsdIterative = new Vwapsd(1.5);
var vwapsdBatch = new Vwapsd(1.5);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Iterative
var iterativeVwap = new List<double>();
for (int i = 0; i < bars.Count; i++)
{
vwapsdIterative.Update(bars[i]);
iterativeVwap.Add(vwapsdIterative.Vwap.Value);
}
// Batch
var batchResult = vwapsdBatch.Update(bars);
// Compare last 50 values
for (int i = 50; i < 100; i++)
{
Assert.Equal(iterativeVwap[i], batchResult[i].Value, precision: 10);
}
}
[Fact]
public void Vwapsd_StaticCalculate_TBarSeries_Works()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var (upper, lower, vwap, stdev) = Vwapsd.Calculate(bars, 1.5);
Assert.Equal(50, upper.Count);
Assert.Equal(50, lower.Count);
Assert.Equal(50, vwap.Count);
Assert.Equal(50, stdev.Count);
Assert.True(double.IsFinite(vwap.Last.Value));
}
[Fact]
public void Vwapsd_SpanCalculate_ValidatesInput()
{
double[] price = [100, 101, 102, 103, 104];
double[] volume = [1000, 1100, 1200, 1300, 1400];
double[] upper = new double[5];
double[] lower = new double[5];
double[] vwap = new double[5];
double[] wrongSize = new double[3];
// NumDevs must be >= MinNumDevs
Assert.Throws<ArgumentOutOfRangeException>(() =>
Vwapsd.Calculate(price.AsSpan(), volume.AsSpan(),
upper.AsSpan(), lower.AsSpan(), vwap.AsSpan(), 0));
// NumDevs must be <= MaxNumDevs
Assert.Throws<ArgumentOutOfRangeException>(() =>
Vwapsd.Calculate(price.AsSpan(), volume.AsSpan(),
upper.AsSpan(), lower.AsSpan(), vwap.AsSpan(), 6.0));
// All arrays must be same length
Assert.Throws<ArgumentException>(() =>
Vwapsd.Calculate(price.AsSpan(), volume.AsSpan(),
wrongSize.AsSpan(), lower.AsSpan(), vwap.AsSpan(), 1.0));
}
[Fact]
public void Vwapsd_SpanCalculate_HandlesNaN()
{
double[] price = [100, 101, double.NaN, 103, 104];
double[] volume = [1000, 1100, 1200, 1300, 1400];
double[] upper = new double[5];
double[] lower = new double[5];
double[] vwap = new double[5];
Vwapsd.Calculate(price.AsSpan(), volume.AsSpan(),
upper.AsSpan(), lower.AsSpan(), vwap.AsSpan(), 1.0);
foreach (var val in vwap)
{
Assert.True(double.IsFinite(val), $"VWAP should be finite, got {val}");
}
}
[Fact]
public void Vwapsd_FlatLine_ReturnsSameValueForVwap()
{
var vwapsd = new Vwapsd(1.0);
for (int i = 0; i < 30; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 100, 100, 100, 1000);
vwapsd.Update(bar);
}
// With constant price, VWAP should equal the price
Assert.Equal(100.0, vwapsd.Vwap.Value, precision: 6);
// StdDev of zero variance = 0, so upper = lower = vwap
Assert.Equal(vwapsd.Vwap.Value, vwapsd.Upper.Value, precision: 6);
Assert.Equal(vwapsd.Vwap.Value, vwapsd.Lower.Value, precision: 6);
}
[Fact]
public void Vwapsd_HigherNumDevs_WiderBands()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var vwapsd1 = new Vwapsd(1.0);
var vwapsd2 = new Vwapsd(2.0);
var vwapsd3 = new Vwapsd(3.0);
for (int i = 0; i < bars.Count; i++)
{
vwapsd1.Update(bars[i]);
vwapsd2.Update(bars[i]);
vwapsd3.Update(bars[i]);
}
// Same VWAP for all
Assert.Equal(vwapsd1.Vwap.Value, vwapsd2.Vwap.Value, precision: 10);
Assert.Equal(vwapsd2.Vwap.Value, vwapsd3.Vwap.Value, precision: 10);
// Higher numDevs = wider bands
Assert.True(vwapsd2.Width.Value > vwapsd1.Width.Value,
$"Width with numDevs=2 ({vwapsd2.Width.Value}) should be > width with numDevs=1 ({vwapsd1.Width.Value})");
Assert.True(vwapsd3.Width.Value > vwapsd2.Width.Value,
$"Width with numDevs=3 ({vwapsd3.Width.Value}) should be > width with numDevs=2 ({vwapsd2.Width.Value})");
}
[Fact]
public void Vwapsd_ZeroVolume_DoesNotAffectVwap()
{
var vwapsd = new Vwapsd(1.0);
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1000);
vwapsd.Update(bar1);
double vwapAfterBar1 = vwapsd.Vwap.Value;
// Zero volume bar should not change VWAP
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 200, 200, 200, 200, 0);
vwapsd.Update(bar2);
double vwapAfterBar2 = vwapsd.Vwap.Value;
Assert.Equal(vwapAfterBar1, vwapAfterBar2, precision: 10);
}
[Fact]
public void Vwapsd_Prime_SetsStateCorrectly()
{
var vwapsd = new Vwapsd(1.0);
double[] history = [100, 101, 102, 103, 104, 105, 106];
vwapsd.Prime(history);
Assert.True(vwapsd.IsHot);
Assert.True(double.IsFinite(vwapsd.Vwap.Value));
}
[Fact]
public void Vwapsd_UpdateTValue_UsesVolumeOfOne()
{
var vwapsd = new Vwapsd(1.0);
// Using Update(TValue) should use volume=1
vwapsd.Update(new TValue(DateTime.UtcNow, 100.0));
vwapsd.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 200.0));
// With equal volume (1 each), VWAP = (100+200)/2 = 150
Assert.Equal(150.0, vwapsd.Vwap.Value, precision: 10);
}
[Fact]
public void Vwapsd_UpdateTSeries_Works()
{
var vwapsd = new Vwapsd(1.5);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var result = vwapsd.Update(bars);
Assert.Equal(50, result.Count);
Assert.True(double.IsFinite(result.Last.Value));
}
[Fact]
public void Vwapsd_UpdateTSeries_PriceOnly_Works()
{
var vwapsd = new Vwapsd(1.5);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries priceSeries = bars.Close;
var result = vwapsd.Update(priceSeries);
Assert.Equal(50, result.Count);
Assert.True(double.IsFinite(result.Last.Value));
}
[Fact]
public void Vwapsd_VolumeWeighting_AffectsVwap()
{
var vwapsd = new Vwapsd(1.0);
// High volume at low price
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 10000);
vwapsd.Update(bar1);
// Low volume at high price
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 200, 200, 200, 200, 100);
vwapsd.Update(bar2);
// VWAP should be closer to 100 due to higher volume
// VWAP = (100*10000 + 200*100) / (10000+100) = 1020000/10100 ≈ 100.99
double expectedVwap = (100.0 * 10000 + 200.0 * 100) / (10000 + 100);
Assert.Equal(expectedVwap, vwapsd.Vwap.Value, precision: 10);
Assert.True(vwapsd.Vwap.Value < 110, "VWAP should be heavily weighted toward 100");
}
[Fact]
public void Vwapsd_FractionalNumDevs_Works()
{
var vwapsd = new Vwapsd(1.5);
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1);
vwapsd.Update(bar1);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 200, 200, 200, 200, 1);
vwapsd.Update(bar2);
// VWAP = 150, StdDev = 50
// With numDevs=1.5: Upper = 150 + 1.5*50 = 225, Lower = 150 - 1.5*50 = 75
Assert.Equal(150.0, vwapsd.Vwap.Value, precision: 10);
Assert.Equal(50.0, vwapsd.StdDev.Value, precision: 10);
Assert.Equal(225.0, vwapsd.Upper.Value, precision: 10);
Assert.Equal(75.0, vwapsd.Lower.Value, precision: 10);
Assert.Equal(150.0, vwapsd.Width.Value, precision: 10); // 225 - 75
}
[Fact]
public void Vwapsd_BoundaryNumDevs_Min_Works()
{
var vwapsd = new Vwapsd(0.1); // Minimum allowed
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1);
vwapsd.Update(bar1);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 200, 200, 200, 200, 1);
vwapsd.Update(bar2);
// VWAP = 150, StdDev = 50
// With numDevs=0.1: Upper = 150 + 0.1*50 = 155, Lower = 150 - 0.1*50 = 145
Assert.Equal(150.0, vwapsd.Vwap.Value, precision: 10);
Assert.Equal(50.0, vwapsd.StdDev.Value, precision: 10);
Assert.Equal(155.0, vwapsd.Upper.Value, precision: 10);
Assert.Equal(145.0, vwapsd.Lower.Value, precision: 10);
}
[Fact]
public void Vwapsd_BoundaryNumDevs_Max_Works()
{
var vwapsd = new Vwapsd(5.0); // Maximum allowed
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1);
vwapsd.Update(bar1);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 200, 200, 200, 200, 1);
vwapsd.Update(bar2);
// VWAP = 150, StdDev = 50
// With numDevs=5.0: Upper = 150 + 5.0*50 = 400, Lower = 150 - 5.0*50 = -100
Assert.Equal(150.0, vwapsd.Vwap.Value, precision: 10);
Assert.Equal(50.0, vwapsd.StdDev.Value, precision: 10);
Assert.Equal(400.0, vwapsd.Upper.Value, precision: 10);
Assert.Equal(-100.0, vwapsd.Lower.Value, precision: 10);
}
}
@@ -0,0 +1,613 @@
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for VWAPSD (Volume Weighted Average Price with Standard Deviation Bands).
/// VWAP is a standard institutional calculation. Validation focuses on:
/// 1. Internal consistency between streaming, batch, and span modes
/// 2. Mathematical correctness of VWAP formula
/// 3. Standard deviation bands calculation accuracy with configurable numDevs
/// 4. Volume weighting behavior
/// </summary>
public sealed class VwapsdValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public VwapsdValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_Streaming_Batch_Consistency()
{
double[] numDevsValues = { 0.5, 1.0, 2.0, 3.0 };
foreach (var numDevs in numDevsValues)
{
// Generate test data
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming mode
var streamingVwapsd = new Vwapsd(numDevs);
var streamingVwap = new List<double>();
var streamingUpper = new List<double>();
var streamingLower = new List<double>();
for (int i = 0; i < bars.Count; i++)
{
streamingVwapsd.Update(bars[i]);
streamingVwap.Add(streamingVwapsd.Vwap.Value);
streamingUpper.Add(streamingVwapsd.Upper.Value);
streamingLower.Add(streamingVwapsd.Lower.Value);
}
// Batch mode
var batchVwapsd = new Vwapsd(numDevs);
var batchResult = batchVwapsd.Update(bars);
// Compare last 100 values
int compareCount = Math.Min(100, bars.Count - 2);
for (int i = bars.Count - compareCount; i < bars.Count; i++)
{
Assert.Equal(streamingVwap[i], batchResult[i].Value, precision: 10);
}
}
_output.WriteLine("VWAPSD Streaming vs Batch consistency validated successfully");
}
[Fact]
public void Validate_Streaming_Span_Consistency()
{
double[] numDevsValues = { 0.5, 1.0, 2.0, 3.0 };
foreach (var numDevs in numDevsValues)
{
// Generate test data
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming mode
var streamingVwapsd = new Vwapsd(numDevs);
var streamingVwap = new List<double>();
var streamingUpper = new List<double>();
var streamingLower = new List<double>();
for (int i = 0; i < bars.Count; i++)
{
streamingVwapsd.Update(bars[i]);
streamingVwap.Add(streamingVwapsd.Vwap.Value);
streamingUpper.Add(streamingVwapsd.Upper.Value);
streamingLower.Add(streamingVwapsd.Lower.Value);
}
// Span mode - using HLC3 for price
double[] price = new double[bars.Count];
double[] volume = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
price[i] = (bars[i].High + bars[i].Low + bars[i].Close) / 3.0;
volume[i] = bars[i].Volume;
}
double[] spanVwap = new double[bars.Count];
double[] spanUpper = new double[bars.Count];
double[] spanLower = new double[bars.Count];
Vwapsd.Calculate(price.AsSpan(), volume.AsSpan(),
spanUpper.AsSpan(), spanLower.AsSpan(),
spanVwap.AsSpan(), numDevs);
// Compare last 100 values
int compareCount = Math.Min(100, bars.Count - 2);
for (int i = bars.Count - compareCount; i < bars.Count; i++)
{
Assert.Equal(streamingVwap[i], spanVwap[i], precision: 10);
Assert.Equal(streamingUpper[i], spanUpper[i], precision: 10);
Assert.Equal(streamingLower[i], spanLower[i], precision: 10);
}
}
_output.WriteLine("VWAPSD Streaming vs Span consistency validated successfully");
}
[Fact]
public void Validate_VwapFormula_ManualCalculation()
{
// Manually verify VWAP calculation: sum(price × volume) / sum(volume)
var vwapsd = new Vwapsd(1.0);
// Create known test data
var testData = new (double price, double volume)[]
{
(100.0, 1000),
(102.0, 1500),
(98.0, 800),
(105.0, 2000),
(103.0, 1200)
};
double sumPV = 0;
double sumVol = 0;
for (int i = 0; i < testData.Length; i++)
{
var (price, vol) = testData[i];
sumPV += price * vol;
sumVol += vol;
double expectedVwap = sumPV / sumVol;
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price, price, price, vol);
vwapsd.Update(bar);
Assert.Equal(expectedVwap, vwapsd.Vwap.Value, precision: 10);
_output.WriteLine($"Bar {i + 1}: Price={price}, Vol={vol}, Expected VWAP={expectedVwap:F4}, Actual={vwapsd.Vwap.Value:F4}");
}
_output.WriteLine("VWAPSD formula validation completed successfully");
}
[Fact]
public void Validate_StdDevFormula_ManualCalculation()
{
// Manually verify variance calculation: (sum(price² × vol) / sum(vol)) - VWAP²
var vwapsd = new Vwapsd(1.0);
// Create test data with known variance
var testData = new (double price, double volume)[]
{
(100.0, 1.0),
(200.0, 1.0) // Equal weights, max variance
};
for (int i = 0; i < testData.Length; i++)
{
var (price, vol) = testData[i];
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price, price, price, vol);
vwapsd.Update(bar);
}
// After 2 bars: VWAP = (100 + 200) / 2 = 150
// MeanP2 = (100² + 200²) / 2 = (10000 + 40000) / 2 = 25000
// Variance = 25000 - 150² = 25000 - 22500 = 2500
// StdDev = sqrt(2500) = 50
Assert.Equal(150.0, vwapsd.Vwap.Value, precision: 10);
Assert.Equal(50.0, vwapsd.StdDev.Value, precision: 10);
_output.WriteLine("VWAPSD StdDev formula validation completed successfully");
}
[Fact]
public void Validate_NumDevsEffect_BandWidth()
{
// Verify that numDevs properly scales the band width
var vwapsd1 = new Vwapsd(1.0);
var vwapsd2 = new Vwapsd(2.0);
var vwapsd3 = new Vwapsd(3.0);
// Create test data with known variance
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 200, 200, 200, 200, 1);
vwapsd1.Update(bar1);
vwapsd1.Update(bar2);
vwapsd2.Update(bar1);
vwapsd2.Update(bar2);
vwapsd3.Update(bar1);
vwapsd3.Update(bar2);
// VWAP = 150, StdDev = 50 for all
Assert.Equal(150.0, vwapsd1.Vwap.Value, precision: 10);
Assert.Equal(150.0, vwapsd2.Vwap.Value, precision: 10);
Assert.Equal(150.0, vwapsd3.Vwap.Value, precision: 10);
Assert.Equal(50.0, vwapsd1.StdDev.Value, precision: 10);
Assert.Equal(50.0, vwapsd2.StdDev.Value, precision: 10);
Assert.Equal(50.0, vwapsd3.StdDev.Value, precision: 10);
// With numDevs=1: Upper = 200, Lower = 100, Width = 100
// With numDevs=2: Upper = 250, Lower = 50, Width = 200
// With numDevs=3: Upper = 300, Lower = 0, Width = 300
Assert.Equal(200.0, vwapsd1.Upper.Value, precision: 10);
Assert.Equal(100.0, vwapsd1.Lower.Value, precision: 10);
Assert.Equal(100.0, vwapsd1.Width.Value, precision: 10);
Assert.Equal(250.0, vwapsd2.Upper.Value, precision: 10);
Assert.Equal(50.0, vwapsd2.Lower.Value, precision: 10);
Assert.Equal(200.0, vwapsd2.Width.Value, precision: 10);
Assert.Equal(300.0, vwapsd3.Upper.Value, precision: 10);
Assert.Equal(0.0, vwapsd3.Lower.Value, precision: 10);
Assert.Equal(300.0, vwapsd3.Width.Value, precision: 10);
_output.WriteLine("VWAPSD numDevs effect validation completed successfully");
}
[Fact]
public void Validate_BandCharacteristics()
{
// Verify core VWAPSD characteristics:
// 1. Upper >= VWAP >= Lower
// 2. Bands are symmetric around VWAP
// 3. Band width is proportional to numDevs × StdDev
double numDevs = 1.5;
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var vwapsd = new Vwapsd(numDevs);
for (int i = 0; i < bars.Count; i++)
{
vwapsd.Update(bars[i]);
// Skip first bar where StdDev is 0
if (i > 0)
{
// Upper >= VWAP >= Lower
Assert.True(vwapsd.Upper.Value >= vwapsd.Vwap.Value,
$"Upper ({vwapsd.Upper.Value}) should be >= VWAP ({vwapsd.Vwap.Value})");
Assert.True(vwapsd.Vwap.Value >= vwapsd.Lower.Value,
$"VWAP ({vwapsd.Vwap.Value}) should be >= Lower ({vwapsd.Lower.Value})");
// Symmetry: Upper - VWAP == VWAP - Lower
double upperOffset = vwapsd.Upper.Value - vwapsd.Vwap.Value;
double lowerOffset = vwapsd.Vwap.Value - vwapsd.Lower.Value;
Assert.Equal(upperOffset, lowerOffset, precision: 9);
// Width = 2 × numDevs × StdDev
double expectedWidth = 2.0 * numDevs * vwapsd.StdDev.Value;
Assert.Equal(expectedWidth, vwapsd.Width.Value, precision: 9);
}
}
_output.WriteLine("VWAPSD band characteristics validated successfully");
}
[Fact]
public void Validate_VolumeWeighting()
{
// Verify that VWAP is properly volume-weighted
var vwapsd = new Vwapsd(1.0);
// High volume at low price, low volume at high price
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 10000);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 200, 200, 200, 200, 100);
vwapsd.Update(bar1);
vwapsd.Update(bar2);
// VWAP should be closer to 100 (high volume price)
// VWAP = (100 × 10000 + 200 × 100) / (10000 + 100) = 1020000 / 10100 ≈ 100.99
double expectedVwap = (100.0 * 10000 + 200.0 * 100) / (10000 + 100);
Assert.Equal(expectedVwap, vwapsd.Vwap.Value, precision: 10);
Assert.True(vwapsd.Vwap.Value < 110, "VWAP should be heavily weighted toward 100");
_output.WriteLine($"Volume weighting verified: VWAP = {vwapsd.Vwap.Value:F4} (expected ≈ 100.99)");
}
[Fact]
public void Validate_NaN_Handling()
{
double numDevs = 1.0;
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var vwapsd = new Vwapsd(numDevs);
int nanCount = 0;
for (int i = 0; i < bars.Count; i++)
{
if (i == 50 || i == 51)
{
// Inject NaN price
vwapsd.Update(new TValue(bars[i].Time, double.NaN), bars[i].Volume, isNew: true);
nanCount++;
}
else
{
vwapsd.Update(bars[i]);
}
Assert.True(double.IsFinite(vwapsd.Vwap.Value),
$"VWAP should be finite after NaN at index {i}");
Assert.True(double.IsFinite(vwapsd.Upper.Value),
$"Upper should be finite after NaN at index {i}");
Assert.True(double.IsFinite(vwapsd.Lower.Value),
$"Lower should be finite after NaN at index {i}");
}
_output.WriteLine($"VWAPSD NaN handling validated ({nanCount} NaN values handled)");
}
[Fact]
public void Validate_BarCorrection()
{
double numDevs = 1.5;
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var vwapsd = new Vwapsd(numDevs);
// Process all bars
for (int i = 0; i < bars.Count - 1; i++)
{
vwapsd.Update(bars[i]);
}
// Record state before last bar
vwapsd.Update(bars[^1]);
double originalVwap = vwapsd.Vwap.Value;
double originalUpper = vwapsd.Upper.Value;
// Correct last bar with different value
var correctedBar = new TBar(bars[^1].Time, 200, 210, 190, 200, 5000);
vwapsd.Update(correctedBar, isNew: false);
double correctedVwap = vwapsd.Vwap.Value;
// Should be different
Assert.NotEqual(originalVwap, correctedVwap);
// Restore original bar
vwapsd.Update(bars[^1], isNew: false);
double restoredVwap = vwapsd.Vwap.Value;
double restoredUpper = vwapsd.Upper.Value;
// Should match original
Assert.Equal(originalVwap, restoredVwap, precision: 10);
Assert.Equal(originalUpper, restoredUpper, precision: 10);
_output.WriteLine("VWAPSD bar correction validated successfully");
}
[Fact]
public void Validate_SessionReset()
{
// Verify that session reset properly clears VWAP accumulation
var vwapsd = new Vwapsd(1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Process first session
for (int i = 0; i < 25; i++)
{
vwapsd.Update(bars[i]);
}
double session1Vwap = vwapsd.Vwap.Value;
// Reset for new session
var resetBar = new TBar(DateTime.UtcNow, 200, 200, 200, 200, 1000);
vwapsd.Update(resetBar, isNew: true, reset: true);
// After reset, VWAP should be just the reset bar's price
Assert.Equal(200.0, vwapsd.Vwap.Value, precision: 10);
Assert.NotEqual(session1Vwap, vwapsd.Vwap.Value);
_output.WriteLine("VWAPSD session reset validated successfully");
}
[Fact]
public void Validate_DifferentNumDevs()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] numDevsValues = { 0.5, 1.0, 1.5, 2.0, 3.0 };
var avgWidths = new List<double>();
foreach (var numDevs in numDevsValues)
{
var vwapsd = new Vwapsd(numDevs);
double sumWidth = 0;
int count = 0;
for (int i = 0; i < bars.Count; i++)
{
vwapsd.Update(bars[i]);
if (vwapsd.IsHot)
{
sumWidth += vwapsd.Width.Value;
count++;
}
}
double avgWidth = count > 0 ? sumWidth / count : 0;
avgWidths.Add(avgWidth);
_output.WriteLine($"NumDevs {numDevs}: Average width = {avgWidth:F4}");
}
// Higher numDevs should give wider bands
for (int i = 1; i < avgWidths.Count; i++)
{
Assert.True(avgWidths[i] > avgWidths[i - 1],
$"Higher numDevs should produce wider bands");
}
}
[Fact]
public void Validate_ZeroVolumeBars()
{
// Zero volume bars should not affect VWAP
var vwapsd = new Vwapsd(1.0);
// First bar with volume
var bar1 = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1000);
vwapsd.Update(bar1);
double vwapAfterBar1 = vwapsd.Vwap.Value;
// Multiple zero-volume bars with different prices
for (int i = 0; i < 5; i++)
{
var zeroVolBar = new TBar(DateTime.UtcNow.AddMinutes(i + 1), 200 + i * 10, 200 + i * 10, 200 + i * 10, 200 + i * 10, 0);
vwapsd.Update(zeroVolBar);
}
// VWAP should remain unchanged
Assert.Equal(vwapAfterBar1, vwapsd.Vwap.Value, precision: 10);
_output.WriteLine("VWAPSD zero volume handling validated successfully");
}
[Fact]
public void Validate_ConstantPrice_ZeroStdDev()
{
// With constant price, StdDev should be 0 and all bands should equal VWAP
var vwapsd = new Vwapsd(2.0);
for (int i = 0; i < 100; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 100, 100, 100, 1000);
vwapsd.Update(bar);
}
Assert.Equal(100.0, vwapsd.Vwap.Value, precision: 6);
Assert.Equal(0.0, vwapsd.StdDev.Value, precision: 6);
Assert.Equal(100.0, vwapsd.Upper.Value, precision: 6);
Assert.Equal(100.0, vwapsd.Lower.Value, precision: 6);
_output.WriteLine("VWAPSD constant price validation completed");
}
[Fact]
public void Validate_LargeDataset_Performance()
{
// Process large dataset to verify stability
var vwapsd = new Vwapsd(1.5);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(10000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var sw = System.Diagnostics.Stopwatch.StartNew();
for (int i = 0; i < bars.Count; i++)
{
vwapsd.Update(bars[i]);
// Verify all values remain finite
Assert.True(double.IsFinite(vwapsd.Vwap.Value), $"VWAP not finite at index {i}");
Assert.True(double.IsFinite(vwapsd.StdDev.Value), $"StdDev not finite at index {i}");
Assert.True(double.IsFinite(vwapsd.Upper.Value), $"Upper not finite at index {i}");
Assert.True(double.IsFinite(vwapsd.Lower.Value), $"Lower not finite at index {i}");
}
sw.Stop();
_output.WriteLine($"Processed {bars.Count} bars in {sw.ElapsedMilliseconds}ms ({bars.Count * 1000.0 / sw.ElapsedMilliseconds:F0} bars/sec)");
}
[Fact]
public void Validate_StaticCalculate_TBarSeries()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var (upper, lower, vwap, stdev) = Vwapsd.Calculate(bars, 1.5);
Assert.Equal(bars.Count, upper.Count);
Assert.Equal(bars.Count, lower.Count);
Assert.Equal(bars.Count, vwap.Count);
Assert.Equal(bars.Count, stdev.Count);
// Verify streaming matches static
var streamingVwapsd = new Vwapsd(1.5);
for (int i = 0; i < bars.Count; i++)
{
streamingVwapsd.Update(bars[i]);
}
Assert.Equal(streamingVwapsd.Vwap.Value, vwap.Last.Value, precision: 10);
Assert.Equal(streamingVwapsd.Upper.Value, upper.Last.Value, precision: 10);
Assert.Equal(streamingVwapsd.Lower.Value, lower.Last.Value, precision: 10);
_output.WriteLine("VWAPSD static Calculate validated successfully");
}
[Fact]
public void Validate_FractionalNumDevs()
{
// Test fractional numDevs values within valid range
double[] fractionalValues = { 0.1, 0.25, 0.5, 0.75, 1.25, 1.5, 2.5, 4.5 };
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var numDevs in fractionalValues)
{
var vwapsd = new Vwapsd(numDevs);
for (int i = 0; i < bars.Count; i++)
{
vwapsd.Update(bars[i]);
}
Assert.True(double.IsFinite(vwapsd.Vwap.Value));
Assert.True(double.IsFinite(vwapsd.Upper.Value));
Assert.True(double.IsFinite(vwapsd.Lower.Value));
Assert.True(vwapsd.Width.Value >= 0);
_output.WriteLine($"NumDevs {numDevs:F2}: VWAP={vwapsd.Vwap.Value:F4}, Width={vwapsd.Width.Value:F4}");
}
_output.WriteLine("VWAPSD fractional numDevs validation completed");
}
[Fact]
public void Validate_BoundaryNumDevs()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Test minimum boundary (0.1)
var vwapsdMin = new Vwapsd(0.1);
for (int i = 0; i < bars.Count; i++)
{
vwapsdMin.Update(bars[i]);
}
Assert.True(vwapsdMin.Width.Value > 0 || vwapsdMin.StdDev.Value == 0);
_output.WriteLine($"Min numDevs (0.1): Width={vwapsdMin.Width.Value:F6}");
// Test maximum boundary (5.0)
var vwapsdMax = new Vwapsd(5.0);
for (int i = 0; i < bars.Count; i++)
{
vwapsdMax.Update(bars[i]);
}
Assert.True(vwapsdMax.Width.Value >= vwapsdMin.Width.Value);
_output.WriteLine($"Max numDevs (5.0): Width={vwapsdMax.Width.Value:F6}");
// Verify width ratio matches numDevs ratio
if (vwapsdMin.StdDev.Value > 0)
{
double expectedRatio = 5.0 / 0.1; // 50x
double actualRatio = vwapsdMax.Width.Value / vwapsdMin.Width.Value;
Assert.Equal(expectedRatio, actualRatio, precision: 8);
}
_output.WriteLine("VWAPSD boundary numDevs validation completed");
}
}
+401
View File
@@ -0,0 +1,401 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// VWAPSD: Volume Weighted Average Price with Standard Deviation Bands
/// A volatility channel indicator using VWAP as the center line with configurable
/// standard deviation bands.
/// </summary>
/// <remarks>
/// The VWAPSD calculation process:
/// 1. Calculate cumulative price×volume sum (sum_pv)
/// 2. Calculate cumulative volume sum (sum_vol)
/// 3. Calculate cumulative price²×volume sum (sum_pv2)
/// 4. VWAP = sum_pv / sum_vol
/// 5. Variance = (sum_pv2 / sum_vol) - VWAP²
/// 6. StdDev = √Variance
/// 7. Bands = VWAP ± (numDevs × StdDev)
///
/// Key characteristics:
/// - Volume-weighted price average as center line
/// - Configurable number of standard deviations for bands
/// - Bands adapt to volume-weighted price dispersion
/// - Can reset on session boundaries or run continuously
///
/// Sources:
/// Standard VWAP calculation with configurable deviation bands
/// Common in institutional trading for intraday analysis
/// </remarks>
[SkipLocalsInit]
public sealed class Vwapsd : AbstractBase
{
private readonly double _numDevs;
private const double DefaultNumDevs = 2.0;
private const double MinNumDevs = 0.1;
private const double MaxNumDevs = 5.0;
// State for streaming with bar correction
[StructLayout(LayoutKind.Auto)]
private record struct State(
double SumPV, // Cumulative price × volume
double SumVol, // Cumulative volume
double SumPV2, // Cumulative price² × volume
int Count, // Bar count since reset
double LastValidPrice,
double LastValidVolume,
bool IsInitialized);
private State _state;
private State _p_state;
private int _index;
public override bool IsHot => _index >= WarmupPeriod;
/// <summary>
/// Upper band (VWAP + numDevs × StdDev)
/// </summary>
public TValue Upper { get; private set; }
/// <summary>
/// Lower band (VWAP - numDevs × StdDev)
/// </summary>
public TValue Lower { get; private set; }
/// <summary>
/// VWAP value (center line)
/// </summary>
public TValue Vwap { get; private set; }
/// <summary>
/// Standard deviation of volume-weighted prices
/// </summary>
public TValue StdDev { get; private set; }
/// <summary>
/// Band width (Upper - Lower = 2 × numDevs × StdDev)
/// </summary>
public TValue Width { get; private set; }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vwapsd(double numDevs = DefaultNumDevs)
{
if (numDevs < MinNumDevs)
{
throw new ArgumentOutOfRangeException(nameof(numDevs),
$"Number of deviations must be at least {MinNumDevs}.");
}
if (numDevs > MaxNumDevs)
{
throw new ArgumentOutOfRangeException(nameof(numDevs),
$"Number of deviations must not exceed {MaxNumDevs}.");
}
_numDevs = numDevs;
WarmupPeriod = 2; // Need at least 2 bars for variance
Name = $"Vwapsd({numDevs:F1})";
Init();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Init()
{
_index = 0;
_state = new State(0, 0, 0, 0, double.NaN, double.NaN, false);
_p_state = _state;
Vwap = new TValue(DateTime.UtcNow, 0);
Upper = new TValue(DateTime.UtcNow, 0);
Lower = new TValue(DateTime.UtcNow, 0);
StdDev = new TValue(DateTime.UtcNow, 0);
Width = new TValue(DateTime.UtcNow, 0);
}
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static double GetFiniteValue(double value, ref double lastValid)
{
if (double.IsFinite(value))
{
lastValid = value;
return value;
}
return double.IsFinite(lastValid) ? lastValid : 0;
}
/// <summary>
/// Updates the indicator with a new bar. Uses HLC3 as price and bar volume.
/// </summary>
/// <param name="bar">The input bar with OHLCV data</param>
/// <param name="isNew">True for new bar, false for bar correction</param>
/// <param name="reset">True to reset VWAP calculation (e.g., new session)</param>
/// <returns>The VWAP value</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true, bool reset = false)
{
double hlc3 = (bar.High + bar.Low + bar.Close) / 3.0;
return Update(new TValue(bar.Time, hlc3), bar.Volume, isNew, reset);
}
/// <summary>
/// Updates the indicator with price and volume values.
/// </summary>
/// <param name="input">Price value (typically HLC3)</param>
/// <param name="volume">Volume value</param>
/// <param name="isNew">True for new bar, false for bar correction</param>
/// <param name="reset">True to reset VWAP calculation (e.g., new session)</param>
/// <returns>The VWAP value</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, double volume, bool isNew = true, bool reset = false)
{
// State management for bar correction
if (isNew)
{
_p_state = _state;
_index++;
}
else
{
// Restore previous state
_state = _p_state;
}
double lastValidPrice = _state.LastValidPrice;
double lastValidVolume = _state.LastValidVolume;
double price = GetFiniteValue(input.Value, ref lastValidPrice);
double vol = GetFiniteValue(volume, ref lastValidVolume);
_state = _state with { LastValidPrice = lastValidPrice, LastValidVolume = lastValidVolume };
// Handle reset
if (reset || !_state.IsInitialized)
{
if (vol > 0)
{
_state = _state with
{
SumPV = price * vol,
SumVol = vol,
SumPV2 = price * price * vol,
Count = 1,
IsInitialized = true
};
}
else
{
_state = _state with
{
SumPV = 0,
SumVol = 0,
SumPV2 = 0,
Count = 0,
IsInitialized = true
};
}
}
else
{
// Accumulate values
if (vol > 0)
{
_state = _state with
{
SumPV = _state.SumPV + price * vol,
SumVol = _state.SumVol + vol,
SumPV2 = _state.SumPV2 + price * price * vol,
Count = _state.Count + 1
};
}
}
// Calculate VWAP
double vwap = _state.SumVol > 0 ? _state.SumPV / _state.SumVol : price;
// Calculate variance and standard deviation
double variance = 0;
if (_state.SumVol > 0 && _state.Count > 1)
{
double meanP2 = _state.SumPV2 / _state.SumVol;
double vwapSquared = vwap * vwap;
variance = Math.Max(0, meanP2 - vwapSquared);
}
double stdev = Math.Sqrt(variance);
// Calculate bands
double upper = vwap + _numDevs * stdev;
double lower = vwap - _numDevs * stdev;
// Update output values
Vwap = new TValue(input.Time, vwap);
Upper = new TValue(input.Time, upper);
Lower = new TValue(input.Time, lower);
StdDev = new TValue(input.Time, stdev);
Width = new TValue(input.Time, upper - lower);
Last = Vwap;
return Last;
}
/// <summary>
/// Updates the indicator with a TValue. Assumes volume of 1.0 for each update.
/// For proper VWAP calculation, use Update(TBar) or Update(TValue, double volume).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
return Update(input, 1.0, isNew, false);
}
/// <summary>
/// Updates the indicator with a bar series.
/// </summary>
public TSeries Update(TBarSeries source)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
int len = source.Count;
TSeries result = new(capacity: len);
for (int i = 0; i < len; i++)
{
Update(source[i], isNew: true);
result.Add(Last.Time, Last.Value, isNew: true);
}
return result;
}
/// <summary>
/// Updates the indicator with a price series (uses volume=1 for each bar).
/// </summary>
public override TSeries Update(TSeries source)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
int len = source.Count;
TSeries result = new(capacity: len);
for (int i = 0; i < len; i++)
{
Update(source[i], isNew: true);
result.Add(Last.Time, Last.Value, isNew: true);
}
return result;
}
public override void Reset()
{
Init();
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
step ??= TimeSpan.FromSeconds(1);
DateTime startTime = DateTime.UtcNow;
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(startTime + i * step.Value, source[i]), 1.0, isNew: true, reset: false);
}
}
/// <summary>
/// Calculates VWAP SD Bands for a bar series.
/// </summary>
/// <returns>Tuple of (Upper, Lower, Vwap, StdDev)</returns>
public static (TSeries Upper, TSeries Lower, TSeries Vwap, TSeries StdDev) Calculate(
TBarSeries source,
double numDevs = DefaultNumDevs)
{
Vwapsd vwapsd = new(numDevs);
int len = source.Count;
TSeries upper = new(capacity: len);
TSeries lower = new(capacity: len);
TSeries vwap = new(capacity: len);
TSeries stdev = new(capacity: len);
for (int i = 0; i < len; i++)
{
vwapsd.Update(source[i], isNew: true);
upper.Add(vwapsd.Upper.Time, vwapsd.Upper.Value, isNew: true);
lower.Add(vwapsd.Lower.Time, vwapsd.Lower.Value, isNew: true);
vwap.Add(vwapsd.Vwap.Time, vwapsd.Vwap.Value, isNew: true);
stdev.Add(vwapsd.StdDev.Time, vwapsd.StdDev.Value, isNew: true);
}
return (upper, lower, vwap, stdev);
}
/// <summary>
/// Calculates VWAP SD Bands using span arrays.
/// </summary>
public static void Calculate(
ReadOnlySpan<double> price,
ReadOnlySpan<double> volume,
Span<double> upper,
Span<double> lower,
Span<double> vwap,
double numDevs = DefaultNumDevs)
{
int len = price.Length;
if (len != volume.Length || len != upper.Length || len != lower.Length || len != vwap.Length)
{
throw new ArgumentException("All spans must have the same length.", nameof(price));
}
if (numDevs < MinNumDevs)
{
throw new ArgumentOutOfRangeException(nameof(numDevs),
$"Number of deviations must be at least {MinNumDevs}.");
}
if (numDevs > MaxNumDevs)
{
throw new ArgumentOutOfRangeException(nameof(numDevs),
$"Number of deviations must not exceed {MaxNumDevs}.");
}
if (len == 0)
{
return;
}
double sumPV = 0, sumVol = 0, sumPV2 = 0;
int count = 0;
double lastValidPrice = double.NaN;
double lastValidVolume = double.NaN;
for (int i = 0; i < len; i++)
{
double p = GetFiniteValue(price[i], ref lastValidPrice);
double v = GetFiniteValue(volume[i], ref lastValidVolume);
if (v > 0)
{
sumPV += p * v;
sumVol += v;
sumPV2 += p * p * v;
count++;
}
double vwapVal = sumVol > 0 ? sumPV / sumVol : p;
double variance = 0;
if (sumVol > 0 && count > 1)
{
double meanP2 = sumPV2 / sumVol;
double vwapSquared = vwapVal * vwapVal;
variance = Math.Max(0, meanP2 - vwapSquared);
}
double stdev = Math.Sqrt(variance);
vwap[i] = vwapVal;
upper[i] = vwapVal + numDevs * stdev;
lower[i] = vwapVal - numDevs * stdev;
}
}
}