namespace QuanTAlib.Test; using Xunit; /// /// Validation tests for RV (Realized Volatility). /// RV calculates volatility from squared log returns, smoothed with SMA. /// Formula: RV = SMA(√(Σr²)) × annualizationFactor /// public class RvValidationTests { 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 squared log return calculation. /// [Theory] [InlineData(100.0, 101.0)] [InlineData(100.0, 110.0)] [InlineData(100.0, 90.0)] public void Rv_SquaredLogReturn_IsCorrect(double prevPrice, double curPrice) { double logReturn = Math.Log(curPrice / prevPrice); double squaredReturn = logReturn * logReturn; Assert.True(squaredReturn >= 0, "Squared return must be non-negative"); Assert.Equal(Math.Pow(logReturn, 2), squaredReturn, 15); } /// /// Validates realized variance formula: sum of squared returns. /// [Fact] public void Rv_RealizedVarianceFormula_IsCorrect() { double[] squaredReturns = { 0.0001, 0.0004, 0.0009, 0.0016, 0.0025 }; double sumSquared = 0; for (int i = 0; i < squaredReturns.Length; i++) { sumSquared += squaredReturns[i]; } // Expected sum = 0.0055 Assert.Equal(0.0055, sumSquared, 10); // Realized volatility = sqrt(sum) double rv = Math.Sqrt(sumSquared); Assert.Equal(Math.Sqrt(0.0055), rv, 10); } /// /// Validates annualization factor: √(252) for daily data. /// [Theory] [InlineData(252, 15.8745078663875)] [InlineData(365, 19.1049731745428)] [InlineData(52, 7.21110255092798)] public void Rv_AnnualizationFactor_IsCorrect(int annualPeriods, double expectedFactor) { double factor = Math.Sqrt(annualPeriods); Assert.Equal(expectedFactor, factor, 10); } /// /// Validates known calculation with manual verification. /// [Fact] public void Rv_KnownCalculation_IsCorrect() { // Prices: 100, 102, 101, 103, 102, 104 (6 prices = 5 returns) double[] prices = { 100.0, 102.0, 101.0, 103.0, 102.0, 104.0 }; // Manual calculation with period=5 (all 5 returns), smoothingPeriod=1 (no smoothing) double sumSquared = 0; for (int i = 1; i < prices.Length; i++) { double r = Math.Log(prices[i] / prices[i - 1]); sumSquared += r * r; } double expected = Math.Sqrt(sumSquared); // Verify with indicator (no annualization, smoothing=1) var rv = new Rv(period: 5, smoothingPeriod: 1, annualize: false); for (int i = 0; i < prices.Length; i++) { rv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i])); } Assert.Equal(expected, rv.Last.Value, 10); } /// /// Validates constant prices produce zero volatility. /// [Fact] public void Rv_ConstantPrices_ProducesZeroVolatility() { var rv = new Rv(period: 5, smoothingPeriod: 3, annualize: false); for (int i = 0; i < 20; i++) { rv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0)); } Assert.Equal(0.0, rv.Last.Value, 10); } /// /// Validates SMA smoothing of raw volatilities. /// [Fact] public void Rv_SmaSmoothing_WorksCorrectly() { var prices = GeneratePriceSeries(50); // Short smoothing vs long smoothing var rvShort = new Rv(period: 5, smoothingPeriod: 3, annualize: false); var rvLong = new Rv(period: 5, smoothingPeriod: 10, annualize: false); var shortResults = new List(); var longResults = new List(); for (int i = 0; i < prices.Count; i++) { rvShort.Update(prices[i]); rvLong.Update(prices[i]); if (rvShort.IsHot && rvLong.IsHot) { shortResults.Add(rvShort.Last.Value); longResults.Add(rvLong.Last.Value); } } // Longer smoothing should produce smoother (less variable) results double shortVar = Variance(shortResults); double longVar = Variance(longResults); Assert.True(shortResults.Count > 0, "Should have results"); Assert.True(longVar < shortVar, "Longer smoothing should be smoother"); } // === Consistency Tests === /// /// Validates streaming and batch produce identical results. /// [Fact] public void Rv_StreamingMatchesBatch() { var prices = GeneratePriceSeries(100); // Streaming calculation var streamingRv = new Rv(5, 10); for (int i = 0; i < prices.Count; i++) { streamingRv.Update(prices[i]); } // Batch calculation var batchResult = Rv.Batch(prices, 5, 10); Assert.Equal(batchResult.Last.Value, streamingRv.Last.Value, 8); } /// /// Validates TSeries input matches TValue streaming. /// [Fact] public void Rv_TSeriesInput_MatchesStreaming() { var prices = GeneratePriceSeries(100); // Streaming var streamingRv = new Rv(5, 10); for (int i = 0; i < prices.Count; i++) { streamingRv.Update(prices[i]); } // TSeries batch var batchRv = new Rv(5, 10); var batchResult = batchRv.Update(prices); Assert.Equal(batchResult.Last.Value, streamingRv.Last.Value, 10); } /// /// Validates annualized output is scaled correctly. /// [Fact] public void Rv_Annualized_ScaledCorrectly() { var prices = GeneratePriceSeries(50); var rvRaw = new Rv(5, 10, annualize: false); var rvAnn = new Rv(5, 10, annualize: true, annualPeriods: 252); for (int i = 0; i < prices.Count; i++) { rvRaw.Update(prices[i]); rvAnn.Update(prices[i]); } double expectedRatio = Math.Sqrt(252); double actualRatio = rvAnn.Last.Value / rvRaw.Last.Value; Assert.Equal(expectedRatio, actualRatio, 6); } /// /// Validates TBar update uses only Close price. /// [Fact] public void Rv_TBar_UsesOnlyClose() { var bars = GenerateTestData(50); var rvBar = new Rv(5, 10); for (int i = 0; i < bars.Count; i++) { rvBar.Update(bars[i]); } var rvClose = new Rv(5, 10); for (int i = 0; i < bars.Count; i++) { rvClose.Update(new TValue(bars[i].Time, bars[i].Close)); } Assert.Equal(rvClose.Last.Value, rvBar.Last.Value, 10); } // === Parameter Sensitivity === /// /// Validates shorter period is more responsive. /// [Fact] public void Rv_ShorterPeriod_MoreResponsive() { var prices = GeneratePriceSeries(50); var rvShort = new Rv(3, 5); var rvLong = new Rv(10, 5); var shortResults = new List(); var longResults = new List(); for (int i = 0; i < prices.Count; i++) { rvShort.Update(prices[i]); rvLong.Update(prices[i]); if (rvShort.IsHot && rvLong.IsHot) { shortResults.Add(rvShort.Last.Value); longResults.Add(rvLong.Last.Value); } } double shortVar = Variance(shortResults); double longVar = Variance(longResults); Assert.True(shortResults.Count > 0, "Should have results"); Assert.True(shortVar > longVar * 0.5, "Shorter period should be more variable"); } /// /// Validates different parameters produce different results. /// [Fact] public void Rv_DifferentParameters_ProduceDifferentResults() { var prices = GeneratePriceSeries(50); var rv1 = new Rv(5, 10); var rv2 = new Rv(5, 20); var rv3 = new Rv(10, 10); for (int i = 0; i < prices.Count; i++) { rv1.Update(prices[i]); rv2.Update(prices[i]); rv3.Update(prices[i]); } Assert.NotEqual(rv1.Last.Value, rv2.Last.Value); Assert.NotEqual(rv1.Last.Value, rv3.Last.Value); } // === Edge Cases === /// /// Validates handling of very small price changes. /// [Fact] public void Rv_VerySmallChanges_HandledCorrectly() { var rv = new Rv(5, 10, annualize: false); double price = 100.0; for (int i = 0; i < 30; i++) { price += 0.001 * (i % 2 == 0 ? 1 : -1); rv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } Assert.True(double.IsFinite(rv.Last.Value)); Assert.True(rv.Last.Value >= 0); Assert.True(rv.Last.Value < 0.01, "Small changes should produce small RV"); } /// /// Validates handling of large price swings. /// [Fact] public void Rv_LargePriceSwings_HandledCorrectly() { var rv = new Rv(5, 10, annualize: false); double price = 100.0; for (int i = 0; i < 30; i++) { price *= (i % 2 == 0 ? 1.1 : 0.9); rv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } Assert.True(double.IsFinite(rv.Last.Value)); Assert.True(rv.Last.Value > 0, "Large swings should produce positive RV"); } /// /// Validates warmup period calculation. /// [Theory] [InlineData(5, 10, 15)] [InlineData(5, 20, 25)] [InlineData(10, 10, 20)] public void Rv_WarmupPeriod_IsCorrect(int period, int smoothing, int expectedWarmup) { var rv = new Rv(period, smoothing); Assert.Equal(expectedWarmup, rv.WarmupPeriod); } /// /// Validates output is always non-negative. /// [Fact] public void Rv_Output_IsNonNegative() { var prices = GeneratePriceSeries(100); var rv = new Rv(5, 10); for (int i = 0; i < prices.Count; i++) { rv.Update(prices[i]); if (rv.IsHot) { Assert.True(rv.Last.Value >= 0, $"RV should be non-negative at bar {i}"); } } } /// /// Validates bar correction works correctly. /// [Fact] public void Rv_BarCorrection_WorksCorrectly() { var rv = new Rv(5, 10); var prices = GeneratePriceSeries(30); for (int i = 0; i < 20; i++) { rv.Update(prices[i], isNew: true); } rv.Update(prices[20], isNew: true); double afterNew = rv.Last.Value; var correctedPrice = new TValue(prices[20].Time, prices[20].Value * 2.0); rv.Update(correctedPrice, isNew: false); double afterCorrection = rv.Last.Value; rv.Update(prices[20], isNew: false); double afterRestore = rv.Last.Value; Assert.NotEqual(afterNew, afterCorrection); Assert.Equal(afterNew, afterRestore, 10); } /// /// Validates iterative corrections converge. /// [Fact] public void Rv_IterativeCorrections_Converge() { var rv = new Rv(5, 10); var prices = GeneratePriceSeries(30); for (int i = 0; i < 20; i++) { rv.Update(prices[i], isNew: true); } for (int j = 0; j < 5; j++) { var tempPrice = new TValue(prices[19].Time, prices[19].Value * (1.0 + j * 0.01)); rv.Update(tempPrice, isNew: false); } rv.Update(prices[19], isNew: false); double afterCorrections = rv.Last.Value; var rvFresh = new Rv(5, 10); for (int i = 0; i < 20; i++) { rvFresh.Update(prices[i], isNew: true); } double freshValue = rvFresh.Last.Value; Assert.Equal(freshValue, afterCorrections, 10); } // === Comparison Tests === /// /// Validates RV vs HV produce correlated but different results. /// [Fact] public void Rv_VsHv_RelatedButDifferent() { var bars = GenerateTestData(50); // RV with period=14, smoothing=1 (similar to HV behavior) var rv = new Rv(14, 1, annualize: false); var hv = new Hv(14, annualize: false); for (int i = 0; i < bars.Count; i++) { rv.Update(bars[i]); hv.Update(bars[i]); } // Both should produce positive values Assert.True(rv.Last.Value > 0); Assert.True(hv.Last.Value > 0); // They measure similar concepts but with different formulas // RV uses sum of squared returns, HV uses standard deviation // Both should be in similar magnitude range double ratio = rv.Last.Value / hv.Last.Value; Assert.True(ratio > 0.1 && ratio < 10, "RV and HV should be in similar range"); } /// /// Validates stability over repeated runs with same seed. /// [Fact] public void Rv_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 rv = new Rv(5, 10); for (int i = 0; i < bars.Count; i++) { rv.Update(bars[i]); } results.Add(rv.Last.Value); } Assert.Equal(results[0], results[1], 15); Assert.Equal(results[1], results[2], 15); } /// /// Validates RV responds to volatility regime changes. /// [Fact] public void Rv_RespondsToVolatilityRegimeChange() { var rv = new Rv(5, 5, annualize: false); // Low volatility regime double price = 100.0; for (int i = 0; i < 20; i++) { price *= (i % 2 == 0 ? 1.001 : 0.999); rv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } double lowVolValue = rv.Last.Value; // High volatility regime for (int i = 20; i < 40; i++) { price *= (i % 2 == 0 ? 1.05 : 0.95); rv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } double highVolValue = rv.Last.Value; Assert.True(highVolValue > lowVolValue * 5, "RV should significantly increase with higher volatility regime"); } /// /// Validates RV produces reasonable volatility estimate. /// [Fact] public void Rv_ProducesReasonableVolatilityEstimate() { var prices = GeneratePriceSeries(100); var rv = new Rv(5, 10, annualize: false); for (int i = 0; i < prices.Count; i++) { rv.Update(prices[i]); } Assert.True(double.IsFinite(rv.Last.Value)); Assert.True(rv.Last.Value > 0); Assert.True(rv.Last.Value < 1, "Raw RV should be < 100%"); } // === 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)); } }