// 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));
}
}