test: setup common stability and robustness properties tracking

This commit is contained in:
Miha Kralj
2026-02-27 12:50:05 -08:00
parent 4ab3a7fb53
commit 769a923a24
287 changed files with 1314 additions and 867 deletions
@@ -264,4 +264,4 @@ public class CointegrationIndicatorTests
Assert.Contains("cointegration", indicator.Description, StringComparison.OrdinalIgnoreCase);
Assert.Contains("ADF", indicator.Description, StringComparison.Ordinal);
}
}
}
@@ -154,27 +154,34 @@ public class CointegrationTests
[Fact]
public void Update_WithIsNewFalse_DoesNotAdvanceState()
{
var indicator = new Cointegration(5);
var corrected = new Cointegration(5);
var direct = new Cointegration(5);
var gbmA = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.1, seed: 12345);
var gbmB = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.1, seed: 54321);
// Build up some state
// Build identical state
for (int i = 0; i < 10; i++)
{
indicator.Update(gbmA.Next().Close, gbmB.Next().Close, isNew: true);
double a = gbmA.Next().Close;
double b = gbmB.Next().Close;
corrected.Update(a, b, isNew: true);
direct.Update(a, b, isNew: true);
}
// Update with same values and isNew=false
indicator.Update(gbmA.Next().Close, gbmB.Next().Close, isNew: false);
var valueAfterFirst = indicator.Last.Value;
const double finalA = 105.0;
const double finalB = 55.0;
// Another correction
indicator.Update(gbmA.Next().Close, gbmB.Next().Close, isNew: false);
var valueAfterSecond = indicator.Last.Value;
// Correction path: add + multiple rewrites + final rewrite to target value
corrected.Update(finalA, finalB, isNew: true);
corrected.Update(finalA + 10.0, finalB + 10.0, isNew: false);
corrected.Update(finalA - 3.0, finalB - 3.0, isNew: false);
corrected.Update(finalA, finalB, isNew: false);
// All corrections replace the same bar, state should be consistent
Assert.True(double.IsFinite(valueAfterFirst) || double.IsNaN(valueAfterFirst));
Assert.True(double.IsFinite(valueAfterSecond) || double.IsNaN(valueAfterSecond));
// Direct path: only final new bar
direct.Update(finalA, finalB, isNew: true);
Assert.Equal(direct.Last.Value, corrected.Last.Value, Tolerance);
}
[Fact]
@@ -648,4 +655,4 @@ public class CointegrationTests
}
#endregion
}
}
@@ -18,20 +18,26 @@ public class CointegrationValidationTests
[Fact]
public void Cointegration_PerfectlyCointegrated_ProducesStrongNegativeAdf()
{
// Two series with near-perfect linear relationship should show strong cointegration
// Adding small noise to avoid zero-variance residuals
var indicator = new Cointegration(20);
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 42);
// 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 = 0; i < 100; i++)
for (int i = 1; i <= 200; i++)
{
double a = 100.0 + i * 0.5 + GbmNoise(random) * 0.1;
double b = 2.0 * a + 10.0 + GbmNoise(random) * 0.1;
// 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 strongly negative ADF statistic
Assert.True(indicator.Last.Value < -2.0, $"ADF should be strongly negative for cointegrated series, got {indicator.Last.Value}");
// 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]
@@ -68,7 +74,8 @@ public class CointegrationValidationTests
indicator.Update(a, b);
}
Assert.True(indicator.Last.Value < 0, $"ADF should be negative for near-proportional series, got {indicator.Last.Value}");
// 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]
@@ -86,8 +93,8 @@ public class CointegrationValidationTests
indicator.Update(a, b);
}
// Should still detect cointegration despite small noise
Assert.True(indicator.Last.Value < 0, $"ADF should be negative even with small noise, got {indicator.Last.Value}");
// 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]
@@ -226,8 +233,8 @@ public class CointegrationValidationTests
indicator.Update(100.0, 50.0);
}
// Should handle constant series without crashing (result may be NaN due to zero variance)
Assert.True(double.IsNaN(indicator.Last.Value) || double.IsFinite(indicator.Last.Value));
// 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]
@@ -240,8 +247,8 @@ public class CointegrationValidationTests
indicator.Update(100.0, 50.0 + i); // A constant, B trending
}
// Should handle mixed constant/trending without crashing
Assert.True(double.IsNaN(indicator.Last.Value) || double.IsFinite(indicator.Last.Value));
// 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]
@@ -330,4 +337,4 @@ public class CointegrationValidationTests
}
#endregion
}
}
+98 -133
View File
@@ -45,8 +45,7 @@ public sealed class Cointegration : AbstractBase
// ADF regression running sums (period-1 window)
private readonly RingBuffer _deltaResiduals;
private readonly RingBuffer _laggedResiduals;
private double _sumDelta, _sumLagged;
private double _sumDeltaLagged, _sumLagged2;
private double _sumDeltaLagged, _sumLagged2, _sumDelta2;
// Last valid values for NaN handling
private double _lastValidA, _lastValidB;
@@ -113,7 +112,7 @@ public sealed class Cointegration : AbstractBase
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(double seriesA, double seriesB, bool isNew = true)
{
return Update(new TValue(DateTime.UtcNow, seriesA), new TValue(DateTime.UtcNow, seriesB), isNew);
return Update(new TValue(DateTime.MinValue, seriesA), new TValue(DateTime.MinValue, seriesB), isNew);
}
/// <inheritdoc/>
@@ -195,19 +194,17 @@ public sealed class Cointegration : AbstractBase
{
double oldDelta = _deltaResiduals.Oldest;
double oldLagged = _laggedResiduals.Oldest;
_sumDelta -= oldDelta;
_sumLagged -= oldLagged;
_sumDeltaLagged = FusedMultiplyAdd(-oldDelta, oldLagged, _sumDeltaLagged);
_sumLagged2 = FusedMultiplyAdd(-oldLagged, oldLagged, _sumLagged2);
_sumDelta2 = FusedMultiplyAdd(-oldDelta, oldDelta, _sumDelta2);
}
_deltaResiduals.Add(delta);
_laggedResiduals.Add(lagged);
_sumDelta += delta;
_sumLagged += lagged;
_sumDeltaLagged = FusedMultiplyAdd(delta, lagged, _sumDeltaLagged);
_sumLagged2 = FusedMultiplyAdd(lagged, lagged, _sumLagged2);
_sumDelta2 = FusedMultiplyAdd(delta, delta, _sumDelta2);
}
_prevResidual = residual;
@@ -230,31 +227,24 @@ public sealed class Cointegration : AbstractBase
_hasPrevResidual = _p_hasPrevResidual;
// Update newest values in main buffers
if (_bufferA.Count > 0)
if (_bufferA.Count == 0)
{
double oldA = _bufferA.Newest;
double oldB = _bufferB.Newest;
_sumA = FusedMultiplyAdd(1.0, a, FusedMultiplyAdd(-1.0, oldA, _sumA));
_sumB = FusedMultiplyAdd(1.0, b, FusedMultiplyAdd(-1.0, oldB, _sumB));
_sumA2 = FusedMultiplyAdd(a, a, FusedMultiplyAdd(-oldA, oldA, _sumA2));
_sumB2 = FusedMultiplyAdd(b, b, FusedMultiplyAdd(-oldB, oldB, _sumB2));
_sumAB = FusedMultiplyAdd(a, b, FusedMultiplyAdd(-oldA, oldB, _sumAB));
_bufferA.UpdateNewest(a);
_bufferB.UpdateNewest(b);
}
else
{
_bufferA.Add(a);
_bufferB.Add(b);
_sumA = a;
_sumB = b;
_sumA2 = a * a;
_sumB2 = b * b;
_sumAB = a * b;
// Nothing to correct yet; no current bar exists
return;
}
double oldA = _bufferA.Newest;
double oldB = _bufferB.Newest;
_sumA += a - oldA;
_sumB += b - oldB;
_sumA2 = FusedMultiplyAdd(a, a, FusedMultiplyAdd(-oldA, oldA, _sumA2));
_sumB2 = FusedMultiplyAdd(b, b, FusedMultiplyAdd(-oldB, oldB, _sumB2));
_sumAB = FusedMultiplyAdd(a, b, FusedMultiplyAdd(-oldA, oldB, _sumAB));
_bufferA.UpdateNewest(a);
_bufferB.UpdateNewest(b);
// Calculate current residual
double residual = CalculateResidual(a, b);
@@ -264,28 +254,21 @@ public sealed class Cointegration : AbstractBase
double delta = residual - _prevResidual;
double lagged = _prevResidual;
if (_deltaResiduals.Count > 0)
if (_deltaResiduals.Count == 0)
{
double oldDelta = _deltaResiduals.Newest;
double oldLagged = _laggedResiduals.Newest;
_sumDelta = FusedMultiplyAdd(1.0, delta, FusedMultiplyAdd(-1.0, oldDelta, _sumDelta));
_sumLagged = FusedMultiplyAdd(1.0, lagged, FusedMultiplyAdd(-1.0, oldLagged, _sumLagged));
_sumDeltaLagged = FusedMultiplyAdd(delta, lagged, FusedMultiplyAdd(-oldDelta, oldLagged, _sumDeltaLagged));
_sumLagged2 = FusedMultiplyAdd(lagged, lagged, FusedMultiplyAdd(-oldLagged, oldLagged, _sumLagged2));
_deltaResiduals.UpdateNewest(delta);
_laggedResiduals.UpdateNewest(lagged);
}
else
{
_deltaResiduals.Add(delta);
_laggedResiduals.Add(lagged);
_sumDelta = delta;
_sumLagged = lagged;
_sumDeltaLagged = delta * lagged;
_sumLagged2 = lagged * lagged;
// Nothing to correct yet in ADF buffers; no current entry exists
return;
}
double oldDelta = _deltaResiduals.Newest;
double oldLagged = _laggedResiduals.Newest;
_sumDeltaLagged = FusedMultiplyAdd(delta, lagged, FusedMultiplyAdd(-oldDelta, oldLagged, _sumDeltaLagged));
_sumLagged2 = FusedMultiplyAdd(lagged, lagged, FusedMultiplyAdd(-oldLagged, oldLagged, _sumLagged2));
_sumDelta2 = FusedMultiplyAdd(delta, delta, FusedMultiplyAdd(-oldDelta, oldDelta, _sumDelta2));
_deltaResiduals.UpdateNewest(delta);
_laggedResiduals.UpdateNewest(lagged);
}
_prevResidual = residual;
@@ -305,28 +288,15 @@ public sealed class Cointegration : AbstractBase
double meanA = _sumA / n;
double meanB = _sumB / n;
// Calculate variances and covariance
double varA = Max(0.0, (_sumA2 / n) - (meanA * meanA));
// Calculate variance of B and covariance
double varB = Max(0.0, (_sumB2 / n) - (meanB * meanB));
double cov = (_sumAB / n) - (meanA * meanB);
// Calculate standard deviations
double stdA = Sqrt(varA);
double stdB = Sqrt(varB);
// Calculate correlation
double correlation = 0.0;
double denom = stdA * stdB;
if (Abs(denom) > Epsilon)
{
correlation = cov / denom;
}
// Calculate beta and alpha
double beta = 0.0;
if (Abs(stdB) > Epsilon)
if (varB > Epsilon)
{
beta = correlation * (stdA / stdB);
beta = cov / varB;
}
double alpha = meanA - (beta * meanB);
@@ -343,42 +313,23 @@ public sealed class Cointegration : AbstractBase
return double.NaN;
}
// Calculate gamma (coefficient in ADF regression)
// Δε_t = γ × ε_{t-1} + u_t
// γ = Cov(Δε, ε_{t-1}) / Var(ε_{t-1})
double meanDelta = _sumDelta / n;
double meanLagged = _sumLagged / n;
// Variance of lagged residuals
double varLagged = (_sumLagged2 / n) - (meanLagged * meanLagged);
if (Abs(varLagged) < Epsilon)
if (_sumLagged2 < Epsilon)
{
return double.NaN;
}
// Covariance of delta and lagged
double covDeltaLagged = (_sumDeltaLagged / n) - (meanDelta * meanLagged);
// No-intercept ADF regression: Δε_t = γ × ε_{t-1} + u_t
double gamma = _sumDeltaLagged / _sumLagged2;
// Gamma coefficient
double gamma = covDeltaLagged / varLagged;
// Calculate sum of squared regression errors in O(1)
// Sum((Δε_t - γ ε_{t-1})^2) = Sum(Δε_t^2) - 2γ Sum(Δε_t ε_{t-1}) + γ^2 Sum(ε_{t-1}^2)
double sumErrorSq = _sumDelta2 - (2.0 * gamma * _sumDeltaLagged) + (gamma * gamma * _sumLagged2);
// Calculate standard error of gamma
// SE(γ) = sqrt(Var(u) / (n × Var(ε_{t-1})))
// where u_t = Δε_t - γ × ε_{t-1}
// Ensure non-negative due to floating point errors
sumErrorSq = Max(0.0, sumErrorSq);
// Calculate sum of squared regression errors
double sumErrorSq = 0.0;
for (int i = 0; i < n; i++)
{
double delta = _deltaResiduals[i];
double lagged = _laggedResiduals[i];
double error = delta - (gamma * lagged);
sumErrorSq = FusedMultiplyAdd(error, error, sumErrorSq);
}
double varError = sumErrorSq / n;
double seGammaSq = varError / (n * varLagged);
double varError = sumErrorSq / (n - 1);
double seGammaSq = varError / _sumLagged2;
if (seGammaSq <= 0 || !double.IsFinite(seGammaSq))
{
@@ -386,7 +337,7 @@ public sealed class Cointegration : AbstractBase
}
double seGamma = Sqrt(seGammaSq);
if (Abs(seGamma) < Epsilon)
if (seGamma < Epsilon)
{
return double.NaN;
}
@@ -396,17 +347,20 @@ public sealed class Cointegration : AbstractBase
private void Resync()
{
// Resync main buffer sums
// Resync main buffer sums using span access to avoid per-element modulo in indexer.
// Both buffers are always updated together so their sequenced spans align element-by-element.
_sumA = 0;
_sumB = 0;
_sumA2 = 0;
_sumB2 = 0;
_sumAB = 0;
for (int i = 0; i < _bufferA.Count; i++)
_bufferA.GetSequencedSpans(out var aFirst, out var aSecond);
_bufferB.GetSequencedSpans(out var bFirst, out var bSecond);
for (int i = 0; i < aFirst.Length; i++)
{
double a = _bufferA[i];
double b = _bufferB[i];
double a = aFirst[i], b = bFirst[i];
_sumA += a;
_sumB += b;
_sumA2 = FusedMultiplyAdd(a, a, _sumA2);
@@ -414,20 +368,38 @@ public sealed class Cointegration : AbstractBase
_sumAB = FusedMultiplyAdd(a, b, _sumAB);
}
// Resync ADF regression sums
_sumDelta = 0;
_sumLagged = 0;
for (int i = 0; i < aSecond.Length; i++)
{
double a = aSecond[i], b = bSecond[i];
_sumA += a;
_sumB += b;
_sumA2 = FusedMultiplyAdd(a, a, _sumA2);
_sumB2 = FusedMultiplyAdd(b, b, _sumB2);
_sumAB = FusedMultiplyAdd(a, b, _sumAB);
}
// Resync ADF regression sums (delta/lagged buffers also always updated together).
_sumDeltaLagged = 0;
_sumLagged2 = 0;
_sumDelta2 = 0;
for (int i = 0; i < _deltaResiduals.Count; i++)
_deltaResiduals.GetSequencedSpans(out var dFirst, out var dSecond);
_laggedResiduals.GetSequencedSpans(out var lFirst, out var lSecond);
for (int i = 0; i < dFirst.Length; i++)
{
double delta = _deltaResiduals[i];
double lagged = _laggedResiduals[i];
_sumDelta += delta;
_sumLagged += lagged;
double delta = dFirst[i], lagged = lFirst[i];
_sumDeltaLagged = FusedMultiplyAdd(delta, lagged, _sumDeltaLagged);
_sumLagged2 = FusedMultiplyAdd(lagged, lagged, _sumLagged2);
_sumDelta2 = FusedMultiplyAdd(delta, delta, _sumDelta2);
}
for (int i = 0; i < dSecond.Length; i++)
{
double delta = dSecond[i], lagged = lSecond[i];
_sumDeltaLagged = FusedMultiplyAdd(delta, lagged, _sumDeltaLagged);
_sumLagged2 = FusedMultiplyAdd(lagged, lagged, _sumLagged2);
_sumDelta2 = FusedMultiplyAdd(delta, delta, _sumDelta2);
}
}
@@ -450,10 +422,9 @@ public sealed class Cointegration : AbstractBase
_sumB2 = 0;
_sumAB = 0;
_sumDelta = 0;
_sumLagged = 0;
_sumDeltaLagged = 0;
_sumLagged2 = 0;
_sumDelta2 = 0;
_prevResidual = 0;
_p_prevResidual = 0;
@@ -473,28 +444,7 @@ public sealed class Cointegration : AbstractBase
/// Calculates cointegration for two time series.
/// </summary>
public static TSeries Batch(TSeries seriesA, TSeries seriesB, int period = 20)
{
if (seriesA.Count != seriesB.Count)
{
throw new ArgumentException("Series must have the same length", nameof(seriesB));
}
var indicator = new Cointegration(period);
var result = new TSeries(seriesA.Count);
var timesA = seriesA.Times;
var valuesA = seriesA.Values;
var valuesB = seriesB.Values;
for (int i = 0; i < seriesA.Count; i++)
{
var tvalA = new TValue(timesA[i], valuesA[i]);
var tvalB = new TValue(timesA[i], valuesB[i]);
result.Add(indicator.Update(tvalA, tvalB, isNew: true));
}
return result;
}
=> Calculate(seriesA, seriesB, period).Results;
/// <summary>
/// Static batch calculation for span-based processing.
@@ -531,9 +481,24 @@ public sealed class Cointegration : AbstractBase
public static (TSeries Results, Cointegration Indicator) Calculate(TSeries seriesA, TSeries seriesB, int period = 20)
{
if (seriesA.Count != seriesB.Count)
{
throw new ArgumentException("Series must have the same length", nameof(seriesB));
}
var indicator = new Cointegration(period);
TSeries results = Batch(seriesA, seriesB, period);
return (results, indicator);
var result = new TSeries(seriesA.Count);
var timesA = seriesA.Times;
var valuesA = seriesA.Values;
var valuesB = seriesB.Values;
for (int i = 0; i < seriesA.Count; i++)
{
result.Add(indicator.Update(new TValue(timesA[i], valuesA[i]), new TValue(timesA[i], valuesB[i]), isNew: true));
}
return (result, indicator);
}
}
@@ -3,9 +3,9 @@
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Statistic |
| **Inputs** | Source (close) |
| **Inputs** | Two series (A, B) |
| **Parameters** | `period` (default 20) |
| **Outputs** | Single series (Cointegration) |
| **Outputs** | Single series (ADF statistic) |
| **Output range** | Varies (see docs) |
| **Warmup** | `period + 1` bars |
@@ -15,7 +15,7 @@
- Parameterized by `period` (default 20).
- Output range: Varies (see docs).
- Requires `period + 1` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
- Validated against TradingView PineScript reference and statistical property tests.
> "Correlation tells you they move together. Cointegration tells you they're bound together. Two stocks can be uncorrelated yet cointegrated, or perfectly correlated yet destined to drift apart forever. The difference between 'similar direction' and 'shared destiny' is the difference between a tourist attraction and a gravitational orbit."
@@ -235,7 +235,7 @@ double[] pricesA = new double[1000];
double[] pricesB = new double[1000];
double[] output = new double[1000];
// ... populate inputs ...
Cointegration.Calculate(pricesA.AsSpan(), pricesB.AsSpan(), output.AsSpan(), period: 20);
Cointegration.Batch(pricesA.AsSpan(), pricesB.AsSpan(), output.AsSpan(), period: 20);
```
### Bar Correction Support