using Skender.Stock.Indicators; using Tulip; namespace QuanTAlib.Test; using QuanTAlib.Tests; using Xunit; /// /// Validation tests for HV (Historical Volatility / Close-to-Close Volatility). /// HV is the standard volatility estimator using log returns of closing prices. /// Formula: σ = √(Var(log returns)) × √(annualPeriods) /// Uses population variance over rolling window. /// public class HvValidationTests { private static TBarSeries GenerateTestData(int count = 100) { var gbm = new GBM(seed: 42); return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); } private static TSeries GeneratePriceSeries(int count = 100) { var gbm = new GBM(seed: 42); var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var t = new List(count); var v = new List(count); for (int i = 0; i < count; i++) { t.Add(bars[i].Time); v.Add(bars[i].Close); } return new TSeries(t, v); } // === Mathematical Validation === /// /// Validates log return formula: r_t = ln(price_t / price_{t-1}) /// [Theory] [InlineData(100.0, 101.0, 0.00995033)] // ~1% return [InlineData(100.0, 110.0, 0.09531018)] // ~10% return [InlineData(100.0, 90.0, -0.10536052)] // ~-10% return [InlineData(100.0, 100.0, 0.0)] // no change public void Hv_LogReturnFormula_IsCorrect(double prevPrice, double curPrice, double expectedReturn) { double logReturn = Math.Log(curPrice / prevPrice); Assert.Equal(expectedReturn, logReturn, 6); } /// /// Validates population variance formula: Var = E[X²] - E[X]² /// [Fact] public void Hv_PopulationVarianceFormula_IsCorrect() { // Known values: 1, 2, 3, 4, 5 double[] values = { 1, 2, 3, 4, 5 }; double sum = 0, sumSq = 0; for (int i = 0; i < values.Length; i++) { sum += values[i]; sumSq += values[i] * values[i]; } double mean = sum / values.Length; double variance = (sumSq / values.Length) - (mean * mean); // Expected: mean = 3, E[X²] = (1+4+9+16+25)/5 = 11 // Var = 11 - 9 = 2 Assert.Equal(2.0, variance, 10); } /// /// Validates standard deviation is square root of variance. /// [Fact] public void Hv_StandardDeviationFormula_IsCorrect() { double variance = 4.0; double stdDev = Math.Sqrt(variance); Assert.Equal(2.0, stdDev, 10); } /// /// Validates annualization factor: √(annualPeriods) /// [Theory] [InlineData(252, 15.8745078663875)] // Daily trading days [InlineData(365, 19.1049731745428)] // Calendar days [InlineData(52, 7.21110255092798)] // Weekly [InlineData(12, 3.46410161513775)] // Monthly public void Hv_AnnualizationFactor_IsCorrect(int annualPeriods, double expectedFactor) { double factor = Math.Sqrt(annualPeriods); Assert.Equal(expectedFactor, factor, 10); } /// /// Validates known volatility calculation. /// [Fact] public void Hv_KnownCalculation_IsCorrect() { // Prices: 100, 102, 101, 103, 102 (5 prices = 4 returns) double[] prices = { 100.0, 102.0, 101.0, 103.0, 102.0 }; double[] returns = new double[4]; for (int i = 1; i < prices.Length; i++) { returns[i - 1] = Math.Log(prices[i] / prices[i - 1]); } // Calculate population std dev double sum = 0, sumSq = 0; for (int i = 0; i < returns.Length; i++) { sum += returns[i]; sumSq += returns[i] * returns[i]; } double mean = sum / returns.Length; double variance = (sumSq / returns.Length) - (mean * mean); double expected = Math.Sqrt(variance); // Verify with indicator var hv = new Hv(period: 4, annualize: false); for (int i = 0; i < prices.Length; i++) { hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i])); } Assert.Equal(expected, hv.Last.Value, 10); } /// /// Validates that constant prices produce zero volatility. /// [Fact] public void Hv_ConstantPrices_ProducesZeroVolatility() { var hv = new Hv(period: 10, annualize: false); for (int i = 0; i < 20; i++) { hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0)); } // All returns are 0, so variance and std dev are 0 Assert.Equal(0.0, hv.Last.Value, 10); } /// /// Validates rolling window properly removes old values. /// [Fact] public void Hv_RollingWindow_RemovesOldValues() { var hv = new Hv(period: 5, annualize: false); // First phase: volatile returns double[] volatilePrices = { 100, 110, 90, 120, 80, 100 }; // 6 prices = 5 returns for (int i = 0; i < volatilePrices.Length; i++) { hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), volatilePrices[i])); } double highVolValue = hv.Last.Value; // Second phase: constant prices (5 more) for (int i = 6; i < 11; i++) { hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0)); } double afterConstantValue = hv.Last.Value; // Rolling window should now only have zero returns Assert.True(afterConstantValue < highVolValue, "Volatility should drop after constant prices"); Assert.Equal(0.0, afterConstantValue, 10); } // === Consistency Tests === /// /// Validates streaming and batch produce identical results. /// [Fact] public void Hv_StreamingMatchesBatch() { var prices = GeneratePriceSeries(100); // Streaming calculation var streamingHv = new Hv(14); for (int i = 0; i < prices.Count; i++) { streamingHv.Update(prices[i]); } // Batch calculation var batchResult = Hv.Batch(prices, 14); // Compare last values Assert.Equal(batchResult.Last.Value, streamingHv.Last.Value, 8); } /// /// Validates TSeries input matches TValue streaming. /// [Fact] public void Hv_TSeriesInput_MatchesStreaming() { var prices = GeneratePriceSeries(100); // Streaming var streamingHv = new Hv(14); for (int i = 0; i < prices.Count; i++) { streamingHv.Update(prices[i]); } // TSeries batch var batchHv = new Hv(14); var batchResult = batchHv.Update(prices); Assert.Equal(batchResult.Last.Value, streamingHv.Last.Value, 10); } /// /// Validates Span batch matches streaming. /// [Fact] public void Hv_SpanBatch_MatchesStreaming() { var prices = GeneratePriceSeries(100); // Streaming var streamingHv = new Hv(14); for (int i = 0; i < prices.Count; i++) { streamingHv.Update(prices[i]); } // Span batch var output = new double[prices.Count]; Hv.Batch(prices.Values, output, 14); Assert.Equal(output[^1], streamingHv.Last.Value, 10); } /// /// Validates annualized output is scaled correctly. /// [Fact] public void Hv_Annualized_ScaledCorrectly() { var prices = GeneratePriceSeries(50); // Non-annualized var hvRaw = new Hv(14, annualize: false); // Annualized (default 252 periods) var hvAnn = new Hv(14, annualize: true, annualPeriods: 252); for (int i = 0; i < prices.Count; i++) { hvRaw.Update(prices[i]); hvAnn.Update(prices[i]); } double expectedRatio = Math.Sqrt(252); double actualRatio = hvAnn.Last.Value / hvRaw.Last.Value; Assert.Equal(expectedRatio, actualRatio, 6); } /// /// Validates TBar update uses only Close price. /// [Fact] public void Hv_TBar_UsesOnlyClose() { var bars = GenerateTestData(50); // Using TBar var hvBar = new Hv(14); for (int i = 0; i < bars.Count; i++) { hvBar.Update(bars[i]); } // Using just Close prices var hvClose = new Hv(14); for (int i = 0; i < bars.Count; i++) { hvClose.Update(new TValue(bars[i].Time, bars[i].Close)); } Assert.Equal(hvClose.Last.Value, hvBar.Last.Value, 10); } // === Parameter Sensitivity === /// /// Validates shorter period produces more responsive volatility. /// [Fact] public void Hv_ShorterPeriod_MoreResponsive() { var prices = GeneratePriceSeries(50); var hvShort = new Hv(5); var hvLong = new Hv(20); var shortResults = new List(); var longResults = new List(); for (int i = 0; i < prices.Count; i++) { hvShort.Update(prices[i]); hvLong.Update(prices[i]); if (hvShort.IsHot && hvLong.IsHot) { shortResults.Add(hvShort.Last.Value); longResults.Add(hvLong.Last.Value); } } // Shorter period should have higher variance in results double shortVar = Variance(shortResults); double longVar = Variance(longResults); Assert.True(shortResults.Count > 0, "Should have hot results"); Assert.True(shortVar > longVar * 0.5, "Shorter period should generally be more variable"); } /// /// Validates different periods produce different results. /// [Fact] public void Hv_DifferentPeriods_ProduceDifferentResults() { var prices = GeneratePriceSeries(50); var hv10 = new Hv(10); var hv14 = new Hv(14); var hv20 = new Hv(20); for (int i = 0; i < prices.Count; i++) { hv10.Update(prices[i]); hv14.Update(prices[i]); hv20.Update(prices[i]); } Assert.NotEqual(hv10.Last.Value, hv14.Last.Value); Assert.NotEqual(hv14.Last.Value, hv20.Last.Value); } // === Edge Cases === /// /// Validates handling of very small price changes. /// [Fact] public void Hv_VerySmallChanges_HandledCorrectly() { var hv = new Hv(14, annualize: false); double price = 100.0; for (int i = 0; i < 30; i++) { price += 0.001 * (i % 2 == 0 ? 1 : -1); // Tiny oscillation hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } Assert.True(double.IsFinite(hv.Last.Value)); Assert.True(hv.Last.Value >= 0, "Volatility should be non-negative"); Assert.True(hv.Last.Value < 0.01, "Small changes should produce small volatility"); } /// /// Validates handling of large price swings. /// [Fact] public void Hv_LargePriceSwings_HandledCorrectly() { var hv = new Hv(14, annualize: false); double price = 100.0; for (int i = 0; i < 30; i++) { price *= (i % 2 == 0 ? 1.1 : 0.9); // 10% swings hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } Assert.True(double.IsFinite(hv.Last.Value)); Assert.True(hv.Last.Value > 0, "Large swings should produce positive volatility"); } /// /// Validates warmup period calculation (period + 1). /// [Theory] [InlineData(10, 11)] [InlineData(14, 15)] [InlineData(20, 21)] public void Hv_WarmupPeriod_IsPeriodPlusOne(int period, int expectedWarmup) { var hv = new Hv(period); Assert.Equal(expectedWarmup, hv.WarmupPeriod); } /// /// Validates output is always non-negative (volatility property). /// [Fact] public void Hv_Output_IsNonNegative() { var prices = GeneratePriceSeries(100); var hv = new Hv(14); for (int i = 0; i < prices.Count; i++) { hv.Update(prices[i]); if (hv.IsHot) { Assert.True(hv.Last.Value >= 0, $"Volatility should be non-negative at bar {i}"); } } } /// /// Validates bar correction works correctly. /// [Fact] public void Hv_BarCorrection_WorksCorrectly() { var hv = new Hv(14); var prices = GeneratePriceSeries(30); // Feed initial prices for (int i = 0; i < 20; i++) { hv.Update(prices[i], isNew: true); } // Add new price hv.Update(prices[20], isNew: true); double afterNew = hv.Last.Value; // Correct with very different price var correctedPrice = new TValue(prices[20].Time, prices[20].Value * 2.0); hv.Update(correctedPrice, isNew: false); double afterCorrection = hv.Last.Value; // Restore original hv.Update(prices[20], isNew: false); double afterRestore = hv.Last.Value; Assert.NotEqual(afterNew, afterCorrection); Assert.Equal(afterNew, afterRestore, 10); } /// /// Validates iterative corrections converge to same result. /// [Fact] public void Hv_IterativeCorrections_Converge() { var hv = new Hv(14); var prices = GeneratePriceSeries(30); // Feed prices and make corrections for (int i = 0; i < 20; i++) { hv.Update(prices[i], isNew: true); } // Multiple corrections on same price for (int j = 0; j < 5; j++) { var tempPrice = new TValue(prices[19].Time, prices[19].Value * (1.0 + j * 0.01)); hv.Update(tempPrice, isNew: false); } // Final correction back to original hv.Update(prices[19], isNew: false); double afterCorrections = hv.Last.Value; // Fresh calculation var hvFresh = new Hv(14); for (int i = 0; i < 20; i++) { hvFresh.Update(prices[i], isNew: true); } double freshValue = hvFresh.Last.Value; Assert.Equal(freshValue, afterCorrections, 10); } // === Comparison with Other Estimators === /// /// Validates HV vs HLV: close-to-close vs high-low estimator. /// [Fact] public void Hv_VsHlv_DifferentBehavior() { var bars = GenerateTestData(50); var hv = new Hv(14, annualize: false); var hlv = new Hlv(14, annualize: false); for (int i = 0; i < bars.Count; i++) { hv.Update(bars[i]); hlv.Update(bars[i]); } // Both should produce positive values Assert.True(hv.Last.Value > 0); Assert.True(hlv.Last.Value > 0); // They should generally be different (HLV uses high-low range) Assert.NotEqual(hv.Last.Value, hlv.Last.Value); } /// /// Validates HV stability over repeated runs with same seed. /// [Fact] public void Hv_Stability_ConsistentOverRepeatedRuns() { var results = new List(); for (int run = 0; run < 3; run++) { var gbm = new GBM(seed: 42); var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var hv = new Hv(14); for (int i = 0; i < bars.Count; i++) { hv.Update(bars[i]); } results.Add(hv.Last.Value); } Assert.Equal(results[0], results[1], 15); Assert.Equal(results[1], results[2], 15); } /// /// Validates HV responds to volatility regime changes. /// [Fact] public void Hv_RespondsToVolatilityRegimeChange() { var hv = new Hv(10, annualize: false); // Low volatility regime: small price changes double price = 100.0; for (int i = 0; i < 20; i++) { price *= (i % 2 == 0 ? 1.001 : 0.999); // 0.1% changes hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } double lowVolValue = hv.Last.Value; // High volatility regime: large price changes for (int i = 20; i < 40; i++) { price *= (i % 2 == 0 ? 1.05 : 0.95); // 5% changes hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } double highVolValue = hv.Last.Value; Assert.True(highVolValue > lowVolValue * 5, "HV should significantly increase with higher volatility regime"); } /// /// Validates HV produces reasonable volatility estimate. /// [Fact] public void Hv_ProducesReasonableVolatilityEstimate() { var prices = GeneratePriceSeries(100); var hv = new Hv(14, annualize: false); for (int i = 0; i < prices.Count; i++) { hv.Update(prices[i]); } Assert.True(double.IsFinite(hv.Last.Value)); Assert.True(hv.Last.Value > 0); Assert.True(hv.Last.Value < 1, "Raw daily volatility should be < 100%"); } // === Tulip Cross-Validation === /// /// Validates HV against Tulip's volatility indicator (annualised HV, ×√252). /// Tulip uses: σ = stddev(log returns) × √252 which exactly matches /// QuanTAlib Hv(period, annualize:true, annualPeriods:252). /// [Fact] public void Hv_Matches_Tulip_Batch() { const int period = 20; var bars = GenerateTestData(500); double[] closeData = new double[bars.Count]; for (int i = 0; i < bars.Count; i++) { closeData[i] = bars[i].Close; } // QuanTAlib batch — annualised with 252 trading days (matches Tulip) var qResult = Hv.Batch(bars.Close, period, annualize: true, annualPeriods: 252); // Tulip volatility indicator var tulipIndicator = Tulip.Indicators.volatility; double[][] inputs = { closeData }; double[] options = { period }; int lookback = tulipIndicator.Start(options); double[][] outputs = { new double[closeData.Length - lookback] }; tulipIndicator.Run(inputs, options, outputs); double[] tResult = outputs[0]; // Tulip volatility annualisation produces ~4e-6 divergence vs QuanTAlib — intentional. ValidationHelper.VerifyData(qResult, tResult, lookback, tolerance: 1e-5); } [Fact] public void Hv_Matches_Tulip_Streaming() { const int period = 14; var bars = GenerateTestData(500); double[] closeData = new double[bars.Count]; for (int i = 0; i < bars.Count; i++) { closeData[i] = bars[i].Close; } // QuanTAlib streaming var hv = new Hv(period, annualize: true, annualPeriods: 252); var qResults = new List(); foreach (var bar in bars) { qResults.Add(hv.Update(new TValue(bar.Time, bar.Close)).Value); } // Tulip var tulipIndicator = Tulip.Indicators.volatility; double[][] inputs = { closeData }; double[] options = { period }; int lookback = tulipIndicator.Start(options); double[][] outputs = { new double[closeData.Length - lookback] }; tulipIndicator.Run(inputs, options, outputs); double[] tResult = outputs[0]; // Tulip volatility annualisation produces ~4e-6 divergence vs QuanTAlib — intentional. ValidationHelper.VerifyData(qResults, tResult, lookback, tolerance: 1e-5); } // === Skender Cross-Validation === /// /// Validates HV against Skender GetStdDev on log returns. /// Skender returns sample standard deviation, so values are converted to /// population standard deviation by multiplying with √((n-1)/n). /// [Fact] public void Validate_Skender_LogReturnsStdDev_NonAnnualized() { using var data = new ValidationTestData(); const int period = 14; var qResult = Hv.Batch(data.Data, period, annualize: false); var logReturnQuotes = BuildLogReturnQuotes(data.SkenderQuotes); var sResult = logReturnQuotes.GetStdDev(period).ToList(); int compared = 0; for (int priceIdx = period; priceIdx < qResult.Count; priceIdx++) { double qValue = qResult[priceIdx].Value; double? sPop = sResult[priceIdx - 1].StdDev; if (!sPop.HasValue || !double.IsFinite(sPop.Value) || !double.IsFinite(qValue)) { continue; } double expected = sPop.Value; double diff = Math.Abs(qValue - expected); Assert.True( diff <= 1e-10, $"Mismatch at priceIdx={priceIdx}: QuanTAlib={qValue:G17}, Skender(pop)={sPop.Value:G17}, Expected(pop)={expected:G17}, Diff={diff:G17}"); compared++; } Assert.True(compared > 100, $"Expected >100 comparisons, got {compared}"); } /// /// Validates annualized HV against Skender log-returns StdDev with matching /// population conversion and annualization factor (√252). /// [Fact] public void Validate_Skender_LogReturnsStdDev_Annualized() { using var data = new ValidationTestData(); const int period = 14; const int annualPeriods = 252; var qResult = Hv.Batch(data.Data, period, annualize: true, annualPeriods: annualPeriods); var logReturnQuotes = BuildLogReturnQuotes(data.SkenderQuotes); var sResult = logReturnQuotes.GetStdDev(period).ToList(); double annualFactor = Math.Sqrt(annualPeriods); int compared = 0; for (int priceIdx = period; priceIdx < qResult.Count; priceIdx++) { double qValue = qResult[priceIdx].Value; double? sPop = sResult[priceIdx - 1].StdDev; if (!sPop.HasValue || !double.IsFinite(sPop.Value) || !double.IsFinite(qValue)) { continue; } double expected = sPop.Value * annualFactor; double diff = Math.Abs(qValue - expected); Assert.True( diff <= 1e-9, $"Mismatch at priceIdx={priceIdx}: QuanTAlib={qValue:G17}, Skender(pop)={sPop.Value:G17}, Expected(annualized pop)={expected:G17}, Diff={diff:G17}"); compared++; } Assert.True(compared > 100, $"Expected >100 comparisons, got {compared}"); } // === Helper Methods === private static List BuildLogReturnQuotes(IReadOnlyList quotes) { var returns = new List(Math.Max(0, quotes.Count - 1)); for (int i = 1; i < quotes.Count; i++) { double prev = (double)quotes[i - 1].Close; double cur = (double)quotes[i].Close; double logReturn = Math.Log(cur / prev); returns.Add(new Quote { Date = quotes[i].Date, Open = (decimal)logReturn, High = (decimal)logReturn, Low = (decimal)logReturn, Close = (decimal)logReturn, Volume = 0m }); } return returns; } 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)); } }