feat: add RRSI (Rocket RSI) — Ehlers TASC May 2018

Algorithm: SuperSmoother-filtered momentum → Ehlers RSI → Fisher Transform
- 2-pole Butterworth IIR pre-filter removes noise
- Ehlers RSI (raw summation, not Wilder) outputs [-1,1]
- arctanh produces Gaussian-distributed zero-mean oscillator

Files: Rrsi.cs, Rrsi.Quantower.cs, Rrsi.md, 31+7 tests
Integration: sidebar, indices, Python bridge (Exports, _bridge, oscillators, SPEC)
Build: 0 warnings, 0 errors | Tests: 15,963 passed, 0 failed
This commit is contained in:
Miha Kralj
2026-03-17 09:25:32 -07:00
parent 15f4bb90f3
commit eb9e41fc2e
12 changed files with 1028 additions and 0 deletions
+1
View File
@@ -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)
+1
View File
@@ -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 |
+1
View File
@@ -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 Smootherfiltered 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. |
+70
View File
@@ -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 Smootherfiltered 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);
}
}
+392
View File
@@ -0,0 +1,392 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// RRSI: Rocket RSI (Ehlers)
/// </summary>
/// <remarks>
/// Combines a Super Smootherfiltered RSI with the Fisher Transform
/// to produce a zero-mean Gaussian-distributed oscillator with sharp
/// turning-point signals.
///
/// Pipeline:
/// <list type="number">
/// <item>Half-cycle momentum: <c>Mom = Close Close[rsiLength1]</c></item>
/// <item>Super Smoother (2-pole Butterworth IIR) on <c>(Mom + Mom[1])/2</c></item>
/// <item>Ehlers RSI: <c>RSI = (CU CD)/(CU + CD)</c> over <c>rsiLength</c>
/// bars of filtered momentum differences (result already in [1, 1])</item>
/// <item>Fisher Transform: <c>RocketRSI = arctanh(clamp(RSI, ±0.999))</c></item>
/// </list>
///
/// Reference: John F. Ehlers, "Rocket RSI", TASC May 2018.
/// </remarks>
[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;
/// <inheritdoc />
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>Smooth filter length.</summary>
public int SmoothLength => _smoothLength;
/// <summary>RSI accumulation length.</summary>
public int RsiLength => _rsiLength;
/// <summary>
/// Creates a Rocket RSI indicator.
/// </summary>
/// <param name="smoothLength">Super Smoother period (must be &gt; 0, default 10).</param>
/// <param name="rsiLength">RSI accumulation period (must be &gt; 0, default 10).</param>
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;
}
/// <summary>
/// Creates a Rocket RSI with a source publisher.
/// </summary>
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<long>(len);
var v = new List<double>(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<double> source, TimeSpan? step = null)
{
foreach (double value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
/// <summary>Batch-process a series.</summary>
public static TSeries Batch(TSeries source, int smoothLength = 10, int rsiLength = 10)
{
var ind = new Rrsi(smoothLength, rsiLength);
return ind.Update(source);
}
/// <summary>Batch-process span data.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> 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<double>.Shared.Rent(len);
double[] filtRented = ArrayPool<double>.Shared.Rent(len);
Span<double> momArr = momRented.AsSpan(0, len);
Span<double> 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<double>.Shared.Return(momRented);
ArrayPool<double>.Shared.Return(filtRented);
}
}
/// <summary>Calculate and return both results and indicator.</summary>
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;
}
}
+87
View File
@@ -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 0100), 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.
@@ -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<IWatchlistIndicator>(indicator);
}
[Fact]
public void Indicator_MinHistoryDepths_IsZero()
{
Assert.Equal(0, RrsiIndicator.MinHistoryDepths);
}
}
+390
View File
@@ -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<ArgumentException>(() => 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<ArgumentException>(() => Rrsi.Batch(src, dst));
}
[Fact]
public void Batch_Span_InvalidSmoothLength_Throws()
{
double[] src = new double[10];
double[] dst = new double[10];
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => Rrsi.Batch(src, dst, rsiLength: 0));
}
[Fact]
public void Batch_Span_Empty_NoException()
{
var ex = Record.Exception(() => Rrsi.Batch(ReadOnlySpan<double>.Empty, Span<double>.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}");
}
}
+1
View File
@@ -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 |
+1
View File
@@ -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])
+12
View File
@@ -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 Smootherfiltered 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)
+11
View File
@@ -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)