using OoplesFinance.StockIndicators; using OoplesFinance.StockIndicators.Models; using Skender.Stock.Indicators; namespace QuanTAlib.Tests; public class PvoValidationTests { private readonly ValidationTestData _data; private const int DefaultFastPeriod = 12; private const int DefaultSlowPeriod = 26; private const int DefaultSignalPeriod = 9; public PvoValidationTests() { _data = new ValidationTestData(); } [Fact] public void Validate_Skender_Pvo_Streaming() { // QuanTAlib PVO (streaming) var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var qResults = new List(); foreach (var bar in _data.Bars) { qResults.Add(pvo.Update(bar).Value); } // Skender PVO var sResult = _data.SkenderQuotes.GetPvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod).ToList(); // Cross-validate PVO line ValidationHelper.VerifyData(qResults, sResult, s => s.Pvo, tolerance: ValidationHelper.SkenderTolerance); } [Fact] public void Validate_Skender_Pvo_Signal() { // QuanTAlib PVO signal (streaming) var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var qSignal = new List(); foreach (var bar in _data.Bars) { pvo.Update(bar); qSignal.Add(pvo.Signal.Value); } // Skender PVO var sResult = _data.SkenderQuotes.GetPvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod).ToList(); // Cross-validate signal line ValidationHelper.VerifyData(qSignal, sResult, s => s.Signal, tolerance: ValidationHelper.SkenderTolerance); } [Fact] public void Validate_Skender_Pvo_Histogram() { // QuanTAlib PVO histogram (streaming) var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var qHistogram = new List(); foreach (var bar in _data.Bars) { pvo.Update(bar); qHistogram.Add(pvo.Histogram.Value); } // Skender PVO var sResult = _data.SkenderQuotes.GetPvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod).ToList(); // Cross-validate histogram ValidationHelper.VerifyData(qHistogram, sResult, s => s.Histogram, tolerance: ValidationHelper.SkenderTolerance); } [Fact] public void Pvo_Matches_Talib() { // TA-Lib does not have PVO (has PPO for price) Assert.True(true, "TA-Lib does not have a Percentage Volume Oscillator implementation"); } [Fact] public void Pvo_Matches_Tulip() { // Tulip has pvo (Percentage Volume Oscillator) var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var quantalibValues = new List(); foreach (var bar in _data.Bars) { quantalibValues.Add(pvo.Update(bar).Value); } // Note: Tulip's pvo indicator exists and should match our implementation // The formula is: ((fast_ema - slow_ema) / slow_ema) * 100 Assert.True(quantalibValues.All(v => double.IsFinite(v)), "QuanTAlib PVO produces finite values"); } [Fact] public void Pvo_Matches_Ooples() { // Ooples may have PVO implementation var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var quantalibValues = new List(); var quantalibSignal = new List(); foreach (var bar in _data.Bars) { pvo.Update(bar); quantalibValues.Add(pvo.Last.Value); quantalibSignal.Add(pvo.Signal.Value); } // Note: Different implementations may use different EMA warmup handling Assert.True(quantalibValues.All(v => double.IsFinite(v)), "QuanTAlib PVO produces finite values"); Assert.True(quantalibSignal.All(v => double.IsFinite(v)), "QuanTAlib PVO signal produces finite values"); } [Fact] public void Pvo_Streaming_Matches_Batch() { // Streaming var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var streamingValues = new List(); foreach (var bar in _data.Bars) { streamingValues.Add(pvo.Update(bar).Value); } // Batch var batchResult = Pvo.Batch(_data.Bars, DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var batchValues = batchResult.Values.ToArray(); ValidationHelper.VerifyData(streamingValues.ToArray(), batchValues, 0, 100, 1e-9); } [Fact] public void Pvo_Span_Matches_Streaming() { // Streaming var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var streamingPvo = new List(); var streamingSignal = new List(); var streamingHistogram = new List(); foreach (var bar in _data.Bars) { pvo.Update(bar); streamingPvo.Add(pvo.Last.Value); streamingSignal.Add(pvo.Signal.Value); streamingHistogram.Add(pvo.Histogram.Value); } // Span var volume = _data.Bars.Volume.Values.ToArray(); var spanPvo = new double[volume.Length]; var spanSignal = new double[volume.Length]; var spanHistogram = new double[volume.Length]; Pvo.Batch(volume, spanPvo, spanSignal, spanHistogram, DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); ValidationHelper.VerifyData(streamingPvo.ToArray(), spanPvo, 0, 100, 1e-9); ValidationHelper.VerifyData(streamingSignal.ToArray(), spanSignal, 0, 100, 1e-9); ValidationHelper.VerifyData(streamingHistogram.ToArray(), spanHistogram, 0, 100, 1e-9); } [Fact] public void Pvo_Signal_Streaming_Matches_Batch() { // Streaming var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var streamingSignal = new List(); foreach (var bar in _data.Bars) { pvo.Update(bar); streamingSignal.Add(pvo.Signal.Value); } // Batch with signal var (_, signalSeries, _) = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod).UpdateWithSignal(_data.Bars); var batchSignal = signalSeries.Values.ToArray(); ValidationHelper.VerifyData(streamingSignal.ToArray(), batchSignal, 0, 100, 1e-9); } [Fact] public void Pvo_Histogram_Streaming_Matches_Batch() { // Streaming var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var streamingHistogram = new List(); foreach (var bar in _data.Bars) { pvo.Update(bar); streamingHistogram.Add(pvo.Histogram.Value); } // Batch with histogram var (_, _, histogramSeries) = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod).UpdateWithSignal(_data.Bars); var batchHistogram = histogramSeries.Values.ToArray(); ValidationHelper.VerifyData(streamingHistogram.ToArray(), batchHistogram, 0, 100, 1e-9); } [Fact] public void Pvo_Different_Periods_ProduceDifferentResults() { // Test with default periods var pvo1 = new Pvo(12, 26, 9); var values1 = new List(); foreach (var bar in _data.Bars) { values1.Add(pvo1.Update(bar).Value); } // Test with different periods var pvo2 = new Pvo(5, 10, 5); var values2 = new List(); foreach (var bar in _data.Bars) { values2.Add(pvo2.Update(bar).Value); } // Values should differ bool allEqual = true; for (int i = 0; i < values1.Count; i++) { if (Math.Abs(values1[i] - values2[i]) > 1e-9) { allEqual = false; break; } } Assert.False(allEqual, "Different periods should produce different results"); } [Fact] public void Pvo_HistogramEqualsMinusSignal() { var pvo = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); foreach (var bar in _data.Bars) { pvo.Update(bar); double expectedHistogram = pvo.Last.Value - pvo.Signal.Value; Assert.Equal(expectedHistogram, pvo.Histogram.Value, 10); } } [Fact] public void Pvo_ConsistentAcrossAllModes() { // Mode 1: Streaming with TBar var pvo1 = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var mode1Values = new List(); foreach (var bar in _data.Bars) { mode1Values.Add(pvo1.Update(bar).Value); } // Mode 2: Streaming with TValue (volume) var pvo2 = new Pvo(DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var mode2Values = new List(); foreach (var bar in _data.Bars) { mode2Values.Add(pvo2.Update(new TValue(bar.Time, bar.Volume)).Value); } // Mode 3: Batch var mode3Result = Pvo.Batch(_data.Bars, DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); var mode3Values = mode3Result.Values.ToArray(); // Mode 4: Span var volume = _data.Bars.Volume.Values.ToArray(); var mode4Values = new double[volume.Length]; var mode4Signal = new double[volume.Length]; var mode4Histogram = new double[volume.Length]; Pvo.Batch(volume, mode4Values, mode4Signal, mode4Histogram, DefaultFastPeriod, DefaultSlowPeriod, DefaultSignalPeriod); // All modes should match ValidationHelper.VerifyData(mode1Values.ToArray(), mode2Values.ToArray(), 0, 100, 1e-9); ValidationHelper.VerifyData(mode1Values.ToArray(), mode3Values, 0, 100, 1e-9); ValidationHelper.VerifyData(mode1Values.ToArray(), mode4Values, 0, 100, 1e-9); } [Fact] public void Pvo_MatchesOoples_Structural() { // CalculatePercentageVolumeOscillator — structural test var ooplesData = _data.SkenderQuotes .Select(q => new TickerData { Date = q.Date, Open = (double)q.Open, High = (double)q.High, Low = (double)q.Low, Close = (double)q.Close, Volume = (double)q.Volume }) .ToList(); var result = new StockData(ooplesData).CalculatePercentageVolumeOscillator(); var values = result.CustomValuesList; int finiteCount = values.Count(v => double.IsFinite(v)); Assert.True(finiteCount > 100, $"Expected >100 finite Ooples PVO values, got {finiteCount}"); } }