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