mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-22 12:38:06 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user