From 4cc4c1a0c40588e8947931ddd21b887e6dbba358 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Tue, 17 Mar 2026 09:38:18 -0700 Subject: [PATCH] =?UTF-8?q?feat(rrsi):=20add=20validation=20tests=20?= =?UTF-8?q?=E2=80=94=20manual=20Ehlers=20algorithm=20cross-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Manual step-by-step reference implementation of Ehlers TASC May 2018 - Theory tests across 5 parameter combinations (5/5, 8/10, 10/10, 10/20, 20/10) - arctanh mathematical identity verification - Streaming/batch/span consistency validation - Event-based vs streaming agreement check - All-outputs-finite verification across 4 parameter sets - Super Smoother coefficient unity DC gain validation - 18 new tests, 15981 total passing, 0 warnings --- .../rrsi/tests/Rrsi.Validation.Tests.cs | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 lib/oscillators/rrsi/tests/Rrsi.Validation.Tests.cs diff --git a/lib/oscillators/rrsi/tests/Rrsi.Validation.Tests.cs b/lib/oscillators/rrsi/tests/Rrsi.Validation.Tests.cs new file mode 100644 index 00000000..b1f6195b --- /dev/null +++ b/lib/oscillators/rrsi/tests/Rrsi.Validation.Tests.cs @@ -0,0 +1,322 @@ +using System.Runtime.CompilerServices; +using Xunit; +using Xunit.Abstractions; + +namespace QuanTAlib.Tests; + +/// +/// Validates Rocket RSI against manual step-by-step computation of the +/// Ehlers TASC May 2018 algorithm. No external library implements RocketRSI, +/// so validation is self-consistency + manual reference implementation. +/// +public sealed class RrsiValidationTests(ITestOutputHelper output) : IDisposable +{ + private readonly ValidationTestData _testData = new(); + private readonly ITestOutputHelper _output = output; + private bool _disposed; + + private const int TestSmooth = 10; + private const int TestRsi = 10; + + public void Dispose() + { + Dispose(disposing: true); + } + + private void Dispose(bool disposing) + { + if (_disposed) { return; } + _disposed = true; + if (disposing) { _testData?.Dispose(); } + } + + #region Manual Computation Cross-Validation + + /// + /// Validates that our RocketRSI Batch exactly matches a manual + /// step-by-step reference implementation of Ehlers' algorithm. + /// + [Fact] + [SkipLocalsInit] + public void Validate_Against_Manual_Computation() + { + double[] values = _testData.RawData.ToArray(); + int smoothLength = TestSmooth; + int rsiLength = TestRsi; + + // Batch output + double[] batchOutput = new double[values.Length]; + Rrsi.Batch(values.AsSpan(), batchOutput.AsSpan(), smoothLength, rsiLength); + + // Manual reference implementation — Ehlers TASC May 2018 + double[] manualOutput = ManualRocketRsi(values, smoothLength, rsiLength); + + // Compare after warmup + int startIdx = smoothLength + rsiLength; + int validCount = 0; + for (int i = startIdx; i < values.Length; i++) + { + Assert.True(Math.Abs(manualOutput[i] - batchOutput[i]) < 1e-9, + $"RocketRSI mismatch at i={i}: manual={manualOutput[i]:F9}, batch={batchOutput[i]:F9}"); + validCount++; + } + + Assert.True(validCount > 100, $"Expected >100 valid comparisons, got {validCount}"); + _output.WriteLine($"RocketRSI manual validation: {validCount} points at 1e-9 tolerance."); + } + + /// + /// Tests with different parameter combinations. + /// + [Theory] + [InlineData(5, 5)] + [InlineData(8, 10)] + [InlineData(10, 10)] + [InlineData(10, 20)] + [InlineData(20, 10)] + public void Validate_Manual_DifferentParams(int smoothLength, int rsiLength) + { + double[] values = _testData.RawData.ToArray(); + + double[] batchOutput = new double[values.Length]; + Rrsi.Batch(values.AsSpan(), batchOutput.AsSpan(), smoothLength, rsiLength); + + double[] manualOutput = ManualRocketRsi(values, smoothLength, rsiLength); + + int startIdx = smoothLength + rsiLength; + int validCount = 0; + for (int i = startIdx; i < values.Length; i++) + { + Assert.True(Math.Abs(manualOutput[i] - batchOutput[i]) < 1e-9, + $"RocketRSI mismatch at i={i}, smooth={smoothLength}, rsi={rsiLength}: " + + $"manual={manualOutput[i]:F9}, batch={batchOutput[i]:F9}"); + validCount++; + } + + Assert.True(validCount > 0, $"No valid comparison points for smooth={smoothLength}, rsi={rsiLength}"); + _output.WriteLine($"RocketRSI smooth={smoothLength}, rsi={rsiLength}: validated {validCount} points."); + } + + /// + /// Validates arctanh identity used in Fisher Transform step. + /// + [Fact] + public void Validate_Arctanh_Identity() + { + double[] testValues = [-0.999, -0.9, -0.5, 0.0, 0.5, 0.9, 0.999]; + + foreach (double v in testValues) + { + double formula = 0.5 * Math.Log((1.0 + v) / (1.0 - v)); + double builtin = Math.Atanh(v); + + Assert.True(Math.Abs(formula - builtin) < 1e-12, + $"arctanh({v}): formula={formula}, builtin={builtin}"); + } + + _output.WriteLine("arctanh mathematical identity verified for RocketRSI."); + } + + #endregion + + #region Consistency Validation + + /// + /// Validates streaming matches batch across all data points. + /// + [Fact] + [SkipLocalsInit] + public void Validate_Streaming_Batch_Span_Agree() + { + double[] tData = _testData.RawData.ToArray(); + + // Batch TSeries + TSeries batchSeries = Rrsi.Batch(_testData.Data, TestSmooth, TestRsi); + + // Batch Span + double[] spanOutput = new double[tData.Length]; + Rrsi.Batch(tData.AsSpan(), spanOutput.AsSpan(), TestSmooth, TestRsi); + + // Batch and Span should be identical + for (int i = 0; i < tData.Length; i++) + { + Assert.Equal(batchSeries.Values[i], spanOutput[i], 12); + } + + // Streaming + var rrsi = new Rrsi(TestSmooth, TestRsi); + double[] streamResults = new double[tData.Length]; + for (int i = 0; i < tData.Length; i++) + { + streamResults[i] = rrsi.Update(_testData.Data[i]).Value; + } + + // Streaming vs Batch should match + for (int i = 0; i < tData.Length; i++) + { + Assert.Equal(streamResults[i], batchSeries.Values[i], 9); + } + + _output.WriteLine("RocketRSI streaming/batch/span agreement verified."); + } + + /// + /// Validates event-based matches streaming. + /// + [Fact] + [SkipLocalsInit] + public void Validate_Event_Matches_Streaming() + { + // Streaming + var streamRrsi = new Rrsi(TestSmooth, TestRsi); + double[] streamResults = new double[_testData.Data.Count]; + for (int i = 0; i < _testData.Data.Count; i++) + { + streamResults[i] = streamRrsi.Update(_testData.Data[i]).Value; + } + + // Event-based + var eventSource = new TSeries(); + var eventRrsi = new Rrsi(eventSource, TestSmooth, TestRsi); + double[] eventResults = new double[_testData.Data.Count]; + for (int i = 0; i < _testData.Data.Count; i++) + { + eventSource.Add(_testData.Data[i]); + eventResults[i] = eventRrsi.Last.Value; + } + + for (int i = 0; i < _testData.Data.Count; i++) + { + Assert.Equal(streamResults[i], eventResults[i], 12); + } + + _output.WriteLine("RocketRSI event-based matches streaming."); + } + + /// + /// All batch outputs must be finite. + /// + [Theory] + [InlineData(5, 5)] + [InlineData(10, 10)] + [InlineData(20, 20)] + [InlineData(50, 10)] + public void Validate_All_Outputs_Finite(int smoothLength, int rsiLength) + { + double[] values = _testData.RawData.ToArray(); + double[] results = new double[values.Length]; + + Rrsi.Batch(values.AsSpan(), results.AsSpan(), smoothLength, rsiLength); + + for (int i = 0; i < values.Length; i++) + { + Assert.True(double.IsFinite(results[i]), + $"RocketRSI not finite at i={i}, smooth={smoothLength}, rsi={rsiLength}: {results[i]}"); + } + + _output.WriteLine($"RocketRSI smooth={smoothLength}, rsi={rsiLength}: all {values.Length} outputs finite."); + } + + #endregion + + #region Super Smoother Component Validation + + /// + /// Validates that the Super Smoother coefficients satisfy the constraint c1 + c2 + c3 = 1 + /// (unity DC gain). + /// + [Theory] + [InlineData(5)] + [InlineData(8)] + [InlineData(10)] + [InlineData(20)] + [InlineData(50)] + public void Validate_SuperSmoother_Coefficients_UnitGain(int smoothLength) + { + double a1 = Math.Exp(-1.414 * Math.PI / smoothLength); + double b1 = 2.0 * a1 * Math.Cos(1.414 * Math.PI / smoothLength); + double c2 = b1; + double c3 = -(a1 * a1); + double c1 = 1.0 - c2 - c3; + + double sum = c1 + c2 + c3; + Assert.True(Math.Abs(sum - 1.0) < 1e-12, + $"SuperSmoother DC gain should be 1.0, got {sum} for smoothLength={smoothLength}"); + + _output.WriteLine($"SuperSmoother coefficients for L={smoothLength}: c1={c1:F6}, c2={c2:F6}, c3={c3:F6}, sum={sum:F12}"); + } + + #endregion + + #region Manual Reference Implementation + + /// + /// Pure reference implementation of Ehlers Rocket RSI (TASC May 2018). + /// This is a direct transcription of the EasyLanguage code for verification. + /// + private static double[] ManualRocketRsi(double[] source, int smoothLength, int rsiLength) + { + int len = source.Length; + double[] output = new double[len]; + double[] mom = new double[len]; + double[] filt = new double[len]; + + // Super Smoother coefficients + double a1 = Math.Exp(-1.414 * Math.PI / smoothLength); + double b1 = 2.0 * a1 * Math.Cos(1.414 * Math.PI / smoothLength); + double c2 = b1; + double c3 = -(a1 * a1); + double c1 = 1.0 - c2 - c3; + + // Pass 1: Momentum + for (int i = 0; i < len; i++) + { + mom[i] = (i >= rsiLength - 1) ? source[i] - source[i - rsiLength + 1] : 0.0; + } + + // Pass 2: Super Smoother Filter + filt[0] = mom[0]; + if (len > 1) + { + filt[1] = (c1 * (mom[1] + mom[0]) * 0.5) + (c2 * filt[0]); + } + for (int i = 2; i < len; i++) + { + filt[i] = (c1 * (mom[i] + mom[i - 1]) * 0.5) + (c2 * filt[i - 1]) + (c3 * filt[i - 2]); + } + + // Pass 3: Ehlers RSI + Fisher Transform + for (int i = 0; i < len; i++) + { + double cu = 0.0; + double cd = 0.0; + int lookback = Math.Min(rsiLength, i); + + for (int j = 0; j < lookback; j++) + { + double diff = filt[i - j] - filt[i - j - 1]; + if (diff > 0.0) + { + cu += diff; + } + else if (diff < 0.0) + { + cd -= diff; + } + } + + double cuCd = cu + cd; + double myRsi = (cuCd > 1e-10) ? (cu - cd) / cuCd : 0.0; + + // Clamp + if (myRsi > 0.999) { myRsi = 0.999; } + else if (myRsi < -0.999) { myRsi = -0.999; } + + output[i] = 0.5 * Math.Log((1.0 + myRsi) / (1.0 - myRsi)); + } + + return output; + } + + #endregion +}