using Xunit.Abstractions; namespace QuanTAlib.Tests; /// /// Validation tests for UBANDS (Ehlers Ultimate Bands) indicator. /// Note: UBANDS is a proprietary indicator by John F. Ehlers (2024), not available in /// standard libraries like TA-Lib, Skender, Tulip, or Ooples. Validation focuses on /// internal consistency between streaming, batch, and span modes, plus verification /// that the middle band matches the standalone USF indicator. /// public sealed class UbandsValidationTests : IDisposable { private readonly ValidationTestData _testData; private readonly ITestOutputHelper _output; private bool _disposed; public UbandsValidationTests(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_Streaming_Batch_Consistency() { int[] periods = { 5, 10, 14, 20, 50 }; double[] multipliers = { 0.5, 1.0, 2.0 }; foreach (var period in periods) { foreach (var multiplier in multipliers) { // Generate test data 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)); TSeries series = bars.Close; // Streaming mode var streamingUbands = new Ubands(period, multiplier); var streamingResults = new List(); var streamingUpper = new List(); var streamingLower = new List(); foreach (var val in series) { streamingUbands.Update(val); streamingResults.Add(streamingUbands.Middle.Value); streamingUpper.Add(streamingUbands.Upper.Value); streamingLower.Add(streamingUbands.Lower.Value); } // Batch mode var batchResult = Ubands.Batch(series, period, multiplier); // Compare last 100 values int compareCount = Math.Min(100, series.Count - period); for (int i = series.Count - compareCount; i < series.Count; i++) { Assert.Equal(streamingResults[i], batchResult[i].Value, precision: 10); } } } _output.WriteLine("UBANDS Streaming vs Batch consistency validated successfully"); } [Fact] public void Validate_Streaming_Span_Consistency() { int[] periods = { 5, 10, 14, 20, 50 }; double[] multipliers = { 0.5, 1.0, 2.0 }; foreach (var period in periods) { foreach (var multiplier in multipliers) { // Generate test data 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)); TSeries series = bars.Close; // Streaming mode var streamingUbands = new Ubands(period, multiplier); var streamingUpper = new List(); var streamingMiddle = new List(); var streamingLower = new List(); foreach (var val in series) { streamingUbands.Update(val); streamingUpper.Add(streamingUbands.Upper.Value); streamingMiddle.Add(streamingUbands.Middle.Value); streamingLower.Add(streamingUbands.Lower.Value); } // Span mode double[] source = series.Values.ToArray(); double[] spanUpper = new double[series.Count]; double[] spanMiddle = new double[series.Count]; double[] spanLower = new double[series.Count]; Ubands.Batch(source.AsSpan(), spanUpper.AsSpan(), spanMiddle.AsSpan(), spanLower.AsSpan(), period, multiplier); // Compare last 100 values int compareCount = Math.Min(100, series.Count - period); for (int i = series.Count - compareCount; i < series.Count; i++) { Assert.Equal(streamingUpper[i], spanUpper[i], precision: 10); Assert.Equal(streamingMiddle[i], spanMiddle[i], precision: 10); Assert.Equal(streamingLower[i], spanLower[i], precision: 10); } } } _output.WriteLine("UBANDS Streaming vs Span consistency validated successfully"); } [Fact] public void Validate_MiddleBand_MatchesUsf() { // The middle band of UBANDS should match the standalone USF indicator // Both use the same Ehlers Ultrasmooth Filter algorithm but calculate coefficients independently int[] periods = { 5, 10, 20 }; foreach (var period in periods) { 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)); TSeries series = bars.Close; var ubands = new Ubands(period, 1.0); var usf = new Usf(period); var ubandsMiddle = new List(); var usfValues = new List(); foreach (var val in series) { ubands.Update(val); usf.Update(val); ubandsMiddle.Add(ubands.Middle.Value); usfValues.Add(usf.Last.Value); } // Compare after warmup - using relative tolerance due to independent FP calculations // UBANDS reimplements USF internally, so minor numerical differences are expected double maxRelDiff = 0; for (int i = period; i < series.Count; i++) { double relDiff = Math.Abs(usfValues[i] - ubandsMiddle[i]) / Math.Abs(usfValues[i]); maxRelDiff = Math.Max(maxRelDiff, relDiff); Assert.True(relDiff < 0.001, // 0.1% tolerance $"Period {period}, index {i}: USF={usfValues[i]:F6}, UBANDS={ubandsMiddle[i]:F6}, diff={relDiff:P4}"); } _output.WriteLine($"Period {period}: UBANDS middle band matches USF (max rel diff: {maxRelDiff:P4})"); } } [Fact] public void Validate_BandCharacteristics() { // Verify core UBANDS characteristics: // 1. Upper >= Middle >= Lower (symmetric around middle) // 2. Width = 2 × mult × RMS (symmetry) // 3. Bands adapt to volatility int period = 10; double multiplier = 1.0; 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)); TSeries series = bars.Close; var ubands = new Ubands(period, multiplier); foreach (var val in series) { ubands.Update(val); // Upper >= Middle >= Lower Assert.True(ubands.Upper.Value >= ubands.Middle.Value, $"Upper ({ubands.Upper.Value}) should be >= Middle ({ubands.Middle.Value})"); Assert.True(ubands.Middle.Value >= ubands.Lower.Value, $"Middle ({ubands.Middle.Value}) should be >= Lower ({ubands.Lower.Value})"); // Symmetry: Upper - Middle == Middle - Lower double upperOffset = ubands.Upper.Value - ubands.Middle.Value; double lowerOffset = ubands.Middle.Value - ubands.Lower.Value; Assert.Equal(upperOffset, lowerOffset, precision: 10); } _output.WriteLine("UBANDS band characteristics validated successfully"); } [Fact] public void Validate_NaN_Handling() { int period = 10; double multiplier = 1.0; 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)); TSeries series = bars.Close; var ubands = new Ubands(period, multiplier); int nanCount = 0; for (int i = 0; i < series.Count; i++) { TValue inputVal; if (i == 50 || i == 51) { inputVal = new TValue(series[i].Time, double.NaN); nanCount++; } else { inputVal = series[i]; } ubands.Update(inputVal); Assert.True(double.IsFinite(ubands.Upper.Value), $"Upper band should be finite after NaN at index {i}"); Assert.True(double.IsFinite(ubands.Middle.Value), $"Middle band should be finite after NaN at index {i}"); Assert.True(double.IsFinite(ubands.Lower.Value), $"Lower band should be finite after NaN at index {i}"); } _output.WriteLine($"UBANDS NaN handling validated ({nanCount} NaN values handled)"); } [Fact] public void Validate_BarCorrection() { int period = 10; double multiplier = 1.0; var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42); var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); TSeries series = bars.Close; var ubands = new Ubands(period, multiplier); // Process all bars for (int i = 0; i < series.Count - 1; i++) { ubands.Update(series[i]); } // Record state before last bar ubands.Update(series[^1]); double originalMiddle = ubands.Middle.Value; double originalUpper = ubands.Upper.Value; // Correct last bar with different value var correctedVal = new TValue(series[^1].Time, 200.0); ubands.Update(correctedVal, isNew: false); double correctedMiddle = ubands.Middle.Value; // Should be different Assert.NotEqual(originalMiddle, correctedMiddle); // Restore original bar ubands.Update(series[^1], isNew: false); double restoredMiddle = ubands.Middle.Value; double restoredUpper = ubands.Upper.Value; // Should match original Assert.Equal(originalMiddle, restoredMiddle, precision: 10); Assert.Equal(originalUpper, restoredUpper, precision: 10); _output.WriteLine("UBANDS bar correction validated successfully"); } [Fact] public void Validate_DifferentPeriods() { double multiplier = 1.0; 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)); TSeries series = bars.Close; int[] periods = { 5, 10, 20, 50 }; var avgWidths = new List(); foreach (var period in periods) { var ubands = new Ubands(period, multiplier); double sumWidth = 0; int count = 0; foreach (var val in series) { ubands.Update(val); if (ubands.IsHot) { sumWidth += ubands.Width.Value; count++; } } double avgWidth = count > 0 ? sumWidth / count : 0; avgWidths.Add(avgWidth); _output.WriteLine($"Period {period}: Average width = {avgWidth:F4}"); } // All widths should be positive foreach (var width in avgWidths) { Assert.True(width >= 0, "Average band width should be non-negative"); } } [Fact] public void Validate_DifferentMultipliers() { int period = 10; 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)); TSeries series = bars.Close; double[] multipliers = { 0.5, 1.0, 1.5, 2.0 }; var avgWidths = new List(); foreach (var multiplier in multipliers) { var ubands = new Ubands(period, multiplier); double sumWidth = 0; int count = 0; foreach (var val in series) { ubands.Update(val); if (ubands.IsHot) { sumWidth += ubands.Width.Value; count++; } } double avgWidth = count > 0 ? sumWidth / count : 0; avgWidths.Add(avgWidth); _output.WriteLine($"Multiplier {multiplier}: Average width = {avgWidth:F4}"); } // Higher multipliers should give wider bands for (int i = 1; i < avgWidths.Count; i++) { Assert.True(avgWidths[i] > avgWidths[i - 1], $"Higher multiplier should produce wider bands"); } } [Fact] public void Validate_SmoothingQuality() { // USF should provide superior smoothing with minimal lag int period = 20; var gbm = new GBM(startPrice: 100.0, mu: 0.0, sigma: 0.15, seed: 42); var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); TSeries series = bars.Close; var ubands = new Ubands(period, 1.0); var middleValues = new List(); var sourceValues = new List(); foreach (var val in series) { ubands.Update(val); middleValues.Add(ubands.Middle.Value); sourceValues.Add(val.Value); } // Calculate noise reduction: variance of differences should be lower for smoothed var sourceDiffs = new List(); var middleDiffs = new List(); for (int i = period + 1; i < series.Count; i++) { sourceDiffs.Add(sourceValues[i] - sourceValues[i - 1]); middleDiffs.Add(middleValues[i] - middleValues[i - 1]); } double sourceVar = sourceDiffs.Select(x => x * x).Average(); double middleVar = middleDiffs.Select(x => x * x).Average(); _output.WriteLine($"Source variance: {sourceVar:F4}"); _output.WriteLine($"Middle (USF) variance: {middleVar:F4}"); _output.WriteLine($"Noise reduction: {(1 - middleVar / sourceVar) * 100:F1}%"); Assert.True(middleVar < sourceVar, "Smoothed signal should have lower variance"); } }