mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-19 11:08:05 +00:00
Add Price Volume Trend (PVT) Indicator and Tests
- Implemented the PvtIndicator class for calculating Price Volume Trend in Quantower. - Created unit tests for the Pvt class to validate calculations and state management. - Added validation tests to ensure consistency with OoplesFinance's implementation. - Developed a comprehensive documentation (Pvt.md) explaining the PVT concept, calculations, and usage. - Included methods for batch calculations and streaming updates for PVT.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class PvdIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void PvdIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new PvdIndicator();
|
||||
|
||||
Assert.Equal("PVD - Price Volume Divergence", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
Assert.Equal(14, indicator.PricePeriod);
|
||||
Assert.Equal(14, indicator.VolumePeriod);
|
||||
Assert.Equal(3, indicator.SmoothingPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_ShortName_IsConstant()
|
||||
{
|
||||
var indicator = new PvdIndicator();
|
||||
Assert.Equal("PVD", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_MinHistoryDepths_CalculatedCorrectly()
|
||||
{
|
||||
var indicator = new PvdIndicator
|
||||
{
|
||||
PricePeriod = 10,
|
||||
VolumePeriod = 20,
|
||||
SmoothingPeriod = 5
|
||||
};
|
||||
|
||||
// max(10,20) + 5 + 1 = 26
|
||||
Assert.Equal(26, indicator.MinHistoryDepths);
|
||||
Assert.Equal(26, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_MinHistoryDepths_DefaultValue()
|
||||
{
|
||||
var indicator = new PvdIndicator();
|
||||
|
||||
// max(14,14) + 3 + 1 = 18
|
||||
Assert.Equal(18, indicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_Initialize_CreatesInternalPvd()
|
||||
{
|
||||
var indicator = new PvdIndicator();
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new PvdIndicator
|
||||
{
|
||||
PricePeriod = 5,
|
||||
VolumePeriod = 5,
|
||||
SmoothingPeriod = 2
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double close = 100 + i * 0.5;
|
||||
double volume = 100000 + (i % 3 == 0 ? 20000 : -10000);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), close - 1, close + 1, close - 2, close, volume);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new PvdIndicator
|
||||
{
|
||||
PricePeriod = 3,
|
||||
VolumePeriod = 3,
|
||||
SmoothingPeriod = 2
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 10; 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(10), 105, 115, 100, 112, 80000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_PositiveDivergence_PriceUpVolumeDown()
|
||||
{
|
||||
var indicator = new PvdIndicator
|
||||
{
|
||||
PricePeriod = 2,
|
||||
VolumePeriod = 2,
|
||||
SmoothingPeriod = 1
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Establish baseline with stable prices and volumes
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 100000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 105, 95, 100, 100000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(2), 100, 105, 95, 100, 100000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
// Price up, volume down = positive divergence
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(3), 108, 112, 105, 110, 70000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(val > 0, $"PVD should be positive when price up and volume down: {val}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_NegativeDivergence_PriceUpVolumeUp()
|
||||
{
|
||||
var indicator = new PvdIndicator
|
||||
{
|
||||
PricePeriod = 2,
|
||||
VolumePeriod = 2,
|
||||
SmoothingPeriod = 1
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Establish baseline
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100, 100000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 105, 95, 100, 100000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(2), 100, 105, 95, 100, 100000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
// Price up, volume up = negative (same direction, no divergence)
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(3), 108, 112, 105, 110, 130000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(val < 0, $"PVD should be negative when price and volume move same direction: {val}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_NoDivergence_StablePriceAndVolume()
|
||||
{
|
||||
var indicator = new PvdIndicator
|
||||
{
|
||||
PricePeriod = 2,
|
||||
VolumePeriod = 2,
|
||||
SmoothingPeriod = 1
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// All bars with same values - no momentum
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, 100000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.Equal(0, val, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PvdIndicator_CustomPeriods_Applied()
|
||||
{
|
||||
var indicator = new PvdIndicator
|
||||
{
|
||||
PricePeriod = 5,
|
||||
VolumePeriod = 10,
|
||||
SmoothingPeriod = 3
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double close = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), close - 1, close + 2, close - 2, close, 100000 + i * 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class PvdIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Price Period", sortIndex: 0, minimum: 1, maximum: 100, increment: 1, decimalPlaces: 0)]
|
||||
public int PricePeriod { get; set; } = 14;
|
||||
|
||||
[InputParameter("Volume Period", sortIndex: 1, minimum: 1, maximum: 100, increment: 1, decimalPlaces: 0)]
|
||||
public int VolumePeriod { get; set; } = 14;
|
||||
|
||||
[InputParameter("Smoothing Period", sortIndex: 2, minimum: 1, maximum: 50, increment: 1, decimalPlaces: 0)]
|
||||
public int SmoothingPeriod { get; set; } = 3;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Pvd _pvd = null!;
|
||||
private readonly LineSeries _series;
|
||||
|
||||
#pragma warning disable S2325 // Instance property required by Quantower indicator interface
|
||||
public int MinHistoryDepths => Math.Max(PricePeriod, VolumePeriod) + SmoothingPeriod + 1;
|
||||
#pragma warning restore S2325
|
||||
int IWatchlistIndicator.MinHistoryDepths => Math.Max(PricePeriod, VolumePeriod) + SmoothingPeriod + 1;
|
||||
|
||||
public override string ShortName => "PVD";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volume/pvd/Pvd.Quantower.cs";
|
||||
|
||||
public PvdIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "PVD - Price Volume Divergence";
|
||||
Description = "Price Volume Divergence measures divergence between price and volume momentum";
|
||||
|
||||
_series = new LineSeries(name: "PVD", color: Color.Yellow, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_pvd = new Pvd(PricePeriod, VolumePeriod, SmoothingPeriod);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
TBar bar = this.GetInputBar(args);
|
||||
TValue result = _pvd.Update(bar, args.IsNewBar());
|
||||
|
||||
_series.SetValue(result.Value, _pvd.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class PvdTests
|
||||
{
|
||||
private readonly GBM _gbm;
|
||||
private readonly TBarSeries _bars;
|
||||
private const int TestDataLength = 1000;
|
||||
|
||||
public PvdTests()
|
||||
{
|
||||
_gbm = new GBM(seed: 42);
|
||||
_bars = new TBarSeries();
|
||||
for (int i = 0; i < TestDataLength; i++)
|
||||
{
|
||||
_bars.Add(_gbm.Next());
|
||||
}
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParameters_SetsCorrectValues()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
Assert.Equal("Pvd(14,14,3)", pvd.Name);
|
||||
Assert.Equal(17, pvd.WarmupPeriod); // max(14,14) + 3
|
||||
Assert.False(pvd.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomPeriods_SetsCorrectValues()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 10, volumePeriod: 20, smoothingPeriod: 5);
|
||||
Assert.Equal("Pvd(10,20,5)", pvd.Name);
|
||||
Assert.Equal(25, pvd.WarmupPeriod); // max(10,20) + 5
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPricePeriod_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Pvd(pricePeriod: 0));
|
||||
Assert.Equal("pricePeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidVolumePeriod_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Pvd(volumePeriod: 0));
|
||||
Assert.Equal("volumePeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidSmoothingPeriod_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Pvd(smoothingPeriod: 0));
|
||||
Assert.Equal("smoothingPeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Pvd(pricePeriod: -1));
|
||||
Assert.Throws<ArgumentException>(() => new Pvd(volumePeriod: -5));
|
||||
Assert.Throws<ArgumentException>(() => new Pvd(smoothingPeriod: -2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basic Calculation Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
var bar = _bars[0];
|
||||
var result = pvd.Update(bar);
|
||||
Assert.IsType<TValue>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_SetsLastProperty()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
var bar = _bars[0];
|
||||
var result = pvd.Update(bar);
|
||||
Assert.Equal(result.Value, pvd.Last.Value);
|
||||
Assert.Equal(result.Time, pvd.Last.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_SingleBar_ReturnsZero()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
var result = pvd.Update(_bars[0]);
|
||||
Assert.Equal(0.0, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AfterWarmup_ReturnsFiniteValue()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 5, volumePeriod: 5, smoothingPeriod: 3);
|
||||
for (int i = 0; i < pvd.WarmupPeriod + 10; i++)
|
||||
{
|
||||
pvd.Update(_bars[i]);
|
||||
}
|
||||
Assert.True(double.IsFinite(pvd.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_DetectsPositiveDivergence()
|
||||
{
|
||||
// Create scenario: price up, volume down = positive divergence
|
||||
var pvd = new Pvd(pricePeriod: 2, volumePeriod: 2, smoothingPeriod: 1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Establish baseline
|
||||
pvd.Update(new TBar(time, 100.0, 100.0, 100.0, 100.0, 1000.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(1), 100.0, 100.0, 100.0, 100.0, 1000.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(2), 100.0, 100.0, 100.0, 100.0, 1000.0), isNew: true);
|
||||
|
||||
// Price up, volume down
|
||||
pvd.Update(new TBar(time.AddMinutes(3), 110.0, 110.0, 110.0, 110.0, 800.0), isNew: true);
|
||||
|
||||
// Should show divergence (price up + volume down = positive)
|
||||
Assert.True(pvd.Last.Value > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_DetectsNegativeDivergence()
|
||||
{
|
||||
// Create scenario: price up, volume up = negative divergence (same direction)
|
||||
var pvd = new Pvd(pricePeriod: 2, volumePeriod: 2, smoothingPeriod: 1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Establish baseline
|
||||
pvd.Update(new TBar(time, 100.0, 100.0, 100.0, 100.0, 1000.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(1), 100.0, 100.0, 100.0, 100.0, 1000.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(2), 100.0, 100.0, 100.0, 100.0, 1000.0), isNew: true);
|
||||
|
||||
// Price up, volume up
|
||||
pvd.Update(new TBar(time.AddMinutes(3), 110.0, 110.0, 110.0, 110.0, 1200.0), isNew: true);
|
||||
|
||||
// Should show negative divergence (same direction)
|
||||
Assert.True(pvd.Last.Value < 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State Management Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
pvd.Update(_bars[i], isNew: true);
|
||||
}
|
||||
|
||||
_ = pvd.Last.Value;
|
||||
pvd.Update(_bars[20], isNew: true);
|
||||
// State should advance (can't easily verify internal state, but no exception means success)
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_RollsBackState()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
pvd.Update(_bars[i], isNew: true);
|
||||
}
|
||||
|
||||
_ = pvd.Last.Value;
|
||||
|
||||
// Update with isNew=false should rollback and recalculate
|
||||
pvd.Update(_bars[25], isNew: false);
|
||||
double valueAfterCorrection = pvd.Last.Value;
|
||||
|
||||
// Values may differ since we're using different input
|
||||
// The key is that state was rolled back properly
|
||||
Assert.True(double.IsFinite(valueAfterCorrection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IterativeCorrections_RestoreState()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
|
||||
// Build up state
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
pvd.Update(_bars[i], isNew: true);
|
||||
}
|
||||
|
||||
_ = pvd.Last.Value;
|
||||
|
||||
// Make several corrections
|
||||
for (int c = 0; c < 5; c++)
|
||||
{
|
||||
pvd.Update(_bars[30], isNew: false);
|
||||
}
|
||||
|
||||
// Apply final new bar
|
||||
pvd.Update(_bars[30], isNew: true);
|
||||
double afterCorrections = pvd.Last.Value;
|
||||
|
||||
// After applying the same bar as new, should get same result
|
||||
Assert.True(double.IsFinite(afterCorrections));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
|
||||
// Build up state
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
pvd.Update(_bars[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.True(pvd.IsHot);
|
||||
|
||||
pvd.Reset();
|
||||
|
||||
Assert.False(pvd.IsHot);
|
||||
Assert.Equal(default, pvd.Last);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Warmup and IsHot Tests
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FalseBeforeWarmup()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 5, volumePeriod: 5, smoothingPeriod: 3);
|
||||
|
||||
for (int i = 0; i < pvd.WarmupPeriod - 1; i++)
|
||||
{
|
||||
pvd.Update(_bars[i], isNew: true);
|
||||
Assert.False(pvd.IsHot);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_TrueAfterWarmup()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 5, volumePeriod: 5, smoothingPeriod: 3);
|
||||
|
||||
for (int i = 0; i < pvd.WarmupPeriod; i++)
|
||||
{
|
||||
pvd.Update(_bars[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.True(pvd.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_CalculatedCorrectly()
|
||||
{
|
||||
var pvd1 = new Pvd(pricePeriod: 10, volumePeriod: 5, smoothingPeriod: 3);
|
||||
Assert.Equal(13, pvd1.WarmupPeriod); // max(10,5) + 3
|
||||
|
||||
var pvd2 = new Pvd(pricePeriod: 5, volumePeriod: 20, smoothingPeriod: 5);
|
||||
Assert.Equal(25, pvd2.WarmupPeriod); // max(5,20) + 5
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NaN and Infinity Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNInput_UsesLastValidValue()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 3, volumePeriod: 3, smoothingPeriod: 2);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build up state
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
pvd.Update(new TBar(time.AddMinutes(i), 100.0 + i, 100.0 + i, 100.0 + i, 100.0 + i, 1000.0 + i * 10), isNew: true);
|
||||
}
|
||||
|
||||
_ = pvd.Last.Value;
|
||||
|
||||
// Update with NaN close - should use last valid
|
||||
pvd.Update(new TBar(time.AddMinutes(10), double.NaN, double.NaN, double.NaN, double.NaN, 1100.0), isNew: true);
|
||||
|
||||
// Should return NaN when close is NaN and no prior valid close
|
||||
// But since we have prior valid, it should use that
|
||||
Assert.True(double.IsFinite(pvd.Last.Value) || double.IsNaN(pvd.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_InfinityInput_UsesLastValidValue()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 3, volumePeriod: 3, smoothingPeriod: 2);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build up state
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
pvd.Update(new TBar(time.AddMinutes(i), 100.0 + i, 100.0 + i, 100.0 + i, 100.0 + i, 1000.0 + i * 10), isNew: true);
|
||||
}
|
||||
|
||||
// Update with Infinity
|
||||
pvd.Update(new TBar(time.AddMinutes(10), double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity, 1100.0), isNew: true);
|
||||
|
||||
Assert.True(double.IsFinite(pvd.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NegativeInfinityInput_UsesLastValidValue()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 3, volumePeriod: 3, smoothingPeriod: 2);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build up state
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
pvd.Update(new TBar(time.AddMinutes(i), 100.0 + i, 100.0 + i, 100.0 + i, 100.0 + i, 1000.0 + i * 10), isNew: true);
|
||||
}
|
||||
|
||||
// Update with negative infinity
|
||||
pvd.Update(new TBar(time.AddMinutes(10), double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity, 1100.0), isNew: true);
|
||||
|
||||
Assert.True(double.IsFinite(pvd.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Span_HandlesNaN()
|
||||
{
|
||||
double[] closes = [100, 101, double.NaN, 103, 104];
|
||||
double[] volumes = [1000, 1100, 1200, 1300, 1400];
|
||||
double[] output = new double[5];
|
||||
|
||||
Pvd.Calculate(closes.AsSpan(), volumes.AsSpan(), output.AsSpan(), pricePeriod: 2, volumePeriod: 2, smoothingPeriod: 1);
|
||||
|
||||
// Should handle NaN gracefully - result might be NaN or computed value
|
||||
Assert.True(output.Length == 5);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mode Consistency Tests
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResults()
|
||||
{
|
||||
int period = 10;
|
||||
|
||||
// Mode 1: Streaming Update
|
||||
var pvdStreaming = new Pvd(pricePeriod: period, volumePeriod: period, smoothingPeriod: 3);
|
||||
for (int i = 0; i < _bars.Count; i++)
|
||||
{
|
||||
pvdStreaming.Update(_bars[i], isNew: true);
|
||||
}
|
||||
var streamingResults = new List<double>();
|
||||
pvdStreaming.Reset();
|
||||
for (int i = 0; i < _bars.Count; i++)
|
||||
{
|
||||
streamingResults.Add(pvdStreaming.Update(_bars[i], isNew: true).Value);
|
||||
}
|
||||
|
||||
// Mode 2: Batch via instance Update(TBarSeries)
|
||||
var pvdBatch = new Pvd(pricePeriod: period, volumePeriod: period, smoothingPeriod: 3);
|
||||
var batchResult = pvdBatch.Update(_bars);
|
||||
|
||||
// Mode 3: Static Calculate(TBarSeries)
|
||||
var staticResult = Pvd.Calculate(_bars, pricePeriod: period, volumePeriod: period, smoothingPeriod: 3);
|
||||
|
||||
// Mode 4: Static Calculate(Span)
|
||||
double[] closes = new double[_bars.Count];
|
||||
double[] volumes = new double[_bars.Count];
|
||||
double[] spanOutput = new double[_bars.Count];
|
||||
for (int i = 0; i < _bars.Count; i++)
|
||||
{
|
||||
closes[i] = _bars[i].Close;
|
||||
volumes[i] = _bars[i].Volume;
|
||||
}
|
||||
Pvd.Calculate(closes.AsSpan(), volumes.AsSpan(), spanOutput.AsSpan(), pricePeriod: period, volumePeriod: period, smoothingPeriod: 3);
|
||||
|
||||
// Compare last 100 values (after warmup)
|
||||
int compareStart = _bars.Count - 100;
|
||||
for (int i = compareStart; i < _bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchResult[i].Value, staticResult[i].Value, precision: 10);
|
||||
Assert.Equal(batchResult[i].Value, spanOutput[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Span API Tests
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Span_ValidatesLengths()
|
||||
{
|
||||
double[] closes = [1, 2, 3, 4, 5];
|
||||
double[] volumes = [100, 200, 300]; // Wrong length
|
||||
double[] output = new double[5];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Pvd.Calculate(closes.AsSpan(), volumes.AsSpan(), output.AsSpan()));
|
||||
Assert.Equal("volume", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Span_ValidatesOutputLength()
|
||||
{
|
||||
double[] closes = [1, 2, 3, 4, 5];
|
||||
double[] volumes = [100, 200, 300, 400, 500];
|
||||
double[] output = new double[3]; // Too short
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Pvd.Calculate(closes.AsSpan(), volumes.AsSpan(), output.AsSpan()));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Span_ValidatesPricePeriod()
|
||||
{
|
||||
double[] closes = [1, 2, 3, 4, 5];
|
||||
double[] volumes = [100, 200, 300, 400, 500];
|
||||
double[] output = new double[5];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Pvd.Calculate(closes.AsSpan(), volumes.AsSpan(), output.AsSpan(), pricePeriod: 0));
|
||||
Assert.Equal("pricePeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Span_ValidatesVolumePeriod()
|
||||
{
|
||||
double[] closes = [1, 2, 3, 4, 5];
|
||||
double[] volumes = [100, 200, 300, 400, 500];
|
||||
double[] output = new double[5];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Pvd.Calculate(closes.AsSpan(), volumes.AsSpan(), output.AsSpan(), volumePeriod: 0));
|
||||
Assert.Equal("volumePeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Span_ValidatesSmoothingPeriod()
|
||||
{
|
||||
double[] closes = [1, 2, 3, 4, 5];
|
||||
double[] volumes = [100, 200, 300, 400, 500];
|
||||
double[] output = new double[5];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Pvd.Calculate(closes.AsSpan(), volumes.AsSpan(), output.AsSpan(), smoothingPeriod: 0));
|
||||
Assert.Equal("smoothingPeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Span_LargeData_NoStackOverflow()
|
||||
{
|
||||
int size = 10000;
|
||||
double[] closes = new double[size];
|
||||
double[] volumes = new double[size];
|
||||
double[] output = new double[size];
|
||||
|
||||
for (int i = 0; i < size; i++)
|
||||
{
|
||||
closes[i] = 100.0 + i * 0.01;
|
||||
volumes[i] = 1000000.0 + i * 100;
|
||||
}
|
||||
|
||||
// Should not stack overflow
|
||||
Pvd.Calculate(closes.AsSpan(), volumes.AsSpan(), output.AsSpan());
|
||||
|
||||
Assert.True(double.IsFinite(output[size - 1]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Chaining Tests
|
||||
|
||||
[Fact]
|
||||
public void Pub_FiresOnUpdate()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
int eventCount = 0;
|
||||
|
||||
pvd.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
pvd.Update(_bars[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(10, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chaining_ProcessBars_Works()
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
var pvd = new Pvd(pricePeriod: 5, volumePeriod: 5, smoothingPeriod: 2);
|
||||
|
||||
// Process bars through indicator
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
pvd.Update(gbm.Next());
|
||||
}
|
||||
|
||||
Assert.True(pvd.IsHot);
|
||||
Assert.True(double.IsFinite(pvd.Last.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_ZeroVolume_HandlesGracefully()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 2, volumePeriod: 2, smoothingPeriod: 1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
pvd.Update(new TBar(time, 100.0, 100.0, 100.0, 100.0, 0.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(1), 101.0, 101.0, 101.0, 101.0, 0.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(2), 102.0, 102.0, 102.0, 102.0, 0.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(3), 103.0, 103.0, 103.0, 103.0, 0.0), isNew: true);
|
||||
|
||||
Assert.True(double.IsFinite(pvd.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NegativeVolume_TreatedAsZero()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 2, volumePeriod: 2, smoothingPeriod: 1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
pvd.Update(new TBar(time, 100.0, 100.0, 100.0, 100.0, -1000.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(1), 101.0, 101.0, 101.0, 101.0, -500.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(2), 102.0, 102.0, 102.0, 102.0, 1000.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(3), 103.0, 103.0, 103.0, 103.0, 1200.0), isNew: true);
|
||||
|
||||
Assert.True(double.IsFinite(pvd.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantPriceAndVolume_ReturnsZero()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 3, volumePeriod: 3, smoothingPeriod: 1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// All same values - no momentum in either direction
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
pvd.Update(new TBar(time.AddMinutes(i), 100.0, 100.0, 100.0, 100.0, 1000.0), isNew: true);
|
||||
}
|
||||
|
||||
// With no change, ROC is 0, so divergence should be 0
|
||||
Assert.Equal(0.0, pvd.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_MinimumPeriods_Works()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 1, volumePeriod: 1, smoothingPeriod: 1);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
pvd.Update(new TBar(time, 100.0, 100.0, 100.0, 100.0, 1000.0), isNew: true);
|
||||
pvd.Update(new TBar(time.AddMinutes(1), 105.0, 105.0, 105.0, 105.0, 900.0), isNew: true);
|
||||
|
||||
Assert.True(double.IsFinite(pvd.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AsymmetricPeriods_Works()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 5, volumePeriod: 20, smoothingPeriod: 3);
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
pvd.Update(_bars[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.True(pvd.IsHot);
|
||||
Assert.True(double.IsFinite(pvd.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TValueInput_ThrowsNotSupported()
|
||||
{
|
||||
var pvd = new Pvd();
|
||||
var value = new TValue(DateTime.UtcNow, 100);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => pvd.Update(value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for PVD (Price Volume Divergence) indicator.
|
||||
/// PVD is a custom indicator not found in standard libraries (TA-Lib, Skender, Tulip, Ooples).
|
||||
/// Validation focuses on mathematical correctness and self-consistency.
|
||||
/// </summary>
|
||||
public class PvdValidationTests
|
||||
{
|
||||
private readonly TBarSeries _data;
|
||||
private const int TestDataLength = 500;
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
public PvdValidationTests()
|
||||
{
|
||||
var gbm = new GBM(seed: 123);
|
||||
_data = new TBarSeries();
|
||||
for (int i = 0; i < TestDataLength; i++)
|
||||
{
|
||||
_data.Add(gbm.Next());
|
||||
}
|
||||
}
|
||||
|
||||
#region Self-Consistency Validation
|
||||
|
||||
[Fact]
|
||||
public void Pvd_StreamingVsBatch_ExactMatch()
|
||||
{
|
||||
int pricePeriod = 14;
|
||||
int volumePeriod = 14;
|
||||
int smoothingPeriod = 3;
|
||||
|
||||
// Streaming calculation
|
||||
var pvdStreaming = new Pvd(pricePeriod, volumePeriod, smoothingPeriod);
|
||||
var streamingResults = new List<double>();
|
||||
for (int i = 0; i < _data.Count; i++)
|
||||
{
|
||||
streamingResults.Add(pvdStreaming.Update(_data[i], isNew: true).Value);
|
||||
}
|
||||
|
||||
// Batch calculation (uses static Calculate which uses span internally)
|
||||
var batchResults = Pvd.Calculate(_data, pricePeriod, volumePeriod, smoothingPeriod);
|
||||
|
||||
// Compare after full warmup (streaming and span may differ during warmup due to smoothing initialization)
|
||||
Assert.Equal(_data.Count, batchResults.Count);
|
||||
int warmup = Math.Max(pricePeriod, volumePeriod) + smoothingPeriod;
|
||||
for (int i = warmup; i < _data.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batchResults[i].Value, precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_SpanVsBatch_ExactMatch()
|
||||
{
|
||||
int pricePeriod = 10;
|
||||
int volumePeriod = 10;
|
||||
int smoothingPeriod = 5;
|
||||
|
||||
// Extract data for span
|
||||
double[] closes = new double[_data.Count];
|
||||
double[] volumes = new double[_data.Count];
|
||||
for (int i = 0; i < _data.Count; i++)
|
||||
{
|
||||
closes[i] = _data[i].Close;
|
||||
volumes[i] = _data[i].Volume;
|
||||
}
|
||||
|
||||
// Span calculation
|
||||
double[] spanResults = new double[_data.Count];
|
||||
Pvd.Calculate(closes.AsSpan(), volumes.AsSpan(), spanResults.AsSpan(), pricePeriod, volumePeriod, smoothingPeriod);
|
||||
|
||||
// Batch calculation
|
||||
var batchResults = Pvd.Calculate(_data, pricePeriod, volumePeriod, smoothingPeriod);
|
||||
|
||||
// Compare after warmup
|
||||
int startCompare = Math.Max(pricePeriod, volumePeriod) + smoothingPeriod;
|
||||
for (int i = startCompare; i < _data.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchResults[i].Value, spanResults[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_DifferentPeriods_ProduceDifferentResults()
|
||||
{
|
||||
var pvd1 = Pvd.Calculate(_data, pricePeriod: 5, volumePeriod: 5, smoothingPeriod: 3);
|
||||
var pvd2 = Pvd.Calculate(_data, pricePeriod: 20, volumePeriod: 20, smoothingPeriod: 3);
|
||||
|
||||
// After warmup, values should differ
|
||||
int compareIdx = _data.Count - 1;
|
||||
Assert.NotEqual(pvd1[compareIdx].Value, pvd2[compareIdx].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_AsymmetricPeriods_Work()
|
||||
{
|
||||
// Price period longer than volume period
|
||||
var pvd1 = Pvd.Calculate(_data, pricePeriod: 20, volumePeriod: 5, smoothingPeriod: 3);
|
||||
|
||||
// Volume period longer than price period
|
||||
var pvd2 = Pvd.Calculate(_data, pricePeriod: 5, volumePeriod: 20, smoothingPeriod: 3);
|
||||
|
||||
// Results should differ
|
||||
int compareIdx = _data.Count - 1;
|
||||
Assert.NotEqual(pvd1[compareIdx].Value, pvd2[compareIdx].Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mathematical Correctness Validation
|
||||
|
||||
[Fact]
|
||||
public void Pvd_KnownScenario_PositiveDivergence()
|
||||
{
|
||||
// Price up, volume down = positive divergence
|
||||
var bars = new TBarSeries();
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build baseline
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
bars.Add(new TBar(time.AddMinutes(i), 100.0, 100.0, 100.0, 100.0, 1000.0));
|
||||
}
|
||||
|
||||
// Price increasing, volume decreasing
|
||||
bars.Add(new TBar(time.AddMinutes(5), 110.0, 110.0, 110.0, 110.0, 800.0));
|
||||
|
||||
var result = Pvd.Calculate(bars, pricePeriod: 2, volumePeriod: 2, smoothingPeriod: 1);
|
||||
|
||||
// Last value should be positive (divergence detected)
|
||||
Assert.True(result[^1].Value > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_KnownScenario_NegativeDivergence()
|
||||
{
|
||||
// Price up, volume up = negative (same direction, no divergence)
|
||||
var bars = new TBarSeries();
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build baseline
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
bars.Add(new TBar(time.AddMinutes(i), 100.0, 100.0, 100.0, 100.0, 1000.0));
|
||||
}
|
||||
|
||||
// Price increasing, volume also increasing
|
||||
bars.Add(new TBar(time.AddMinutes(5), 110.0, 110.0, 110.0, 110.0, 1200.0));
|
||||
|
||||
var result = Pvd.Calculate(bars, pricePeriod: 2, volumePeriod: 2, smoothingPeriod: 1);
|
||||
|
||||
// Last value should be negative (price and volume moving same direction)
|
||||
Assert.True(result[^1].Value < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_KnownScenario_NoMomentum()
|
||||
{
|
||||
// No price change = zero divergence
|
||||
var bars = new TBarSeries();
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// All same values
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
bars.Add(new TBar(time.AddMinutes(i), 100.0, 100.0, 100.0, 100.0, 1000.0));
|
||||
}
|
||||
|
||||
var result = Pvd.Calculate(bars, pricePeriod: 3, volumePeriod: 3, smoothingPeriod: 2);
|
||||
|
||||
// Should be zero (no momentum in either direction)
|
||||
Assert.Equal(0.0, result[^1].Value, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_ManualCalculation_MatchesFormula()
|
||||
{
|
||||
// Create known data
|
||||
var bars = new TBarSeries();
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
double[] closes = [100.0, 102.0, 104.0, 106.0, 105.0];
|
||||
double[] volumes = [1000.0, 1100.0, 900.0, 1200.0, 800.0];
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
bars.Add(new TBar(time.AddMinutes(i), closes[i], closes[i], closes[i], closes[i], volumes[i]));
|
||||
}
|
||||
|
||||
// Manual calculation for last bar with period=2, smoothing=1
|
||||
// Price ROC at index 4: (105 - 104) / 104 * 100 = 0.9615...
|
||||
// Volume ROC at index 4: (800 - 900) / 900 * 100 = -11.111...
|
||||
// Price momentum = 1 (positive)
|
||||
// Volume momentum = -1 (negative)
|
||||
// Magnitude = |0.9615| + |-11.111| = 12.073...
|
||||
// Divergence = 1 * -(-1) * 12.073 = 12.073... (positive: price up, volume down)
|
||||
|
||||
var result = Pvd.Calculate(bars, pricePeriod: 2, volumePeriod: 2, smoothingPeriod: 1);
|
||||
|
||||
// Last value should be positive
|
||||
Assert.True(result[^1].Value > 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Smoothing Validation
|
||||
|
||||
[Fact]
|
||||
public void Pvd_SmoothingPeriod1_NoSmoothing()
|
||||
{
|
||||
var result1 = Pvd.Calculate(_data, pricePeriod: 10, volumePeriod: 10, smoothingPeriod: 1);
|
||||
var result3 = Pvd.Calculate(_data, pricePeriod: 10, volumePeriod: 10, smoothingPeriod: 3);
|
||||
|
||||
// Smoothing should make values different (and generally smoother)
|
||||
bool foundDifference = false;
|
||||
for (int i = 20; i < _data.Count; i++)
|
||||
{
|
||||
if (Math.Abs(result1[i].Value - result3[i].Value) > Tolerance)
|
||||
{
|
||||
foundDifference = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.True(foundDifference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_HigherSmoothing_ReducesVolatility()
|
||||
{
|
||||
var result1 = Pvd.Calculate(_data, pricePeriod: 10, volumePeriod: 10, smoothingPeriod: 1);
|
||||
var result10 = Pvd.Calculate(_data, pricePeriod: 10, volumePeriod: 10, smoothingPeriod: 10);
|
||||
|
||||
// Calculate variance of last 100 values
|
||||
double variance1 = CalculateVariance(result1.Skip(400).Select(x => x.Value).ToArray());
|
||||
double variance10 = CalculateVariance(result10.Skip(400).Select(x => x.Value).ToArray());
|
||||
|
||||
// Higher smoothing should reduce variance
|
||||
Assert.True(variance10 <= variance1);
|
||||
}
|
||||
|
||||
private static double CalculateVariance(double[] values)
|
||||
{
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
double mean = values.Average();
|
||||
return values.Sum(v => (v - mean) * (v - mean)) / values.Length;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases Validation
|
||||
|
||||
[Fact]
|
||||
public void Pvd_ZeroVolume_HandlesGracefully()
|
||||
{
|
||||
var bars = new TBarSeries();
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Mix of zero and non-zero volumes
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double volume = i % 3 == 0 ? 0.0 : 1000.0 + i * 10;
|
||||
bars.Add(new TBar(time.AddMinutes(i), 100.0 + i, 101.0 + i, 99.0 + i, 100.5 + i, volume));
|
||||
}
|
||||
|
||||
var result = Pvd.Calculate(bars, pricePeriod: 3, volumePeriod: 3, smoothingPeriod: 2);
|
||||
|
||||
// Should complete without errors
|
||||
Assert.Equal(20, result.Count);
|
||||
Assert.True(double.IsFinite(result[^1].Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_SingleBar_ReturnsZero()
|
||||
{
|
||||
var bars = new TBarSeries();
|
||||
bars.Add(new TBar(DateTime.UtcNow, 100.0, 100.0, 100.0, 100.0, 1000.0));
|
||||
|
||||
var result = Pvd.Calculate(bars, pricePeriod: 5, volumePeriod: 5, smoothingPeriod: 2);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(0.0, result[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_LargeDataset_CompletesWithoutError()
|
||||
{
|
||||
var gbm = new GBM(seed: 456);
|
||||
var largeData = new TBarSeries();
|
||||
|
||||
for (int i = 0; i < 10000; i++)
|
||||
{
|
||||
largeData.Add(gbm.Next());
|
||||
}
|
||||
|
||||
var result = Pvd.Calculate(largeData, pricePeriod: 14, volumePeriod: 14, smoothingPeriod: 3);
|
||||
|
||||
Assert.Equal(10000, result.Count);
|
||||
Assert.True(double.IsFinite(result[^1].Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset and State Validation
|
||||
|
||||
[Fact]
|
||||
public void Pvd_ResetAndRecalculate_SameResult()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 10, volumePeriod: 10, smoothingPeriod: 3);
|
||||
|
||||
// First pass
|
||||
for (int i = 0; i < _data.Count; i++)
|
||||
{
|
||||
pvd.Update(_data[i], isNew: true);
|
||||
}
|
||||
double firstResult = pvd.Last.Value;
|
||||
|
||||
// Reset and second pass
|
||||
pvd.Reset();
|
||||
for (int i = 0; i < _data.Count; i++)
|
||||
{
|
||||
pvd.Update(_data[i], isNew: true);
|
||||
}
|
||||
double secondResult = pvd.Last.Value;
|
||||
|
||||
Assert.Equal(firstResult, secondResult, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pvd_BarCorrection_ProducesConsistentResults()
|
||||
{
|
||||
var pvd = new Pvd(pricePeriod: 5, volumePeriod: 5, smoothingPeriod: 2);
|
||||
|
||||
// Process bars up to correction point
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
pvd.Update(_data[i], isNew: true);
|
||||
}
|
||||
|
||||
_ = pvd.Last.Value;
|
||||
|
||||
// Make multiple corrections
|
||||
for (int c = 0; c < 3; c++)
|
||||
{
|
||||
pvd.Update(_data[50], isNew: false);
|
||||
}
|
||||
|
||||
// Final value after corrections should be consistent
|
||||
pvd.Update(_data[50], isNew: true);
|
||||
double valueAfterCorrections = pvd.Last.Value;
|
||||
|
||||
Assert.True(double.IsFinite(valueAfterCorrections));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Documentation Validation
|
||||
|
||||
/// <summary>
|
||||
/// PVD is not implemented in major libraries.
|
||||
/// This test documents the validation status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ValidationStatus_NotInMajorLibraries()
|
||||
{
|
||||
// PVD is a custom indicator created for QuanTAlib
|
||||
// Not found in: TA-Lib, Skender.Stock.Indicators, Tulip, OoplesFinance
|
||||
// Validation is performed through self-consistency tests and mathematical verification
|
||||
Assert.True(true, "PVD is a custom indicator - validated through self-consistency and math verification");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// PVD: Price Volume Divergence
|
||||
/// Measures the divergence between price momentum and volume momentum.
|
||||
/// Detects situations where price and volume are moving in opposite directions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The PVD indicator calculates:
|
||||
/// 1. Price ROC = (Close - Close[pricePeriod]) / Close[pricePeriod] * 100
|
||||
/// 2. Volume ROC = (Volume - Volume[volumePeriod]) / Volume[volumePeriod] * 100
|
||||
/// 3. Price Momentum = Sign(Price ROC)
|
||||
/// 4. Volume Momentum = Sign(Volume ROC)
|
||||
/// 5. Magnitude = |Price ROC| + |Volume ROC|
|
||||
/// 6. Raw Divergence = Price Momentum * -Volume Momentum * Magnitude
|
||||
/// 7. PVD = SMA(Raw Divergence, smoothingPeriod)
|
||||
///
|
||||
/// Key characteristics:
|
||||
/// - Positive values indicate price up/volume down or price down/volume up divergence
|
||||
/// - Negative values indicate price and volume moving in same direction
|
||||
/// - Zero indicates no significant momentum in either price or volume
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Pvd : ITValuePublisher
|
||||
{
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double LastValidClose,
|
||||
double LastValidVolume,
|
||||
double LastValidPvd,
|
||||
int Index);
|
||||
|
||||
private State _s;
|
||||
private State _ps;
|
||||
private readonly RingBuffer _priceBuffer;
|
||||
private readonly RingBuffer _volumeBuffer;
|
||||
private readonly RingBuffer _divergenceBuffer;
|
||||
private readonly int _pricePeriod;
|
||||
private readonly int _volumePeriod;
|
||||
|
||||
public string Name { get; }
|
||||
public TValue Last { get; private set; }
|
||||
public bool IsHot => _s.Index >= WarmupPeriod;
|
||||
public int WarmupPeriod { get; }
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of PVD.
|
||||
/// </summary>
|
||||
/// <param name="pricePeriod">Lookback period for price momentum (default 14).</param>
|
||||
/// <param name="volumePeriod">Lookback period for volume momentum (default 14).</param>
|
||||
/// <param name="smoothingPeriod">Period for smoothing divergence (default 3).</param>
|
||||
/// <exception cref="ArgumentException">Thrown when any period is less than 1.</exception>
|
||||
public Pvd(int pricePeriod = 14, int volumePeriod = 14, int smoothingPeriod = 3)
|
||||
{
|
||||
if (pricePeriod < 1)
|
||||
{
|
||||
throw new ArgumentException("Price period must be >= 1", nameof(pricePeriod));
|
||||
}
|
||||
|
||||
if (volumePeriod < 1)
|
||||
{
|
||||
throw new ArgumentException("Volume period must be >= 1", nameof(volumePeriod));
|
||||
}
|
||||
|
||||
if (smoothingPeriod < 1)
|
||||
{
|
||||
throw new ArgumentException("Smoothing period must be >= 1", nameof(smoothingPeriod));
|
||||
}
|
||||
|
||||
_pricePeriod = pricePeriod;
|
||||
_volumePeriod = volumePeriod;
|
||||
WarmupPeriod = Math.Max(pricePeriod, volumePeriod) + smoothingPeriod;
|
||||
Name = $"Pvd({pricePeriod},{volumePeriod},{smoothingPeriod})";
|
||||
|
||||
_priceBuffer = new RingBuffer(pricePeriod + 1);
|
||||
_volumeBuffer = new RingBuffer(volumePeriod + 1);
|
||||
_divergenceBuffer = new RingBuffer(smoothingPeriod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the indicator state.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Reset()
|
||||
{
|
||||
_priceBuffer.Clear();
|
||||
_volumeBuffer.Clear();
|
||||
_divergenceBuffer.Clear();
|
||||
_s = default;
|
||||
_ps = default;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the PVD indicator with a new bar.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
_priceBuffer.Snapshot();
|
||||
_volumeBuffer.Snapshot();
|
||||
_divergenceBuffer.Snapshot();
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
_priceBuffer.Restore();
|
||||
_volumeBuffer.Restore();
|
||||
_divergenceBuffer.Restore();
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
// Handle NaN/Infinity in close price
|
||||
double close = double.IsFinite(input.Close) ? input.Close : s.LastValidClose;
|
||||
if (double.IsFinite(input.Close))
|
||||
{
|
||||
s.LastValidClose = input.Close;
|
||||
}
|
||||
|
||||
// Handle NaN/Infinity in volume
|
||||
double volume = double.IsFinite(input.Volume) ? input.Volume : s.LastValidVolume;
|
||||
if (double.IsFinite(input.Volume))
|
||||
{
|
||||
s.LastValidVolume = input.Volume;
|
||||
}
|
||||
|
||||
// Add to buffers
|
||||
_priceBuffer.Add(close);
|
||||
_volumeBuffer.Add(volume);
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
s.Index++;
|
||||
}
|
||||
|
||||
double pvdValue;
|
||||
|
||||
if (_priceBuffer.Count <= _pricePeriod || _volumeBuffer.Count <= _volumePeriod)
|
||||
{
|
||||
pvdValue = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Get previous values for ROC calculation
|
||||
double prevClose = _priceBuffer[_priceBuffer.Count - 1 - _pricePeriod];
|
||||
double prevVolumeRaw = _volumeBuffer[_volumeBuffer.Count - 1 - _volumePeriod];
|
||||
|
||||
// Clamp volumes to non-negative (matching static Calculate behavior)
|
||||
double currVolume = Math.Max(volume, 0.0);
|
||||
double prevVolume = Math.Max(prevVolumeRaw, 0.0);
|
||||
|
||||
// Calculate ROC percentages
|
||||
double priceRoc = prevClose > 0 ? (close - prevClose) / prevClose * 100.0 : 0.0;
|
||||
double volumeRoc = prevVolume > 0 ? (currVolume - prevVolume) / prevVolume * 100.0 : 0.0;
|
||||
|
||||
// Get momentum signs
|
||||
int priceMomentum;
|
||||
if (priceRoc > 0)
|
||||
{
|
||||
priceMomentum = 1;
|
||||
}
|
||||
else if (priceRoc < 0)
|
||||
{
|
||||
priceMomentum = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
priceMomentum = 0;
|
||||
}
|
||||
|
||||
int volumeMomentum;
|
||||
if (volumeRoc > 0)
|
||||
{
|
||||
volumeMomentum = 1;
|
||||
}
|
||||
else if (volumeRoc < 0)
|
||||
{
|
||||
volumeMomentum = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
volumeMomentum = 0;
|
||||
}
|
||||
|
||||
// Calculate magnitude and raw divergence
|
||||
double magnitude = Math.Abs(priceRoc) + Math.Abs(volumeRoc);
|
||||
double divergenceRaw = priceMomentum * -volumeMomentum * magnitude;
|
||||
|
||||
// Add to smoothing buffer
|
||||
_divergenceBuffer.Add(divergenceRaw);
|
||||
|
||||
// Calculate smoothed value (SMA of divergence)
|
||||
double sum = 0.0;
|
||||
int count = _divergenceBuffer.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
sum += _divergenceBuffer[i];
|
||||
}
|
||||
|
||||
pvdValue = count > 0 ? sum / count : divergenceRaw;
|
||||
}
|
||||
|
||||
s.LastValidPvd = pvdValue;
|
||||
_s = s;
|
||||
|
||||
Last = new TValue(input.Time, pvdValue);
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates PVD with a TValue input.
|
||||
/// </summary>
|
||||
/// <exception cref="NotSupportedException">
|
||||
/// PVD requires OHLCV bar data to calculate Price and Volume ROC.
|
||||
/// Use Update(TBar) instead.
|
||||
/// </exception>
|
||||
#pragma warning disable S2325 // Method signature must match ITValuePublisher contract
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
#pragma warning restore S2325
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"PVD requires OHLCV bar data to calculate Price and Volume ROC. " +
|
||||
"Use Update(TBar) instead.");
|
||||
}
|
||||
|
||||
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++)
|
||||
{
|
||||
TValue val = Update(source[i], isNew: true);
|
||||
t.Add(val.Time);
|
||||
v.Add(val.Value);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
public static TSeries Calculate(TBarSeries source, int pricePeriod = 14, int volumePeriod = 14, int smoothingPeriod = 3)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var t = source.Close.Times.ToArray();
|
||||
var v = new double[source.Count];
|
||||
|
||||
Calculate(source.Close.Values, source.Volume.Values, v, pricePeriod, volumePeriod, smoothingPeriod);
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Calculate(ReadOnlySpan<double> close, ReadOnlySpan<double> volume, Span<double> output,
|
||||
int pricePeriod = 14, int volumePeriod = 14, int smoothingPeriod = 3)
|
||||
{
|
||||
if (close.Length != volume.Length)
|
||||
{
|
||||
throw new ArgumentException("Close and Volume spans must be of the same length", nameof(volume));
|
||||
}
|
||||
|
||||
if (close.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Output span must be of the same length as input", nameof(output));
|
||||
}
|
||||
|
||||
if (pricePeriod < 1)
|
||||
{
|
||||
throw new ArgumentException("Price period must be >= 1", nameof(pricePeriod));
|
||||
}
|
||||
|
||||
if (volumePeriod < 1)
|
||||
{
|
||||
throw new ArgumentException("Volume period must be >= 1", nameof(volumePeriod));
|
||||
}
|
||||
|
||||
if (smoothingPeriod < 1)
|
||||
{
|
||||
throw new ArgumentException("Smoothing period must be >= 1", nameof(smoothingPeriod));
|
||||
}
|
||||
|
||||
int len = close.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int maxPeriod = Math.Max(pricePeriod, volumePeriod);
|
||||
|
||||
// Allocate buffer for raw divergence
|
||||
Span<double> rawDivergence = len <= 256 ? stackalloc double[len] : new double[len];
|
||||
|
||||
// Calculate raw divergence for each bar (use NaN to mark invalid entries)
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
if (i < maxPeriod)
|
||||
{
|
||||
rawDivergence[i] = double.NaN; // Mark as invalid - no ROC data yet
|
||||
continue;
|
||||
}
|
||||
|
||||
double currClose = close[i];
|
||||
double currVolume = Math.Max(volume[i], 0.0);
|
||||
double prevClose = close[i - pricePeriod];
|
||||
double prevVolume = Math.Max(volume[i - volumePeriod], 0.0);
|
||||
|
||||
double priceRoc = prevClose > 0 ? (currClose - prevClose) / prevClose * 100.0 : 0.0;
|
||||
double volumeRoc = prevVolume > 0 ? (currVolume - prevVolume) / prevVolume * 100.0 : 0.0;
|
||||
|
||||
int priceMomentum;
|
||||
if (priceRoc > 0)
|
||||
{
|
||||
priceMomentum = 1;
|
||||
}
|
||||
else if (priceRoc < 0)
|
||||
{
|
||||
priceMomentum = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
priceMomentum = 0;
|
||||
}
|
||||
|
||||
int volumeMomentum;
|
||||
if (volumeRoc > 0)
|
||||
{
|
||||
volumeMomentum = 1;
|
||||
}
|
||||
else if (volumeRoc < 0)
|
||||
{
|
||||
volumeMomentum = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
volumeMomentum = 0;
|
||||
}
|
||||
|
||||
double magnitude = Math.Abs(priceRoc) + Math.Abs(volumeRoc);
|
||||
rawDivergence[i] = priceMomentum * -volumeMomentum * magnitude;
|
||||
}
|
||||
|
||||
// Apply SMA smoothing (only over valid divergence entries, skip NaN)
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
if (i < maxPeriod)
|
||||
{
|
||||
// No valid divergence data yet - output 0 (matching instance behavior)
|
||||
output[i] = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate SMA over valid entries in the smoothing window
|
||||
double sum = 0.0;
|
||||
int validCount = 0;
|
||||
int windowStart = Math.Max(maxPeriod, i - smoothingPeriod + 1);
|
||||
|
||||
for (int j = windowStart; j <= i; j++)
|
||||
{
|
||||
sum += rawDivergence[j];
|
||||
validCount++;
|
||||
}
|
||||
|
||||
output[i] = validCount > 0 ? sum / validCount : 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
# PVD: Price Volume Divergence
|
||||
|
||||
> "When price and volume disagree, one of them is lying."
|
||||
|
||||
Price Volume Divergence (PVD) quantifies the disagreement between price momentum and volume momentum. The indicator identifies situations where price movement lacks volume confirmation—a classic warning signal that the current trend may be weakening or about to reverse.
|
||||
|
||||
## Historical Context
|
||||
|
||||
The relationship between price and volume has been a cornerstone of technical analysis since Charles Dow first articulated his theories in the late 1800s. The core principle: volume should confirm price movements. Rising prices on rising volume suggest strong conviction; rising prices on declining volume suggest weak hands.
|
||||
|
||||
PVD formalizes this intuition into a measurable oscillator. Unlike simple volume overlays or static divergence rules, PVD produces a continuous signal that can be smoothed and compared across different timeframes. The indicator combines Rate of Change (ROC) calculations for both price and volume, then measures the magnitude of their disagreement.
|
||||
|
||||
This implementation follows the design principles established in the QuanTAlib PineScript reference, with optimizations for streaming calculation and bar correction support.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. Rate of Change Calculation
|
||||
|
||||
Both price and volume momentum are measured using percentage Rate of Change:
|
||||
|
||||
$$
|
||||
ROC_{price,t} = \frac{C_t - C_{t-p}}{C_{t-p}} \times 100
|
||||
$$
|
||||
|
||||
$$
|
||||
ROC_{volume,t} = \frac{V_t - V_{t-v}}{V_{t-v}} \times 100
|
||||
$$
|
||||
|
||||
where:
|
||||
- $C_t$ = Close price at time $t$
|
||||
- $V_t$ = Volume at time $t$
|
||||
- $p$ = Price lookback period
|
||||
- $v$ = Volume lookback period
|
||||
|
||||
### 2. Momentum Sign Extraction
|
||||
|
||||
The direction of momentum is captured as a sign function:
|
||||
|
||||
$$
|
||||
M_{price} = \text{sign}(ROC_{price}) = \begin{cases}
|
||||
+1 & \text{if } ROC_{price} > 0 \\
|
||||
-1 & \text{if } ROC_{price} < 0 \\
|
||||
0 & \text{if } ROC_{price} = 0
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
$$
|
||||
M_{volume} = \text{sign}(ROC_{volume}) = \begin{cases}
|
||||
+1 & \text{if } ROC_{volume} > 0 \\
|
||||
-1 & \text{if } ROC_{volume} < 0 \\
|
||||
0 & \text{if } ROC_{volume} = 0
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
### 3. Divergence Calculation
|
||||
|
||||
The raw divergence combines direction disagreement with magnitude:
|
||||
|
||||
$$
|
||||
\text{Magnitude}_t = |ROC_{price,t}| + |ROC_{volume,t}|
|
||||
$$
|
||||
|
||||
$$
|
||||
D_{raw,t} = M_{price} \times (-M_{volume}) \times \text{Magnitude}_t
|
||||
$$
|
||||
|
||||
The negation of $M_{volume}$ means:
|
||||
- **Positive PVD**: Price and volume moving in opposite directions (divergence)
|
||||
- **Negative PVD**: Price and volume moving in same direction (confirmation)
|
||||
- **Zero PVD**: No momentum in price or volume
|
||||
|
||||
### 4. Smoothing Filter
|
||||
|
||||
Raw divergence is smoothed using a Simple Moving Average:
|
||||
|
||||
$$
|
||||
PVD_t = \frac{1}{s} \sum_{i=0}^{s-1} D_{raw,t-i}
|
||||
$$
|
||||
|
||||
where $s$ = smoothing period.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Divergence Interpretation
|
||||
|
||||
| Price | Volume | $M_p \times (-M_v)$ | PVD Sign | Interpretation |
|
||||
| :---: | :---: | :---: | :---: | :--- |
|
||||
| ↑ | ↓ | +1 × +1 = +1 | **Positive** | Bearish divergence (price up on declining volume) |
|
||||
| ↓ | ↑ | -1 × -1 = +1 | **Positive** | Bullish divergence (price down on rising volume) |
|
||||
| ↑ | ↑ | +1 × -1 = -1 | **Negative** | Bullish confirmation |
|
||||
| ↓ | ↓ | -1 × +1 = -1 | **Negative** | Bearish confirmation |
|
||||
| — | — | 0 | **Zero** | No momentum |
|
||||
|
||||
### Magnitude Weighting
|
||||
|
||||
The magnitude term ensures that small price/volume changes produce small PVD values, while large movements produce large signals. This prevents noise from creating false divergence signals when both price and volume are essentially flat.
|
||||
|
||||
### Parameter Relationships
|
||||
|
||||
- **pricePeriod**: Lookback for price momentum (default: 14)
|
||||
- **volumePeriod**: Lookback for volume momentum (default: 14)
|
||||
- **smoothingPeriod**: SMA window for noise reduction (default: 3)
|
||||
|
||||
Asymmetric periods (different pricePeriod and volumePeriod) can be useful when price and volume have different characteristic timescales.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, Scalar)
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| SUB | 4 | 1 | 4 |
|
||||
| DIV | 2 | 15 | 30 |
|
||||
| MUL | 3 | 3 | 9 |
|
||||
| ABS | 2 | 1 | 2 |
|
||||
| CMP | 4 | 1 | 4 |
|
||||
| ADD (SMA sum) | s | 1 | s |
|
||||
| DIV (SMA) | 1 | 15 | 15 |
|
||||
| **Total** | — | — | **~64 + s cycles** |
|
||||
|
||||
For default smoothingPeriod=3: ~67 cycles per bar.
|
||||
|
||||
### Batch Mode (512 values, SIMD potential)
|
||||
|
||||
The ROC and magnitude calculations are SIMD-friendly. However, the sign extraction and multiplication introduce branching that limits vectorization benefits. The SMA smoothing pass is straightforward to vectorize.
|
||||
|
||||
| Mode | Cycles/bar | Total (512 bars) |
|
||||
| :--- | :---: | :---: |
|
||||
| Scalar streaming | ~67 | ~34,304 |
|
||||
| Partial SIMD | ~45 | ~23,040 |
|
||||
| **Improvement** | **33%** | — |
|
||||
|
||||
### Memory Footprint
|
||||
|
||||
| Component | Size |
|
||||
| :--- | :--- |
|
||||
| State record struct | 40 bytes |
|
||||
| Price RingBuffer | (pricePeriod + 1) × 8 bytes |
|
||||
| Volume RingBuffer | (volumePeriod + 1) × 8 bytes |
|
||||
| Divergence RingBuffer | smoothingPeriod × 8 bytes |
|
||||
| **Total (default params)** | ~320 bytes |
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 9/10 | Direct ROC calculation, minimal approximation |
|
||||
| **Timeliness** | 7/10 | SMA smoothing adds lag proportional to period |
|
||||
| **Overshoot** | 8/10 | Magnitude weighting prevents wild swings |
|
||||
| **Smoothness** | 7/10 | Configurable via smoothingPeriod |
|
||||
| **Interpretability** | 9/10 | Clear positive/negative divergence meaning |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **TA-Lib** | N/A | Not implemented |
|
||||
| **Skender** | N/A | Not implemented |
|
||||
| **Tulip** | N/A | Not implemented |
|
||||
| **Ooples** | N/A | Not implemented |
|
||||
| **Self-consistency** | ✅ | Streaming == Batch == Span |
|
||||
| **Math verification** | ✅ | Manual calculation tests pass |
|
||||
|
||||
PVD is a custom indicator not found in standard technical analysis libraries. Validation is performed through:
|
||||
1. Self-consistency across all calculation modes
|
||||
2. Manual calculation verification with known inputs
|
||||
3. Edge case testing (zero volume, constant prices, etc.)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Warmup Period**: PVD requires `max(pricePeriod, volumePeriod) + smoothingPeriod` bars before producing meaningful values. During warmup, the indicator returns zero.
|
||||
|
||||
2. **Interpretation Confusion**: Positive PVD means divergence (price/volume disagreement), not necessarily bullish. A positive PVD with rising prices suggests bearish divergence (weak rally).
|
||||
|
||||
3. **Smoothing Trade-off**: Higher smoothingPeriod reduces noise but increases lag. For short-term trading, use smoothingPeriod=1-2. For position trading, 5-10 may be appropriate.
|
||||
|
||||
4. **Zero Volume Handling**: Zero volume produces zero volume ROC, which yields zero divergence. Markets with frequent zero-volume bars may produce misleading flat periods.
|
||||
|
||||
5. **Asymmetric Periods**: Using different pricePeriod and volumePeriod changes the warmup calculation. The effective warmup is `max(pricePeriod, volumePeriod) + smoothingPeriod`.
|
||||
|
||||
6. **Bar Correction (isNew=false)**: When correcting a bar, internal state rolls back to the previous bar's state. Multiple corrections in sequence are supported but each uses the same rollback point.
|
||||
|
||||
## References
|
||||
|
||||
- Dow, C. (1900-1902). *Wall Street Journal* editorials on price-volume relationships.
|
||||
- Murphy, J. J. (1999). *Technical Analysis of the Financial Markets*. New York Institute of Finance.
|
||||
- Achelis, S. B. (2001). *Technical Analysis from A to Z*. McGraw-Hill.
|
||||
Reference in New Issue
Block a user