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)