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
This commit is contained in:
Miha Kralj
2026-03-12 12:34:16 -07:00
parent 8937b0c0fa
commit 060649192f
1149 changed files with 1780 additions and 3316 deletions
@@ -0,0 +1,308 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class CvIndicatorTests
{
[Fact]
public void CvIndicator_Constructor_SetsDefaults()
{
var indicator = new CvIndicator();
Assert.Equal(20, indicator.Period);
Assert.Equal(0.2, indicator.Alpha);
Assert.Equal(0.7, indicator.Beta);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("CV - Conditional Volatility (GARCH(1,1))", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void CvIndicator_ShortName_IncludesParameters()
{
var indicator = new CvIndicator { Period = 14, Alpha = 0.15, Beta = 0.75 };
Assert.Contains("CV", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("0.15", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("0.75", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void CvIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new CvIndicator();
Assert.Equal(0, CvIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void CvIndicator_Initialize_CreatesInternalCv()
{
var indicator = new CvIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void CvIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new CvIndicator { Period = 5 };
indicator.Initialize();
// Add historical data with volatility
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i * 2 + (i % 2 == 0 ? 5 : -5); // Add some volatility
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
Assert.True(val >= 0); // CV should be non-negative
}
[Fact]
public void CvIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new CvIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar
indicator.HistoricalData.AddBar(now.AddMinutes(30), 120, 128, 115, 125, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void CvIndicator_DifferentPeriods_Work()
{
int[] periods = { 5, 10, 20, 50 };
foreach (var period in periods)
{
var indicator = new CvIndicator { Period = period };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 60; i++)
{
double basePrice = 100 + i + (i % 3 == 0 ? 10 : -5); // Add volatility
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"Period {period} should produce finite value");
Assert.True(val >= 0, $"Period {period} should produce non-negative CV");
}
}
[Fact]
public void CvIndicator_DifferentAlphaValues_Work()
{
double[] alphas = { 0.05, 0.1, 0.2, 0.3 };
foreach (var alpha in alphas)
{
var indicator = new CvIndicator { Alpha = alpha, Beta = 0.6 }; // Keep alpha + beta < 1
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"Alpha {alpha} should produce finite value");
Assert.True(val >= 0, $"Alpha {alpha} should produce non-negative CV");
}
}
[Fact]
public void CvIndicator_DifferentBetaValues_Work()
{
double[] betas = { 0.5, 0.6, 0.7, 0.8 };
foreach (var beta in betas)
{
var indicator = new CvIndicator { Alpha = 0.1, Beta = beta }; // Keep alpha + beta < 1
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"Beta {beta} should produce finite value");
Assert.True(val >= 0, $"Beta {beta} should produce non-negative CV");
}
}
[Fact]
public void CvIndicator_StationarityConstraint_AdjustsBeta()
{
// Test that when alpha + beta >= 1, OnInit adjusts beta
var indicator = new CvIndicator { Alpha = 0.5, Beta = 0.6 }; // Sum = 1.1, violates constraint
indicator.Initialize();
// Beta should be adjusted to maintain stationarity (0.99 - alpha)
Assert.True(indicator.Alpha + indicator.Beta < 1.0,
"After initialization, alpha + beta should be less than 1");
}
[Fact]
public void CvIndicator_DifferentSourceTypes_Work()
{
SourceType[] sources = { SourceType.Close, SourceType.High, SourceType.Low, SourceType.HL2, SourceType.HLC3 };
foreach (var source in sources)
{
var indicator = new CvIndicator { Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 40; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"Source {source} should produce finite value");
}
}
[Fact]
public void CvIndicator_Period_CanBeChanged()
{
var indicator = new CvIndicator();
Assert.Equal(20, indicator.Period);
indicator.Period = 14;
Assert.Equal(14, indicator.Period);
indicator.Period = 50;
Assert.Equal(50, indicator.Period);
}
[Fact]
public void CvIndicator_Alpha_CanBeChanged()
{
var indicator = new CvIndicator();
Assert.Equal(0.2, indicator.Alpha);
indicator.Alpha = 0.15;
Assert.Equal(0.15, indicator.Alpha);
indicator.Alpha = 0.25;
Assert.Equal(0.25, indicator.Alpha);
}
[Fact]
public void CvIndicator_Beta_CanBeChanged()
{
var indicator = new CvIndicator();
Assert.Equal(0.7, indicator.Beta);
indicator.Beta = 0.6;
Assert.Equal(0.6, indicator.Beta);
indicator.Beta = 0.8;
Assert.Equal(0.8, indicator.Beta);
}
[Fact]
public void CvIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new CvIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void CvIndicator_SourceCodeLink_IsValid()
{
var indicator = new CvIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Cv.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void CvIndicator_VolatilityClustering_ProducesVaryingOutput()
{
var indicator = new CvIndicator { Period = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
var values = new List<double>();
// Add data with varying volatility
for (int i = 0; i < 50; i++)
{
// First 20 bars: low volatility, next 20 bars: high volatility, last 10: low again
double volatilityFactor;
if (i < 20)
{
volatilityFactor = 1.0;
}
else if (i < 40)
{
volatilityFactor = 5.0;
}
else
{
volatilityFactor = 1.0;
}
double basePrice = 100 + (i % 2 == 0 ? volatilityFactor : -volatilityFactor);
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + volatilityFactor, basePrice - volatilityFactor, basePrice, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
if (i >= 10) // After warmup
{
values.Add(indicator.LinesSeries[0].GetValue(0));
}
}
// Verify we got varying volatility values (GARCH captures clustering)
double min = values.Min();
double max = values.Max();
Assert.True(max > min, "CV should vary with changing volatility patterns");
}
}
+459
View File
@@ -0,0 +1,459 @@
namespace QuanTAlib.Tests;
using Xunit;
public class CvTests
{
private static TBarSeries GenerateTestData(int count = 100)
{
var gbm = new GBM(seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
[Fact]
public void Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Cv(0));
Assert.Throws<ArgumentException>(() => new Cv(-1));
Assert.Throws<ArgumentException>(() => new Cv(20, 0.0)); // alpha = 0
Assert.Throws<ArgumentException>(() => new Cv(20, 1.0)); // alpha = 1
Assert.Throws<ArgumentException>(() => new Cv(20, 0.2, 0.0)); // beta = 0
Assert.Throws<ArgumentException>(() => new Cv(20, 0.2, 1.0)); // beta = 1
Assert.Throws<ArgumentException>(() => new Cv(20, 0.5, 0.6)); // alpha + beta >= 1
var valid = new Cv(10, 0.2, 0.7);
Assert.Equal(10, valid.Period);
Assert.Equal(0.2, valid.Alpha);
Assert.Equal(0.7, valid.Beta);
}
[Fact]
public void WarmupPeriod_IsCorrect()
{
var cv = new Cv(20);
Assert.Equal(21, cv.WarmupPeriod); // period + 1
Assert.True(cv.WarmupPeriod > 0);
}
[Fact]
public void Properties_Accessible()
{
var cv = new Cv(20, 0.15, 0.75);
Assert.Equal(20, cv.Period);
Assert.Equal(0.15, cv.Alpha);
Assert.Equal(0.75, cv.Beta);
Assert.Equal("Cv(20,0.15,0.75)", cv.Name);
}
[Fact]
public void BasicCalculation_DoesNotCrash()
{
var cv = new Cv(5);
var bars = GenerateTestData(100);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
var result = cv.Update(new TValue(times[i], close[i]));
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void Calc_ReturnsValue()
{
var cv = new Cv(10);
for (int i = 0; i < 15; i++)
{
var result = cv.Update(new TValue(DateTime.UtcNow, 100 + i));
Assert.True(double.IsFinite(result.Value));
}
Assert.True(cv.IsHot);
}
[Fact]
public void Calc_IsNew_AcceptsParameter()
{
var cv = new Cv(10);
var result1 = cv.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
var result2 = cv.Update(new TValue(DateTime.UtcNow, 101), isNew: true);
var result3 = cv.Update(new TValue(DateTime.UtcNow, 102), isNew: false);
Assert.True(double.IsFinite(result1.Value));
Assert.True(double.IsFinite(result2.Value));
Assert.True(double.IsFinite(result3.Value));
}
[Fact]
public void Calc_IsNew_False_UpdatesValue()
{
var cv = new Cv(5);
for (int i = 0; i < 10; i++)
{
cv.Update(new TValue(DateTime.UtcNow, 100 + i), isNew: true);
}
var baseline = cv.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
var updated = cv.Update(new TValue(DateTime.UtcNow, 150), isNew: false);
Assert.NotEqual(baseline.Value, updated.Value);
}
[Fact]
public void IsHot_BecomesTrueAfterWarmup()
{
int period = 10;
var cv = new Cv(period);
for (int i = 0; i < period - 1; i++)
{
cv.Update(new TValue(DateTime.UtcNow, 100 + i));
Assert.False(cv.IsHot);
}
cv.Update(new TValue(DateTime.UtcNow, 110));
Assert.True(cv.IsHot);
}
[Fact]
public void Reset_Works()
{
var cv = new Cv(10);
for (int i = 0; i < 15; i++)
{
cv.Update(new TValue(DateTime.UtcNow, 100 + i));
}
Assert.True(cv.IsHot);
cv.Reset();
Assert.False(cv.IsHot);
}
[Fact]
public void SingleValue_ReturnsPositiveVolatility()
{
var cv = new Cv(5);
var result = cv.Update(new TValue(DateTime.UtcNow, 100));
// First value should still return a value (using default variance)
Assert.True(double.IsFinite(result.Value));
Assert.True(result.Value >= 0);
}
[Fact]
public void IterativeCorrections_ChangesValue()
{
var cv = new Cv(20);
var bars = GenerateTestData(50);
var times = bars.Times;
var close = bars.CloseValues;
TValue lastValue = default;
for (int i = 0; i < bars.Count; i++)
{
lastValue = cv.Update(new TValue(times[i], close[i]), isNew: true);
}
double originalValue = lastValue.Value;
// Verify that isNew=false with different price produces different output
var correctedValue = cv.Update(new TValue(DateTime.UtcNow, 999.99), isNew: false);
Assert.NotEqual(originalValue, correctedValue.Value);
// Verify output is still finite and positive
Assert.True(double.IsFinite(correctedValue.Value));
Assert.True(correctedValue.Value >= 0);
}
[Fact]
public void IsNew_Consistency()
{
var cv = new Cv(10);
for (int i = 0; i < 10; i++)
{
cv.Update(new TValue(DateTime.UtcNow, 100 + i), isNew: true);
}
var result1 = cv.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
_ = cv.Update(new TValue(DateTime.UtcNow, 115), isNew: false);
var result3 = cv.Update(new TValue(DateTime.UtcNow, 110), isNew: false);
// GARCH has path-dependent state that may cause slight differences due to omega calculation
// on first entry to GARCH phase. Check that values are within 1% of each other.
double tolerance = Math.Max(Math.Abs(result1.Value) * 0.01, 0.2);
Assert.True(Math.Abs(result1.Value - result3.Value) < tolerance,
$"Values should be similar: {result1.Value} vs {result3.Value}");
}
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var cv = new Cv(5);
for (int i = 0; i < 10; i++)
{
cv.Update(new TValue(DateTime.UtcNow, 100 + i));
}
var resultNan = cv.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(resultNan.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var cv = new Cv(5);
for (int i = 0; i < 10; i++)
{
cv.Update(new TValue(DateTime.UtcNow, 100 + i));
}
var resultInf = cv.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(resultInf.Value));
}
[Fact]
public void LargeDataset_Performance()
{
var cv = new Cv(50);
var bars = GenerateTestData(5000);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
var result = cv.Update(new TValue(times[i], close[i]));
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void TSeries_Update_MatchesStreaming()
{
int period = 20;
var cvStream = new Cv(period);
var cvBatch = new Cv(period);
var bars = GenerateTestData(100);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
cvStream.Update(new TValue(times[i], close[i]));
}
var ts = new TSeries();
for (int i = 0; i < bars.Count; i++)
{
ts.Add(new TValue(times[i], close[i]));
}
var result = cvBatch.Update(ts);
Assert.Equal(cvStream.Last.Value, result[result.Count - 1].Value, 1e-9);
}
[Fact]
public void BatchCalc_MatchesIterativeCalc()
{
var cv = new Cv(20);
var bars = GenerateTestData(200);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
cv.Update(new TValue(times[i], close[i]));
}
var iterativeResult = cv.Last.Value;
var ts = new TSeries();
for (int i = 0; i < bars.Count; i++)
{
ts.Add(new TValue(times[i], close[i]));
}
var batchResult = Cv.Batch(ts, 20);
Assert.Equal(iterativeResult, batchResult[batchResult.Count - 1].Value, 1e-8);
}
[Fact]
public void StaticBatch_Works()
{
var bars = GenerateTestData(100);
var times = bars.Times;
var close = bars.CloseValues;
var ts = new TSeries();
for (int i = 0; i < bars.Count; i++)
{
ts.Add(new TValue(times[i], close[i]));
}
var result = Cv.Batch(ts, 20);
Assert.Equal(100, result.Count);
Assert.True(double.IsFinite(result[result.Count - 1].Value));
}
[Fact]
public void StaticBatch_ValidatesInput()
{
var ts = new TSeries();
for (int i = 0; i < 10; i++)
{
ts.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i));
}
Assert.Throws<ArgumentException>(() => Cv.Batch(ts, 0));
Assert.Throws<ArgumentException>(() => Cv.Batch(ts, -1));
Assert.Throws<ArgumentException>(() => Cv.Batch(ts, 5, 0.0)); // alpha = 0
Assert.Throws<ArgumentException>(() => Cv.Batch(ts, 5, 0.5, 0.6)); // alpha + beta >= 1
}
[Fact]
public void Batch_NaN_Safe()
{
var values = new double[] { 100, 101, 102, double.NaN, 104, 105 };
var output = new double[values.Length];
Cv.Batch(values, output, 3);
Assert.True(output.Length == 6);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]));
}
}
[Fact]
public void ConstantPrices_LowVolatility()
{
var cv = new Cv(10);
for (int i = 0; i < 20; i++)
{
cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
// Constant prices should have very low volatility (approaching zero)
Assert.True(cv.Last.Value < 1.0, "Constant prices should have very low volatility");
}
[Fact]
public void HighVolatility_ProducesHigherValue()
{
var cvStable = new Cv(10);
var cvVolatile = new Cv(10);
// Stable prices (small changes)
for (int i = 0; i < 20; i++)
{
cvStable.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i * 0.01));
}
// Volatile prices (alternating)
for (int i = 0; i < 20; i++)
{
double volatilePrice = 100 + (i % 2 == 0 ? 5 : -5);
cvVolatile.Update(new TValue(DateTime.UtcNow.AddMinutes(i), volatilePrice));
}
Assert.True(cvVolatile.Last.Value > cvStable.Last.Value,
"Higher volatility should produce higher CV");
}
[Fact]
public void DifferentParameters_ProduceDistinctValues()
{
var bars = GenerateTestData(50);
var times = bars.Times;
var close = bars.CloseValues;
var cv1 = new Cv(20, 0.1, 0.8);
var cv2 = new Cv(20, 0.2, 0.7);
var cv3 = new Cv(20, 0.3, 0.6);
for (int i = 0; i < bars.Count; i++)
{
cv1.Update(new TValue(times[i], close[i]));
cv2.Update(new TValue(times[i], close[i]));
cv3.Update(new TValue(times[i], close[i]));
}
Assert.True(double.IsFinite(cv1.Last.Value));
Assert.True(double.IsFinite(cv2.Last.Value));
Assert.True(double.IsFinite(cv3.Last.Value));
}
[Fact]
public void VolatilityClustering_HighVolFollowsHighVol()
{
var cv = new Cv(10, 0.2, 0.7);
// Low volatility period
for (int i = 0; i < 15; i++)
{
cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i * 0.1));
}
double lowVolResult = cv.Last.Value;
// High volatility shock
cv.Update(new TValue(DateTime.UtcNow.AddMinutes(15), 120)); // +20%
cv.Update(new TValue(DateTime.UtcNow.AddMinutes(16), 100)); // -16.7%
double afterShock = cv.Last.Value;
// GARCH should show elevated volatility after the shock
Assert.True(afterShock > lowVolResult, "GARCH should capture volatility clustering");
}
[Fact]
public void MeanReversion_VolReturnsToLongRun()
{
var cv = new Cv(10, 0.1, 0.8); // High beta = slower decay
// Establish long-run variance
for (int i = 0; i < 15; i++)
{
cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i * 0.5));
}
// Introduce shock
cv.Update(new TValue(DateTime.UtcNow.AddMinutes(15), 130));
double shockVol = cv.Last.Value;
// Let it decay
for (int i = 16; i < 50; i++)
{
cv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + (i - 16) * 0.1));
}
double decayedVol = cv.Last.Value;
// Volatility should decay (mean revert) after shock
Assert.True(decayedVol < shockVol * 0.9, "Volatility should mean-revert after shock");
}
[Fact]
public void Chainability_Works()
{
var cv = new Cv(20);
var sma = new Sma(5);
var bars = GenerateTestData(100);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
var cvResult = cv.Update(new TValue(times[i], close[i]));
sma.Update(cvResult);
}
Assert.True(sma.IsHot);
Assert.True(double.IsFinite(sma.Last.Value));
}
}
@@ -0,0 +1,454 @@
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)");
}
}