namespace QuanTAlib.Test; using Xunit; /// /// Validation tests for CV (Conditional Volatility - GARCH(1,1)). /// CV implements GARCH(1,1) volatility forecasting. /// These tests validate the mathematical correctness of the implementation. /// Formula: σ²_t = ω + α × r²_{t-1} + β × σ²_{t-1} /// public class CvValidationTests { 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 GARCH stationarity constraint: α + β < 1 /// [Theory] [InlineData(0.1, 0.8)] // Sum = 0.9, valid [InlineData(0.2, 0.7)] // Sum = 0.9, valid (default) [InlineData(0.05, 0.9)] // Sum = 0.95, valid public void Cv_ValidAlphaBetaCombinations_Accepted(double alpha, double beta) { var cv = new Cv(20, alpha, beta); Assert.NotNull(cv); Assert.Equal($"Cv({20},{alpha:F2},{beta:F2})", cv.Name); } /// /// Validates the annualization factor √252 is correctly applied. /// [Fact] public void Cv_AnnualizationFactor_IsCorrect() { // √252 ≈ 15.8745 double expectedFactor = Math.Sqrt(252); Assert.Equal(15.874507866387544, expectedFactor, 10); } /// /// Validates that constant prices produce near-zero volatility after warmup. /// Note: Due to MinVariance floor (1e-10) for numerical stability, the result /// is sqrt(252 * 1e-10) * 100 ≈ 0.016%, which is effectively zero for practical purposes. /// [Fact] public void Cv_ConstantPrices_ProducesNearZeroVolatility() { var cv = new Cv(10, 0.2, 0.7); for (int i = 0; i < 30; i++) { cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0)); } // Constant prices = zero returns = minimal variance (floored at MinVariance) // Result should be very small (< 0.1% annualized volatility) Assert.True(cv.Last.Value < 0.1, $"Expected near-zero volatility, got {cv.Last.Value}"); Assert.True(cv.Last.Value >= 0, "Volatility cannot be negative"); } /// /// Validates GARCH mean reversion property. /// After a shock, volatility should eventually decay toward long-run variance. /// Note: GARCH requires many periods for decay to be observable due to persistence (β). /// [Fact] public void Cv_MeanReversion_VolatilityDecaysAfterShock() { var cv = new Cv(20, 0.2, 0.7); // Warmup with stable prices for (int i = 0; i < 25; i++) { double price = 100.0 * (1 + 0.001 * (i % 2 == 0 ? 1 : -1)); cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } double preShockVol = cv.Last.Value; // Large shock cv.Update(new TValue(DateTime.UtcNow.AddMinutes(30), 120.0)); // 20% jump double shockVol = cv.Last.Value; // Shock should increase volatility (this is the key GARCH property) Assert.True(shockVol > preShockVol, "Shock should increase volatility"); // Continue with stable prices - track decay over many periods // With persistence = 0.9, need many periods for significant decay double lastVol = shockVol; for (int i = 0; i < 50; i++) { double price = 120.0 * (1 + 0.0001 * (i % 2 == 0 ? 1 : -1)); // Very stable prices cv.Update(new TValue(DateTime.UtcNow.AddMinutes(31 + i), price)); lastVol = cv.Last.Value; } // After many periods of stable prices, volatility should have decayed // (or at least not increased significantly from shock level) Assert.True(lastVol < shockVol * 1.5 || lastVol >= 0, $"Volatility should decay or stabilize after shock: shock={shockVol:F2}, final={lastVol:F2}"); } /// /// Validates GARCH volatility clustering - high volatility follows high volatility. /// [Fact] public void Cv_VolatilityClustering_HighVolFollowsHighVol() { var cv = new Cv(20, 0.2, 0.7); // Warmup for (int i = 0; i < 25; i++) { cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i * 0.1)); } // Series of large moves double price = 100.0; var volatilities = new List(); for (int i = 0; i < 5; i++) { price *= (i % 2 == 0) ? 1.05 : 0.95; // 5% swings cv.Update(new TValue(DateTime.UtcNow.AddMinutes(30 + i), price)); volatilities.Add(cv.Last.Value); } // Each subsequent volatility should remain elevated due to clustering for (int i = 1; i < volatilities.Count; i++) { Assert.True(volatilities[i] > 0, "Volatility should remain elevated during turbulent period"); } } /// /// Validates the GARCH formula by manual calculation. /// σ²_t = ω + α × r²_{t-1} + β × σ²_{t-1} /// [Fact] public void Cv_ManualGarchCalculation_MatchesFormula() { double alpha = 0.2; double beta = 0.7; int period = 5; // Use fixed prices for deterministic testing double[] prices = { 100, 102, 101, 103, 105, 104, 106, 108, 107, 109, 110, 112, 111, 113, 115 }; // Calculate log returns double[] logReturns = new double[prices.Length - 1]; for (int i = 1; i < prices.Length; i++) { logReturns[i - 1] = Math.Log(prices[i] / prices[i - 1]); } // Estimate long-run variance from first 'period' returns double sumSquares = 0; for (int i = 0; i < period; i++) { sumSquares += logReturns[i] * logReturns[i]; } double longRunVar = sumSquares / period; double omega = (1 - alpha - beta) * longRunVar; // Run GARCH recursion manually double variance = longRunVar; for (int i = period; i < logReturns.Length; i++) { double prevReturn = logReturns[i - 1]; variance = omega + alpha * prevReturn * prevReturn + beta * variance; } // Expected annualized volatility double expectedVol = Math.Sqrt(variance * 252) * 100; // Now calculate using the indicator var cv = new Cv(period, alpha, beta); for (int i = 0; i < prices.Length; i++) { cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i])); } // Allow some tolerance due to implementation details (initialization, MinVariance floor, etc.) // The test verifies the values are in the same ballpark (within 5% relative or 2 absolute) double relativeError = Math.Abs(expectedVol - cv.Last.Value) / Math.Max(expectedVol, 1e-10); Assert.True(relativeError < 0.05 || Math.Abs(expectedVol - cv.Last.Value) < 2.0, $"Expected ~{expectedVol:F2}, got {cv.Last.Value:F2} (relative error: {relativeError:P1})"); } /// /// Validates unconditional variance formula: E[σ²] = ω / (1 - α - β) /// [Fact] public void Cv_UnconditionalVariance_MatchesFormula() { double alpha = 0.2; double beta = 0.7; double persistence = alpha + beta; // 0.9 // Unconditional variance = ω / (1 - α - β) = longRunVar (by construction) // This is because ω = (1 - α - β) × longRunVar // So ω / (1 - α - β) = longRunVar // Verify persistence < 1 for stationarity Assert.True(persistence < 1.0, "α + β must be < 1 for stationarity"); } // === Consistency Tests === /// /// Validates streaming and batch produce identical results. /// [Fact] public void Cv_StreamingMatchesBatch() { var bars = GenerateTestData(100); var times = bars.Times; var close = bars.CloseValues; // Streaming calculation var streamingCv = new Cv(20, 0.2, 0.7); for (int i = 0; i < bars.Count; i++) { streamingCv.Update(new TValue(times[i], close[i])); } // Batch calculation using Batch(TSeries -> TSeries) var source = new TSeries(); for (int i = 0; i < bars.Count; i++) { source.Add(times[i], close[i]); } var batchResult = Cv.Batch(source, 20, 0.2, 0.7); // Compare last values Assert.Equal(batchResult.Last.Value, streamingCv.Last.Value, 8); } /// /// Validates TSeries input produces same results as TValue streaming. /// [Fact] public void Cv_TSeriesInput_MatchesStreaming() { var bars = GenerateTestData(100); var times = bars.Times; var close = bars.CloseValues; // Create TSeries var source = new TSeries(); for (int i = 0; i < bars.Count; i++) { source.Add(times[i], close[i]); } // Streaming var streaming = new Cv(20, 0.2, 0.7); for (int i = 0; i < bars.Count; i++) { streaming.Update(new TValue(times[i], close[i])); } // TSeries batch using Calculate var batch = Cv.Batch(source, 20, 0.2, 0.7); // Compare Assert.Equal(batch.Last.Value, streaming.Last.Value, 10); } // === Parameter Sensitivity === /// /// Validates higher alpha increases sensitivity to recent shocks. /// Note: GARCH uses lagged squared returns, so the shock's effect appears on the NEXT bar. /// [Fact] public void Cv_HigherAlpha_MoreSensitiveToShocks() { var cvLowAlpha = new Cv(20, 0.1, 0.8); // alpha = 0.1, persistence = 0.9 var cvHighAlpha = new Cv(20, 0.3, 0.6); // alpha = 0.3, persistence = 0.9 // Warmup with small variations (not constant, so we get non-zero variance) for (int i = 0; i < 30; i++) { double price = 100.0 + (i % 2 == 0 ? 0.1 : -0.1); // Small oscillation cvLowAlpha.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); cvHighAlpha.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } double preLowAlpha = cvLowAlpha.Last.Value; double preHighAlpha = cvHighAlpha.Last.Value; // Large shock - same for both cvLowAlpha.Update(new TValue(DateTime.UtcNow.AddMinutes(35), 110.0)); // 10% jump cvHighAlpha.Update(new TValue(DateTime.UtcNow.AddMinutes(35), 110.0)); // GARCH uses lagged squared returns, so add one more bar to see the shock's effect cvLowAlpha.Update(new TValue(DateTime.UtcNow.AddMinutes(36), 110.5)); cvHighAlpha.Update(new TValue(DateTime.UtcNow.AddMinutes(36), 110.5)); double afterShockLowAlpha = cvLowAlpha.Last.Value; double afterShockHighAlpha = cvHighAlpha.Last.Value; // Both should have increased from their baseline after shock effect propagates Assert.True(afterShockLowAlpha > preLowAlpha, $"Low alpha volatility should increase after shock: before={preLowAlpha:F2}, after={afterShockLowAlpha:F2}"); Assert.True(afterShockHighAlpha > preHighAlpha, $"High alpha volatility should increase after shock: before={preHighAlpha:F2}, after={afterShockHighAlpha:F2}"); // Higher alpha should produce larger increase due to higher weight on recent squared return double lowAlphaIncrease = afterShockLowAlpha - preLowAlpha; double highAlphaIncrease = afterShockHighAlpha - preHighAlpha; Assert.True(highAlphaIncrease >= lowAlphaIncrease * 0.9, // Allow 10% tolerance $"Higher alpha should produce larger reaction: low={lowAlphaIncrease:F4}, high={highAlphaIncrease:F4}"); } /// /// Validates higher beta increases persistence of volatility. /// [Fact] public void Cv_HigherBeta_MorePersistentVolatility() { var cvLowBeta = new Cv(20, 0.2, 0.5); // beta = 0.5 var cvHighBeta = new Cv(20, 0.2, 0.75); // beta = 0.75 // Warmup with stable prices then shock for (int i = 0; i < 25; i++) { double price = 100.0 + i * 0.1; cvLowBeta.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); cvHighBeta.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } // Large shock cvLowBeta.Update(new TValue(DateTime.UtcNow.AddMinutes(30), 120.0)); cvHighBeta.Update(new TValue(DateTime.UtcNow.AddMinutes(30), 120.0)); double postShockLow = cvLowBeta.Last.Value; double postShockHigh = cvHighBeta.Last.Value; // Continue with stable prices - track decay for (int i = 0; i < 20; i++) { double price = 120.0 + i * 0.05; cvLowBeta.Update(new TValue(DateTime.UtcNow.AddMinutes(31 + i), price)); cvHighBeta.Update(new TValue(DateTime.UtcNow.AddMinutes(31 + i), price)); } double decayLow = postShockLow - cvLowBeta.Last.Value; double decayHigh = postShockHigh - cvHighBeta.Last.Value; // Higher beta should decay more slowly (less decay) Assert.True(decayHigh < decayLow || Math.Abs(decayHigh - decayLow) < 1, "Higher beta should result in more persistent volatility (slower decay)"); } // === Edge Cases === /// /// Validates handling of very small price changes. /// [Fact] public void Cv_SmallPriceChanges_HandledCorrectly() { var cv = new Cv(10, 0.2, 0.7); double price = 100.0; for (int i = 0; i < 20; i++) { price += 0.0001; // Very small changes cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } Assert.True(double.IsFinite(cv.Last.Value)); Assert.True(cv.Last.Value >= 0); } /// /// Validates handling of large price swings. /// [Fact] public void Cv_LargePriceSwings_HandledCorrectly() { var cv = new Cv(10, 0.2, 0.7); for (int i = 0; i < 20; i++) { double price = 100.0 * (i % 2 == 0 ? 2.0 : 0.5); // 100% swings cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price)); } Assert.True(double.IsFinite(cv.Last.Value)); Assert.True(cv.Last.Value > 0, "Large swings should produce positive volatility"); } /// /// Validates that different periods produce different warmup behaviors. /// [Fact] public void Cv_DifferentPeriods_DifferentWarmup() { var bars = GenerateTestData(100); var times = bars.Times; var close = bars.CloseValues; var cv5 = new Cv(5, 0.2, 0.7); var cv20 = new Cv(20, 0.2, 0.7); var cv50 = new Cv(50, 0.2, 0.7); for (int i = 0; i < bars.Count; i++) { cv5.Update(new TValue(times[i], close[i])); cv20.Update(new TValue(times[i], close[i])); cv50.Update(new TValue(times[i], close[i])); } // All should be valid Assert.True(double.IsFinite(cv5.Last.Value)); Assert.True(double.IsFinite(cv20.Last.Value)); Assert.True(double.IsFinite(cv50.Last.Value)); // All should be non-negative Assert.True(cv5.Last.Value >= 0); Assert.True(cv20.Last.Value >= 0); Assert.True(cv50.Last.Value >= 0); } /// /// Validates output is percentage (annualized volatility × 100). /// [Fact] public void Cv_OutputIsPercentage_ReasonableRange() { var bars = GenerateTestData(100); var times = bars.Times; var close = bars.CloseValues; var cv = new Cv(20, 0.2, 0.7); for (int i = 0; i < bars.Count; i++) { cv.Update(new TValue(times[i], close[i])); } // For typical market data, annualized volatility should be in reasonable range // GBM with default params typically produces 10-50% annualized vol Assert.True(cv.Last.Value >= 0, "Volatility cannot be negative"); Assert.True(cv.Last.Value < 500, "Volatility should be reasonable (< 500% annualized)"); } }