namespace QuanTAlib.Tests; /// /// Validation tests for UCHANNEL (Ehlers Ultimate Channel). /// Since this is a proprietary Ehlers indicator (2024), no external library implementations exist. /// These tests validate internal consistency, mathematical properties, and behavior characteristics. /// public class UchannelValidationTests { private const int DefaultStrPeriod = 20; private const int DefaultCenterPeriod = 20; private const double DefaultMultiplier = 1.0; /// /// Validates that streaming and batch calculations produce identical results. /// [Fact] public void Uchannel_StreamingVsBatch_MatchWithinTolerance() { var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42); var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); // Streaming var streaming = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); var streamMiddle = new List(); var streamUpper = new List(); var streamLower = new List(); foreach (var bar in bars) { streaming.Update(bar); streamMiddle.Add(streaming.Middle.Value); streamUpper.Add(streaming.Upper.Value); streamLower.Add(streaming.Lower.Value); } // Batch var (batchUpper, batchMiddle, batchLower, _) = Uchannel.Calculate(bars, DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); // Compare all values (skip first few for warmup) for (int i = DefaultCenterPeriod; i < bars.Count; i++) { Assert.Equal(streamMiddle[i], batchMiddle[i].Value, precision: 10); Assert.Equal(streamUpper[i], batchUpper[i].Value, precision: 10); Assert.Equal(streamLower[i], batchLower[i].Value, precision: 10); } } /// /// Validates that span-based calculation matches streaming calculation. /// [Fact] public void Uchannel_SpanVsStreaming_MatchWithinTolerance() { var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42); var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); // Streaming var streaming = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); var streamMiddle = new List(); foreach (var bar in bars) { streaming.Update(bar); streamMiddle.Add(streaming.Middle.Value); } // Span double[] highArr = bars.High.Values.ToArray(); double[] lowArr = bars.Low.Values.ToArray(); double[] closeArr = bars.Close.Values.ToArray(); double[] spanUpper = new double[highArr.Length]; double[] spanMiddle = new double[highArr.Length]; double[] spanLower = new double[highArr.Length]; Uchannel.Batch(highArr.AsSpan(), lowArr.AsSpan(), closeArr.AsSpan(), spanUpper.AsSpan(), spanMiddle.AsSpan(), spanLower.AsSpan(), DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); // Compare all values for (int i = DefaultCenterPeriod; i < bars.Count; i++) { Assert.Equal(streamMiddle[i], spanMiddle[i], precision: 10); } } /// /// Validates USF (Ultrasmooth Filter) mathematical properties: /// The middle line should lag less than a simple moving average. /// [Fact] public void Uchannel_USF_HasLessLagThanSMA() { int period = 20; var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.1, seed: 42); var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var uchannel = new Uchannel(period, period, 1.0); var sma = new Sma(period); double uchannelLagSum = 0; double smaLagSum = 0; foreach (var bar in bars) { uchannel.Update(bar); sma.Update(new TValue(bar.Time, bar.Close)); if (uchannel.IsHot && sma.IsHot) { // Measure deviation from close (proxy for lag in trending market) uchannelLagSum += Math.Abs(uchannel.Middle.Value - bar.Close); smaLagSum += Math.Abs(sma.Last.Value - bar.Close); } } // USF should have less overall deviation (implying less lag) Assert.True(uchannelLagSum < smaLagSum, $"USF lag sum ({uchannelLagSum:F4}) should be less than SMA lag sum ({smaLagSum:F4})"); } /// /// Validates that the channel bands are symmetric around the middle. /// Upper - Middle should equal Middle - Lower. /// [Fact] public void Uchannel_Bands_AreSymmetric() { var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42); var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); foreach (var bar in bars) { uchannel.Update(bar); double upperDist = uchannel.Upper.Value - uchannel.Middle.Value; double lowerDist = uchannel.Middle.Value - uchannel.Lower.Value; Assert.Equal(upperDist, lowerDist, precision: 10); } } /// /// Validates that band width equals 2 × STR × multiplier. /// [Fact] public void Uchannel_Width_Equals2xSTRxMultiplier() { double multiplier = 1.5; var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42); var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, multiplier); foreach (var bar in bars) { uchannel.Update(bar); double expectedWidth = 2 * uchannel.STR.Value * multiplier; Assert.Equal(expectedWidth, uchannel.Width.Value, precision: 10); } } /// /// Validates that STR (Smoothed True Range) is always non-negative. /// [Fact] public void Uchannel_STR_AlwaysNonNegative() { var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.2, seed: 42); var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); foreach (var bar in bars) { uchannel.Update(bar); Assert.True(uchannel.STR.Value >= 0, $"STR ({uchannel.STR.Value}) should always be non-negative"); } } /// /// Validates that True Range calculation handles gaps correctly. /// True Range should account for gap between prev close and current high/low. /// [Fact] public void Uchannel_TrueRange_HandlesGapsCorrectly() { var uchannel = new Uchannel(3, 3, 1.0); // Day 1: Normal bar var bar1 = new TBar(DateTime.UtcNow, 102.0, 98.0, 100.0, 100.0, 1000); uchannel.Update(bar1); // TR = 102 - 98 = 4 // Day 2: Gap up (open above prev close) var bar2 = new TBar(DateTime.UtcNow.AddDays(1), 115.0, 110.0, 112.0, 114.0, 1000); uchannel.Update(bar2); // True High = max(115, 100) = 115 // True Low = min(110, 100) = 100 // TR = 115 - 100 = 15 // Day 3: Gap down (open below prev close) var bar3 = new TBar(DateTime.UtcNow.AddDays(2), 108.0, 90.0, 95.0, 92.0, 1000); uchannel.Update(bar3); // True High = max(108, 114) = 114 // True Low = min(90, 114) = 90 // TR = 114 - 90 = 24 // STR should reflect these larger TR values due to gaps Assert.True(uchannel.STR.Value > 0); } /// /// Validates that different STR and center periods work independently. /// [Fact] public void Uchannel_DifferentPeriods_ProduceDifferentResults() { var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42); var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var uchannel1 = new Uchannel(10, 20, 1.0); // Short STR, long center var uchannel2 = new Uchannel(20, 10, 1.0); // Long STR, short center var uchannel3 = new Uchannel(15, 15, 1.0); // Equal periods foreach (var bar in bars) { uchannel1.Update(bar); uchannel2.Update(bar); uchannel3.Update(bar); } // Middle lines should differ (different center periods) Assert.NotEqual(uchannel1.Middle.Value, uchannel2.Middle.Value); // STR should differ (different STR periods) Assert.NotEqual(uchannel1.STR.Value, uchannel2.STR.Value); } /// /// Validates that the multiplier scales the band width proportionally. /// [Fact] public void Uchannel_Multiplier_ScalesBandWidthProportionally() { var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42); var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var uchannel1 = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, 1.0); var uchannel2 = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, 2.0); var uchannel3 = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, 0.5); foreach (var bar in bars) { uchannel1.Update(bar); uchannel2.Update(bar); uchannel3.Update(bar); } // Width should scale proportionally with multiplier Assert.Equal(uchannel1.Width.Value * 2, uchannel2.Width.Value, precision: 10); Assert.Equal(uchannel1.Width.Value / 2, uchannel3.Width.Value, precision: 10); // Middle should be the same (same center period) Assert.Equal(uchannel1.Middle.Value, uchannel2.Middle.Value, precision: 10); Assert.Equal(uchannel1.Middle.Value, uchannel3.Middle.Value, precision: 10); } /// /// Validates that bar correction (isNew=false) works correctly. /// [Fact] public void Uchannel_BarCorrection_WorksCorrectly() { var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); // Process all bars foreach (var bar in bars) { uchannel.Update(bar); } double originalMiddle = uchannel.Middle.Value; double originalUpper = uchannel.Upper.Value; double originalSTR = uchannel.STR.Value; // Simulate tick corrections for (int tick = 0; tick < 20; tick++) { var correctionBar = new TBar(DateTime.UtcNow, 150.0 + tick, 140.0, 145.0, 148.0, 1000); uchannel.Update(correctionBar, isNew: false); } // Restore with original last bar uchannel.Update(bars[^1], isNew: false); Assert.Equal(originalMiddle, uchannel.Middle.Value, precision: 10); Assert.Equal(originalUpper, uchannel.Upper.Value, precision: 10); Assert.Equal(originalSTR, uchannel.STR.Value, precision: 10); } /// /// Validates that the indicator converges to stable values. /// [Fact] public void Uchannel_ConvergesToStableValues() { var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); // Feed constant bars for (int i = 0; i < 100; i++) { var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 105.0, 95.0, 100.0, 100.0, 1000); uchannel.Update(bar); } double middle50 = uchannel.Middle.Value; // Feed more constant bars for (int i = 0; i < 100; i++) { var bar = new TBar(DateTime.UtcNow.AddMinutes(100 + i), 105.0, 95.0, 100.0, 100.0, 1000); uchannel.Update(bar); } double middle100 = uchannel.Middle.Value; // Should converge to close value (100.0) Assert.True(Math.Abs(middle50 - 100.0) < 0.1); Assert.True(Math.Abs(middle100 - 100.0) < 0.01); } /// /// Validates that STR converges to the True Range value for constant volatility. /// [Fact] public void Uchannel_STR_ConvergesToTrueRange() { var uchannel = new Uchannel(10, 10, 1.0); // Feed bars with constant TR = 10 (high=105, low=95) // TBar constructor: (time, open, high, low, close, volume) for (int i = 0; i < 100; i++) { var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100.0, 105.0, 95.0, 100.0, 1000); uchannel.Update(bar); } // STR should converge to TR value (10.0) Assert.True(Math.Abs(uchannel.STR.Value - 10.0) < 0.1, $"STR ({uchannel.STR.Value}) should converge to TR (10.0)"); } /// /// Validates behavior with high volatility data. /// [Fact] public void Uchannel_HighVolatility_ProducesWiderBands() { var gbmLow = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.05, seed: 42); var gbmHigh = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.30, seed: 42); var barsLow = gbmLow.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var barsHigh = gbmHigh.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var uchannelLow = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); var uchannelHigh = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); foreach (var bar in barsLow) { uchannelLow.Update(bar); } foreach (var bar in barsHigh) { uchannelHigh.Update(bar); } // High volatility should produce wider bands Assert.True(uchannelHigh.Width.Value > uchannelLow.Width.Value, $"High vol width ({uchannelHigh.Width.Value:F4}) should be > low vol width ({uchannelLow.Width.Value:F4})"); } /// /// Validates that the indicator handles edge case with period = 1. /// [Fact] public void Uchannel_Period1_Works() { var uchannel = new Uchannel(1, 1, 1.0); var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); foreach (var bar in bars) { uchannel.Update(bar); Assert.True(double.IsFinite(uchannel.Middle.Value)); Assert.True(double.IsFinite(uchannel.Upper.Value)); Assert.True(double.IsFinite(uchannel.Lower.Value)); } } /// /// Validates that reset properly clears all state. /// [Fact] public void Uchannel_Reset_ClearsAllState() { var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier); var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42); var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); // Process bars foreach (var bar in bars) { uchannel.Update(bar); } double valueBefore = uchannel.Middle.Value; // Reset uchannel.Reset(); // Process same bars again foreach (var bar in bars) { uchannel.Update(bar); } double valueAfter = uchannel.Middle.Value; // Should produce same results Assert.Equal(valueBefore, valueAfter, precision: 10); } }