2026-01-31 17:25:39 -08:00
|
|
|
|
namespace QuanTAlib.Test;
|
|
|
|
|
|
|
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validation tests for CCV (Close-to-Close Volatility).
|
|
|
|
|
|
/// CCV is a standard volatility measure but with specific smoothing options.
|
|
|
|
|
|
/// These tests validate the mathematical correctness of the implementation.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class CcvValidationTests
|
|
|
|
|
|
{
|
|
|
|
|
|
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 that CCV calculates annualized log return volatility correctly.
|
|
|
|
|
|
/// Formula: σ_annual = StdDev(ln(C_t/C_{t-1})) × √252
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_MatchesManualLogReturnCalculation()
|
|
|
|
|
|
{
|
|
|
|
|
|
int period = 10;
|
|
|
|
|
|
var ccv = new Ccv(period, 1); // SMA method
|
|
|
|
|
|
|
|
|
|
|
|
// Use fixed prices for deterministic testing
|
|
|
|
|
|
double[] prices = { 100, 102, 101, 103, 105, 104, 106, 108, 107, 109, 110 };
|
|
|
|
|
|
|
|
|
|
|
|
// Feed all prices to the indicator (first price initializes, rest produce returns)
|
|
|
|
|
|
for (int i = 0; i < prices.Length; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
ccv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate log returns (prices[1]/prices[0], prices[2]/prices[1], etc.)
|
|
|
|
|
|
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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate expected stddev manually for last 'period' returns
|
|
|
|
|
|
int startIdx = Math.Max(0, logReturns.Length - period);
|
|
|
|
|
|
double sum = 0;
|
|
|
|
|
|
int count = 0;
|
|
|
|
|
|
for (int i = startIdx; i < logReturns.Length; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
sum += logReturns[i];
|
|
|
|
|
|
count++;
|
|
|
|
|
|
}
|
|
|
|
|
|
double mean = sum / count;
|
|
|
|
|
|
|
|
|
|
|
|
double squaredSum = 0;
|
|
|
|
|
|
for (int i = startIdx; i < logReturns.Length; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
squaredSum += Math.Pow(logReturns[i] - mean, 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
double stdDev = Math.Sqrt(squaredSum / count);
|
|
|
|
|
|
double expectedAnnualized = stdDev * Math.Sqrt(252);
|
|
|
|
|
|
|
|
|
|
|
|
// Compare (allow for floating-point tolerance - small differences expected due to
|
|
|
|
|
|
// the indicator using a rolling window vs manual batch calculation)
|
|
|
|
|
|
Assert.Equal(expectedAnnualized, ccv.Last.Value, 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates the annualization factor √252 is correctly applied.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_AnnualizationFactor_IsCorrect()
|
|
|
|
|
|
{
|
|
|
|
|
|
// √252 ≈ 15.8745
|
|
|
|
|
|
double expectedFactor = Math.Sqrt(252);
|
|
|
|
|
|
Assert.Equal(15.874507866387544, expectedFactor, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates that constant prices produce zero volatility.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_ConstantPrices_ProducesZeroVolatility()
|
|
|
|
|
|
{
|
|
|
|
|
|
var ccv = new Ccv(10, 1);
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < 20; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
ccv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Constant prices = zero log returns = zero stddev = zero volatility
|
|
|
|
|
|
Assert.Equal(0.0, ccv.Last.Value, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates that the EMA method (2) applies warmup compensation correctly.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_EmaMethod_WarmsUpCorrectly()
|
|
|
|
|
|
{
|
|
|
|
|
|
var ccv = new Ccv(20, 2); // EMA method
|
|
|
|
|
|
var bars = GenerateTestData(50);
|
|
|
|
|
|
var times = bars.Times;
|
|
|
|
|
|
var close = bars.CloseValues;
|
|
|
|
|
|
|
|
|
|
|
|
var results = new List<double>();
|
|
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
var result = ccv.Update(new TValue(times[i], close[i]));
|
|
|
|
|
|
results.Add(result.Value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Early values should exist and be finite
|
|
|
|
|
|
Assert.All(results, r => Assert.True(double.IsFinite(r)));
|
|
|
|
|
|
|
|
|
|
|
|
// Values should generally stabilize after warmup
|
|
|
|
|
|
Assert.True(results[^1] >= 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates known volatility scenario with specific returns.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_KnownReturns_ProducesExpectedVolatility()
|
|
|
|
|
|
{
|
|
|
|
|
|
var ccv = new Ccv(5, 1); // SMA method, 5 periods
|
|
|
|
|
|
|
|
|
|
|
|
// Create prices that produce known log returns
|
|
|
|
|
|
// If we have returns of: 1%, 1%, 1%, 1%, 1% (all same)
|
|
|
|
|
|
// Then stddev = 0, volatility = 0
|
|
|
|
|
|
double price = 100.0;
|
|
|
|
|
|
double returnRate = 0.01; // 1% daily return
|
|
|
|
|
|
|
|
|
|
|
|
ccv.Update(new TValue(DateTime.UtcNow, price)); // First price
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
price *= (1 + returnRate);
|
|
|
|
|
|
ccv.Update(new TValue(DateTime.UtcNow.AddMinutes(i + 1), price));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Constant returns should produce near-zero volatility
|
|
|
|
|
|
// (log(1.01) is constant, so stddev ≈ 0)
|
|
|
|
|
|
Assert.True(ccv.Last.Value < 0.01, "Constant returns should have near-zero volatility");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates that CCV responds to varying volatility correctly.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_VaryingVolatility_RespondsCorrectly()
|
|
|
|
|
|
{
|
|
|
|
|
|
var ccvLow = new Ccv(10, 1);
|
|
|
|
|
|
var ccvHigh = new Ccv(10, 1);
|
|
|
|
|
|
|
|
|
|
|
|
// Low volatility: small price changes
|
|
|
|
|
|
double priceLow = 100.0;
|
|
|
|
|
|
for (int i = 0; i < 20; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
priceLow *= (1 + 0.001 * (i % 2 == 0 ? 1 : -1)); // ±0.1%
|
|
|
|
|
|
ccvLow.Update(new TValue(DateTime.UtcNow.AddMinutes(i), priceLow));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// High volatility: large price changes
|
|
|
|
|
|
double priceHigh = 100.0;
|
|
|
|
|
|
for (int i = 0; i < 20; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
priceHigh *= (1 + 0.05 * (i % 2 == 0 ? 1 : -1)); // ±5%
|
|
|
|
|
|
ccvHigh.Update(new TValue(DateTime.UtcNow.AddMinutes(i), priceHigh));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Assert.True(ccvHigh.Last.Value > ccvLow.Last.Value,
|
|
|
|
|
|
"Higher price volatility should produce higher CCV");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === Consistency Tests ===
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates streaming and batch produce identical results.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_StreamingMatchesBatch()
|
|
|
|
|
|
{
|
|
|
|
|
|
var bars = GenerateTestData(100);
|
|
|
|
|
|
var times = bars.Times;
|
|
|
|
|
|
var close = bars.CloseValues;
|
|
|
|
|
|
|
|
|
|
|
|
// Streaming calculation
|
|
|
|
|
|
var streamingCcv = new Ccv(20, 1);
|
|
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
streamingCcv.Update(new TValue(times[i], close[i]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Batch calculation
|
|
|
|
|
|
var source = new double[bars.Count];
|
|
|
|
|
|
var output = new double[bars.Count];
|
|
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
source[i] = close[i];
|
|
|
|
|
|
}
|
|
|
|
|
|
Ccv.Batch(source, output, 20, 1);
|
|
|
|
|
|
|
|
|
|
|
|
// Compare last values
|
|
|
|
|
|
Assert.Equal(output[^1], streamingCcv.Last.Value, 8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates all three smoothing methods produce valid results.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Theory]
|
|
|
|
|
|
[InlineData(1)] // SMA
|
|
|
|
|
|
[InlineData(2)] // EMA
|
|
|
|
|
|
[InlineData(3)] // WMA
|
|
|
|
|
|
public void Ccv_AllMethods_ProduceConsistentResults(int method)
|
|
|
|
|
|
{
|
|
|
|
|
|
var bars = GenerateTestData(100);
|
|
|
|
|
|
var times = bars.Times;
|
|
|
|
|
|
var close = bars.CloseValues;
|
|
|
|
|
|
|
|
|
|
|
|
var ccv = new Ccv(20, method);
|
|
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
var result = ccv.Update(new TValue(times[i], close[i]));
|
|
|
|
|
|
Assert.True(double.IsFinite(result.Value), $"Method {method} should produce finite values");
|
|
|
|
|
|
Assert.True(result.Value >= 0, $"Method {method} should produce non-negative values");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === Edge Cases ===
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates handling of very small price changes.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_SmallPriceChanges_HandledCorrectly()
|
|
|
|
|
|
{
|
|
|
|
|
|
var ccv = new Ccv(10, 1);
|
|
|
|
|
|
|
|
|
|
|
|
double price = 100.0;
|
|
|
|
|
|
for (int i = 0; i < 20; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
price += 0.0001; // Very small changes
|
|
|
|
|
|
ccv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Assert.True(double.IsFinite(ccv.Last.Value));
|
|
|
|
|
|
Assert.True(ccv.Last.Value >= 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates handling of large price swings.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_LargePriceSwings_HandledCorrectly()
|
|
|
|
|
|
{
|
|
|
|
|
|
var ccv = new Ccv(10, 1);
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < 20; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
double price = 100.0 * (i % 2 == 0 ? 2.0 : 0.5); // 100% swings
|
|
|
|
|
|
ccv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Assert.True(double.IsFinite(ccv.Last.Value));
|
|
|
|
|
|
Assert.True(ccv.Last.Value > 0, "Large swings should produce positive volatility");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Validates that different periods produce different sensitivities.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Ccv_DifferentPeriods_ProduceDifferentValues()
|
|
|
|
|
|
{
|
|
|
|
|
|
var bars = GenerateTestData(100);
|
|
|
|
|
|
var times = bars.Times;
|
|
|
|
|
|
var close = bars.CloseValues;
|
|
|
|
|
|
|
|
|
|
|
|
var ccv5 = new Ccv(5, 1);
|
|
|
|
|
|
var ccv20 = new Ccv(20, 1);
|
|
|
|
|
|
var ccv50 = new Ccv(50, 1);
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
ccv5.Update(new TValue(times[i], close[i]));
|
|
|
|
|
|
ccv20.Update(new TValue(times[i], close[i]));
|
|
|
|
|
|
ccv50.Update(new TValue(times[i], close[i]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// All should be valid
|
|
|
|
|
|
Assert.True(double.IsFinite(ccv5.Last.Value));
|
|
|
|
|
|
Assert.True(double.IsFinite(ccv20.Last.Value));
|
|
|
|
|
|
Assert.True(double.IsFinite(ccv50.Last.Value));
|
|
|
|
|
|
|
|
|
|
|
|
// Shorter periods typically react more to recent volatility
|
|
|
|
|
|
// (but this depends on market data, so just check they're different or similar)
|
|
|
|
|
|
Assert.True(ccv5.Last.Value >= 0);
|
|
|
|
|
|
Assert.True(ccv20.Last.Value >= 0);
|
|
|
|
|
|
Assert.True(ccv50.Last.Value >= 0);
|
|
|
|
|
|
}
|
2026-02-27 12:50:05 -08:00
|
|
|
|
}
|