Files
QuanTAlib/lib/channels/regchannel/Regchannel.Validation.Tests.cs
T
Miha Kralj 75c6a9f135 Enhance validation tests for various indicators with external library comparisons
- Added detailed comments explaining the validation limitations for MMA and ZLEMA due to differences in algorithm implementations.
- Implemented validation tests for True Range against TALib and Tulip, ensuring directional agreement.
- Updated Ulcer Index validation to clarify differences in algorithmic approaches between QuanTAlib and Skender.
- Enhanced Ease of Movement tests to verify directional agreement with Tulip's EMV, noting differences in volume scaling.
- Expanded Klinger Volume Oscillator tests to validate against Skender and Tulip, focusing on directional agreement across multiple period configurations.
- Improved Negative Volume Index tests to compare percentage changes with Tulip, addressing differences in starting values.
- Updated Positive Volume Index tests to validate against Tulip, emphasizing percentage change comparisons.
- Enhanced Williams Accumulation/Distribution tests to verify directional agreement with Tulip, highlighting formula differences.
2026-02-11 14:46:56 -08:00

664 lines
22 KiB
C#

using TALib;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class RegchannelValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public RegchannelValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose() => Dispose(true);
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_ManualCalculation_ThreePoints()
{
var series = new TSeries();
var t0 = DateTime.UtcNow;
// Points: (0,100), (1,120), (2,110)
series.Add(new TValue(t0, 100));
series.Add(new TValue(t0.AddMinutes(1), 120));
series.Add(new TValue(t0.AddMinutes(2), 110));
var ind = new Regchannel(10, 1.0);
// Bar 0: regression = 100, slope = 0, stdDev = 0
ind.Update(series[0]);
Assert.Equal(100.0, ind.Last.Value, 1e-10);
Assert.Equal(0.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10);
// Bar 1: Two points (100, 120 at x=0,1)
// Perfect line through points: y = 100 + 20*x
ind.Update(series[1]);
Assert.Equal(120.0, ind.Last.Value, 1e-10);
Assert.Equal(20.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10);
// Bar 2: Linear regression of (100, 120, 110)
// slope = 5, intercept = 105, regression at x=2 = 115
ind.Update(series[2]);
Assert.Equal(115.0, ind.Last.Value, 1e-10);
Assert.Equal(5.0, ind.Slope, 1e-10);
// Residuals: 100-105=-5, 120-110=10, 110-115=-5
// StdDev = sqrt((25+100+25)/3) = sqrt(50)
double expectedStdDev = Math.Sqrt(50);
Assert.Equal(expectedStdDev, ind.StdDev, 1e-10);
_output.WriteLine("Regchannel manual calculation validated");
}
[Fact]
public void Validate_LinearTrend_ZeroResiduals()
{
var series = new TSeries();
var t0 = DateTime.UtcNow;
// Perfect linear trend: 100, 110, 120, 130, 140
for (int i = 0; i < 5; i++)
{
series.Add(new TValue(t0.AddMinutes(i), 100 + i * 10));
}
var ind = new Regchannel(5, 2.0);
foreach (var tv in series)
{
ind.Update(tv);
}
// Perfect linear fit: slope = 10, no residuals
Assert.Equal(140.0, ind.Last.Value, 1e-10);
Assert.Equal(10.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10);
Assert.Equal(140.0, ind.Upper.Value, 1e-10);
Assert.Equal(140.0, ind.Lower.Value, 1e-10);
_output.WriteLine("Regchannel linear trend validated");
}
[Fact]
public void Validate_ConstantValues_ZeroResiduals()
{
var series = new TSeries();
var t0 = DateTime.UtcNow;
// Constant values: 100, 100, 100, 100, 100
for (int i = 0; i < 5; i++)
{
series.Add(new TValue(t0.AddMinutes(i), 100));
}
var ind = new Regchannel(5, 2.0);
foreach (var tv in series)
{
ind.Update(tv);
}
// Constant: slope = 0, no residuals
Assert.Equal(100.0, ind.Last.Value, 1e-10);
Assert.Equal(0.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10);
_output.WriteLine("Regchannel constant values validated");
}
[Fact]
public void Validate_AllModes_Consistency()
{
int[] periods = { 5, 10, 20, 50 };
double[] multipliers = { 1.0, 2.0, 3.0 };
foreach (int period in periods)
{
foreach (double multiplier in multipliers)
{
// Batch (instance)
var inst = new Regchannel(period, multiplier);
var (bMid, bUp, bLo) = inst.Update(_testData.Data);
// Static batch
var (sMid, sUp, sLo) = Regchannel.Batch(_testData.Data, period, multiplier);
ValidationHelper.VerifySeriesEqual(bMid, sMid);
ValidationHelper.VerifySeriesEqual(bUp, sUp);
ValidationHelper.VerifySeriesEqual(bLo, sLo);
// Streaming
var streaming = new Regchannel(period, multiplier);
var sMidStream = new TSeries();
var sUpStream = new TSeries();
var sLoStream = new TSeries();
foreach (var tv in _testData.Data)
{
streaming.Update(tv);
sMidStream.Add(streaming.Last);
sUpStream.Add(streaming.Upper);
sLoStream.Add(streaming.Lower);
}
ValidationHelper.VerifySeriesEqual(sMid, sMidStream);
ValidationHelper.VerifySeriesEqual(sUp, sUpStream);
ValidationHelper.VerifySeriesEqual(sLo, sLoStream);
// Span
double[] source = _testData.ClosePrices.ToArray();
double[] spanMid = new double[source.Length];
double[] spanUp = new double[source.Length];
double[] spanLo = new double[source.Length];
Regchannel.Batch(source.AsSpan(), spanMid.AsSpan(), spanUp.AsSpan(), spanLo.AsSpan(), period, multiplier);
for (int i = 0; i < source.Length; i++)
{
Assert.Equal(sMid[i].Value, spanMid[i], 9);
Assert.Equal(sUp[i].Value, spanUp[i], 9);
Assert.Equal(sLo[i].Value, spanLo[i], 9);
}
}
}
_output.WriteLine("Regchannel mode consistency validated (batch/stream/span)");
}
[Fact]
public void Validate_EventingMode_MatchesBatch()
{
const int period = 20;
const double multiplier = 2.0;
var pub = new TSeries();
var evtInd = new Regchannel(pub, period, multiplier);
var evtMid = new TSeries();
var evtUp = new TSeries();
var evtLo = new TSeries();
foreach (var tv in _testData.Data)
{
pub.Add(tv);
evtMid.Add(evtInd.Last);
evtUp.Add(evtInd.Upper);
evtLo.Add(evtInd.Lower);
}
var (bMid, bUp, bLo) = Regchannel.Batch(_testData.Data, period, multiplier);
ValidationHelper.VerifySeriesEqual(bMid, evtMid);
ValidationHelper.VerifySeriesEqual(bUp, evtUp);
ValidationHelper.VerifySeriesEqual(bLo, evtLo);
_output.WriteLine("Regchannel eventing mode validated");
}
[Fact]
public void Validate_Calculate_ReturnsHotIndicator()
{
const int period = 15;
const double multiplier = 2.5;
var ((mid, up, lo), ind) = Regchannel.Calculate(_testData.Data, period, multiplier);
Assert.True(ind.IsHot);
Assert.Equal(period, ind.WarmupPeriod);
Assert.Equal(mid.Last.Value, ind.Last.Value, 1e-10);
Assert.Equal(up.Last.Value, ind.Upper.Value, 1e-10);
Assert.Equal(lo.Last.Value, ind.Lower.Value, 1e-10);
// Continue streaming
var next = new TValue(DateTime.UtcNow, 100);
ind.Update(next);
Assert.True(ind.IsHot);
_output.WriteLine("Regchannel Calculate validated");
}
[Fact]
public void Validate_Prime_MatchesBatch()
{
const int period = 25;
const double multiplier = 1.5;
var (bMid, bUp, bLo) = Regchannel.Batch(_testData.Data, period, multiplier);
var primed = new Regchannel(period, multiplier);
var subset = new TSeries();
for (int i = 0; i < 200; i++)
{
subset.Add(_testData.Data[i]);
}
primed.Prime(subset);
for (int i = 200; i < _testData.Data.Count; i++)
{
primed.Update(_testData.Data[i]);
}
Assert.Equal(bMid.Last.Value, primed.Last.Value, 1e-9);
Assert.Equal(bUp.Last.Value, primed.Upper.Value, 1e-9);
Assert.Equal(bLo.Last.Value, primed.Lower.Value, 1e-9);
_output.WriteLine("Regchannel Prime validated against batch");
}
[Fact]
public void Validate_LargeDataset_FiniteOutputs()
{
var (mid, up, lo) = Regchannel.Batch(_testData.Data, 50, 2.0);
ValidationHelper.VerifyAllFinite(mid, startIndex: 0);
ValidationHelper.VerifyAllFinite(up, startIndex: 0);
ValidationHelper.VerifyAllFinite(lo, startIndex: 0);
// Upper >= Middle >= Lower always
for (int i = 0; i < mid.Count; i++)
{
Assert.True(up[i].Value >= mid[i].Value, $"Upper >= Middle at {i}");
Assert.True(lo[i].Value <= mid[i].Value, $"Lower <= Middle at {i}");
}
_output.WriteLine("Regchannel large dataset validated");
}
[Fact]
public void Validate_BandSymmetry_AllBars()
{
var ind = new Regchannel(20, 2.0);
var (mid, up, lo) = ind.Update(_testData.Data);
for (int i = 0; i < mid.Count; i++)
{
double upperWidth = up[i].Value - mid[i].Value;
double lowerWidth = mid[i].Value - lo[i].Value;
Assert.Equal(upperWidth, lowerWidth, 1e-10);
}
_output.WriteLine("Regchannel band symmetry validated for all bars");
}
[Fact]
public void Validate_MultiplierScaling()
{
double[] multipliers = { 1.0, 2.0, 3.0, 4.0 };
double[] widths = new double[multipliers.Length];
for (int i = 0; i < multipliers.Length; i++)
{
var ind = new Regchannel(20, multipliers[i]);
foreach (var tv in _testData.Data)
{
ind.Update(tv);
}
widths[i] = ind.Upper.Value - ind.Lower.Value;
}
// Widths should scale linearly with multiplier
double baseWidth = widths[0];
for (int i = 1; i < multipliers.Length; i++)
{
double expected = baseWidth * multipliers[i];
Assert.Equal(expected, widths[i], 1e-9);
}
_output.WriteLine("Regchannel multiplier scaling validated");
}
[Fact]
public void Validate_PeriodEffect_SmoothingAndSlope()
{
int[] periods = { 5, 10, 20, 50 };
double[] slopes = new double[periods.Length];
double[] middles = new double[periods.Length];
for (int i = 0; i < periods.Length; i++)
{
var ind = new Regchannel(periods[i], 2.0);
foreach (var tv in _testData.Data)
{
ind.Update(tv);
}
slopes[i] = ind.Slope;
middles[i] = ind.Last.Value;
}
// All should produce finite values
foreach (var s in slopes)
{
Assert.True(double.IsFinite(s));
}
foreach (var m in middles)
{
Assert.True(double.IsFinite(m));
}
_output.WriteLine("Regchannel period effect validated");
}
[Fact]
public void Validate_StateRestoration_Iterative()
{
var ind = new Regchannel(15, 2.5);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
// Build up state
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
ind.Update(new TValue(bar.Time, bar.Close), isNew: true);
}
// Multiple corrections
var rememberedBar = gbm.Next(isNew: true);
var remembered = new TValue(rememberedBar.Time, rememberedBar.Close);
ind.Update(remembered, isNew: true);
double midBefore = ind.Last.Value;
double upBefore = ind.Upper.Value;
double loBefore = ind.Lower.Value;
double slopeBefore = ind.Slope;
double stdDevBefore = ind.StdDev;
for (int i = 0; i < 10; i++)
{
var corrected = gbm.Next(isNew: false);
ind.Update(new TValue(corrected.Time, corrected.Close), isNew: false);
}
// Restore with remembered value
ind.Update(remembered, isNew: false);
Assert.Equal(midBefore, ind.Last.Value, 1e-6);
Assert.Equal(upBefore, ind.Upper.Value, 1e-6);
Assert.Equal(loBefore, ind.Lower.Value, 1e-6);
Assert.Equal(slopeBefore, ind.Slope, 1e-6);
Assert.Equal(stdDevBefore, ind.StdDev, 1e-6);
_output.WriteLine("Regchannel state restoration validated");
}
[Fact]
public void Validate_BandWidthFormula()
{
// Band width = 2 * multiplier * stdDev
var ind = new Regchannel(20, 3.0);
foreach (var tv in _testData.Data)
{
ind.Update(tv);
double expectedWidth = 2 * 3.0 * ind.StdDev;
double actualWidth = ind.Upper.Value - ind.Lower.Value;
Assert.Equal(expectedWidth, actualWidth, 1e-10);
}
_output.WriteLine("Regchannel band width formula validated");
}
[Fact]
public void Validate_SlopeDirection()
{
// Test uptrend detection
var uptrend = new TSeries();
var t0 = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
uptrend.Add(new TValue(t0.AddMinutes(i), 100 + i * 2 + (i % 3))); // Noisy uptrend
}
var indUp = new Regchannel(10, 2.0);
foreach (var tv in uptrend)
{
indUp.Update(tv);
}
Assert.True(indUp.Slope > 0, "Uptrend should have positive slope");
// Test downtrend detection
var downtrend = new TSeries();
for (int i = 0; i < 20; i++)
{
downtrend.Add(new TValue(t0.AddMinutes(i), 200 - i * 2 + (i % 3))); // Noisy downtrend
}
var indDown = new Regchannel(10, 2.0);
foreach (var tv in downtrend)
{
indDown.Update(tv);
}
Assert.True(indDown.Slope < 0, "Downtrend should have negative slope");
_output.WriteLine("Regchannel slope direction validated");
}
[Fact]
public void Validate_SlidingWindow_Correctness()
{
const int period = 5;
var ind = new Regchannel(period, 2.0);
// Feed specific values
double[] values = { 100, 110, 120, 130, 140, 150, 160, 170 };
var t0 = DateTime.UtcNow;
foreach (double v in values)
{
ind.Update(new TValue(t0, v));
t0 = t0.AddMinutes(1);
}
// Window should contain last 5: 130,140,150,160,170
// Linear regression of 130,140,150,160,170 at x=0,1,2,3,4
// Perfect linear fit: slope = 10, intercept = 130
// regression at x=4 = 130 + 10*4 = 170
Assert.Equal(170.0, ind.Last.Value, 1e-10);
Assert.Equal(10.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10); // Perfect linear fit
_output.WriteLine("Regchannel sliding window validated");
}
[Fact]
public void Validate_Residuals_NonLinearData()
{
// Test with data that doesn't fit a perfect line
var ind = new Regchannel(4, 1.0);
var t0 = DateTime.UtcNow;
// Values: 100, 120, 100, 120 (oscillating)
ind.Update(new TValue(t0, 100));
ind.Update(new TValue(t0.AddMinutes(1), 120));
ind.Update(new TValue(t0.AddMinutes(2), 100));
ind.Update(new TValue(t0.AddMinutes(3), 120));
// These values don't fit a line well, so stdDev should be significant
Assert.True(ind.StdDev > 5, "Oscillating data should have significant residuals");
// Bands should be wider than regression value
Assert.True(ind.Upper.Value > ind.Last.Value, "Upper > Middle with residuals");
Assert.True(ind.Lower.Value < ind.Last.Value, "Lower < Middle with residuals");
_output.WriteLine("Regchannel residuals for non-linear data validated");
}
[Fact]
public void Validate_StdDev_Formula()
{
// Verify stdDev calculation: sqrt(sum(residual^2)/n)
var ind = new Regchannel(5, 2.0);
var t0 = DateTime.UtcNow;
// Known values for manual calculation
double[] values = { 100, 105, 98, 107, 102 };
foreach (double v in values)
{
ind.Update(new TValue(t0, v));
t0 = t0.AddMinutes(1);
}
// Calculate expected regression manually
// x: 0,1,2,3,4 y: 100,105,98,107,102
// sumX = 10, sumX2 = 30, sumY = 512, sumXY = 1053
// denom = 5*30 - 10*10 = 50
// slope = (5*1053 - 10*512) / 50 = (5265-5120)/50 = 2.9
// intercept = (512 - 2.9*10) / 5 = (512-29)/5 = 96.6
// predicted: 96.6, 99.5, 102.4, 105.3, 108.2
// residuals: 3.4, 5.5, -4.4, 1.7, -6.2
// sum(r^2) = 11.56 + 30.25 + 19.36 + 2.89 + 38.44 = 102.5
// stdDev = sqrt(102.5/5) = sqrt(20.5) ≈ 4.53
// Slope should be positive (trend is slightly upward)
Assert.True(ind.Slope > 0 && ind.Slope < 5, $"Slope={ind.Slope} should be small positive");
// StdDev should be non-trivial since data doesn't fit perfectly
Assert.True(ind.StdDev > 0 && ind.StdDev < 10, $"StdDev={ind.StdDev} should be positive");
_output.WriteLine("Regchannel stdDev formula validated");
}
// ═══════════════════════════════════════════════════════════════
// TALib Validation
// TALib LinearReg computes the linear regression value at the end
// of the lookback window — same as Regchannel's midline (centerline).
// ═══════════════════════════════════════════════════════════════
[Fact]
public void Validate_Talib_LinearReg_Centerline()
{
int[] periods = { 5, 10, 20, 50 };
double[] sourceData = _testData.RawData.ToArray();
double[] linregOutput = new double[sourceData.Length];
foreach (var period in periods)
{
var (qMid, _, _) = Regchannel.Batch(_testData.Data, period, 2.0);
var retCode = Functions.LinearReg<double>(
sourceData,
0..^0,
linregOutput,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.LinearRegLookback(period);
ValidationHelper.VerifyData(qMid, linregOutput, outRange, lookback);
}
_output.WriteLine("Regchannel centerline validated against TALib LinearReg for all periods");
}
[Fact]
public void Validate_Talib_LinearRegSlope()
{
int[] periods = { 5, 10, 20, 50 };
double[] sourceData = _testData.RawData.ToArray();
double[] slopeOutput = new double[sourceData.Length];
foreach (var period in periods)
{
// Stream Regchannel and collect slopes
var ind = new Regchannel(period, 2.0);
var slopes = new List<double>();
foreach (var tv in _testData.Data)
{
ind.Update(tv);
slopes.Add(ind.Slope);
}
var retCode = Functions.LinearRegSlope<double>(
sourceData,
0..^0,
slopeOutput,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.LinearRegSlopeLookback(period);
// Compare slopes from end of series (converged)
int count = slopes.Count;
int start = Math.Max(0, count - 100);
var (offset, _) = outRange.GetOffsetAndLength(slopeOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback)
{
continue;
}
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= slopeOutput.Length)
{
continue;
}
Assert.True(
Math.Abs(slopes[i] - slopeOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Slope mismatch at {i}: QuanTAlib={slopes[i]:G17}, TALib={slopeOutput[tIndex]:G17}");
}
}
_output.WriteLine("Regchannel slope validated against TALib LinearRegSlope for all periods");
}
[Fact]
public void Validate_Tulip_LinearReg_Centerline()
{
int[] periods = { 5, 10, 20, 50 };
double[] sourceData = _testData.RawData.ToArray();
foreach (var period in periods)
{
var (qMid, _, _) = Regchannel.Batch(_testData.Data, period, 2.0);
var linregIndicator = Tulip.Indicators.linreg;
double[][] inputs = { sourceData };
double[] options = { period };
double[][] outputs = { new double[sourceData.Length - period + 1] };
linregIndicator.Run(inputs, options, outputs);
var tLinreg = outputs[0];
int offset = period - 1; // Tulip output starts at index (period-1)
// Compare last 100 values
int count = qMid.Count;
int start = Math.Max(0, count - 100);
for (int i = start; i < count; i++)
{
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= tLinreg.Length)
{
continue;
}
Assert.True(
Math.Abs(qMid[i].Value - tLinreg[tIndex]) <= ValidationHelper.TulipTolerance,
$"Mismatch at {i}: QuanTAlib={qMid[i].Value:G17}, Tulip={tLinreg[tIndex]:G17}");
}
}
_output.WriteLine("Regchannel centerline validated against Tulip linreg for all periods");
}
}