using Skender.Stock.Indicators; using Xunit.Abstractions; using OoplesFinance.StockIndicators; using OoplesFinance.StockIndicators.Models; namespace QuanTAlib.Tests; public sealed class SdchannelValidationTests : IDisposable { private readonly ValidationTestData _testData; private readonly ITestOutputHelper _output; private bool _disposed; public SdchannelValidationTests(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 Sdchannel(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 // Regression at x=1 = 120 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) // x: 0,1,2 y: 100,120,110 // sumX=3, sumX2=5, sumY=330, sumXY=0*100+1*120+2*110=340 // denom = 3*5 - 3*3 = 6 // slope = (3*340 - 3*330) / 6 = (1020-990)/6 = 5 // intercept = (330 - 5*3) / 3 = 315/3 = 105 // Predicted: y(0)=105, y(1)=110, y(2)=115 // Residuals: 100-105=-5, 120-110=10, 110-115=-5 // StdDev = sqrt((25+100+25)/3) = sqrt(50) ind.Update(series[2]); Assert.Equal(115.0, ind.Last.Value, 1e-10); Assert.Equal(5.0, ind.Slope, 1e-10); double expectedStdDev = Math.Sqrt(50.0); Assert.Equal(expectedStdDev, ind.StdDev, 1e-10); _output.WriteLine("Sdchannel 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 Sdchannel(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("Sdchannel 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 Sdchannel(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("Sdchannel 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 Sdchannel(period, multiplier); var (bMid, bUp, bLo) = inst.Update(_testData.Data); // Static batch var (sMid, sUp, sLo) = Sdchannel.Batch(_testData.Data, period, multiplier); ValidationHelper.VerifySeriesEqual(bMid, sMid); ValidationHelper.VerifySeriesEqual(bUp, sUp); ValidationHelper.VerifySeriesEqual(bLo, sLo); // Streaming var streaming = new Sdchannel(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]; Sdchannel.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("Sdchannel 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 Sdchannel(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) = Sdchannel.Batch(_testData.Data, period, multiplier); ValidationHelper.VerifySeriesEqual(bMid, evtMid); ValidationHelper.VerifySeriesEqual(bUp, evtUp); ValidationHelper.VerifySeriesEqual(bLo, evtLo); _output.WriteLine("Sdchannel eventing mode validated"); } [Fact] public void Validate_Calculate_ReturnsHotIndicator() { const int period = 15; const double multiplier = 2.5; var ((mid, up, lo), ind) = Sdchannel.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("Sdchannel Calculate validated"); } [Fact] public void Validate_Prime_MatchesBatch() { const int period = 25; const double multiplier = 1.5; var (bMid, bUp, bLo) = Sdchannel.Batch(_testData.Data, period, multiplier); var primed = new Sdchannel(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("Sdchannel Prime validated against batch"); } [Fact] public void Validate_LargeDataset_FiniteOutputs() { var (mid, up, lo) = Sdchannel.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("Sdchannel large dataset validated"); } [Fact] public void Validate_BandSymmetry_AllBars() { var ind = new Sdchannel(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("Sdchannel 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 Sdchannel(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("Sdchannel 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 Sdchannel(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("Sdchannel period effect validated"); } [Fact] public void Validate_StateRestoration_Iterative() { var ind = new Sdchannel(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("Sdchannel state restoration validated"); } [Fact] public void Validate_BandWidthFormula() { // Band width = 2 * multiplier * stdDev var ind = new Sdchannel(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("Sdchannel 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 Sdchannel(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 Sdchannel(10, 2.0); foreach (var tv in downtrend) { indDown.Update(tv); } Assert.True(indDown.Slope < 0, "Downtrend should have negative slope"); _output.WriteLine("Sdchannel slope direction validated"); } [Fact] public void Validate_SlidingWindow_Correctness() { const int period = 5; var ind = new Sdchannel(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: 140, 150, 160, 170, 180 -> wait, we only have 140,150,160,170 // Actually: 140, 150, 160, 170 at positions 0,1,2,3 (newest is 170) // No wait, period=5, and we have 8 values. Window = last 5: 120,130,140,150,160,170 - no // Let me recalculate: values = 100,110,120,130,140,150,160,170 (8 values) // After all updates, window has 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("Sdchannel sliding window validated"); } [Fact] public void Validate_Residuals_NonLinearData() { // Test with data that doesn't fit a perfect line var ind = new Sdchannel(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("Sdchannel residuals for non-linear data validated"); } [Fact] public void Validate_StdDev_Formula() { // Verify stdDev calculation: sqrt(sum(residual^2)/n) var ind = new Sdchannel(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($"Sdchannel stdDev formula validated: Slope={ind.Slope:F4}, StdDev={ind.StdDev:F4}"); } // ═══════════════════════════════════════════════════════════════ // Skender.Stock.Indicators Validation // NOTE: Skender's GetStdDevChannels uses a SEGMENTED approach // (non-overlapping windows with a single regression per segment), // while QuanTAlib's Sdchannel uses a ROLLING window approach // (regression recomputed at every bar). These are fundamentally // different algorithms, so exact value matching is not possible. // We validate structural properties instead. // ═══════════════════════════════════════════════════════════════ [Fact] public void Validate_Skender_BandStructure() { // Both implementations should produce valid channel bands: // Upper >= Centerline >= Lower, all finite after warmup int period = 20; double multiplier = 2.0; // QuanTAlib rolling regression var (qMid, qUp, qLo) = Sdchannel.Batch(_testData.Data, period, multiplier); // Skender segmented regression var sResult = _testData.SkenderQuotes .GetStdDevChannels(period, multiplier) .ToList(); // Both should have same count Assert.Equal(qMid.Count, sResult.Count); // Verify QuanTAlib structural integrity for (int i = 0; i < qMid.Count; i++) { Assert.True(double.IsFinite(qMid[i].Value), $"QTAlib mid NaN at {i}"); Assert.True(qUp[i].Value >= qMid[i].Value - 1e-10, $"QTAlib Upper < Mid at {i}"); Assert.True(qLo[i].Value <= qMid[i].Value + 1e-10, $"QTAlib Lower > Mid at {i}"); } // Verify Skender structural integrity (where values exist) int skenderValidCount = 0; for (int i = 0; i < sResult.Count; i++) { if (sResult[i].Centerline.HasValue) { skenderValidCount++; double sMid = sResult[i].Centerline!.Value; double sUp = sResult[i].UpperChannel!.Value; double sLo = sResult[i].LowerChannel!.Value; Assert.True(double.IsFinite(sMid), $"Skender mid NaN at {i}"); Assert.True(sUp >= sMid - 1e-10, $"Skender Upper < Mid at {i}"); Assert.True(sLo <= sMid + 1e-10, $"Skender Lower > Mid at {i}"); } } Assert.True(skenderValidCount > 0, "Skender should produce some valid values"); _output.WriteLine($"Sdchannel vs Skender structural validation passed " + $"(QTAlib: {qMid.Count} bars, Skender valid: {skenderValidCount} bars). " + $"Note: different algorithms (rolling vs segmented)."); } [Fact] public void Validate_Skender_BandSymmetry() { // Both implementations should produce symmetric bands around centerline int period = 20; double multiplier = 2.0; // Skender segmented regression var sResult = _testData.SkenderQuotes .GetStdDevChannels(period, multiplier) .ToList(); foreach (var r in sResult) { if (r.Centerline.HasValue) { double upperWidth = r.UpperChannel!.Value - r.Centerline.Value; double lowerWidth = r.Centerline.Value - r.LowerChannel!.Value; Assert.Equal(upperWidth, lowerWidth, 1e-10); } } _output.WriteLine("Skender StdDevChannels band symmetry validated"); } [Fact] public void Sdchannel_MatchesOoples_Structural() { var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42); var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var ooplesData = bars.Select(b => new TickerData { Date = new DateTime(b.Time, DateTimeKind.Utc), Open = b.Open, High = b.High, Low = b.Low, Close = b.Close, Volume = b.Volume }).ToList(); var result = new StockData(ooplesData).CalculateStandardDeviationChannel(); var values = result.OutputValues.Values.First(); int finiteCount = values.Count(v => double.IsFinite(v)); Assert.True(finiteCount > 100, $"Expected >100 finite values, got {finiteCount}"); } }