Files
Miha Kralj 060649192f docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files
- 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
2026-03-12 12:34:16 -07:00

455 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: α + β &lt; 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)");
}
}