diff --git a/_sidebar.md b/_sidebar.md index 9d5ee2e8..d0afb36f 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -161,6 +161,7 @@ * [REFLEX - Ehlers Reflex](/lib/oscillators/reflex/Reflex.md) * [REVERSEEMA - Ehlers Reverse EMA](/lib/oscillators/reverseema/ReverseEma.md) * [RVGI - Relative Vigor Index](/lib/oscillators/rvgi/Rvgi.md) + * [RRSI - Rocket RSI (Ehlers)](/lib/oscillators/rrsi/Rrsi.md) * [SMI - Stochastic Momentum Index](/lib/oscillators/smi/Smi.md) * [SQUEEZE - Squeeze Momentum](/lib/oscillators/squeeze/Squeeze.md) * [SQUEEZE_PRO - Squeeze Pro](/lib/oscillators/squeeze_pro/squeeze_pro.md) diff --git a/lib/_index.md b/lib/_index.md index 46e725b0..07a68bf7 100644 --- a/lib/_index.md +++ b/lib/_index.md @@ -309,6 +309,7 @@ | [RV](volatility/rv/Rv.md) | Realized Volatility | Volatility | | [RVI](volatility/rvi/Rvi.md) | Relative Volatility Index | Volatility | | [RVGI](oscillators/rvgi/Rvgi.md) | Relative Vigor Index | Oscillators | +| [RRSI](oscillators/rrsi/Rrsi.md) | Rocket RSI (Ehlers) | Oscillators | | [RWMA](trends_FIR/rwma/Rwma.md) | Range Weighted MA | Trends (FIR) | | [SAK](filters/sak/Sak.md) | Ehlers Swiss Army Knife | Filters | | [SAM](momentum/sam/Sam.md) | Smoothed Adaptive Momentum | Momentum | diff --git a/lib/oscillators/_index.md b/lib/oscillators/_index.md index 3b13a513..19bf33b7 100644 --- a/lib/oscillators/_index.md +++ b/lib/oscillators/_index.md @@ -42,6 +42,7 @@ Oscillators fluctuate above and below a centerline or within bounded ranges. Use | [REFLEX](reflex/Reflex.md) | Ehlers Reflex | Ehlers zero-centered reversal oscillator using super smoother with normalized sum-of-differences. | | [REVERSEEMA](reverseema/ReverseEma.md) | Ehlers Reverse EMA | 8-stage cascaded Z-transform inversion subtracts EMA lag, producing zero-centered oscillator signal. | | [RVGI](rvgi/Rvgi.md) | Relative Vigor Index | Open-close vs high-low ratio with SMA smoothing. Measures conviction. | +| [RRSI](rrsi/Rrsi.md) | Rocket RSI | Fisher Transform of Super Smoother–filtered RSI. Sharp cyclic reversal signals. | | [SMI](smi/Smi.md) | Stochastic Momentum Index | Distance from range midpoint. More sensitive than classic Stochastic. | | [SQUEEZE](squeeze/Squeeze.md) | Squeeze | BB width < KC width indicates consolidation. Breakout imminent. | | [SQUEEZE_PRO](squeeze_pro/squeeze_pro.md) | Squeeze Pro | Multi-level BB vs KC squeeze (wide/normal/narrow) with MOM-smoothed momentum. LazyBear. | diff --git a/lib/oscillators/rrsi/Rrsi.Quantower.cs b/lib/oscillators/rrsi/Rrsi.Quantower.cs new file mode 100644 index 00000000..d5b4a789 --- /dev/null +++ b/lib/oscillators/rrsi/Rrsi.Quantower.cs @@ -0,0 +1,70 @@ +using System.Drawing; +using System.Runtime.CompilerServices; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +[SkipLocalsInit] +public sealed class RrsiIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Smooth Length", sortIndex: 1, 1, 500, 1, 0)] + public int SmoothLength { get; set; } = 10; + + [InputParameter("RSI Length", sortIndex: 2, 1, 500, 1, 0)] + public int RsiLength { get; set; } = 10; + + [IndicatorExtensions.DataSourceInput(sortIndex: 3)] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Rrsi _rrsi = null!; + private readonly LineSeries _rrsiLine; + + public static int MinHistoryDepths => 0; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"RRSI ({SmoothLength},{RsiLength})"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/rrsi/Rrsi.Quantower.cs"; + + public RrsiIndicator() + { + OnBackGround = true; + SeparateWindow = true; + Name = "RRSI - Rocket RSI (Ehlers)"; + Description = "Fisher Transform of Super Smoother–filtered RSI for cyclic reversal signals"; + + _rrsiLine = new LineSeries("RocketRSI", Color.DodgerBlue, 2, LineStyle.Solid); + AddLineSeries(_rrsiLine); + + AddLineLevel(0, "Zero", Color.Gray, 1, LineStyle.Dash); + AddLineLevel(2, "Overbought", Color.Red, 1, LineStyle.Dash); + AddLineLevel(-2, "Oversold", Color.Green, 1, LineStyle.Dash); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnInit() + { + _rrsi = new Rrsi(SmoothLength, RsiLength); + base.OnInit(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnUpdate(UpdateArgs args) + { + var priceSelector = Source.GetPriceSelector(); + var item = HistoricalData[0, SeekOriginHistory.End]; + double price = priceSelector(item); + + TValue input = new(item.TimeLeft, price); + TValue result = _rrsi.Update(input, args.IsNewBar()); + + if (!_rrsi.IsHot && !ShowColdValues) + { + return; + } + + _rrsiLine.SetValue(result.Value); + } +} diff --git a/lib/oscillators/rrsi/Rrsi.cs b/lib/oscillators/rrsi/Rrsi.cs new file mode 100644 index 00000000..74712079 --- /dev/null +++ b/lib/oscillators/rrsi/Rrsi.cs @@ -0,0 +1,392 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// RRSI: Rocket RSI (Ehlers) +/// +/// +/// Combines a Super Smoother–filtered RSI with the Fisher Transform +/// to produce a zero-mean Gaussian-distributed oscillator with sharp +/// turning-point signals. +/// +/// Pipeline: +/// +/// Half-cycle momentum: Mom = Close − Close[rsiLength−1] +/// Super Smoother (2-pole Butterworth IIR) on (Mom + Mom[1])/2 +/// Ehlers RSI: RSI = (CU − CD)/(CU + CD) over rsiLength +/// bars of filtered momentum differences (result already in [−1, 1]) +/// Fisher Transform: RocketRSI = arctanh(clamp(RSI, ±0.999)) +/// +/// +/// Reference: John F. Ehlers, "Rocket RSI", TASC May 2018. +/// +[SkipLocalsInit] +public sealed class Rrsi : AbstractBase +{ + private readonly int _smoothLength; + private readonly int _rsiLength; + + // Super Smoother coefficients (computed once) + private readonly double _c1, _c2, _c3; + + // Close history for momentum lookback + private readonly RingBuffer _closeBuf; + + // Filter history for RSI accumulation + private readonly RingBuffer _filtBuf; + + [StructLayout(LayoutKind.Auto)] + private record struct State( + double Mom, + double MomPrev, + double Filt, + double FiltPrev, + double LastValid, + int Count); + + private State _s; + private State _ps; + + /// + public override bool IsHot => _s.Count >= WarmupPeriod; + + /// Smooth filter length. + public int SmoothLength => _smoothLength; + + /// RSI accumulation length. + public int RsiLength => _rsiLength; + + /// + /// Creates a Rocket RSI indicator. + /// + /// Super Smoother period (must be > 0, default 10). + /// RSI accumulation period (must be > 0, default 10). + public Rrsi(int smoothLength = 10, int rsiLength = 10) + { + if (smoothLength <= 0) + { + throw new ArgumentException("Smooth length must be greater than 0", nameof(smoothLength)); + } + if (rsiLength <= 0) + { + throw new ArgumentException("RSI length must be greater than 0", nameof(rsiLength)); + } + + _smoothLength = smoothLength; + _rsiLength = rsiLength; + + // Super Smoother coefficients (Ehlers 2-pole Butterworth) + double a1 = Math.Exp(-1.414 * Math.PI / smoothLength); + double b1 = 2.0 * a1 * Math.Cos(1.414 * Math.PI / smoothLength); + _c2 = b1; + _c3 = -(a1 * a1); + _c1 = 1.0 - _c2 - _c3; + + _closeBuf = new RingBuffer(rsiLength); + _filtBuf = new RingBuffer(rsiLength + 1); + + Name = $"Rrsi({smoothLength},{rsiLength})"; + WarmupPeriod = smoothLength + rsiLength; + } + + /// + /// Creates a Rocket RSI with a source publisher. + /// + public Rrsi(ITValuePublisher source, int smoothLength = 10, int rsiLength = 10) : this(smoothLength, rsiLength) + { + source.Pub += Handle; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override TValue Update(TValue input, bool isNew = true) + { + double value = input.Value; + + // Sanitize NaN/Inf + if (!double.IsFinite(value)) + { + value = double.IsFinite(_s.LastValid) ? _s.LastValid : 0.0; + } + else + { + _s.LastValid = value; + } + + if (isNew) + { + _ps = _s; + _closeBuf.Add(value); + _s.Count++; + } + else + { + _s = _ps; + _closeBuf.UpdateNewest(value); + } + + // Step 1: Half-cycle momentum + double mom; + if (_closeBuf.Count >= _rsiLength) + { + // Close - Close[rsiLength - 1] + // _closeBuf[0] is oldest, _closeBuf[Count-1] is newest + // Close[rsiLength-1] ago = _closeBuf[_closeBuf.Count - _rsiLength] + mom = value - _closeBuf[_closeBuf.Count - _rsiLength]; + } + else + { + mom = 0.0; + } + + // Step 2: Super Smoother Filter on (Mom + MomPrev) / 2 + double filt; + if (_s.Count <= 2) + { + // Not enough history for IIR — pass through + filt = mom; + } + else + { + filt = (_c1 * (mom + _s.Mom) * 0.5) + (_c2 * _s.Filt) + (_c3 * _s.FiltPrev); + } + + // Update state for next bar + _s.FiltPrev = _s.Filt; + _s.Filt = filt; + _s.MomPrev = _s.Mom; + _s.Mom = mom; + + // Step 3: Store Filt for RSI accumulation + if (isNew) + { + _filtBuf.Add(filt); + } + else + { + _filtBuf.UpdateNewest(filt); + } + + // Step 4: Ehlers RSI — accumulate CU/CD over rsiLength Filt differences + double cu = 0.0; + double cd = 0.0; + int filtCount = _filtBuf.Count; + int lookback = Math.Min(_rsiLength, filtCount - 1); + + for (int i = 0; i < lookback; i++) + { + // Filt[i] and Filt[i+1] in Ehlers notation (0 = newest) + // In our buffer: newest = filtCount-1, so Filt[i] = _filtBuf[filtCount - 1 - i] + double filtNewer = _filtBuf[filtCount - 1 - i]; + double filtOlder = _filtBuf[filtCount - 2 - i]; + double diff = filtNewer - filtOlder; + + if (diff > 0.0) + { + cu += diff; + } + else if (diff < 0.0) + { + cd -= diff; // accumulate absolute value + } + } + + // Step 5: Compute RSI in [-1, 1] range + double myRsi; + double cuCd = cu + cd; + if (cuCd > 1e-10) + { + myRsi = (cu - cd) / cuCd; + } + else + { + myRsi = 0.0; + } + + // Clamp to avoid arctanh singularity + if (myRsi > 0.999) + { + myRsi = 0.999; + } + else if (myRsi < -0.999) + { + myRsi = -0.999; + } + + // Step 6: Fisher Transform (arctanh) + double rocketRsi = 0.5 * Math.Log((1.0 + myRsi) / (1.0 - myRsi)); + + Last = new TValue(input.Time, rocketRsi); + PubEvent(Last, isNew); + return Last; + } + + public override TSeries Update(TSeries source) + { + int len = source.Count; + if (len == 0) + { + return []; + } + + var t = new List(len); + var v = new List(len); + CollectionsMarshal.SetCount(t, len); + CollectionsMarshal.SetCount(v, len); + + var tSpan = CollectionsMarshal.AsSpan(t); + var vSpan = CollectionsMarshal.AsSpan(v); + + Batch(source.Values, vSpan, _smoothLength, _rsiLength); + source.Times.CopyTo(tSpan); + + // Replay for streaming state sync + Reset(); + for (int i = 0; i < len; i++) + { + Update(new TValue(source.Times[i], source.Values[i])); + } + + return new TSeries(t, v); + } + + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) + { + foreach (double value in source) + { + Update(new TValue(DateTime.MinValue, value)); + } + } + + /// Batch-process a series. + public static TSeries Batch(TSeries source, int smoothLength = 10, int rsiLength = 10) + { + var ind = new Rrsi(smoothLength, rsiLength); + return ind.Update(source); + } + + /// Batch-process span data. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Batch(ReadOnlySpan source, Span output, + int smoothLength = 10, int rsiLength = 10) + { + if (source.Length != output.Length) + { + throw new ArgumentException("Source and output must have the same length", nameof(output)); + } + if (smoothLength <= 0) + { + throw new ArgumentException("Smooth length must be greater than 0", nameof(smoothLength)); + } + if (rsiLength <= 0) + { + throw new ArgumentException("RSI length must be greater than 0", nameof(rsiLength)); + } + + int len = source.Length; + if (len == 0) + { + return; + } + + // 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; + + // Allocate momentum and filter arrays + double[] momRented = ArrayPool.Shared.Rent(len); + double[] filtRented = ArrayPool.Shared.Rent(len); + Span momArr = momRented.AsSpan(0, len); + Span filtArr = filtRented.AsSpan(0, len); + + try + { + // Pass 1: Momentum + for (int i = 0; i < len; i++) + { + momArr[i] = (i >= rsiLength - 1) + ? source[i] - source[i - rsiLength + 1] + : 0.0; + } + + // Pass 2: Super Smoother + filtArr[0] = momArr[0]; + if (len > 1) + { + filtArr[1] = (c1 * (momArr[1] + momArr[0]) * 0.5) + (c2 * filtArr[0]); + } + for (int i = 2; i < len; i++) + { + filtArr[i] = (c1 * (momArr[i] + momArr[i - 1]) * 0.5) + + (c2 * filtArr[i - 1]) + + (c3 * filtArr[i - 2]); + } + + // Pass 3: RSI + Fisher + 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 = filtArr[i - j] - filtArr[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)); + } + } + finally + { + ArrayPool.Shared.Return(momRented); + ArrayPool.Shared.Return(filtRented); + } + } + + /// Calculate and return both results and indicator. + public static (TSeries Results, Rrsi Indicator) Calculate(TSeries source, + int smoothLength = 10, int rsiLength = 10) + { + var ind = new Rrsi(smoothLength, rsiLength); + return (ind.Update(source), ind); + } + + public override void Reset() + { + _closeBuf.Clear(); + _filtBuf.Clear(); + _s = default; + _ps = default; + Last = default; + } +} diff --git a/lib/oscillators/rrsi/Rrsi.md b/lib/oscillators/rrsi/Rrsi.md new file mode 100644 index 00000000..18bd5fd6 --- /dev/null +++ b/lib/oscillators/rrsi/Rrsi.md @@ -0,0 +1,87 @@ +# Rocket RSI (RRSI) + +**Category:** Oscillators +**Type:** Unbounded zero-mean oscillator +**Author:** John F. Ehlers, TASC May 2018 + +## Description + +Rocket RSI combines Ehlers' Super Smoother filter with a custom RSI calculation +and applies the Fisher Transform to produce a Gaussian-distributed oscillator +with sharp turning-point signals ideal for cyclic reversal detection. + +## Mathematical Foundation + +### Step 1: Half-Cycle Momentum +$$\text{Mom}_i = \text{Close}_i - \text{Close}_{i - (\text{rsiLength} - 1)}$$ + +### Step 2: Super Smoother Filter (2-Pole Butterworth) +Coefficients (computed once): +$$a_1 = e^{-1.414\pi / \text{smoothLength}}, \quad b_1 = 2 a_1 \cos(1.414\pi / \text{smoothLength})$$ +$$c_2 = b_1, \quad c_3 = -a_1^2, \quad c_1 = 1 - c_2 - c_3$$ + +Filter: +$$\text{Filt}_i = c_1 \cdot \frac{\text{Mom}_i + \text{Mom}_{i-1}}{2} + c_2 \cdot \text{Filt}_{i-1} + c_3 \cdot \text{Filt}_{i-2}$$ + +### Step 3: Ehlers RSI (Normalized to ±1) +Over the last `rsiLength` bars of filter differences: +$$CU = \sum_{j=0}^{n-1} \max(\text{Filt}_{i-j} - \text{Filt}_{i-j-1},\ 0)$$ +$$CD = \sum_{j=0}^{n-1} \max(\text{Filt}_{i-j-1} - \text{Filt}_{i-j},\ 0)$$ +$$\text{RSI} = \frac{CU - CD}{CU + CD} \in [-1, 1]$$ + +### Step 4: Fisher Transform +$$\text{RocketRSI} = \frac{1}{2} \ln\left(\frac{1 + \text{clamp(RSI, \pm0.999)}}{1 - \text{clamp(RSI, \pm0.999)}}\right) = \text{arctanh}(\text{RSI})$$ + +## Parameters + +| Parameter | Default | Range | Description | +|-----------|---------|-------|-------------| +| smoothLength | 10 | > 0 | Super Smoother filter period | +| rsiLength | 10 | > 0 | RSI accumulation window | + +## Interpretation + +- **Values > +2**: Overbought — potential sell signal +- **Values < −2**: Oversold — potential buy signal +- **Zero crossings**: Momentum shift +- **Peaks/troughs**: Cyclic turning points + +The Fisher Transform produces a nearly Gaussian distribution, meaning: +- ~68% of values fall within ±1 standard deviation +- Values beyond ±2 are statistically extreme (~5%) +- Values beyond ±3 are very rare (~0.3%) + +## Key Differences from Standard RSI + +1. **Super Smoother pre-filter** removes high-frequency noise +2. **Ehlers RSI** uses raw summation (not Wilder's exponential smoothing) +3. **RSI output is ±1** (not 0–100), already suited for Fisher Transform +4. **Fisher Transform** converts to Gaussian distribution with sharp reversals + +## Warmup Period + +`smoothLength + rsiLength` bars are needed for the IIR filter to stabilize +and the RSI accumulation window to fill. + +## C# Usage + +```csharp +// Streaming +var rrsi = new Rrsi(smoothLength: 10, rsiLength: 10); +foreach (var bar in series) +{ + TValue result = rrsi.Update(bar); + // result.Value is the Rocket RSI +} + +// Batch +TSeries results = Rrsi.Batch(series); + +// Span +Rrsi.Batch(source, output, smoothLength: 10, rsiLength: 10); +``` + +## References + +- Ehlers, J. F. (2018). "Rocket RSI." *Technical Analysis of Stocks & Commodities*, May 2018. +- Ehlers, J. F. (2004). *Cybernetic Analysis for Stocks and Futures*. Wiley. diff --git a/lib/oscillators/rrsi/tests/Rrsi.Quantower.Tests.cs b/lib/oscillators/rrsi/tests/Rrsi.Quantower.Tests.cs new file mode 100644 index 00000000..bd238b11 --- /dev/null +++ b/lib/oscillators/rrsi/tests/Rrsi.Quantower.Tests.cs @@ -0,0 +1,61 @@ +using TradingPlatform.BusinessLayer; +using Xunit; + +namespace QuanTAlib.Tests; + +public sealed class RrsiIndicatorTests +{ + [Fact] + public void Indicator_DefaultParams() + { + var indicator = new RrsiIndicator(); + Assert.Equal(10, indicator.SmoothLength); + Assert.Equal(10, indicator.RsiLength); + Assert.True(indicator.ShowColdValues); + Assert.Contains("RRSI", indicator.Name, StringComparison.Ordinal); + Assert.True(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void Indicator_CustomParams() + { + var indicator = new RrsiIndicator { SmoothLength = 8, RsiLength = 14 }; + Assert.Equal(8, indicator.SmoothLength); + Assert.Equal(14, indicator.RsiLength); + } + + [Fact] + public void Indicator_ShortName_Format() + { + var indicator = new RrsiIndicator { SmoothLength = 8, RsiLength = 14 }; + Assert.Equal("RRSI (8,14)", indicator.ShortName); + } + + [Fact] + public void Indicator_SourceCodeLink_Valid() + { + var indicator = new RrsiIndicator(); + Assert.Contains("Rrsi.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal); + } + + [Fact] + public void Indicator_HasLineSeries() + { + var indicator = new RrsiIndicator(); + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void Indicator_ImplementsIWatchlist() + { + var indicator = new RrsiIndicator(); + Assert.IsAssignableFrom(indicator); + } + + [Fact] + public void Indicator_MinHistoryDepths_IsZero() + { + Assert.Equal(0, RrsiIndicator.MinHistoryDepths); + } +} diff --git a/lib/oscillators/rrsi/tests/Rrsi.Tests.cs b/lib/oscillators/rrsi/tests/Rrsi.Tests.cs new file mode 100644 index 00000000..06a03d90 --- /dev/null +++ b/lib/oscillators/rrsi/tests/Rrsi.Tests.cs @@ -0,0 +1,390 @@ +using Xunit; + +namespace QuanTAlib.Tests; + +public sealed class RrsiTests +{ + private static TSeries GenerateSeries(int count, int seed = 42) + { + var rng = new Random(seed); + var series = new TSeries(); + double price = 100.0; + for (int i = 0; i < count; i++) + { + price += (rng.NextDouble() - 0.5) * 2.0; + series.Add(new TValue(DateTime.UtcNow.AddMinutes(i), price)); + } + return series; + } + + // === A) Constructor === + + [Fact] + public void Constructor_Default_ValidState() + { + var ind = new Rrsi(); + Assert.Equal(10, ind.SmoothLength); + Assert.Equal(10, ind.RsiLength); + Assert.False(ind.IsHot); + Assert.Contains("Rrsi(", ind.Name, StringComparison.Ordinal); + } + + [Fact] + public void Constructor_CustomParams_ValidState() + { + var ind = new Rrsi(smoothLength: 8, rsiLength: 14); + Assert.Equal(8, ind.SmoothLength); + Assert.Equal(14, ind.RsiLength); + Assert.Contains("Rrsi(8,14)", ind.Name, StringComparison.Ordinal); + } + + [Theory] + [InlineData(0, 10)] + [InlineData(-1, 10)] + [InlineData(10, 0)] + [InlineData(10, -1)] + public void Constructor_InvalidParams_Throws(int smooth, int rsi) + { + Assert.Throws(() => new Rrsi(smooth, rsi)); + } + + // === B) Basic calculation === + + [Fact] + public void Update_SingleValue_ReturnsValue() + { + var ind = new Rrsi(); + var result = ind.Update(new TValue(DateTime.UtcNow, 100.0)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Update_EnoughBars_BecomesHot() + { + var ind = new Rrsi(smoothLength: 5, rsiLength: 5); + var series = GenerateSeries(30); + foreach (var tv in series) + { + ind.Update(tv); + } + Assert.True(ind.IsHot); + } + + [Fact] + public void Update_NotEnoughBars_NotHot() + { + var ind = new Rrsi(smoothLength: 10, rsiLength: 10); + var series = GenerateSeries(5); + foreach (var tv in series) + { + ind.Update(tv); + } + Assert.False(ind.IsHot); + } + + // === C) Output range === + + [Fact] + public void Output_IsFinite_ForAll() + { + var ind = new Rrsi(); + var series = GenerateSeries(200); + int bar = 0; + foreach (var tv in series) + { + var result = ind.Update(tv); + Assert.True(double.IsFinite(result.Value), + $"Non-finite at bar {bar}: {result.Value}"); + bar++; + } + } + + [Fact] + public void Output_OscillatesAroundZero() + { + var ind = new Rrsi(); + var series = GenerateSeries(500); + bool hasPositive = false; + bool hasNegative = false; + foreach (var tv in series) + { + double val = ind.Update(tv).Value; + if (val > 0.01) { hasPositive = true; } + if (val < -0.01) { hasNegative = true; } + } + Assert.True(hasPositive, "Should have positive values"); + Assert.True(hasNegative, "Should have negative values"); + } + + [Fact] + public void Output_FlatPrice_NearZero() + { + var ind = new Rrsi(); + for (int i = 0; i < 100; i++) + { + ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 50.0)); + } + Assert.True(Math.Abs(ind.Last.Value) < 0.01, + $"Flat price should yield ~0, got {ind.Last.Value}"); + } + + // === D) Streaming vs Batch === + + [Fact] + public void StreamingMatchesBatch_TSeries() + { + var source = GenerateSeries(100); + var batchResult = Rrsi.Batch(source, 10, 10); + + var streaming = new Rrsi(10, 10); + for (int i = 0; i < source.Count; i++) + { + streaming.Update(source[i]); + } + + // Compare last values + Assert.Equal(batchResult[^1].Value, streaming.Last.Value, 9); + } + + [Fact] + public void SpanBatch_MatchesTSeriesBatch() + { + var source = GenerateSeries(100); + var batchResult = Rrsi.Batch(source, 8, 12); + + double[] output = new double[source.Count]; + Rrsi.Batch(source.Values, output, 8, 12); + + for (int i = 0; i < source.Count; i++) + { + Assert.Equal(batchResult[i].Value, output[i], 9); + } + } + + // === E) Bar correction === + + [Fact] + public void BarCorrection_IsNew_False_DoesNotAdvance() + { + var ind = new Rrsi(); + var series = GenerateSeries(30); + + // Feed first 20 bars normally + for (int i = 0; i < 20; i++) + { + ind.Update(series[i]); + } + + // Bar 20: first tick + _ = ind.Update(series[20], isNew: true); + + // Bar 20: correction ticks (isNew=false) + var result2 = ind.Update(new TValue(series[20].Time, series[20].Value + 0.5), isNew: false); + var result3 = ind.Update(new TValue(series[20].Time, series[20].Value + 0.1), isNew: false); + + // Final tick should give a different result from first + // but indicator should not have advanced count + Assert.True(double.IsFinite(result2.Value)); + Assert.True(double.IsFinite(result3.Value)); + } + + [Fact] + public void BarCorrection_Consistency() + { + var source = GenerateSeries(50); + var ind1 = new Rrsi(8, 10); + var ind2 = new Rrsi(8, 10); + + // ind1: clean feed + foreach (var tv in source) + { + ind1.Update(tv); + } + + // ind2: feed with corrections on every other bar + for (int i = 0; i < source.Count; i++) + { + ind2.Update(source[i], isNew: true); + if (i % 2 == 0) + { + // Correct back to original value + ind2.Update(new TValue(source[i].Time, source[i].Value + 1.0), isNew: false); + ind2.Update(source[i], isNew: false); + } + } + + Assert.Equal(ind1.Last.Value, ind2.Last.Value, 9); + } + + // === F) Reset === + + [Fact] + public void Reset_ClearsState() + { + var ind = new Rrsi(); + var series = GenerateSeries(50); + foreach (var tv in series) + { + ind.Update(tv); + } + Assert.True(ind.IsHot); + + ind.Reset(); + Assert.False(ind.IsHot); + Assert.Equal(0.0, ind.Last.Value); + } + + [Fact] + public void Reset_ReplayProducesSameResult() + { + var source = GenerateSeries(100); + var ind = new Rrsi(); + + foreach (var tv in source) { ind.Update(tv); } + double firstRun = ind.Last.Value; + + ind.Reset(); + foreach (var tv in source) { ind.Update(tv); } + double secondRun = ind.Last.Value; + + Assert.Equal(firstRun, secondRun, 12); + } + + // === G) Dispose === + + [Fact] + public void Dispose_DoesNotThrow() + { + var ind = new Rrsi(); + var ex = Record.Exception(() => ind.Dispose()); + Assert.Null(ex); + } + + // === H) Edge cases === + + [Fact] + public void NaN_Input_Handled() + { + var ind = new Rrsi(); + for (int i = 0; i < 30; i++) + { + ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 50.0 + i)); + } + // Feed NaN + var result = ind.Update(new TValue(DateTime.UtcNow.AddMinutes(30), double.NaN)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Infinity_Input_Handled() + { + var ind = new Rrsi(); + for (int i = 0; i < 30; i++) + { + ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 50.0 + i)); + } + var result = ind.Update(new TValue(DateTime.UtcNow.AddMinutes(30), double.PositiveInfinity)); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Batch_EmptySeries_ReturnsEmpty() + { + var series = new TSeries(); + var result = Rrsi.Batch(series); + Assert.Empty(result); + } + + [Fact] + public void Batch_Span_LengthMismatch_Throws() + { + double[] src = new double[10]; + double[] dst = new double[5]; + Assert.Throws(() => Rrsi.Batch(src, dst)); + } + + [Fact] + public void Batch_Span_InvalidSmoothLength_Throws() + { + double[] src = new double[10]; + double[] dst = new double[10]; + Assert.Throws(() => Rrsi.Batch(src, dst, smoothLength: 0)); + } + + [Fact] + public void Batch_Span_InvalidRsiLength_Throws() + { + double[] src = new double[10]; + double[] dst = new double[10]; + Assert.Throws(() => Rrsi.Batch(src, dst, rsiLength: 0)); + } + + [Fact] + public void Batch_Span_Empty_NoException() + { + var ex = Record.Exception(() => Rrsi.Batch(ReadOnlySpan.Empty, Span.Empty)); + Assert.Null(ex); + } + + // === I) Calculate factory === + + [Fact] + public void Calculate_ReturnsResultsAndIndicator() + { + var source = GenerateSeries(50); + var (results, indicator) = Rrsi.Calculate(source, 10, 10); + Assert.Equal(source.Count, results.Count); + Assert.True(indicator.IsHot); + } + + // === J) Pub event === + + [Fact] + public void PubEvent_FiresOnUpdate() + { + var source = new TSeries(); + var ind = new Rrsi(source, 5, 5); + int count = 0; + ind.Pub += (object? sender, in TValueEventArgs e) => count++; + + for (int i = 0; i < 20; i++) + { + source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 50.0 + i)); + } + + Assert.Equal(20, count); + } + + // === K) Trending input === + + [Fact] + public void StrongUptrend_PositiveOutput() + { + var ind = new Rrsi(smoothLength: 5, rsiLength: 5); + // Feed flat, then strong uptrend + for (int i = 0; i < 20; i++) + { + ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0)); + } + for (int i = 20; i < 50; i++) + { + ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + (i - 20) * 2.0)); + } + Assert.True(ind.Last.Value > 0, $"Strong uptrend should be positive, got {ind.Last.Value}"); + } + + [Fact] + public void StrongDowntrend_NegativeOutput() + { + var ind = new Rrsi(smoothLength: 5, rsiLength: 5); + for (int i = 0; i < 20; i++) + { + ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0)); + } + for (int i = 20; i < 50; i++) + { + ind.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 - (i - 20) * 2.0)); + } + Assert.True(ind.Last.Value < 0, $"Strong downtrend should be negative, got {ind.Last.Value}"); + } +} diff --git a/python/SPEC.md b/python/SPEC.md index cacde64a..d4ed9e0c 100644 --- a/python/SPEC.md +++ b/python/SPEC.md @@ -671,6 +671,7 @@ packages = ["quantalib"] | reflex | `Reflex` | A | period | | reverseema | `ReverseEma` | A | period | | rvgi | `Rvgi` | C (OHLC) | period | +| rrsi | `Rrsi` | A | smoothLength, rsiLength | | smi | `Smi` | I (HLC→2+) | period, smoothK, smoothD | | squeeze | `Squeeze` | I (HLC→multi) | bbPeriod, kcPeriod... | | squeeze_pro | `SqueezePro` | I (HLC→multi) | period, bbMult, kcMultWide/Normal/Narrow | diff --git a/python/quantalib/_bridge.py b/python/quantalib/_bridge.py index 21bb8903..9fc0df65 100644 --- a/python/quantalib/_bridge.py +++ b/python/quantalib/_bridge.py @@ -438,6 +438,7 @@ HAS_DPO = _bind("qtl_dpo", [_dp, _ci, _dp, _ci]) HAS_TRIX = _bind("qtl_trix", [_dp, _ci, _dp, _ci]) HAS_INERTIA = _bind("qtl_inertia", [_dp, _ci, _dp, _ci]) HAS_RSX = _bind("qtl_rsx", [_dp, _ci, _dp, _ci]) +HAS_RRSI = _bind("qtl_rrsi", [_dp, _ci, _dp, _ci, _ci]) HAS_ER = _bind("qtl_er", [_dp, _ci, _dp, _ci]) HAS_CTI = _bind("qtl_cti", [_dp, _ci, _dp, _ci]) HAS_REFLEX = _bind("qtl_reflex", [_dp, _ci, _dp, _ci]) diff --git a/python/quantalib/oscillators.py b/python/quantalib/oscillators.py index 02a2ac47..12cff4b7 100644 --- a/python/quantalib/oscillators.py +++ b/python/quantalib/oscillators.py @@ -25,6 +25,7 @@ __all__ = [ "qqe", "reverseema", "rvgi", + "rrsi", "smi", "squeeze", "stc", @@ -283,6 +284,17 @@ def rvgi(open: object, high: object, low: object, close: object, period: int = 1 return _wrap_multi({"rvgiOutput": rvgiOutput, "signalOutput": signalOutput}, idx, "oscillators", offset) +def rrsi(close: object, smoothLength: int = 10, rsiLength: int = 10, offset: int = 0, **kwargs) -> object: + """Rocket RSI (Ehlers) — Fisher Transform of Super Smoother–filtered RSI.""" + src = _to_np(close) + n = len(src) + out = _np.empty(n, dtype=_np.float64) + _bridge._check(_bridge._lib.qtl_rrsi( + src.ctypes.data_as(_bridge._dp), n, + out.ctypes.data_as(_bridge._dp), smoothLength, rsiLength)) + return _shift(out, offset) + + def smi(high: object, low: object, close: object, kPeriod: int = 14, kSmooth: int = 3, dSmooth: int = 3, blau: int = 3, offset: int = 0, **kwargs) -> object: """Stochastic Momentum Index.""" kPeriod = int(kPeriod) diff --git a/python/src/Exports.cs b/python/src/Exports.cs index e2f3dd36..52dcb1c2 100644 --- a/python/src/Exports.cs +++ b/python/src/Exports.cs @@ -327,6 +327,17 @@ public static unsafe partial class Exports catch { return StatusCodes.QTL_ERR_INTERNAL; } } + // Rrsi: Pattern A (dual period params) + [UnmanagedCallersOnly(EntryPoint = "qtl_rrsi")] + public static int QtlRrsi(double* src, int n, double* dst, int smoothLength, int rsiLength) + { + int v = Chk1(src, dst, n); if (v != 0) return v; + v = ChkPeriod(smoothLength); if (v != 0) return v; + v = ChkPeriod(rsiLength); if (v != 0) return v; + try { Rrsi.Batch(Src(src, n), Dst(dst, n), smoothLength, rsiLength); return StatusCodes.QTL_OK; } + catch { return StatusCodes.QTL_ERR_INTERNAL; } + } + // Er: Pattern A [UnmanagedCallersOnly(EntryPoint = "qtl_er")] public static int QtlEr(double* src, int n, double* dst, int period)