From 78775c1da044974b6dd07dabb790b78c6d9e2e57 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Sun, 14 Dec 2025 19:28:07 -0800 Subject: [PATCH] feat: implement SuperTrend indicator with tests and documentation --- lib/trends/super/Super.Quantower.Tests.cs | 124 ++++++++++++ lib/trends/super/Super.Quantower.cs | 71 +++++++ lib/trends/super/Super.Tests.cs | 143 ++++++++++++++ lib/trends/super/Super.Validation.Tests.cs | 80 ++++++++ lib/trends/super/Super.cs | 220 +++++++++++++++++++++ lib/trends/super/Super.md | 72 +++++++ lib/trends/tema/Tema.Validation.Tests.cs | 3 - lib/trends/vidya/Vidya.Validation.Tests.cs | 3 - 8 files changed, 710 insertions(+), 6 deletions(-) create mode 100644 lib/trends/super/Super.Quantower.Tests.cs create mode 100644 lib/trends/super/Super.Quantower.cs create mode 100644 lib/trends/super/Super.Tests.cs create mode 100644 lib/trends/super/Super.Validation.Tests.cs create mode 100644 lib/trends/super/Super.cs create mode 100644 lib/trends/super/Super.md diff --git a/lib/trends/super/Super.Quantower.Tests.cs b/lib/trends/super/Super.Quantower.Tests.cs new file mode 100644 index 00000000..b8888814 --- /dev/null +++ b/lib/trends/super/Super.Quantower.Tests.cs @@ -0,0 +1,124 @@ +using Xunit; +using TradingPlatform.BusinessLayer; +using QuanTAlib; + +namespace QuanTAlib.Tests; + +public class SuperIndicatorTests +{ + [Fact] + public void SuperIndicator_Constructor_SetsDefaults() + { + var indicator = new SuperIndicator(); + + Assert.Equal(10, indicator.Period); + Assert.Equal(3.0, indicator.Multiplier); + Assert.True(indicator.ShowColdValues); + Assert.Equal("SuperTrend", indicator.Name); + Assert.False(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void SuperIndicator_MinHistoryDepths_EqualsPeriod() + { + var indicator = new SuperIndicator { Period = 20 }; + + Assert.Equal(20, indicator.MinHistoryDepths); + IWatchlistIndicator watchlistIndicator = indicator; + Assert.Equal(20, watchlistIndicator.MinHistoryDepths); + } + + [Fact] + public void SuperIndicator_ShortName_IncludesParameters() + { + var indicator = new SuperIndicator { Period = 20, Multiplier = 2.5 }; + indicator.Initialize(); + + Assert.Contains("Super", indicator.ShortName); + Assert.Contains("20", indicator.ShortName); + Assert.Contains("2.5", indicator.ShortName); + } + + [Fact] + public void SuperIndicator_SourceCodeLink_IsValid() + { + var indicator = new SuperIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink); + Assert.Contains("Super.Quantower.cs", indicator.SourceCodeLink); + } + + [Fact] + public void SuperIndicator_Initialize_CreatesInternalSuper() + { + var indicator = new SuperIndicator { Period = 14 }; + + // Initialize should not throw + indicator.Initialize(); + + // After init, line series should exist (Up and Down) + Assert.Equal(2, indicator.LinesSeries.Length); + } + + [Fact] + public void SuperIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new SuperIndicator { Period = 5 }; + indicator.Initialize(); + + // Add historical data + var now = DateTime.UtcNow; + // Need enough bars for Period + for (int i = 0; i < 20; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i); + + // Process update for each bar to simulate history loading + var args = new UpdateArgs(UpdateReason.HistoricalBar); + indicator.ProcessUpdate(args); + } + + // Line series should have a value (either Up or Down) + // One should be NaN, other should be value, or both NaN if cold + double up = indicator.LinesSeries[0].GetValue(0); + double down = indicator.LinesSeries[1].GetValue(0); + + Assert.True(double.IsFinite(up) || double.IsFinite(down)); + } + + [Fact] + public void SuperIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new SuperIndicator { Period = 5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 20; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i); + } + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + // Add new bar + indicator.HistoricalData.AddBar(now.AddMinutes(20), 120, 130, 110, 125); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void SuperIndicator_Parameters_CanBeChanged() + { + var indicator = new SuperIndicator { Period = 14 }; + Assert.Equal(14, indicator.Period); + + indicator.Period = 20; + indicator.Multiplier = 4.0; + + Assert.Equal(20, indicator.Period); + Assert.Equal(4.0, indicator.Multiplier); + Assert.Equal(20, indicator.MinHistoryDepths); + } +} diff --git a/lib/trends/super/Super.Quantower.cs b/lib/trends/super/Super.Quantower.cs new file mode 100644 index 00000000..c45cd898 --- /dev/null +++ b/lib/trends/super/Super.Quantower.cs @@ -0,0 +1,71 @@ +using System.Drawing; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +public class SuperIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)] + public int Period { get; set; } = 10; + + [InputParameter("Multiplier", sortIndex: 2, 0.1, 100, 0.1, 1)] + public double Multiplier { get; set; } = 3.0; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Super? _super; + protected LineSeries? UpSeries; + protected LineSeries? DownSeries; + + public int MinHistoryDepths => Period; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"Super {Period}:{Multiplier}"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends/super/Super.Quantower.cs"; + + public SuperIndicator() + { + OnBackGround = true; + SeparateWindow = false; + Name = "SuperTrend"; + Description = "Trend-following indicator using ATR"; + + UpSeries = new(name: "SuperTrend Up", color: Color.Green, width: 2, style: LineStyle.Solid); + DownSeries = new(name: "SuperTrend Down", color: Color.Red, width: 2, style: LineStyle.Solid); + + AddLineSeries(UpSeries); + AddLineSeries(DownSeries); + } + + protected override void OnInit() + { + _super = new Super(Period, Multiplier); + base.OnInit(); + } + + protected override void OnUpdate(UpdateArgs args) + { + bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar; + + TBar bar = this.GetInputBar(args); + + TValue result = _super!.Update(bar, isNew); + + if (!_super.IsHot && !ShowColdValues) + { + return; + } + + if (_super.IsBullish) + { + UpSeries!.SetValue(result.Value); + DownSeries!.SetValue(double.NaN); + } + else + { + UpSeries!.SetValue(double.NaN); + DownSeries!.SetValue(result.Value); + } + } +} diff --git a/lib/trends/super/Super.Tests.cs b/lib/trends/super/Super.Tests.cs new file mode 100644 index 00000000..e1d1a80e --- /dev/null +++ b/lib/trends/super/Super.Tests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace QuanTAlib; + +public class SuperTests +{ + [Fact] + public void BasicCalculation_DoesNotCrash() + { + var super = new Super(10, 3.0); + var gbm = new GBM(); + var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + super.Update(bars[i]); + } + + Assert.True(double.IsFinite(super.Last.Value)); + } + + [Fact] + public void IsNew_Consistency() + { + var super = new Super(10, 3.0); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // Feed first 99 + for (int i = 0; i < 99; i++) + { + super.Update(bars[i]); + } + + // Update with 100th point (isNew=true) + super.Update(bars[99], true); + + // Update with modified 100th point (isNew=false) + var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 1.0, bars[99].Low - 1.0, bars[99].Close, bars[99].Volume); + var val2 = super.Update(modifiedBar, false); + + // Create new instance and feed up to modified + var super2 = new Super(10, 3.0); + for (int i = 0; i < 99; i++) + { + super2.Update(bars[i]); + } + var val3 = super2.Update(modifiedBar, true); + + Assert.Equal(val3.Value, val2.Value, 1e-9); + Assert.Equal(super2.UpperBand.Value, super.UpperBand.Value, 1e-9); + Assert.Equal(super2.LowerBand.Value, super.LowerBand.Value, 1e-9); + Assert.Equal(super2.IsBullish, super.IsBullish); + } + + [Fact] + public void Reset_Works() + { + var super = new Super(10, 3.0); + var gbm = new GBM(); + var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + for (int i = 0; i < bars.Count; i++) + { + super.Update(bars[i]); + } + + super.Reset(); + Assert.Equal(0, super.Last.Value); + Assert.False(super.IsHot); + + // Feed again + for (int i = 0; i < bars.Count; i++) + { + super.Update(bars[i]); + } + + Assert.True(double.IsFinite(super.Last.Value)); + } + + [Fact] + public void TBarSeries_Update_Matches_Streaming() + { + var super = new Super(10, 3.0); + var gbm = new GBM(); + var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + var streamingResults = new List(); + for (int i = 0; i < bars.Count; i++) + { + streamingResults.Add(super.Update(bars[i]).Value); + } + + var super2 = new Super(10, 3.0); + var seriesResults = super2.Update(bars); + + Assert.Equal(streamingResults.Count, seriesResults.Count); + for (int i = 0; i < seriesResults.Count; i++) + { + // Handle NaN comparison + if (double.IsNaN(streamingResults[i])) + { + Assert.True(double.IsNaN(seriesResults.Values[i])); + } + else + { + Assert.Equal(streamingResults[i], seriesResults.Values[i], 1e-9); + } + } + } + + [Fact] + public void Warmup_Handling() + { + var super = new Super(10, 3.0); + var gbm = new GBM(); + var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // First 10 bars should be NaN + for (int i = 0; i < 10; i++) + { + var result = super.Update(bars[i]); + Assert.True(double.IsNaN(result.Value), $"Bar {i} should be NaN"); + Assert.False(super.IsHot); + } + + // 11th bar (index 10) should be valid + var result11 = super.Update(bars[10]); + Assert.True(double.IsFinite(result11.Value), "Bar 10 should be finite"); + Assert.True(super.IsHot); + } + + [Fact] + public void Constructor_InvalidParameters_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new Super(0, 3.0)); + Assert.Throws(() => new Super(-1, 3.0)); + Assert.Throws(() => new Super(10, 0)); + Assert.Throws(() => new Super(10, -1.0)); + } +} diff --git a/lib/trends/super/Super.Validation.Tests.cs b/lib/trends/super/Super.Validation.Tests.cs new file mode 100644 index 00000000..7eccff62 --- /dev/null +++ b/lib/trends/super/Super.Validation.Tests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Skender.Stock.Indicators; +using Xunit; +using QuanTAlib.Tests; + +namespace QuanTAlib; + +public class SuperValidationTests : IDisposable +{ + private readonly ValidationTestData _data; + + public SuperValidationTests() + { + _data = new ValidationTestData(); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _data.Dispose(); + } + } + + [Fact] + public void MatchesSkender() + { + var super = new Super(10, 3.0); + var results = new List(); + var upper = new List(); + var lower = new List(); + + for (int i = 0; i < _data.Bars.Count; i++) + { + var res = super.Update(_data.Bars[i]); + results.Add(res.Value); + upper.Add(super.UpperBand.Value); + lower.Add(super.LowerBand.Value); + } + + // Skender uses GetSuperTrend + var skenderResults = _data.SkenderQuotes.GetSuperTrend(10, 3.0).ToList(); + + Assert.Equal(_data.Bars.Count, skenderResults.Count); + + for (int i = 0; i < _data.Bars.Count; i++) + { + // Skender returns null for warmup + if (skenderResults[i].SuperTrend == null) + { + Assert.True(double.IsNaN(results[i])); + continue; + } + + Assert.Equal((double)skenderResults[i].SuperTrend!, results[i], 1e-7); + + if (skenderResults[i].UpperBand != null) + { + Assert.Equal((double)skenderResults[i].UpperBand!, upper[i], 1e-7); + } + + if (skenderResults[i].LowerBand != null) + { + Assert.Equal((double)skenderResults[i].LowerBand!, lower[i], 1e-7); + } + } + } + + // Note: OoplesFinance implementation of SuperTrend diverges significantly from Skender and QuanTAlib. + // This is likely due to different initialization logic for ATR or the SuperTrend state itself. + // Therefore, we do not validate against Ooples for SuperTrend. +} diff --git a/lib/trends/super/Super.cs b/lib/trends/super/Super.cs new file mode 100644 index 00000000..c57685cc --- /dev/null +++ b/lib/trends/super/Super.cs @@ -0,0 +1,220 @@ +using System.Runtime.CompilerServices; + +namespace QuanTAlib; + +/// +/// SuperTrend Indicator +/// A trend-following indicator that uses ATR to define upper and lower bands. +/// +[SkipLocalsInit] +public sealed class Super : ITValuePublisher +{ + private readonly double _multiplier; + private readonly int _period; + private TBar _prevBar; + private TBar _lastInput; + private int _sampleCount; + + private record struct State + { + public bool IsBullish; + public double UpperBand; + public double LowerBand; + public bool IsInitialized; + public double Atr; + public double SumTr; + } + + private State _state; + private State _p_state; + + /// + /// Display name for the indicator. + /// + public string Name => $"Super({_period},{_multiplier})"; + + public event Action? Pub; + + /// + /// Current SuperTrend value. + /// + public TValue Last { get; private set; } + + /// + /// Current Upper Band value. + /// + public TValue UpperBand { get; private set; } + + /// + /// Current Lower Band value. + /// + public TValue LowerBand { get; private set; } + + /// + /// True if the current trend is bullish. + /// + public bool IsBullish => _state.IsBullish; + + /// + /// True if the indicator has enough data to be valid. + /// + public bool IsHot => _sampleCount > _period; + + public Super(int period = 10, double multiplier = 3.0) + { + if (period <= 0) + { + throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 0."); + } + if (multiplier <= 0) + { + throw new ArgumentOutOfRangeException(nameof(multiplier), "Multiplier must be greater than 0."); + } + _period = period; + _multiplier = multiplier; + _state = new State { IsBullish = true, IsInitialized = false }; + _sampleCount = 0; + } + + public void Reset() + { + _state = new State { IsBullish = true, IsInitialized = false }; + _p_state = default; + _prevBar = default; + _lastInput = default; + _sampleCount = 0; + Last = default; + UpperBand = default; + LowerBand = default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue Update(TBar input, bool isNew = true) + { + if (isNew) + { + _p_state = _state; + if (_sampleCount > 0) + { + _prevBar = _lastInput; + } + _sampleCount++; + } + else + { + _state = _p_state; + } + _lastInput = input; + + // Calculate True Range + double tr; + if (_sampleCount <= 1) + { + tr = input.High - input.Low; + } + else + { + double h_l = input.High - input.Low; + double h_pc = Math.Abs(input.High - _prevBar.Close); + double l_pc = Math.Abs(input.Low - _prevBar.Close); + tr = Math.Max(h_l, Math.Max(h_pc, l_pc)); + } + + // Update ATR + // Note: Skender's implementation skips the first bar's TR for the initial SMA calculation. + // We replicate this to match values. + double atr; + if (_sampleCount == 1) + { + atr = 0; + } + else if (_sampleCount <= _period + 1) + { + _state.SumTr += tr; + if (_sampleCount == _period + 1) + { + _state.Atr = _state.SumTr / _period; + } + atr = _state.Atr; + } + else + { + _state.Atr = (_state.Atr * (_period - 1) + tr) / _period; + atr = _state.Atr; + } + + double superTrend = double.NaN; + double upperBand = double.NaN; + double lowerBand = double.NaN; + + if (_sampleCount > _period) + { + double mid = (input.High + input.Low) * 0.5; + double upperEval = mid + (_multiplier * atr); + double lowerEval = mid - (_multiplier * atr); + + if (!_state.IsInitialized) + { + _state.IsBullish = true; // Skender seems to default to Bullish (or determines it dynamically) + _state.UpperBand = upperEval; + _state.LowerBand = lowerEval; + _state.IsInitialized = true; + } + + double prevUpperBand = _state.UpperBand; + double prevLowerBand = _state.LowerBand; + double prevClose = _prevBar.Close; + + // New upper band + if (upperEval < prevUpperBand || prevClose > prevUpperBand) + { + _state.UpperBand = upperEval; + } + + // New lower band + if (lowerEval > prevLowerBand || prevClose < prevLowerBand) + { + _state.LowerBand = lowerEval; + } + + // SuperTrend + if (input.Close <= (_state.IsBullish ? _state.LowerBand : _state.UpperBand)) + { + superTrend = _state.UpperBand; + _state.IsBullish = false; + } + else + { + superTrend = _state.LowerBand; + _state.IsBullish = true; + } + + upperBand = _state.UpperBand; + lowerBand = _state.LowerBand; + } + + Last = new TValue(input.Time, superTrend); + UpperBand = new TValue(input.Time, upperBand); + LowerBand = new TValue(input.Time, lowerBand); + + Pub?.Invoke(Last); + return Last; + } + + public TSeries Update(TBarSeries source) + { + var t = new List(source.Count); + var v = new List(source.Count); + + Reset(); + + for (int i = 0; i < source.Count; i++) + { + var val = Update(source[i], true); + t.Add(val.Time); + v.Add(val.Value); + } + + return new TSeries(t, v); + } +} diff --git a/lib/trends/super/Super.md b/lib/trends/super/Super.md new file mode 100644 index 00000000..27396d82 --- /dev/null +++ b/lib/trends/super/Super.md @@ -0,0 +1,72 @@ +# SuperTrend + +SuperTrend is a trend-following indicator that uses Average True Range (ATR) to define upper and lower bands. It switches between the upper and lower bands based on the closing price relative to the bands, effectively acting as a trailing stop. + +## Core Concepts + +- **Trend Following:** Identifies the current trend direction (bullish or bearish). +- **Volatility Adjusted:** Uses ATR to adapt to market volatility. +- **Trailing Stop:** The indicator line acts as a dynamic support/resistance level. + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| Period | int | 10 | The lookback period for ATR calculation. | +| Multiplier | double | 3.0 | The multiplier for ATR to determine band distance. | + +## Formula + +$$ +\begin{aligned} +TR_t &= \max(H_t - L_t, |H_t - C_{t-1}|, |L_t - C_{t-1}|) \\ +ATR_t &= RMA(TR, Period) \\ +BasicUpper &= \frac{H_t + L_t}{2} + (Multiplier \times ATR_t) \\ +BasicLower &= \frac{H_t + L_t}{2} - (Multiplier \times ATR_t) \\ +\end{aligned} +$$ + +The final bands are calculated by restricting movement against the trend: + +- If $BasicUpper < FinalUpper_{t-1}$ or $C_{t-1} > FinalUpper_{t-1}$, then $FinalUpper_t = BasicUpper$, else $FinalUpper_t = FinalUpper_{t-1}$. +- If $BasicLower > FinalLower_{t-1}$ or $C_{t-1} < FinalLower_{t-1}$, then $FinalLower_t = BasicLower$, else $FinalLower_t = FinalLower_{t-1}$. + +The SuperTrend value switches between FinalUpper and FinalLower based on the close price. + +## C# Implementation + +### Standard Usage + +```csharp +// Create indicator with period 10 and multiplier 3.0 +var super = new Super(10, 3.0); + +// Update with TBar +TBar bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000); +TValue result = super.Update(bar); + +Console.WriteLine($"SuperTrend: {result.Value}"); +Console.WriteLine($"Upper Band: {super.UpperBand.Value}"); +Console.WriteLine($"Lower Band: {super.LowerBand.Value}"); +Console.WriteLine($"Is Bullish: {super.IsBullish}"); +``` + +### Bar Correction (isNew) + +```csharp +// Update with a new bar +super.Update(bar1, isNew: true); + +// Update the same bar (correction) +super.Update(bar1_corrected, isNew: false); +``` + +## Interpretation + +- **Buy Signal:** When the price closes above the SuperTrend line (trend turns bullish). +- **Sell Signal:** When the price closes below the SuperTrend line (trend turns bearish). +- **Support/Resistance:** The SuperTrend line serves as a support level in an uptrend and resistance in a downtrend. + +## References + +- [Skender.Stock.Indicators - SuperTrend](https://dotnet.stockindicators.dev/indicators/SuperTrend/) diff --git a/lib/trends/tema/Tema.Validation.Tests.cs b/lib/trends/tema/Tema.Validation.Tests.cs index 7a7197c1..9a138e28 100644 --- a/lib/trends/tema/Tema.Validation.Tests.cs +++ b/lib/trends/tema/Tema.Validation.Tests.cs @@ -1,9 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using OoplesFinance.StockIndicators; -using OoplesFinance.StockIndicators.Enums; -using OoplesFinance.StockIndicators.Models; using Skender.Stock.Indicators; using TALib; using Tulip; diff --git a/lib/trends/vidya/Vidya.Validation.Tests.cs b/lib/trends/vidya/Vidya.Validation.Tests.cs index 3ba1e32f..a45f6862 100644 --- a/lib/trends/vidya/Vidya.Validation.Tests.cs +++ b/lib/trends/vidya/Vidya.Validation.Tests.cs @@ -1,9 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using OoplesFinance.StockIndicators; -using OoplesFinance.StockIndicators.Enums; -using OoplesFinance.StockIndicators.Models; using Xunit; using Xunit.Abstractions; using QuanTAlib.Tests;