mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-25 22:08:05 +00:00
validation and profiles
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class LrsiIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void LrsiIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new LrsiIndicator();
|
||||
|
||||
Assert.Equal(0.5, indicator.Gamma);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("LRSI - Laguerre RSI", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LrsiIndicator_MinHistoryDepths_EqualsFour()
|
||||
{
|
||||
var indicator = new LrsiIndicator();
|
||||
|
||||
Assert.Equal(4, LrsiIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(4, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LrsiIndicator_ShortName_IncludesGamma()
|
||||
{
|
||||
var indicator = new LrsiIndicator { Gamma = 0.75 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("LRSI", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("0.75", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LrsiIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new LrsiIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Lrsi.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LrsiIndicator_Initialize_CreatesLineSeries()
|
||||
{
|
||||
var indicator = new LrsiIndicator { Gamma = 0.5 };
|
||||
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LrsiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new LrsiIndicator { Gamma = 0.5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double price = 100.0 + Math.Sin(i * 0.3) * 10.0 + i * 0.1;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price + 5, price + 10, price - 5, price);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double value = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(value));
|
||||
Assert.True(value >= 0.0 && value <= 1.0, $"LRSI={value} out of [0,1]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LrsiIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new LrsiIndicator { Gamma = 0.5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double price = 100.0 + i * 0.5;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price + 3, price + 6, price - 3, price);
|
||||
|
||||
var reason = i < 19 ? UpdateReason.HistoricalBar : UpdateReason.NewBar;
|
||||
var args = new UpdateArgs(reason);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double value = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(value));
|
||||
Assert.True(value >= 0.0 && value <= 1.0, $"LRSI={value} out of [0,1]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LrsiIndicator_DifferentSourceTypes_ComputeWithoutError()
|
||||
{
|
||||
foreach (var sourceType in new[] { SourceType.Close, SourceType.Open, SourceType.High, SourceType.Low })
|
||||
{
|
||||
var indicator = new LrsiIndicator
|
||||
{
|
||||
Gamma = 0.5,
|
||||
Source = sourceType
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double price = 100.0 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 5, price - 5, price + 1);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double value = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(value), $"SourceType {sourceType}: value={value}");
|
||||
Assert.True(value >= 0.0 && value <= 1.0, $"SourceType {sourceType}: LRSI={value} out of [0,1]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LrsiIndicator_OutputInRange_ExtendedSeries()
|
||||
{
|
||||
var indicator = new LrsiIndicator { Gamma = 0.5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Feed a volatile sine wave to exercise full range
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100.0 + Math.Sin(i * 0.2) * 20.0;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price + 5, price + 10, price - 5, price);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
|
||||
double v = indicator.LinesSeries[0].GetValue(0);
|
||||
if (double.IsFinite(v))
|
||||
{
|
||||
Assert.True(v >= 0.0 && v <= 1.0, $"Bar {i}: LRSI={v} out of [0,1]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class LrsiIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Gamma", sortIndex: 1, 0.0, 1.0, 0.01, 2)]
|
||||
public double Gamma { get; set; } = 0.5;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput(sortIndex: 2)]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Lrsi _lrsi = null!;
|
||||
private readonly LineSeries _lrsiLine;
|
||||
|
||||
public static int MinHistoryDepths => 4;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"LRSI ({Gamma:F2})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/lrsi/Lrsi.Quantower.cs";
|
||||
|
||||
public LrsiIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "LRSI - Laguerre RSI";
|
||||
Description = "Ehlers' Laguerre RSI: RSI computed over 4-stage Laguerre filter. Output [0,1]. Lower gamma = faster; higher = smoother.";
|
||||
|
||||
_lrsiLine = new LineSeries("LRSI", Color.Yellow, 2, LineStyle.Solid);
|
||||
AddLineSeries(_lrsiLine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_lrsi = new Lrsi(Gamma);
|
||||
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 = _lrsi.Update(input, args.IsNewBar());
|
||||
|
||||
if (!_lrsi.IsHot && !ShowColdValues)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lrsiLine.SetValue(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class LrsiTests
|
||||
{
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
// ───── A) Constructor validation ─────
|
||||
|
||||
[Fact]
|
||||
public void Constructor_GammaNegative_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Lrsi(gamma: -0.1));
|
||||
Assert.Equal("gamma", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_GammaGreaterThanOne_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Lrsi(gamma: 1.1));
|
||||
Assert.Equal("gamma", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_GammaZero_IsValid()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.0);
|
||||
Assert.Equal(0.0, lrsi.Gamma);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_GammaOne_IsValid()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 1.0);
|
||||
Assert.Equal(1.0, lrsi.Gamma);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultGamma_SetsProperties()
|
||||
{
|
||||
var lrsi = new Lrsi();
|
||||
Assert.Equal(0.5, lrsi.Gamma);
|
||||
Assert.Equal("Lrsi(0.50)", lrsi.Name);
|
||||
Assert.Equal(4, lrsi.WarmupPeriod);
|
||||
Assert.Equal(default, lrsi.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomGamma_SetsName()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.75);
|
||||
Assert.Equal("Lrsi(0.75)", lrsi.Name);
|
||||
Assert.Equal(0.75, lrsi.Gamma);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_OutputLengthMismatch_ThrowsArgumentException()
|
||||
{
|
||||
var src = new double[] { 1, 2, 3 };
|
||||
var out1 = new double[4];
|
||||
var ex = Assert.Throws<ArgumentException>(() => Lrsi.Calculate(src, out1));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_GammaNegative_ThrowsArgumentException()
|
||||
{
|
||||
var src = new double[] { 1, 2, 3 };
|
||||
var out1 = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() => Lrsi.Calculate(src, out1, gamma: -0.1));
|
||||
Assert.Equal("gamma", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_GammaGreaterThanOne_ThrowsArgumentException()
|
||||
{
|
||||
var src = new double[] { 1, 2, 3 };
|
||||
var out1 = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() => Lrsi.Calculate(src, out1, gamma: 1.01));
|
||||
Assert.Equal("gamma", ex.ParamName);
|
||||
}
|
||||
|
||||
// ───── B) Basic calculation ─────
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var lrsi = new Lrsi();
|
||||
var result = lrsi.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.IsType<TValue>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_OutputInRange0To1()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.3, seed: 42);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars.Close)
|
||||
{
|
||||
double v = lrsi.Update(bar).Value;
|
||||
Assert.True(v >= 0.0 && v <= 1.0, $"LRSI={v} out of [0,1]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NameIsAccessible()
|
||||
{
|
||||
var lrsi = new Lrsi(0.5);
|
||||
_ = lrsi.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.Equal("Lrsi(0.50)", lrsi.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_LastIsAccessible()
|
||||
{
|
||||
var lrsi = new Lrsi();
|
||||
var t = new TValue(DateTime.UtcNow, 100.0);
|
||||
var result = lrsi.Update(t);
|
||||
Assert.Equal(result, lrsi.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantPrice_ProducesHalfPoint()
|
||||
{
|
||||
// Constant input → all stages equal → cu=cd=0 → LRSI = 0.5
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
var t = DateTime.UtcNow;
|
||||
double last = 0;
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
last = lrsi.Update(new TValue(t.AddMinutes(i), 100.0)).Value;
|
||||
}
|
||||
Assert.Equal(0.5, last, 1e-6);
|
||||
}
|
||||
|
||||
// ───── C) State + bar correction ─────
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
var t = DateTime.UtcNow;
|
||||
lrsi.Update(new TValue(t, 100.0), isNew: true);
|
||||
var v1 = lrsi.Last;
|
||||
lrsi.Update(new TValue(t.AddMinutes(1), 105.0), isNew: true);
|
||||
var v2 = lrsi.Last;
|
||||
Assert.NotEqual(default, v1);
|
||||
Assert.NotEqual(default, v2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_RollsBack()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
double[] prices = [100, 102, 104, 103, 105, 107, 106, 108, 110, 109, 111, 113];
|
||||
var t = DateTime.UtcNow;
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
lrsi.Update(new TValue(t.AddMinutes(i), prices[i]), isNew: true);
|
||||
}
|
||||
|
||||
// Correction with a different price
|
||||
lrsi.Update(new TValue(t.AddMinutes(prices.Length), 150.0), isNew: false);
|
||||
var corrected1 = lrsi.Last.Value;
|
||||
|
||||
// Same correction again must be idempotent
|
||||
lrsi.Update(new TValue(t.AddMinutes(prices.Length), 150.0), isNew: false);
|
||||
var corrected2 = lrsi.Last.Value;
|
||||
|
||||
Assert.Equal(corrected1, corrected2, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IterativeCorrections_Restore()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
double[] prices = [100, 102, 98, 105, 103, 107, 101, 108, 100, 109, 102, 110];
|
||||
var t = DateTime.UtcNow;
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
lrsi.Update(new TValue(t.AddMinutes(i), prices[i]), isNew: true);
|
||||
}
|
||||
|
||||
// Capture last isNew=true state
|
||||
var baseline = lrsi.Last.Value;
|
||||
|
||||
// Multiple corrections (each restores to prior state before applying new price)
|
||||
lrsi.Update(new TValue(t.AddMinutes(prices.Length), 90.0), isNew: false);
|
||||
lrsi.Update(new TValue(t.AddMinutes(prices.Length), 120.0), isNew: false);
|
||||
lrsi.Update(new TValue(t.AddMinutes(prices.Length), prices[^1]), isNew: false);
|
||||
|
||||
// Correction with same price as last isNew=true should reproduce baseline
|
||||
Assert.Equal(baseline, lrsi.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Reset_ClearsState()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 7);
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars.Close)
|
||||
{
|
||||
lrsi.Update(bar, isNew: true);
|
||||
}
|
||||
|
||||
lrsi.Reset();
|
||||
Assert.False(lrsi.IsHot);
|
||||
Assert.Equal(default, lrsi.Last);
|
||||
}
|
||||
|
||||
// ───── D) Warmup / convergence ─────
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_IsFour()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
Assert.Equal(4, lrsi.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsAfterFirstBar()
|
||||
{
|
||||
// LRSI starts hot after first non-zero input moves any filter stage
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
Assert.False(lrsi.IsHot);
|
||||
|
||||
// After first price update the filter stages become non-zero
|
||||
lrsi.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
Assert.True(lrsi.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_RemainsHotAfterReset_ReturnsToFalse()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
lrsi.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
Assert.True(lrsi.IsHot);
|
||||
lrsi.Reset();
|
||||
Assert.False(lrsi.IsHot);
|
||||
}
|
||||
|
||||
// ───── E) Robustness: NaN / Infinity ─────
|
||||
|
||||
[Fact]
|
||||
public void Update_NaN_UsesLastValid()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
var t = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
lrsi.Update(new TValue(t.AddMinutes(i), 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
var result = lrsi.Update(new TValue(t.AddMinutes(20), double.NaN), isNew: true);
|
||||
Assert.True(double.IsFinite(result.Value), $"Expected finite, got {result.Value}");
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_PositiveInfinity_UsesLastValid()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
var t = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
lrsi.Update(new TValue(t.AddMinutes(i), 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
var result = lrsi.Update(new TValue(t.AddMinutes(20), double.PositiveInfinity), isNew: true);
|
||||
Assert.True(double.IsFinite(result.Value), $"Expected finite, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NegativeInfinity_UsesLastValid()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
var t = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
lrsi.Update(new TValue(t.AddMinutes(i), 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
var result = lrsi.Update(new TValue(t.AddMinutes(20), double.NegativeInfinity), isNew: true);
|
||||
Assert.True(double.IsFinite(result.Value), $"Expected finite, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_BatchNaN_AllFinite()
|
||||
{
|
||||
var lrsi = new Lrsi(gamma: 0.5);
|
||||
var t = DateTime.UtcNow;
|
||||
|
||||
double[] prices = [100, 101, double.NaN, 102, 103, double.NaN, double.NaN, 104, 105, 106,
|
||||
107, 108, 109, 110, 111, 112, 113, 114, 115, 116];
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
var result = lrsi.Update(new TValue(t.AddMinutes(i), prices[i]), isNew: true);
|
||||
Assert.True(double.IsFinite(result.Value), $"Not finite at index {i}: {result.Value}");
|
||||
Assert.True(result.Value >= 0.0 && result.Value <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── F) Consistency: batch == streaming == span == eventing ─────
|
||||
|
||||
[Fact]
|
||||
public void Consistency_BatchTSeries_MatchesStreaming()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 2001);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
TSeries source = bars.Close;
|
||||
|
||||
// Streaming
|
||||
var streaming = new Lrsi(0.5);
|
||||
var streamVals = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamVals[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
// Batch TSeries
|
||||
TSeries batchTs = Lrsi.Calculate(source, 0.5);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamVals[i], batchTs.Values[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consistency_BatchSpan_MatchesBatchTSeries()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 2002);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
TSeries source = bars.Close;
|
||||
|
||||
TSeries batchTs = Lrsi.Calculate(source, 0.5);
|
||||
|
||||
var spanOut = new double[source.Count];
|
||||
Lrsi.Calculate(source.Values, spanOut, 0.5);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchTs.Values[i], spanOut[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consistency_Eventing_MatchesStreaming()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 2003);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
TSeries source = bars.Close;
|
||||
|
||||
// Streaming
|
||||
var streaming = new Lrsi(0.5);
|
||||
var streamVals = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamVals[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
// Event-based
|
||||
var eventTs = new TSeries();
|
||||
var eventLrsi = new Lrsi(eventTs, 0.5);
|
||||
var eventVals = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
eventTs.Add(source[i]);
|
||||
eventVals[i] = eventLrsi.Last.Value;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamVals[i], eventVals[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── G) Span API tests ─────
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_EmptySource_DoesNotThrow()
|
||||
{
|
||||
var src = Array.Empty<double>();
|
||||
var out1 = Array.Empty<double>();
|
||||
Lrsi.Calculate(src, out1);
|
||||
Assert.Empty(out1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_LargeData_UsesArrayPool()
|
||||
{
|
||||
// 257 exceeds StackallocThreshold=256; LRSI has no internal buffer
|
||||
// but we exercise the span path with large data (no stack overflow risk here)
|
||||
int n = 500;
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 9999);
|
||||
var bars = gbm.Fetch(n, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var src = bars.Close.Values;
|
||||
var out1 = new double[n];
|
||||
|
||||
Lrsi.Calculate(src, out1);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
Assert.True(out1[i] >= 0.0 && out1[i] <= 1.0, $"out1[{i}]={out1[i]} out of [0,1]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_WithNaN_AllOutputsFinite()
|
||||
{
|
||||
double[] src = [100, 101, double.NaN, 102, 103, double.NaN, 104, 105];
|
||||
var out1 = new double[src.Length];
|
||||
|
||||
Lrsi.Calculate(src, out1);
|
||||
|
||||
for (int i = 0; i < out1.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(out1[i]), $"out1[{i}]={out1[i]} not finite");
|
||||
Assert.True(out1[i] >= 0.0 && out1[i] <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_OutputAlwaysInRange()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 50.0, mu: 0.05, sigma: 0.5, seed: 777);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var src = bars.Close.Values;
|
||||
var out1 = new double[src.Length];
|
||||
|
||||
Lrsi.Calculate(src, out1);
|
||||
|
||||
for (int i = 0; i < src.Length; i++)
|
||||
{
|
||||
Assert.True(out1[i] >= 0.0 && out1[i] <= 1.0, $"out1[{i}]={out1[i]} out of [0,1]");
|
||||
}
|
||||
}
|
||||
|
||||
// ───── H) Chainability ─────
|
||||
|
||||
[Fact]
|
||||
public void Chainability_PubFires()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var lrsi = new Lrsi(source, 0.5);
|
||||
|
||||
int count = 0;
|
||||
lrsi.Pub += (object? _, in TValueEventArgs e) => count++;
|
||||
|
||||
var t = DateTime.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
source.Add(new TValue(t.AddMinutes(i), 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.Equal(10, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chainability_EventBasedChaining_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var lrsi = new Lrsi(source, 0.5);
|
||||
var output = new TSeries();
|
||||
lrsi.Pub += (object? _, in TValueEventArgs e) => output.Add(e.Value);
|
||||
|
||||
var t = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
source.Add(new TValue(t.AddMinutes(i), 100.0 + i * 0.5));
|
||||
}
|
||||
|
||||
Assert.Equal(30, output.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using Xunit;
|
||||
|
||||
using OoplesFinance.StockIndicators;
|
||||
using OoplesFinance.StockIndicators.Models;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Self-consistency validation for LRSI.
|
||||
/// LRSI is not implemented in Skender, TA-Lib, Tulip, or Ooples — validation uses:
|
||||
/// 1. Batch TSeries == streaming consistency
|
||||
/// 2. Calculate(Span) == Calculate(TSeries) consistency
|
||||
/// 3. Eventing path matches streaming
|
||||
/// 4. Output always in [0, 1] under all conditions
|
||||
/// 5. Higher gamma produces smoother (lower variance) output than lower gamma
|
||||
/// 6. Gamma effect: high gamma retains more memory (slower response)
|
||||
/// 7. Determinism: same seed → identical results
|
||||
/// </summary>
|
||||
public sealed class LrsiValidationTests
|
||||
{
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
// ── Self-consistency: batch TSeries == streaming ──
|
||||
|
||||
[Fact]
|
||||
public void Streaming_MatchesBatch_DefaultGamma()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 3001);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
TSeries source = bars.Close;
|
||||
|
||||
var streaming = new Lrsi(0.5);
|
||||
var streamVals = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamVals[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
TSeries batchTs = Lrsi.Calculate(source, 0.5);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamVals[i], batchTs.Values[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Streaming_MatchesBatch_LowGamma()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.3, seed: 3002);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
TSeries source = bars.Close;
|
||||
|
||||
var streaming = new Lrsi(0.1);
|
||||
var streamVals = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamVals[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
TSeries batchTs = Lrsi.Calculate(source, 0.1);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamVals[i], batchTs.Values[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Streaming_MatchesBatch_HighGamma()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 3003);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
TSeries source = bars.Close;
|
||||
|
||||
var streaming = new Lrsi(0.9);
|
||||
var streamVals = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamVals[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
TSeries batchTs = Lrsi.Calculate(source, 0.9);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamVals[i], batchTs.Values[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Self-consistency: Span == TSeries ──
|
||||
|
||||
[Fact]
|
||||
public void Span_MatchesBatch_DefaultGamma()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 3004);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
TSeries source = bars.Close;
|
||||
|
||||
TSeries batchTs = Lrsi.Calculate(source, 0.5);
|
||||
|
||||
var spanOut = new double[source.Count];
|
||||
Lrsi.Calculate(source.Values, spanOut, 0.5);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchTs.Values[i], spanOut[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eventing_MatchesStreaming()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 3005);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
TSeries source = bars.Close;
|
||||
|
||||
var streaming = new Lrsi(0.5);
|
||||
var streamVals = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamVals[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
var eventTs = new TSeries();
|
||||
var eventLrsi = new Lrsi(eventTs, 0.5);
|
||||
var eventVals = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
eventTs.Add(source[i]);
|
||||
eventVals[i] = eventLrsi.Last.Value;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamVals[i], eventVals[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output range: always in [0, 1] ──
|
||||
|
||||
[Fact]
|
||||
public void Output_AlwaysInRange0To1_HighVolatility()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 50.0, mu: 0.05, sigma: 0.8, seed: 3006);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var lrsi = new Lrsi(0.5);
|
||||
foreach (var bar in bars.Close)
|
||||
{
|
||||
double v = lrsi.Update(bar).Value;
|
||||
Assert.True(v >= 0.0 && v <= 1.0, $"LRSI={v} out of [0,1] at high vol");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Output_AlwaysInRange0To1_LowVolatility()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.001, sigma: 0.01, seed: 3007);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var lrsi = new Lrsi(0.5);
|
||||
foreach (var bar in bars.Close)
|
||||
{
|
||||
double v = lrsi.Update(bar).Value;
|
||||
Assert.True(v >= 0.0 && v <= 1.0, $"LRSI={v} out of [0,1] at low vol");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Output_AlwaysInRange_AllGammaValues()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.25, seed: 3008);
|
||||
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (double gamma in new[] { 0.0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0 })
|
||||
{
|
||||
var lrsi = new Lrsi(gamma);
|
||||
foreach (var bar in bars.Close)
|
||||
{
|
||||
double v = lrsi.Update(bar).Value;
|
||||
Assert.True(v >= 0.0 && v <= 1.0, $"gamma={gamma} LRSI={v} out of [0,1]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gamma effect: higher gamma = smoother = less total variation on noisy input ──
|
||||
|
||||
[Fact]
|
||||
public void HigherGamma_ProducesLessTotalVariation_OnZigzagInput()
|
||||
{
|
||||
// The Laguerre filter's gamma controls damping across all 4 stages.
|
||||
// High gamma (e.g. 0.9) heavily damps each stage → LRSI output changes slowly.
|
||||
// Low gamma (e.g. 0.1) passes through price changes quickly → LRSI oscillates more.
|
||||
//
|
||||
// We verify this via total variation: sum of |LRSI[i] - LRSI[i-1]| over a zigzag series.
|
||||
// High gamma must produce strictly lower total variation than low gamma.
|
||||
//
|
||||
// Note: After full convergence to flat, both gammas snap to LRSI=1 on first up-bar
|
||||
// because L1-L3 are all equal (no inter-stage difference to flip with gamma).
|
||||
// Zigzag avoids this degenerate case by continuously exercising all 4 filter stages.
|
||||
|
||||
var t = DateTime.UtcNow;
|
||||
const int n = 500;
|
||||
|
||||
var lrsiLow = new Lrsi(0.1); // fast: high variation
|
||||
var lrsiHigh = new Lrsi(0.9); // slow: low variation
|
||||
|
||||
double tvLow = 0.0;
|
||||
double tvHigh = 0.0;
|
||||
double prevLow = double.NaN;
|
||||
double prevHigh = double.NaN;
|
||||
|
||||
// Zigzag: alternates +3 / -3 around 100, giving constant up/down signal
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double price = 100.0 + (i % 2 == 0 ? 3.0 : -3.0);
|
||||
double vL = lrsiLow.Update(new TValue(t.AddMinutes(i), price), isNew: true).Value;
|
||||
double vH = lrsiHigh.Update(new TValue(t.AddMinutes(i), price), isNew: true).Value;
|
||||
|
||||
if (!double.IsNaN(prevLow))
|
||||
{
|
||||
tvLow += Math.Abs(vL - prevLow);
|
||||
tvHigh += Math.Abs(vH - prevHigh);
|
||||
}
|
||||
|
||||
prevLow = vL;
|
||||
prevHigh = vH;
|
||||
}
|
||||
|
||||
Assert.True(tvHigh < tvLow,
|
||||
$"High gamma total variation ({tvHigh:F4}) should be less than low gamma ({tvLow:F4})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GammaZero_IsMoreResponsiveThanGammaHalf()
|
||||
{
|
||||
// gamma=0: L0 = close, L1 = prevL0, L2 = prevL1, L3 = prevL2
|
||||
// gamma=0.5: smoothed response
|
||||
// After a sharp price move, gamma=0 should react more rapidly.
|
||||
var lrsi0 = new Lrsi(0.0);
|
||||
var lrsi5 = new Lrsi(0.5);
|
||||
|
||||
// Warm up with baseline
|
||||
var t = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
lrsi0.Update(new TValue(t.AddMinutes(i), 100.0), isNew: true);
|
||||
lrsi5.Update(new TValue(t.AddMinutes(i), 100.0), isNew: true);
|
||||
}
|
||||
|
||||
// Single large up-spike — gamma=0 should read more extreme
|
||||
double v0 = lrsi0.Update(new TValue(t.AddMinutes(20), 150.0), isNew: true).Value;
|
||||
double v5 = lrsi5.Update(new TValue(t.AddMinutes(20), 150.0), isNew: true).Value;
|
||||
|
||||
// gamma=0 reacts immediately to spike; gamma=0.5 absorbs it more gradually
|
||||
Assert.True(v0 >= v5, $"gamma=0 ({v0:F6}) should be >= gamma=0.5 ({v5:F6}) on upspike");
|
||||
}
|
||||
|
||||
// ── Determinism ──
|
||||
|
||||
[Fact]
|
||||
public void Determinism_SameSeed_ProducesIdenticalResults()
|
||||
{
|
||||
var gbm1 = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 5001);
|
||||
var gbm2 = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.2, seed: 5001);
|
||||
var bars1 = gbm1.Fetch(150, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var bars2 = gbm2.Fetch(150, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var l1 = new Lrsi(0.5);
|
||||
var l2 = new Lrsi(0.5);
|
||||
|
||||
for (int i = 0; i < bars1.Close.Count; i++)
|
||||
{
|
||||
double v1 = l1.Update(bars1.Close[i]).Value;
|
||||
double v2 = l2.Update(bars2.Close[i]).Value;
|
||||
Assert.Equal(v1, v2, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edge cases ──
|
||||
|
||||
[Fact]
|
||||
public void BatchSpan_EmptySource_ReturnsEmptyOutput()
|
||||
{
|
||||
var src = Array.Empty<double>();
|
||||
var out1 = Array.Empty<double>();
|
||||
Lrsi.Calculate(src, out1);
|
||||
Assert.Empty(out1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Streaming_ConstantPrice_ProducesHalfPoint()
|
||||
{
|
||||
var lrsi = new Lrsi(0.5);
|
||||
var t = DateTime.UtcNow;
|
||||
double last = 0;
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
last = lrsi.Update(new TValue(t.AddMinutes(i), 100.0)).Value;
|
||||
}
|
||||
// Constant price → all stages converge → cu = cd = 0 → LRSI = 0.5
|
||||
Assert.Equal(0.5, last, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Streaming_MonotonicallyRising_ProducesHighValues()
|
||||
{
|
||||
// Strictly rising prices → L0 > L1 > L2 > L3 always after warmup → cu > 0, cd = 0 → LRSI = 1
|
||||
var lrsi = new Lrsi(0.3);
|
||||
var t = DateTime.UtcNow;
|
||||
double price = 100.0;
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
price += 1.0;
|
||||
lrsi.Update(new TValue(t.AddMinutes(i), price), isNew: true);
|
||||
}
|
||||
// Should converge near 1 after sustained rise
|
||||
Assert.True(lrsi.Last.Value > 0.8, $"Expected > 0.8 on sustained rise, got {lrsi.Last.Value:F4}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Streaming_MonotonicallyFalling_ProducesLowValues()
|
||||
{
|
||||
// Strictly falling prices → cd > 0, cu = 0 → LRSI converges near 0
|
||||
var lrsi = new Lrsi(0.3);
|
||||
var t = DateTime.UtcNow;
|
||||
double price = 200.0;
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
price -= 1.0;
|
||||
lrsi.Update(new TValue(t.AddMinutes(i), price), isNew: true);
|
||||
}
|
||||
Assert.True(lrsi.Last.Value < 0.2, $"Expected < 0.2 on sustained fall, got {lrsi.Last.Value:F4}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lrsi_MatchesOoples_Structural()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var ooplesData = bars.Select(b => new TickerData
|
||||
{
|
||||
Date = new DateTime(b.Time, DateTimeKind.Utc),
|
||||
Open = b.Open, High = b.High, Low = b.Low,
|
||||
Close = b.Close, Volume = b.Volume
|
||||
}).ToList();
|
||||
var result = new StockData(ooplesData).CalculateEhlersLaguerreRelativeStrengthIndex();
|
||||
var values = result.CustomValuesList;
|
||||
int finiteCount = values.Count(v => double.IsFinite(v));
|
||||
Assert.True(finiteCount > 100, $"Expected >100 finite values, got {finiteCount}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// LRSI: Laguerre RSI
|
||||
// John Ehlers, "Cybernetic Analysis for Stocks and Futures" (2004), Chapter 14.
|
||||
// A modified RSI that uses a 4-element Laguerre filter as its core moving average.
|
||||
// The gamma (damping) parameter trades responsiveness against smoothness.
|
||||
// Output is dimensionless [0, 1]; no period parameter required.
|
||||
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// LRSI: Laguerre RSI
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ehlers' Laguerre RSI replaces the standard RSI's gain/loss smoothing with
|
||||
/// a 4-stage cascaded Laguerre filter. The four outputs (L0–L3) represent
|
||||
/// successively delayed and damped versions of the input; the RSI-style
|
||||
/// numerator/denominator is computed over the stage-to-stage differences.
|
||||
///
|
||||
/// Filter stages (γ = gamma):
|
||||
/// <code>
|
||||
/// L0 = (1−γ)·price + γ·L0[1]
|
||||
/// L1 = −γ·L0 + L0[1] + γ·L1[1]
|
||||
/// L2 = −γ·L1 + L1[1] + γ·L2[1]
|
||||
/// L3 = −γ·L2 + L2[1] + γ·L3[1]
|
||||
/// </code>
|
||||
///
|
||||
/// RSI computation:
|
||||
/// <code>
|
||||
/// cu = Σ max(L(k)−L(k+1), 0) for k = 0..2
|
||||
/// cd = Σ max(L(k+1)−L(k), 0) for k = 0..2
|
||||
/// LRSI = cu / (cu + cd) [or 0.5 when cu + cd == 0]
|
||||
/// </code>
|
||||
///
|
||||
/// Properties:
|
||||
/// <list type="bullet">
|
||||
/// <item>Output is always in [0, 1]</item>
|
||||
/// <item>WarmupPeriod = 4 (four filter stages)</item>
|
||||
/// <item>Lower γ = faster response; higher γ = smoother output</item>
|
||||
/// <item>Recursive filter — no SIMD possible in streaming path</item>
|
||||
/// </list>
|
||||
///
|
||||
/// References:
|
||||
/// Ehlers, J.F. (2004). Cybernetic Analysis for Stocks and Futures. Wiley. Ch. 14.
|
||||
/// PineScript reference: lrsi.pine
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Lrsi : ITValuePublisher
|
||||
{
|
||||
private readonly double _gamma;
|
||||
private readonly double _oneMinusGamma;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double L0,
|
||||
double L1,
|
||||
double L2,
|
||||
double L3,
|
||||
double LastValid);
|
||||
|
||||
private State _s;
|
||||
private State _ps;
|
||||
|
||||
/// <summary>Display name for the indicator.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>Bars required before output is considered reliable.</summary>
|
||||
public int WarmupPeriod { get; }
|
||||
|
||||
/// <summary>True after 4 bars have been processed through all filter stages.</summary>
|
||||
public bool IsHot => _s.L0 != 0.0 || _s.L1 != 0.0 || _s.L2 != 0.0 || _s.L3 != 0.0;
|
||||
|
||||
/// <summary>Current LRSI value in [0, 1].</summary>
|
||||
public TValue Last { get; private set; }
|
||||
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
private int _count;
|
||||
private int _pcount;
|
||||
|
||||
/// <summary>
|
||||
/// Creates LRSI with the specified gamma damping factor.
|
||||
/// </summary>
|
||||
/// <param name="gamma">Laguerre damping factor in [0.0, 1.0] (default 0.5).
|
||||
/// Lower values produce faster response; higher values produce smoother output.</param>
|
||||
public Lrsi(double gamma = 0.5)
|
||||
{
|
||||
if (gamma < 0.0 || gamma > 1.0)
|
||||
{
|
||||
throw new ArgumentException("gamma must be in [0.0, 1.0]", nameof(gamma));
|
||||
}
|
||||
|
||||
_gamma = gamma;
|
||||
_oneMinusGamma = 1.0 - gamma;
|
||||
|
||||
_s = new State(0.0, 0.0, 0.0, 0.0, 0.5);
|
||||
_ps = _s;
|
||||
|
||||
WarmupPeriod = 4;
|
||||
Name = $"Lrsi({gamma:F2})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates LRSI chained to an ITValuePublisher source.
|
||||
/// </summary>
|
||||
public Lrsi(ITValuePublisher source, double gamma = 0.5) : this(gamma)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void PubEvent(TValue value, bool isNew) =>
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
|
||||
|
||||
/// <summary>Resets all state to initial conditions.</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Reset()
|
||||
{
|
||||
_s = new State(0.0, 0.0, 0.0, 0.0, 0.5);
|
||||
_ps = _s;
|
||||
_count = 0;
|
||||
_pcount = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates LRSI with a new price value.
|
||||
/// </summary>
|
||||
/// <param name="input">Price input (typically close)</param>
|
||||
/// <param name="isNew">True to advance state; false to rewrite the latest bar (bar correction)</param>
|
||||
/// <returns>Current LRSI value as TValue in [0, 1]</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
double value = input.Value;
|
||||
|
||||
// Sanitize input — substitute last-valid on NaN/Infinity
|
||||
if (!double.IsFinite(value))
|
||||
{
|
||||
value = _s.LastValid;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
_pcount = _count;
|
||||
_count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
_count = _pcount;
|
||||
}
|
||||
|
||||
// State local copy — enables JIT struct promotion to registers
|
||||
var s = _s;
|
||||
|
||||
// Update LastValid after rollback so we capture the sanitised value
|
||||
if (double.IsFinite(input.Value))
|
||||
{
|
||||
s.LastValid = value;
|
||||
}
|
||||
|
||||
double g = _gamma;
|
||||
double omg = _oneMinusGamma;
|
||||
|
||||
// Stage 0: first-order IIR lowpass
|
||||
// L0 = (1−γ)·price + γ·L0[1] ≡ FMA(g, prevL0, omg·price)
|
||||
double prevL0 = s.L0;
|
||||
double prevL1 = s.L1;
|
||||
double prevL2 = s.L2;
|
||||
double prevL3 = s.L3;
|
||||
|
||||
s.L0 = Math.FusedMultiplyAdd(g, prevL0, omg * value);
|
||||
|
||||
// Stage 1: −γ·L0 + L0[1] + γ·L1[1] ≡ FMA(g, prevL1, prevL0 − g·s.L0)
|
||||
s.L1 = Math.FusedMultiplyAdd(g, prevL1, Math.FusedMultiplyAdd(-g, s.L0, prevL0));
|
||||
|
||||
// Stage 2: −γ·L1 + L1[1] + γ·L2[1]
|
||||
s.L2 = Math.FusedMultiplyAdd(g, prevL2, Math.FusedMultiplyAdd(-g, s.L1, prevL1));
|
||||
|
||||
// Stage 3: −γ·L2 + L2[1] + γ·L3[1]
|
||||
s.L3 = Math.FusedMultiplyAdd(g, prevL3, Math.FusedMultiplyAdd(-g, s.L2, prevL2));
|
||||
|
||||
// RSI-style: sum up/down stage differences
|
||||
double l0 = s.L0;
|
||||
double l1 = s.L1;
|
||||
double l2 = s.L2;
|
||||
double l3 = s.L3;
|
||||
|
||||
double cu = (l0 > l1 ? l0 - l1 : 0.0)
|
||||
+ (l1 > l2 ? l1 - l2 : 0.0)
|
||||
+ (l2 > l3 ? l2 - l3 : 0.0);
|
||||
|
||||
double cd = (l0 < l1 ? l1 - l0 : 0.0)
|
||||
+ (l1 < l2 ? l2 - l1 : 0.0)
|
||||
+ (l2 < l3 ? l3 - l2 : 0.0);
|
||||
|
||||
double total = cu + cd;
|
||||
double lrsi = total != 0.0 ? cu / total : 0.5;
|
||||
|
||||
_s = s;
|
||||
|
||||
Last = new TValue(input.Time, lrsi);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch-computes LRSI over a TSeries source.
|
||||
/// </summary>
|
||||
public static TSeries Calculate(TSeries source, double gamma = 0.5)
|
||||
{
|
||||
var lrsi = new Lrsi(gamma);
|
||||
int len = source.Count;
|
||||
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);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
vSpan[i] = lrsi.Update(source[i], isNew: true).Value;
|
||||
tSpan[i] = source.Times[i];
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch static: span → span. Uses StackallocThreshold pattern (§2.6).
|
||||
/// No internal buffers needed beyond scalar state — no heap allocation for any input size.
|
||||
/// </summary>
|
||||
/// <param name="source">Input price span</param>
|
||||
/// <param name="output">Output LRSI span (must match source length)</param>
|
||||
/// <param name="gamma">Laguerre damping factor in [0.0, 1.0]</param>
|
||||
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, double gamma = 0.5)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output must have the same length", nameof(output));
|
||||
}
|
||||
|
||||
if (gamma < 0.0 || gamma > 1.0)
|
||||
{
|
||||
throw new ArgumentException("gamma must be in [0.0, 1.0]", nameof(gamma));
|
||||
}
|
||||
|
||||
int len = source.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double g = gamma;
|
||||
double omg = 1.0 - gamma;
|
||||
double l0 = 0.0, l1 = 0.0, l2 = 0.0, l3 = 0.0;
|
||||
double lastValid = 0.5;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = lastValid;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValid = val;
|
||||
}
|
||||
|
||||
double pL0 = l0;
|
||||
double pL1 = l1;
|
||||
double pL2 = l2;
|
||||
double pL3 = l3;
|
||||
|
||||
l0 = Math.FusedMultiplyAdd(g, pL0, omg * val);
|
||||
l1 = Math.FusedMultiplyAdd(g, pL1, Math.FusedMultiplyAdd(-g, l0, pL0));
|
||||
l2 = Math.FusedMultiplyAdd(g, pL2, Math.FusedMultiplyAdd(-g, l1, pL1));
|
||||
l3 = Math.FusedMultiplyAdd(g, pL3, Math.FusedMultiplyAdd(-g, l2, pL2));
|
||||
|
||||
double cu = (l0 > l1 ? l0 - l1 : 0.0)
|
||||
+ (l1 > l2 ? l1 - l2 : 0.0)
|
||||
+ (l2 > l3 ? l2 - l3 : 0.0);
|
||||
|
||||
double cd = (l0 < l1 ? l1 - l0 : 0.0)
|
||||
+ (l1 < l2 ? l2 - l1 : 0.0)
|
||||
+ (l2 < l3 ? l3 - l2 : 0.0);
|
||||
|
||||
double total = cu + cd;
|
||||
output[i] = total != 0.0 ? cu / total : 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gamma damping factor used by this instance.</summary>
|
||||
public double Gamma => _gamma;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
# LRSI: Laguerre RSI
|
||||
|
||||
> "The Laguerre transform lets you trade off between lag and smoothness using a single parameter." — John Ehlers
|
||||
|
||||
Laguerre RSI is an adaptive oscillator invented by John Ehlers that replaces standard RSI's Wilder-smoothed gain/loss averages with a 4-stage cascaded Laguerre filter. A single γ (gamma) parameter controls the entire responsiveness-smoothness trade-off. Output is dimensionless, always in [0, 1]. No period selection required.
|
||||
|
||||
## Historical Context
|
||||
|
||||
John Ehlers introduced Laguerre RSI in *Cybernetic Analysis for Stocks and Futures* (2004, Wiley), drawing on the earlier Laguerre polynomial filter described in the same book. The core insight was that classical RSI's Wilder smoothing is a fixed-lag IIR filter that cannot be tuned without changing the period; Laguerre's four cascaded all-pass stages deliver a free parameter γ that continuously trades lag against noise rejection.
|
||||
|
||||
Standard RSI uses only two price-derived time series (upward and downward RMAs of one-bar differences). Laguerre RSI produces four correlated time series (the filter stages L0–L3) and derives its RSI-like signal from the cumulative up and down differences across consecutive stages. This gives it substantially more information per bar while remaining entirely O(1).
|
||||
|
||||
No external C# library (Skender, TA-Lib, Tulip, OoplesFinance) implements LRSI. Validation is therefore self-consistency only: all four computation modes (streaming, batch TSeries, span, eventing) must produce bit-identical results, and output must remain strictly in [0, 1] under all conditions.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. Laguerre Filter Stages
|
||||
|
||||
Each stage is a first-order all-pass IIR element parameterised by γ:
|
||||
|
||||
$$L_0[n] = (1-\gamma)\cdot p[n] + \gamma \cdot L_0[n-1]$$
|
||||
|
||||
$$L_k[n] = -\gamma \cdot L_{k-1}[n] + L_{k-1}[n-1] + \gamma \cdot L_k[n-1], \quad k = 1,2,3$$
|
||||
|
||||
The stages implement an orthonormal basis: each successive output is a delayed, damped projection of the input with the previous stage's component subtracted. The coefficient γ ∈ [0, 1) acts as a reflection coefficient in the all-pass lattice.
|
||||
|
||||
### 2. RSI Computation on Stage Differences
|
||||
|
||||
$$\text{cu} = \sum_{k=0}^{2} \max(L_k - L_{k+1},\ 0)$$
|
||||
|
||||
$$\text{cd} = \sum_{k=0}^{2} \max(L_{k+1} - L_k,\ 0)$$
|
||||
|
||||
$$\text{LRSI} = \begin{cases} \dfrac{\text{cu}}{\text{cu} + \text{cd}} & \text{if } \text{cu} + \text{cd} \ne 0 \\ 0.5 & \text{otherwise} \end{cases}$$
|
||||
|
||||
The 0.5 default covers the degenerate flat-market case where all stages are identical (no movement in any direction).
|
||||
|
||||
### 3. Gamma Semantics
|
||||
|
||||
| γ | Behaviour |
|
||||
|---|-----------|
|
||||
| 0.0 | No memory: L0 = price, L1 = L0⁻¹, L2 = L1⁻¹, L3 = L2⁻¹ — essentially a 4-tap FIR |
|
||||
| 0.5 | Default: balanced responsiveness and smoothing |
|
||||
| → 1.0 | Extreme smoothing; stages converge toward price mean; LRSI approaches 0.5 everywhere |
|
||||
|
||||
### 4. State Representation
|
||||
|
||||
```
|
||||
record struct State { L0, L1, L2, L3, LastValid }
|
||||
```
|
||||
|
||||
Five doubles only. No circular buffers. Bar correction (`isNew=false`) reduces to a single struct copy — the simplest possible rollback in the library.
|
||||
|
||||
### 5. FMA Usage
|
||||
|
||||
The all-pass recurrence `−γ·L_k + L_{k-1}[n-1] + γ·L_k[n-1]` maps directly to two FMA calls per stage:
|
||||
|
||||
```csharp
|
||||
s.L0 = Math.FusedMultiplyAdd(g, prevL0, omg * value); // g*prevL0 + omg*value
|
||||
s.L1 = Math.FusedMultiplyAdd(g, prevL1, FMA(-g, s.L0, prevL0)); // g*prevL1 + (prevL0 - g*L0)
|
||||
```
|
||||
|
||||
This avoids two intermediate rounding steps per stage, reducing accumulation error over long series.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
The transfer function of a single Laguerre all-pass element is:
|
||||
|
||||
$$H_1(z) = \frac{-\gamma + z^{-1}}{1 - \gamma z^{-1}}$$
|
||||
|
||||
For stage 0, the transfer function is a simple lowpass:
|
||||
|
||||
$$H_0(z) = \frac{1-\gamma}{1 - \gamma z^{-1}}$$
|
||||
|
||||
Cascading four stages shifts the phase progressively while retaining the same magnitude response, spreading spectral energy across the orthogonal basis. The RSI formula then reads out the directional momentum component of this spread.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
|
||||
### Operation Count (Streaming Mode)
|
||||
|
||||
Laguerre RSI uses a 4-pole Laguerre filter to compute a fast RSI-like oscillator.
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| FMA × 4 (4-pole Laguerre filter L0–L3) | 4 | 4 | 16 |
|
||||
| CMP × 4 (up/down classification per pole) | 4 | 1 | 4 |
|
||||
| ADD × 2 (CU, CD sums) | 2 | 1 | 2 |
|
||||
| DIV (CU / (CU+CD)) | 1 | 15 | 15 |
|
||||
| CMP (div-by-zero guard) | 1 | 1 | 1 |
|
||||
| **Total** | **12** | — | **~38 cycles** |
|
||||
|
||||
Four recursive Laguerre poles + RSI ratio. ~38 cycles per bar.
|
||||
|
||||
### Batch Mode (SIMD Analysis)
|
||||
|
||||
| Operation | Vectorizable? | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| Laguerre poles × 4 | **No** | Recursive IIR — each pole depends on prior value |
|
||||
| CU/CD classification | Yes | VCMPPD + masked accumulate |
|
||||
| RSI ratio | Yes | VDIVPD after poles computed |
|
||||
|
||||
| Operation | Count per bar |
|
||||
|-----------|--------------|
|
||||
| FMA calls | 6 (2 per stage for L1–L3) + 1 for L0 |
|
||||
| Mul/Add total | ~14 floating-point ops |
|
||||
| Memory access | 5 doubles read + 5 writes (struct promotion → registers) |
|
||||
| Allocations | 0 (Update path is fully allocation-free) |
|
||||
|
||||
| Metric | Score (1–10) |
|
||||
|--------|-------------|
|
||||
| Lag | 9 (lower than standard RSI at same apparent smoothness) |
|
||||
| Noise rejection | 8 |
|
||||
| Parameterisation | 10 (single γ, mathematically principled) |
|
||||
| Computational cost | 10 (O(1), no history) |
|
||||
| Interpretability | 7 (same overbought/oversold logic as RSI but [0,1] not [0,100]) |
|
||||
|
||||
SIMD analysis: the four stage computations are sequentially dependent (each stage requires the result of the previous). SIMD across a single bar is not applicable. Across bars: the stage recurrence has a feedback term that prevents loop vectorisation. A pure batch SIMD path is therefore infeasible; the scalar loop with FMA is the correct implementation.
|
||||
|
||||
## Validation
|
||||
|
||||
No external C# library implements Laguerre RSI. Validation protocol:
|
||||
|
||||
| Test | Method | Tolerance |
|
||||
|------|--------|-----------|
|
||||
| Streaming == Batch (TSeries) | GBM 300 bars, γ=0.1/0.5/0.9 | 1e-10 |
|
||||
| Span == TSeries | GBM 300 bars | 1e-10 |
|
||||
| Eventing == Streaming | GBM 200 bars | 1e-10 |
|
||||
| Output ∈ [0,1] | High/low volatility GBM | exact |
|
||||
| Constant price → 0.5 | 200 identical bars | 1e-6 |
|
||||
| Rising price → >0.8 | 100 bars +1/bar, γ=0.3 | exact |
|
||||
| Falling price → <0.2 | 100 bars −1/bar, γ=0.3 | exact |
|
||||
| Higher γ ⇒ lower variance | GBM 500 bars, γ=0.1 vs 0.9 | exact ordering |
|
||||
| Determinism | Same GBM seed → identical | 1e-10 |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Confusing [0,1] with [0,100]**: LRSI outputs in unit range; overbought/oversold levels are near 0.8/0.2, not 80/20. Plotting alongside standard RSI without rescaling produces vertical misalignment.
|
||||
|
||||
2. **γ = 1.0 produces constant 0.5**: All stages converge to a weighted mean; cu = cd = 0 for any non-spike input. The implementation returns 0.5 by convention; this is mathematically correct but operationally useless. Warn users who set γ ≥ 0.95.
|
||||
|
||||
3. **Expecting WarmupPeriod to gate output**: LRSI emits valid output from bar 1 (stages begin updating immediately). `WarmupPeriod = 4` is informational — it marks when all four stages have received at least one distinct value. Unlike period-based indicators, there is no discontinuity at the warmup boundary.
|
||||
|
||||
4. **Bar correction rollback is trivially cheap**: Because state is five scalars, `isNew=false` is just `_s = _ps` — no Array.Copy required. Any performance concerns from frequent bar corrections are unfounded for LRSI.
|
||||
|
||||
5. **Recursive filter cannot be vectorised**: Do not attempt a SIMD batch path. The stage-to-stage dependency chain is a strict serial recurrence. The only valid performance improvement is FMA (already applied) and ensuring the JIT promotes the state struct to registers (enabled by the local copy pattern).
|
||||
|
||||
6. **NaN substitution uses last valid close, not 0.5**: Substituting 0 or 0.5 on a NaN bar would distort the filter state. The last seen finite price is the correct substitution — it keeps the filter state continuous.
|
||||
|
||||
7. **γ behaviour is not monotone in lag for all signals**: Lower γ produces a faster filter, but also a noisier RSI signal. The optimum γ for a given instrument depends on frequency content of the underlying price series — there is no universally correct value.
|
||||
|
||||
## References
|
||||
|
||||
- Ehlers, J.F. (2004). *Cybernetic Analysis for Stocks and Futures*. Wiley. Chapter 14.
|
||||
- Ehlers, J.F. (2001). *Rocket Science for Traders*. Wiley. Chapter 9 (Laguerre filter foundations).
|
||||
- Vaidyanathan, P.P. (1993). *Multirate Systems and Filter Banks*. Prentice Hall. (All-pass lattice structures.)
|
||||
@@ -0,0 +1,35 @@
|
||||
//@version=6
|
||||
// LRSI: Laguerre RSI
|
||||
// John Ehlers, "Cybernetic Analysis for Stocks and Futures" (2004)
|
||||
// A modified RSI that uses a 4-element Laguerre filter as its core moving average.
|
||||
// The gamma parameter controls the damping of the filter stages, trading
|
||||
// responsiveness against smoothness. Output is dimensionless [0, 1].
|
||||
|
||||
indicator("LRSI: Laguerre RSI", shorttitle="LRSI", overlay=false)
|
||||
|
||||
gamma = input.float(0.5, "Gamma", minval=0.0, maxval=1.0, step=0.01,
|
||||
tooltip="Damping factor [0,1]. Lower = more responsive; higher = smoother.")
|
||||
|
||||
var float L0 = 0.0
|
||||
var float L1 = 0.0
|
||||
var float L2 = 0.0
|
||||
var float L3 = 0.0
|
||||
|
||||
// Four cascaded Laguerre filter stages
|
||||
// Each stage is a first-order all-pass element with coefficient gamma
|
||||
L0 := (1 - gamma) * close + gamma * nz(L0[1])
|
||||
L1 := -gamma * L0 + nz(L0[1]) + gamma * nz(L1[1])
|
||||
L2 := -gamma * L1 + nz(L1[1]) + gamma * nz(L2[1])
|
||||
L3 := -gamma * L2 + nz(L2[1]) + gamma * nz(L3[1])
|
||||
|
||||
// RSI numerator/denominator computed over stage differences
|
||||
cu = (L0 > L1 ? L0 - L1 : 0) + (L1 > L2 ? L1 - L2 : 0) + (L2 > L3 ? L2 - L3 : 0)
|
||||
cd = (L0 < L1 ? L1 - L0 : 0) + (L1 < L2 ? L2 - L1 : 0) + (L2 < L3 ? L3 - L2 : 0)
|
||||
|
||||
// cu + cd == 0 only when all stages are identical (flat market); default 0.5
|
||||
lrsi = cu + cd != 0 ? cu / (cu + cd) : 0.5
|
||||
|
||||
plot(lrsi, "LRSI", color=color.yellow, linewidth=2)
|
||||
hline(0.8, "Overbought", color=color.red, linestyle=hline.style_dashed)
|
||||
hline(0.5, "Midline", color=color.gray, linestyle=hline.style_dotted)
|
||||
hline(0.2, "Oversold", color=color.green, linestyle=hline.style_dashed)
|
||||
Reference in New Issue
Block a user