Files

341 lines
11 KiB
C#
Raw Permalink Normal View History

namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for Cointegration indicator.
/// Note: Cointegration is not commonly implemented in standard TA libraries.
/// These tests validate against expected statistical properties rather than
/// external library comparisons.
/// </summary>
public class CointegrationValidationTests
{
private const double Tolerance = 1e-6;
2026-02-27 07:48:12 -08:00
// GBM-based noise helper: log-return from seeded GBM price stream as centered noise.
private static double GbmNoise(GBM gbm) => Math.Log(gbm.Next().Close / 100.0);
#region Statistical Property Validation
[Fact]
public void Cointegration_PerfectlyCointegrated_ProducesStrongNegativeAdf()
{
// Two series with near-perfect linear relationship should show strong cointegration.
// Use incremental log-returns (i.i.d.) as noise so residuals are stationary.
// Period=30 gives ADF sufficient window; 200 samples ensure stable regression.
var indicator = new Cointegration(30);
var gbm = new GBM(startPrice: 100.0, sigma: 0.2, seed: 42);
var bars = gbm.Fetch(201, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 1; i <= 200; i++)
{
// Incremental log-return: truly i.i.d. noise, variance ~(0.2²·dt)
double noise = Math.Log(bars[i].Close / bars[i - 1].Close);
double a = 100.0 + i * 0.5 + noise * 0.1;
double b = 2.0 * a + 10.0 + noise * 0.1;
indicator.Update(a, b);
}
// Near-perfect cointegration should produce ADF below the 5% critical value.
// Engle-Granger critical values (residual-based, no constant): -1.95 at 5%, -2.86 for large N.
// With period=30 and 200 samples of near-linear data the statistic should clear -1.95 comfortably.
Assert.True(indicator.Last.Value < -1.95, $"ADF should be below 5% critical value (-1.95) for cointegrated series, got {indicator.Last.Value}");
}
[Fact]
public void Cointegration_IdenticalSeries_ProducesNegativeOrNaN()
{
// Two identical series produce zero residuals, which is mathematically correct
// but results in zero variance for ADF test (division by zero → NaN)
var indicator = new Cointegration(20);
for (int i = 0; i < 100; i++)
{
double value = 100.0 + Math.Sin(i * 0.1) * 10.0;
indicator.Update(value, value);
}
// Identical series produce zero residuals → NaN ADF (mathematically correct)
// This is expected behavior: perfect cointegration with no estimation error
Assert.True(double.IsNaN(indicator.Last.Value) || indicator.Last.Value < 0,
$"ADF should be NaN or negative for identical series, got {indicator.Last.Value}");
}
[Fact]
public void Cointegration_ProportionalSeries_WithNoise_ProducesNegativeAdf()
{
// B = k * A + small noise (near-proportional relationship)
var indicator = new Cointegration(20);
2026-02-27 07:48:12 -08:00
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 43);
for (int i = 0; i < 100; i++)
{
double a = 50.0 + i * 0.3 + Math.Sin(i * 0.2) * 5.0;
2026-02-27 07:48:12 -08:00
double noise = GbmNoise(random) * 0.5;
double b = 1.5 * a + noise;
indicator.Update(a, b);
}
// Proportional series with small noise should produce ADF well below 0; -1.0 is a conservative bound.
Assert.True(indicator.Last.Value < -1.0, $"ADF should be well negative for near-proportional series, got {indicator.Last.Value}");
}
[Fact]
public void Cointegration_LinearWithNoise_StillDetectsCointegration()
{
// B = α + β*A + small_noise
var indicator = new Cointegration(20);
2026-02-27 07:48:12 -08:00
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 44);
for (int i = 0; i < 100; i++)
{
double a = 100.0 + i * 0.2;
2026-02-27 07:48:12 -08:00
double noise = GbmNoise(random) * 0.5; // Small noise
double b = 25.0 + 0.8 * a + noise;
indicator.Update(a, b);
}
// Linear relationship with small noise should still clear -1.0.
Assert.True(indicator.Last.Value < -1.0, $"ADF should be well negative with small noise, got {indicator.Last.Value}");
}
[Fact]
public void Cointegration_DifferentPeriods_ProduceDifferentResults()
{
var indicator10 = new Cointegration(10);
var indicator30 = new Cointegration(30);
for (int i = 0; i < 100; i++)
{
double a = 100.0 + i * 0.3;
double b = 50.0 + 0.5 * a + Math.Sin(i * 0.1);
indicator10.Update(a, b);
indicator30.Update(a, b);
}
// Different periods should yield different ADF values
Assert.NotEqual(indicator10.Last.Value, indicator30.Last.Value);
}
#endregion
#region Consistency Tests
[Fact]
public void Cointegration_BatchMatchesStreaming()
{
var seriesA = new TSeries();
var seriesB = new TSeries();
var baseTime = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double a = 100.0 + i * 0.2 + Math.Sin(i * 0.1) * 3.0;
double b = 30.0 + 0.7 * a + Math.Cos(i * 0.15) * 2.0;
seriesA.Add(baseTime.AddMinutes(i), a);
seriesB.Add(baseTime.AddMinutes(i), b);
}
// Batch calculation
2026-02-10 21:33:16 -08:00
var batchResult = Cointegration.Batch(seriesA, seriesB, 20);
// Streaming calculation
var streamingIndicator = new Cointegration(20);
for (int i = 0; i < seriesA.Count; i++)
{
streamingIndicator.Update(seriesA[i].Value, seriesB[i].Value);
}
// Last values should match
if (double.IsNaN(batchResult.Last.Value) && double.IsNaN(streamingIndicator.Last.Value))
{
Assert.True(true);
}
else
{
Assert.Equal(batchResult.Last.Value, streamingIndicator.Last.Value, Tolerance);
}
}
[Fact]
public void Cointegration_SpanMatchesStreaming()
{
const int length = 50;
var seriesA = new double[length];
var seriesB = new double[length];
var output = new double[length];
for (int i = 0; i < length; i++)
{
seriesA[i] = 100.0 + i * 0.2 + Math.Sin(i * 0.1) * 3.0;
seriesB[i] = 30.0 + 0.7 * seriesA[i] + Math.Cos(i * 0.15) * 2.0;
}
// Span calculation
2026-02-10 21:33:16 -08:00
Cointegration.Batch(seriesA, seriesB, output, 20);
// Streaming calculation
var streamingIndicator = new Cointegration(20);
for (int i = 0; i < length; i++)
{
streamingIndicator.Update(seriesA[i], seriesB[i]);
}
// Last values should match
if (double.IsNaN(output[length - 1]) && double.IsNaN(streamingIndicator.Last.Value))
{
Assert.True(true);
}
else
{
Assert.Equal(output[length - 1], streamingIndicator.Last.Value, Tolerance);
}
}
[Fact]
public void Cointegration_ResetProducesSameResults()
{
var indicator = new Cointegration(20);
// First run
for (int i = 0; i < 50; i++)
{
double a = 100.0 + i * 0.3;
double b = 50.0 + 0.5 * a;
indicator.Update(a, b);
}
var firstResult = indicator.Last.Value;
indicator.Reset();
// Second run with same data
for (int i = 0; i < 50; i++)
{
double a = 100.0 + i * 0.3;
double b = 50.0 + 0.5 * a;
indicator.Update(a, b);
}
var secondResult = indicator.Last.Value;
Assert.Equal(firstResult, secondResult, Tolerance);
}
#endregion
#region Edge Cases
[Fact]
public void Cointegration_ConstantSeries_HandlesGracefully()
{
var indicator = new Cointegration(10);
// Both series are constant
for (int i = 0; i < 20; i++)
{
indicator.Update(100.0, 50.0);
}
// Constant series → zero variance → ADF denominator is zero → NaN is correct.
Assert.True(double.IsNaN(indicator.Last.Value), $"Expected NaN for constant series, got {indicator.Last.Value}");
}
[Fact]
public void Cointegration_OneConstantOneTrending_HandlesGracefully()
{
var indicator = new Cointegration(10);
for (int i = 0; i < 20; i++)
{
indicator.Update(100.0, 50.0 + i); // A constant, B trending
}
// Constant A → zero variance in A → ADF is undefined → NaN.
Assert.True(double.IsNaN(indicator.Last.Value), $"Expected NaN when series A is constant, got {indicator.Last.Value}");
}
[Fact]
public void Cointegration_SmallPeriod_WorksCorrectly()
{
var indicator = new Cointegration(3); // Minimum practical period
2026-02-27 07:48:12 -08:00
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 45);
for (int i = 0; i < 20; i++)
{
2026-02-27 07:48:12 -08:00
double a = 100.0 + i + GbmNoise(random) * 0.1;
double b = 50.0 + 0.5 * a + GbmNoise(random) * 0.1;
indicator.Update(a, b);
}
Assert.True(indicator.IsHot);
// With small periods and noise, result may be finite or NaN
Assert.True(double.IsFinite(indicator.Last.Value) || double.IsNaN(indicator.Last.Value));
}
[Fact]
public void Cointegration_LargePeriod_WorksCorrectly()
{
var indicator = new Cointegration(100);
2026-02-27 07:48:12 -08:00
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 46);
for (int i = 0; i < 150; i++)
{
2026-02-27 07:48:12 -08:00
double a = 100.0 + i * 0.1 + GbmNoise(random) * 0.1;
double b = 30.0 + 0.8 * a + GbmNoise(random) * 0.1;
indicator.Update(a, b);
}
Assert.True(indicator.IsHot);
// Should produce finite or NaN value (both acceptable for edge cases)
Assert.True(double.IsFinite(indicator.Last.Value) || double.IsNaN(indicator.Last.Value));
}
#endregion
#region Numerical Stability
[Fact]
public void Cointegration_LargeValues_MaintainsStability()
{
var indicator = new Cointegration(20);
for (int i = 0; i < 50; i++)
{
double a = 1e8 + i * 1e5;
double b = 2e8 + 2.0 * a;
indicator.Update(a, b);
}
Assert.True(double.IsFinite(indicator.Last.Value) || double.IsNaN(indicator.Last.Value));
}
[Fact]
public void Cointegration_SmallValues_MaintainsStability()
{
var indicator = new Cointegration(20);
for (int i = 0; i < 50; i++)
{
double a = 1e-6 + i * 1e-8;
double b = 2e-6 + 1.5 * a;
indicator.Update(a, b);
}
Assert.True(double.IsFinite(indicator.Last.Value) || double.IsNaN(indicator.Last.Value));
}
[Fact]
public void Cointegration_MixedMagnitudes_HandlesCorrectly()
{
var indicator = new Cointegration(20);
for (int i = 0; i < 50; i++)
{
double a = 1000.0 + i;
double b = 0.001 + 0.000001 * a; // Much smaller scale
indicator.Update(a, b);
}
Assert.True(double.IsFinite(indicator.Last.Value) || double.IsNaN(indicator.Last.Value));
}
#endregion
}