mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-07-30 18:47:42 +00:00
060649192f
- Remove 'C# Implementation Considerations' sections from 34 indicator .md files - Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.) - Move test files into tests/ subdirectories for consistent project structure - Add trader-focused bullet points to indicator documentation
455 lines
16 KiB
C#
455 lines
16 KiB
C#
namespace QuanTAlib.Test;
|
||
|
||
using Xunit;
|
||
|
||
/// <summary>
|
||
/// 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}
|
||
/// </summary>
|
||
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 ===
|
||
|
||
/// <summary>
|
||
/// Validates the GARCH stationarity constraint: α + β < 1
|
||
/// </summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates the annualization factor √252 is correctly applied.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Cv_AnnualizationFactor_IsCorrect()
|
||
{
|
||
// √252 ≈ 15.8745
|
||
double expectedFactor = Math.Sqrt(252);
|
||
Assert.Equal(15.874507866387544, expectedFactor, 10);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 (β).
|
||
/// </summary>
|
||
[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}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates GARCH volatility clustering - high volatility follows high volatility.
|
||
/// </summary>
|
||
[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<double>();
|
||
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");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates the GARCH formula by manual calculation.
|
||
/// σ²_t = ω + α × r²_{t-1} + β × σ²_{t-1}
|
||
/// </summary>
|
||
[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})");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates unconditional variance formula: E[σ²] = ω / (1 - α - β)
|
||
/// </summary>
|
||
[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 ===
|
||
|
||
/// <summary>
|
||
/// Validates streaming and batch produce identical results.
|
||
/// </summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates TSeries input produces same results as TValue streaming.
|
||
/// </summary>
|
||
[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 ===
|
||
|
||
/// <summary>
|
||
/// Validates higher alpha increases sensitivity to recent shocks.
|
||
/// Note: GARCH uses lagged squared returns, so the shock's effect appears on the NEXT bar.
|
||
/// </summary>
|
||
[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}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates higher beta increases persistence of volatility.
|
||
/// </summary>
|
||
[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 ===
|
||
|
||
/// <summary>
|
||
/// Validates handling of very small price changes.
|
||
/// </summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates handling of large price swings.
|
||
/// </summary>
|
||
[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");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates that different periods produce different warmup behaviors.
|
||
/// </summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates output is percentage (annualized volatility × 100).
|
||
/// </summary>
|
||
[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)");
|
||
}
|
||
}
|