Files
Miha Kralj 060649192f 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
2026-03-12 12:34:16 -07:00

659 lines
21 KiB
C#

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
}