// OoplesFinance: CalculateChandeVolatilityIndexDynamicAverageIndicator exists but implements // a different algorithm (Chande Volatility Index Dynamic Average / VIDA) rather than the // Chaikin Volatility Index (EMA of High-Low range, then ROC). The two share the "CVI" // abbreviation but are mathematically distinct. Numeric equality is not expected. using OoplesFinance.StockIndicators; using OoplesFinance.StockIndicators.Models; using Tulip; namespace QuanTAlib.Test; using QuanTAlib.Tests; using Xunit; /// /// Validation tests for CVI (Chaikin's Volatility). /// CVI measures the rate of change of EMA-smoothed high-low range. /// Formula: CVI = ((EMA_t - EMA_{t-rocLength}) / EMA_{t-rocLength}) × 100 /// where EMA is applied to (High - Low) range. /// public class CviValidationTests { private static TBarSeries GenerateTestData(int count = 100) { var gbm = new GBM(seed: 42); return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); } // === Mathematical Validation === /// /// Validates the EMA alpha formula: α = 2 / (smoothLength + 1) /// [Theory] [InlineData(10, 0.181818181818182)] // 2/(10+1) = 0.1818... [InlineData(14, 0.133333333333333)] // 2/(14+1) = 0.1333... [InlineData(20, 0.095238095238095)] // 2/(20+1) = 0.0952... public void Cvi_EmaAlpha_IsCorrect(int smoothLength, double expectedAlpha) { double alpha = 2.0 / (smoothLength + 1); Assert.Equal(expectedAlpha, alpha, 10); } /// /// Validates ROC formula: ((current - prior) / prior) × 100 /// [Fact] public void Cvi_RocFormula_IsCorrect() { // Manual ROC calculation double currentEma = 10.0; double priorEma = 8.0; double expectedRoc = ((currentEma - priorEma) / priorEma) * 100.0; Assert.Equal(25.0, expectedRoc, 10); // (10-8)/8 * 100 = 25% } /// /// Validates that constant high-low range produces zero CVI after warmup. /// [Fact] public void Cvi_ConstantRange_ProducesZeroCvi() { var cvi = new Cvi(10, 10); // Feed constant range bars for (int i = 0; i < 30; i++) { var bar = new TBar( DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 105.0, 95.0, 102.0, 1000.0 // Constant 10-point range ); cvi.Update(bar); } // Constant range means EMA_t = EMA_{t-rocLength}, so ROC = 0 Assert.Equal(0.0, cvi.Last.Value, 5); } /// /// Validates expanding range produces positive CVI. /// [Fact] public void Cvi_ExpandingRange_ProducesPositiveCvi() { var cvi = new Cvi(5, 5); // Gradually expanding range for (int i = 0; i < 20; i++) { double range = 5 + i * 0.5; // Expanding from 5 to 14.5 var bar = new TBar( DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0 ); cvi.Update(bar); } // Expanding range should produce positive CVI (EMA increasing) Assert.True(cvi.Last.Value > 0, "Expanding range should produce positive CVI"); } /// /// Validates contracting range produces negative CVI. /// [Fact] public void Cvi_ContractingRange_ProducesNegativeCvi() { var cvi = new Cvi(5, 5); // Gradually contracting range for (int i = 0; i < 20; i++) { double range = 20 - i * 0.5; // Contracting from 20 to 10.5 if (range < 1) { range = 1; } var bar = new TBar( DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0 ); cvi.Update(bar); } // Contracting range should produce negative CVI (EMA decreasing) Assert.True(cvi.Last.Value < 0, "Contracting range should produce negative CVI"); } /// /// Validates manual CVI calculation matches implementation. /// [Fact] public void Cvi_ManualCalculation_MatchesImplementation() { int rocLength = 3; int smoothLength = 3; double alpha = 2.0 / (smoothLength + 1); // 0.5 // Fixed range values double[] ranges = { 10.0, 12.0, 11.0, 13.0, 15.0, 14.0, 16.0, 18.0, 17.0, 19.0 }; // Calculate EMA manually double[] emas = new double[ranges.Length]; emas[0] = ranges[0]; for (int i = 1; i < ranges.Length; i++) { emas[i] = (ranges[i] - emas[i - 1]) * alpha + emas[i - 1]; } // Calculate ROC for last point int lastIdx = ranges.Length - 1; double oldEma = emas[lastIdx - rocLength]; double currentEma = emas[lastIdx]; double expectedCvi = ((currentEma - oldEma) / oldEma) * 100.0; // Calculate using indicator var cvi = new Cvi(rocLength, smoothLength); for (int i = 0; i < ranges.Length; i++) { cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), ranges[i])); } Assert.Equal(expectedCvi, cvi.Last.Value, 8); } /// /// Validates EMA smoothing property: EMA responds to recent values more. /// [Fact] public void Cvi_EmaSmoothingProperty_RecentValuesWeightedMore() { var cvi = new Cvi(5, 5); // Feed stable values then spike for (int i = 0; i < 15; i++) { cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 10.0)); } double preSpikeValue = cvi.Last.Value; // Single spike cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(15), 20.0)); double postSpikeValue = cvi.Last.Value; // EMA should respond to spike (increasing CVI since range doubled) Assert.True(postSpikeValue > preSpikeValue, "EMA should respond to recent value changes"); } // === Consistency Tests === /// /// Validates streaming and batch produce identical results. /// [Fact] public void Cvi_StreamingMatchesBatch() { var bars = GenerateTestData(100); // Streaming calculation var streamingCvi = new Cvi(10, 10); for (int i = 0; i < bars.Count; i++) { streamingCvi.Update(bars[i]); } // Batch calculation var batchResult = Cvi.Batch(bars, 10, 10); // Compare last values Assert.Equal(batchResult.Last.Value, streamingCvi.Last.Value, 8); } /// /// Validates TBarSeries input matches TBar streaming. /// [Fact] public void Cvi_TBarSeriesInput_MatchesStreaming() { var bars = GenerateTestData(100); // Streaming var streamingCvi = new Cvi(10, 10); for (int i = 0; i < bars.Count; i++) { streamingCvi.Update(bars[i]); } // TBarSeries batch var batchCvi = new Cvi(10, 10); var batchResult = batchCvi.Update(bars); Assert.Equal(batchResult.Last.Value, streamingCvi.Last.Value, 10); } /// /// Validates Span batch matches streaming. /// [Fact] public void Cvi_SpanBatch_MatchesStreaming() { var bars = GenerateTestData(100); // Extract ranges from bars var ranges = new double[bars.Count]; for (int i = 0; i < bars.Count; i++) { ranges[i] = bars[i].High - bars[i].Low; } // Streaming var streamingCvi = new Cvi(10, 10); for (int i = 0; i < bars.Count; i++) { streamingCvi.Update(new TValue(bars.Times[i], ranges[i])); } // Span batch var output = new double[ranges.Length]; Cvi.Batch(ranges, output, 10, 10); Assert.Equal(output[^1], streamingCvi.Last.Value, 10); } // === Parameter Sensitivity === /// /// Validates shorter rocLength produces more volatile CVI. /// [Fact] public void Cvi_ShorterRocLength_MoreVolatile() { var bars = GenerateTestData(100); var cviShort = new Cvi(5, 10); // rocLength = 5 var cviLong = new Cvi(20, 10); // rocLength = 20 var shortResults = new List(); var longResults = new List(); for (int i = 0; i < bars.Count; i++) { cviShort.Update(bars[i]); cviLong.Update(bars[i]); if (cviShort.IsHot && cviLong.IsHot) { shortResults.Add(cviShort.Last.Value); longResults.Add(cviLong.Last.Value); } } // Shorter rocLength should generally produce more volatile CVI values // (comparing values over fewer periods) Assert.True(shortResults.Count > 0, "Should have hot results"); } /// /// Validates shorter smoothLength produces faster response. /// [Fact] public void Cvi_ShorterSmoothLength_FasterResponse() { var cviShort = new Cvi(10, 5); // smoothLength = 5 var cviLong = new Cvi(10, 20); // smoothLength = 20 // Feed stable values for (int i = 0; i < 30; i++) { cviShort.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 10.0)); cviLong.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 10.0)); } double preShortValue = cviShort.Last.Value; double preLongValue = cviLong.Last.Value; // Spike in range cviShort.Update(new TValue(DateTime.UtcNow.AddMinutes(30), 20.0)); cviLong.Update(new TValue(DateTime.UtcNow.AddMinutes(30), 20.0)); double changeShort = Math.Abs(cviShort.Last.Value - preShortValue); double changeLong = Math.Abs(cviLong.Last.Value - preLongValue); // Shorter smoothLength should show larger immediate change Assert.True(changeShort > changeLong, "Shorter smoothLength should respond faster to changes"); } /// /// Validates different parameter combinations produce different results. /// [Fact] public void Cvi_DifferentParameters_ProduceDifferentResults() { var bars = GenerateTestData(50); var cvi1 = new Cvi(10, 10); var cvi2 = new Cvi(14, 10); var cvi3 = new Cvi(10, 14); for (int i = 0; i < bars.Count; i++) { cvi1.Update(bars[i]); cvi2.Update(bars[i]); cvi3.Update(bars[i]); } // Different parameters should produce different values Assert.NotEqual(cvi1.Last.Value, cvi2.Last.Value); Assert.NotEqual(cvi1.Last.Value, cvi3.Last.Value); } // === Edge Cases === /// /// Validates handling of very small ranges. /// [Fact] public void Cvi_VerySmallRanges_HandledCorrectly() { var cvi = new Cvi(5, 5); for (int i = 0; i < 20; i++) { // Very small range (0.0001) var bar = new TBar( DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 100.00005, 99.99995, 100.0, 1000.0 ); cvi.Update(bar); } Assert.True(double.IsFinite(cvi.Last.Value)); } /// /// Validates handling of very large ranges. /// [Fact] public void Cvi_VeryLargeRanges_HandledCorrectly() { var cvi = new Cvi(5, 5); for (int i = 0; i < 20; i++) { // Large range var bar = new TBar( DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 200.0, 50.0, 150.0, 1000.0 ); cvi.Update(bar); } Assert.True(double.IsFinite(cvi.Last.Value)); } /// /// Validates handling of alternating large/small ranges. /// [Fact] public void Cvi_AlternatingRanges_HandledCorrectly() { var cvi = new Cvi(5, 5); for (int i = 0; i < 20; i++) { double range = (i % 2 == 0) ? 5.0 : 20.0; var bar = new TBar( DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0 ); cvi.Update(bar); } Assert.True(double.IsFinite(cvi.Last.Value)); } /// /// Validates warmup period calculation. /// [Theory] [InlineData(10, 10, 20)] [InlineData(14, 10, 24)] [InlineData(5, 20, 25)] public void Cvi_WarmupPeriod_IsCorrect(int rocLength, int smoothLength, int expectedWarmup) { var cvi = new Cvi(rocLength, smoothLength); Assert.Equal(expectedWarmup, cvi.WarmupPeriod); } /// /// Validates output range is reasonable for typical market data. /// [Fact] public void Cvi_OutputRange_IsReasonable() { var bars = GenerateTestData(100); var cvi = new Cvi(10, 10); for (int i = 0; i < bars.Count; i++) { cvi.Update(bars[i]); } // CVI is a percentage ROC, typically between -100% and +100% for normal markets // Extreme values possible but rare Assert.True(cvi.Last.Value > -500, "CVI should be > -500%"); Assert.True(cvi.Last.Value < 500, "CVI should be < +500%"); } /// /// Validates CVI sign indicates volatility direction. /// [Fact] public void Cvi_Sign_IndicatesVolatilityDirection() { // Test expanding volatility var cviExpanding = new Cvi(5, 5); for (int i = 0; i < 15; i++) { double range = 5 + i; // Expanding cviExpanding.Update(new TValue(DateTime.UtcNow.AddMinutes(i), range)); } // Test contracting volatility var cviContracting = new Cvi(5, 5); for (int i = 0; i < 15; i++) { double range = 20 - i; // Contracting if (range < 1) { range = 1; } cviContracting.Update(new TValue(DateTime.UtcNow.AddMinutes(i), range)); } Assert.True(cviExpanding.Last.Value > 0, "Expanding volatility should produce positive CVI"); Assert.True(cviContracting.Last.Value < 0, "Contracting volatility should produce negative CVI"); } /// /// Validates bar correction works correctly. /// [Fact] public void Cvi_BarCorrection_WorksCorrectly() { var cvi = new Cvi(5, 5); var bars = GenerateTestData(20); // Feed initial bars for (int i = 0; i < 15; i++) { cvi.Update(bars[i], isNew: true); } // Add new bar cvi.Update(bars[15], isNew: true); double afterNew = cvi.Last.Value; // Correct with different range var correctedBar = new TBar( bars[15].Time, 100, 200, 50, 150, 1000 // Very different range ); cvi.Update(correctedBar, isNew: false); double afterCorrection = cvi.Last.Value; // Restore original cvi.Update(bars[15], isNew: false); double afterRestore = cvi.Last.Value; Assert.NotEqual(afterNew, afterCorrection); Assert.Equal(afterNew, afterRestore, 10); } // === Tulip Cross-Validation === /// /// Structural validation against Tulip cvi indicator. /// Algorithm variant: Tulip cvi uses a single period for both the EMA /// smoothing window and the ROC lookback, while QuanTAlib uses separate /// rocLength and smoothLength parameters. /// Direct numeric equality is not asserted; test documents the difference and /// verifies both implementations produce finite, bounded output on the same data. /// [Fact] public void Cvi_Tulip_StructuralVariant_BothFinite() { const int period = 10; var bars = GenerateTestData(200); double[] highData = new double[bars.Count]; double[] lowData = new double[bars.Count]; for (int i = 0; i < bars.Count; i++) { highData[i] = bars[i].High; lowData[i] = bars[i].Low; } // QuanTAlib CVI — rocLength=period, smoothLength=period (closest equivalent) _ = Cvi.Batch(bars, rocLength: period, smoothLength: period); // Tulip cvi — single period covers both EMA smoothing and ROC lookback var tulipIndicator = Tulip.Indicators.cvi; double[][] inputs = { highData, lowData }; double[] options = { period }; int lookback = tulipIndicator.Start(options); double[][] outputs = { new double[highData.Length - lookback] }; tulipIndicator.Run(inputs, options, outputs); double[] tResult = outputs[0]; // Structural check: both produce finite output (algorithm variants differ in seeding) Assert.True(tResult.Length > 0, "Tulip cvi must produce output"); foreach (double v in tResult) { Assert.True(double.IsFinite(v), $"Tulip cvi produced non-finite value: {v}"); } // QuanTAlib IsHot lives on the indicator, not on TValue var cviIndicator = new Cvi(rocLength: period, smoothLength: period); foreach (var bar in bars) { cviIndicator.Update(bar); } Assert.True(cviIndicator.IsHot, "QuanTAlib Cvi must be hot after sufficient bars"); } // ── Cross-library: OoplesFinance ──────────────────────────────────── /// /// Structural validation against Ooples CalculateChandeVolatilityIndexDynamicAverageIndicator. /// NOTE: Ooples "CVI" is the Chande Volatility Index Dynamic Average (VIDA) — an adaptive /// moving average that uses CVI as its volatility measure. QuanTAlib CVI is Chaikin's /// Volatility Index: EMA(High-Low range) rate-of-change over rocLength bars. These are /// different algorithms sharing the "CVI" abbreviation. Numeric equality is not expected. /// Both must produce finite output on the same OHLCV data. /// [Fact] public void Cvi_OoplesStructuralVariant_BothFinite() { const int length = 10; var bars = GenerateTestData(200); var ooplesData = new List(); foreach (var bar in bars) { ooplesData.Add(new TickerData { Date = new DateTime(bar.Time, DateTimeKind.Utc), Open = bar.Open, High = bar.High, Low = bar.Low, Close = bar.Close, Volume = bar.Volume }); } var stockData = new StockData(ooplesData); var oResult = stockData.CalculateChandeVolatilityIndexDynamicAverageIndicator(length: length); var oValues = oResult.OutputValues.Values.First(); var cvi = new Cvi(rocLength: length, smoothLength: length); foreach (var bar in bars) { cvi.Update(bar); } int finiteCount = 0; int warmup = length * 2; for (int i = warmup; i < Math.Min(oValues.Count, bars.Count); i++) { if (double.IsFinite(oValues[i])) { finiteCount++; } } Assert.True(oValues.Count > 0, "Ooples CVI (VIDA) must produce output"); Assert.True(finiteCount > 50, $"Expected >50 finite Ooples CVI values, got {finiteCount}"); Assert.True(cvi.IsHot, "QuanTAlib CVI must be hot after 200 bars"); } // === Helper Methods === private static double Variance(List values) { if (values.Count == 0) { return 0; } double mean = values.Average(); return values.Average(v => Math.Pow(v - mean, 2)); } }