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,658 @@
namespace QuanTAlib.Tests;
public class CointegrationTests
{
private const int DefaultPeriod = 20;
private const double Tolerance = 1e-10;
#region Constructor Tests
[Fact]
public void Constructor_WithValidPeriod_SetsProperties()
{
var indicator = new Cointegration(10);
Assert.Equal("Cointegration(10)", indicator.Name);
Assert.Equal(11, indicator.WarmupPeriod); // period + 1
Assert.False(indicator.IsHot);
}
[Fact]
public void Constructor_WithDefaultPeriod_UsesTwenty()
{
var indicator = new Cointegration();
Assert.Equal("Cointegration(20)", indicator.Name);
Assert.Equal(21, indicator.WarmupPeriod);
}
[Fact]
public void Constructor_WithPeriodOne_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Cointegration(1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_WithPeriodZero_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Cointegration(0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_WithNegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Cointegration(-5));
Assert.Equal("period", ex.ParamName);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_ReturnsTValue()
{
var indicator = new Cointegration(DefaultPeriod);
var result = indicator.Update(100.0, 100.0);
Assert.IsType<TValue>(result);
}
[Fact]
public void Update_ReturnsNaN_BeforeWarmup()
{
var indicator = new Cointegration(DefaultPeriod);
// First few updates should return NaN until warmup
for (int i = 0; i < 3; i++)
{
var result = indicator.Update(100.0 + i, 100.0 + i);
Assert.True(double.IsNaN(result.Value));
}
}
[Fact]
public void Update_ReturnsFiniteValue_AfterWarmup()
{
var indicator = new Cointegration(DefaultPeriod);
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);
// Feed enough data to warm up
for (int i = 0; i < DefaultPeriod + 5; i++)
{
indicator.Update(gbmA.Next().Close, gbmB.Next().Close);
}
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void Update_IsHot_BecomesTrueAfterWarmup()
{
var indicator = new Cointegration(DefaultPeriod);
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);
Assert.False(indicator.IsHot);
for (int i = 0; i < DefaultPeriod + 2; i++)
{
indicator.Update(gbmA.Next().Close, gbmB.Next().Close);
}
Assert.True(indicator.IsHot);
}
[Fact]
public void Update_LastProperty_ReturnsLastCalculatedValue()
{
var indicator = new Cointegration(DefaultPeriod);
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);
TValue lastResult = default;
for (int i = 0; i < DefaultPeriod + 5; i++)
{
lastResult = indicator.Update(gbmA.Next().Close, gbmB.Next().Close);
}
Assert.Equal(lastResult.Value, indicator.Last.Value);
}
#endregion
#region isNew Behavior Tests
[Fact]
public void Update_WithIsNewTrue_AdvancesState()
{
var indicator = 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 state past warmup period (period + 1 = 6)
for (int i = 0; i < 8; i++)
{
indicator.Update(gbmA.Next().Close, gbmB.Next().Close, isNew: true);
}
var result1 = indicator.Last;
// Next update with isNew=true should advance and produce different value
indicator.Update(gbmA.Next().Close, gbmB.Next().Close, isNew: true);
var result2 = indicator.Last;
// Values should differ (both should be finite after warmup)
Assert.True(double.IsFinite(result1.Value));
Assert.True(double.IsFinite(result2.Value));
Assert.NotEqual(result1.Value, result2.Value);
}
[Fact]
public void Update_WithIsNewFalse_DoesNotAdvanceState()
{
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 identical state
for (int i = 0; i < 10; i++)
{
double a = gbmA.Next().Close;
double b = gbmB.Next().Close;
corrected.Update(a, b, isNew: true);
direct.Update(a, b, isNew: true);
}
const double finalA = 105.0;
const double finalB = 55.0;
// 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);
// Direct path: only final new bar
direct.Update(finalA, finalB, isNew: true);
Assert.Equal(direct.Last.Value, corrected.Last.Value, Tolerance);
}
[Fact]
public void Update_BarCorrection_RestoresStateCorrectly()
{
var indicator1 = new Cointegration(5);
var indicator2 = new Cointegration(5);
// Build up identical state using stored values
var valuesA = new double[] { 100.0, 101.5, 99.8, 102.3, 100.9, 103.2, 98.7, 104.1, 99.5, 101.8 };
var valuesB = new double[] { 50.0, 51.2, 49.5, 52.0, 50.8, 51.9, 49.2, 52.5, 50.1, 51.5 };
for (int i = 0; i < valuesA.Length; i++)
{
indicator1.Update(valuesA[i], valuesB[i], isNew: true);
indicator2.Update(valuesA[i], valuesB[i], isNew: true);
}
// Both should have same state now
Assert.Equal(indicator1.Last.Value, indicator2.Last.Value, Tolerance);
// Indicator1: add new bar, then correct it, then another new bar
indicator1.Update(105.0, 53.0, isNew: true);
indicator1.Update(999.0, 999.0, isNew: false); // correction (overwrites previous)
indicator1.Update(106.0, 54.0, isNew: true);
// Indicator2: skip the 105/53 bar entirely, just add the 106/54 bar
indicator2.Update(106.0, 54.0, isNew: true);
// Both should have same result since the 105/53 was replaced by correction
// and then 106/54 was added as new - but indicator1 had an intermediate
// correction step that should be equivalent to indicator2 which never
// added the original value.
// Actually the test is wrong - indicator1 has 12 bars, indicator2 has 11 bars
// Let's verify the correction overwrites work correctly instead
var indicator3 = new Cointegration(5);
for (int i = 0; i < valuesA.Length; i++)
{
indicator3.Update(valuesA[i], valuesB[i], isNew: true);
}
// Add with correction pattern
indicator3.Update(105.0, 53.0, isNew: true); // bar 11
var afterFirstNew = indicator3.Last.Value;
indicator3.Update(110.0, 55.0, isNew: false); // correct bar 11
_ = indicator3.Last.Value; // afterCorrection - verify no exception
indicator3.Update(105.0, 53.0, isNew: false); // correct back to original
var afterSecondCorrection = indicator3.Last.Value;
// After correcting back to original values, should match first new
Assert.Equal(afterFirstNew, afterSecondCorrection, Tolerance);
}
[Fact]
public void Update_IterativeCorrections_ProduceSameResult()
{
var indicator = 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 state
for (int i = 0; i < 10; i++)
{
indicator.Update(gbmA.Next().Close, gbmB.Next().Close, isNew: true);
}
double finalA = 50.0;
double finalB = 55.0;
// Apply multiple corrections, each time with different intermediate values
indicator.Update(100.0, 105.0, isNew: true);
indicator.Update(200.0, 205.0, isNew: false);
indicator.Update(300.0, 305.0, isNew: false);
indicator.Update(finalA, finalB, isNew: false);
var resultWithCorrections = indicator.Last.Value;
// Reset and rebuild state using fresh GBMs
indicator.Reset();
var gbmA2 = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.1, seed: 12345);
var gbmB2 = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.1, seed: 54321);
for (int i = 0; i < 10; i++)
{
indicator.Update(gbmA2.Next().Close, gbmB2.Next().Close, isNew: true);
}
// Apply final value directly
indicator.Update(finalA, finalB, isNew: true);
var resultDirect = indicator.Last.Value;
Assert.Equal(resultDirect, resultWithCorrections, Tolerance);
}
#endregion
#region Reset Tests
[Fact]
public void Reset_ClearsState()
{
var indicator = new Cointegration(DefaultPeriod);
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);
for (int i = 0; i < DefaultPeriod + 5; i++)
{
indicator.Update(gbmA.Next().Close, gbmB.Next().Close);
}
Assert.True(indicator.IsHot);
indicator.Reset();
Assert.False(indicator.IsHot);
Assert.Equal(default, indicator.Last);
}
[Fact]
public void Reset_AllowsReuse()
{
var indicator = new Cointegration(DefaultPeriod);
// First use
var gbmA1 = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.1, seed: 12345);
var gbmB1 = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.1, seed: 54321);
for (int i = 0; i < DefaultPeriod + 5; i++)
{
indicator.Update(gbmA1.Next().Close, gbmB1.Next().Close);
}
var firstResult = indicator.Last.Value;
indicator.Reset();
// Second use with same seeds
var gbmA2 = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.1, seed: 12345);
var gbmB2 = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.1, seed: 54321);
for (int i = 0; i < DefaultPeriod + 5; i++)
{
indicator.Update(gbmA2.Next().Close, gbmB2.Next().Close);
}
var secondResult = indicator.Last.Value;
Assert.Equal(firstResult, secondResult, Tolerance);
}
#endregion
#region NaN/Infinity Handling Tests
[Fact]
public void Update_WithNaN_UsesLastValidValue()
{
var indicator = new Cointegration(5);
for (int i = 0; i < 10; i++)
{
indicator.Update(100.0 + i, 100.0 + i * 0.5);
}
_ = indicator.Last.Value; // beforeNaN - verify state before NaN
// Update with NaN
indicator.Update(double.NaN, double.NaN);
var afterNaN = indicator.Last.Value;
// Should still produce a valid (or NaN) result, not crash
Assert.True(double.IsFinite(afterNaN) || double.IsNaN(afterNaN));
}
[Fact]
public void Update_WithInfinity_UsesLastValidValue()
{
var indicator = new Cointegration(5);
for (int i = 0; i < 10; i++)
{
indicator.Update(100.0 + i, 100.0 + i * 0.5);
}
// Update with infinity
indicator.Update(double.PositiveInfinity, double.NegativeInfinity);
var afterInfinity = indicator.Last.Value;
Assert.True(double.IsFinite(afterInfinity) || double.IsNaN(afterInfinity));
}
[Fact]
public void Update_BatchWithNaN_HandlesSafely()
{
var indicator = new Cointegration(5);
for (int i = 0; i < 20; i++)
{
double a = i % 5 == 0 ? double.NaN : 100.0 + i;
double b = i % 7 == 0 ? double.NaN : 100.0 + i * 0.5;
indicator.Update(a, b);
}
// Should complete without exception
Assert.True(true);
}
#endregion
#region Static Calculate Tests
[Fact]
public void Calculate_TSeries_ReturnsCorrectLength()
{
var seriesA = new TSeries();
var seriesB = new TSeries();
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);
for (int i = 0; i < 100; i++)
{
var barA = gbmA.Next();
var barB = gbmB.Next();
seriesA.Add(barA.Time, barA.Close);
seriesB.Add(barB.Time, barB.Close);
}
var result = Cointegration.Batch(seriesA, seriesB, DefaultPeriod);
Assert.Equal(seriesA.Count, result.Count);
}
[Fact]
public void Calculate_TSeries_MatchesStreamingMode()
{
var seriesA = new TSeries();
var seriesB = new TSeries();
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);
for (int i = 0; i < 50; i++)
{
var barA = gbmA.Next();
var barB = gbmB.Next();
seriesA.Add(barA.Time, barA.Close);
seriesB.Add(barB.Time, barB.Close);
}
// Batch calculation
var batchResult = Cointegration.Batch(seriesA, seriesB, DefaultPeriod);
// Streaming calculation
var streamingIndicator = new Cointegration(DefaultPeriod);
var streamingResult = new TSeries();
for (int i = 0; i < seriesA.Count; i++)
{
var result = streamingIndicator.Update(seriesA[i].Value, seriesB[i].Value);
streamingResult.Add(result);
}
// Compare last 10 values (after warmup)
for (int i = seriesA.Count - 10; i < seriesA.Count; i++)
{
if (double.IsNaN(batchResult[i].Value) && double.IsNaN(streamingResult[i].Value))
{
continue;
}
Assert.Equal(batchResult[i].Value, streamingResult[i].Value, Tolerance);
}
}
[Fact]
public void Calculate_TSeries_ThrowsOnMismatchedLengths()
{
var seriesA = new TSeries();
var seriesB = new TSeries();
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);
for (int i = 0; i < 50; i++)
{
var bar = gbmA.Next();
seriesA.Add(bar.Time, bar.Close);
}
for (int i = 0; i < 30; i++)
{
var bar = gbmB.Next();
seriesB.Add(bar.Time, bar.Close);
}
var ex = Assert.Throws<ArgumentException>(() => Cointegration.Batch(seriesA, seriesB, DefaultPeriod));
Assert.Equal("seriesB", ex.ParamName);
}
[Fact]
public void Calculate_Span_MatchesStreaming()
{
const int length = 50;
var seriesA = new double[length];
var seriesB = new double[length];
var output = new double[length];
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);
for (int i = 0; i < length; i++)
{
seriesA[i] = gbmA.Next().Close;
seriesB[i] = gbmB.Next().Close;
}
// Span calculation
Cointegration.Batch(seriesA, seriesB, output, DefaultPeriod);
// Streaming calculation
var streamingIndicator = new Cointegration(DefaultPeriod);
var streamingOutput = new double[length];
for (int i = 0; i < length; i++)
{
var result = streamingIndicator.Update(seriesA[i], seriesB[i]);
streamingOutput[i] = result.Value;
}
// Compare last 10 values
for (int i = length - 10; i < length; i++)
{
if (double.IsNaN(output[i]) && double.IsNaN(streamingOutput[i]))
{
continue;
}
Assert.Equal(output[i], streamingOutput[i], Tolerance);
}
}
[Fact]
public void Calculate_Span_ThrowsOnMismatchedLengths()
{
var seriesA = new double[50];
var seriesB = new double[30];
var output = new double[50];
var ex = Assert.Throws<ArgumentException>(() => Cointegration.Batch(seriesA, seriesB, output, DefaultPeriod));
Assert.Equal("seriesB", ex.ParamName);
}
[Fact]
public void Calculate_Span_ThrowsOnMismatchedOutputLength()
{
var seriesA = new double[50];
var seriesB = new double[50];
var output = new double[30];
var ex = Assert.Throws<ArgumentException>(() => Cointegration.Batch(seriesA, seriesB, output, DefaultPeriod));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Calculate_Span_ThrowsOnInvalidPeriod()
{
var seriesA = new double[50];
var seriesB = new double[50];
var output = new double[50];
var ex = Assert.Throws<ArgumentException>(() => Cointegration.Batch(seriesA, seriesB, output, 1));
Assert.Equal("period", ex.ParamName);
}
#endregion
#region Unsupported Method Tests
[Fact]
public void Update_SingleTValue_ThrowsNotSupported()
{
var indicator = new Cointegration(DefaultPeriod);
Assert.Throws<NotSupportedException>(() => indicator.Update(new TValue(DateTime.UtcNow, 100.0)));
}
[Fact]
public void Update_SingleTSeries_ThrowsNotSupported()
{
var indicator = new Cointegration(DefaultPeriod);
var series = new TSeries();
series.Add(DateTime.UtcNow, 100.0);
Assert.Throws<NotSupportedException>(() => indicator.Update(series));
}
[Fact]
public void Prime_ThrowsNotSupported()
{
var indicator = new Cointegration(DefaultPeriod);
var data = new double[] { 1.0, 2.0, 3.0 };
Assert.Throws<NotSupportedException>(() => indicator.Prime(data));
}
#endregion
#region Cointegration-Specific Tests
[Fact]
public void Update_CointegatedSeries_ProducesNegativeAdf()
{
// Create two cointegrated series: B = A + noise
var indicator = new Cointegration(20);
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 42);
for (int i = 0; i < 100; i++)
{
double a = 100.0 + i * 0.1;
double b = a + Math.Log(random.Next().Close / 100.0) * 0.1; // Highly correlated
indicator.Update(a, b);
}
// Cointegrated series should produce negative ADF statistic
Assert.True(indicator.Last.Value < 0);
}
[Fact]
public void Update_NonCointegatedSeries_ProducesLessNegativeAdf()
{
// Create two non-cointegrated series (random walks)
var indicatorCointegrated = new Cointegration(20);
var indicatorRandom = new Cointegration(20);
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 42);
double walkA = 100.0;
double walkB = 100.0;
for (int i = 0; i < 100; i++)
{
// Cointegrated pair
double a1 = 100.0 + i * 0.1;
double noise1 = Math.Log(random.Next().Close / 100.0);
double b1 = a1 + noise1 * 0.1;
indicatorCointegrated.Update(a1, b1);
// Random walks
walkA += Math.Log(random.Next().Close / 100.0);
walkB += Math.Log(random.Next().Close / 100.0);
indicatorRandom.Update(walkA, walkB);
}
// Note: Due to randomness, we just verify both produce finite values
Assert.True(double.IsFinite(indicatorCointegrated.Last.Value) || double.IsNaN(indicatorCointegrated.Last.Value));
Assert.True(double.IsFinite(indicatorRandom.Last.Value) || double.IsNaN(indicatorRandom.Last.Value));
}
#endregion
#region Event Tests
[Fact]
public void Pub_FiresOnUpdate()
{
var indicator = 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);
int eventCount = 0;
indicator.Pub += (sender, in args) => eventCount++;
for (int i = 0; i < 10; i++)
{
indicator.Update(gbmA.Next().Close, gbmB.Next().Close);
}
Assert.Equal(10, eventCount);
}
#endregion
}
@@ -0,0 +1,340 @@
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;
// 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);
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;
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);
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;
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
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
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
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 45);
for (int i = 0; i < 20; i++)
{
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);
var random = new GBM(startPrice: 100.0, sigma: 1.0, seed: 46);
for (int i = 0; i < 150; i++)
{
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
}