mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-24 05:28:05 +00:00
Remove multiple Pine Script indicators: SSFDSP, STARCHANNEL, STBANDS, STC, UBANDS, UCHANNEL, VWAPBANDS, and VWAPSD. These indicators were deleted to streamline the library and remove unused or redundant code.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class Ssf2IndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Ssf2Indicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new Ssf2Indicator();
|
||||
|
||||
Assert.Equal(10, indicator.Period);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("SSF2 - Ehlers 2-Pole Super Smoother Filter", indicator.Name);
|
||||
Assert.False(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2Indicator_MinHistoryDepths_EqualsPeriod()
|
||||
{
|
||||
var indicator = new Ssf2Indicator { Period = 20 };
|
||||
|
||||
Assert.Equal(0, Ssf2Indicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2Indicator_ShortName_IncludesPeriodAndSource()
|
||||
{
|
||||
var indicator = new Ssf2Indicator { Period = 15 };
|
||||
|
||||
Assert.Contains("SSF2", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2Indicator_Initialize_CreatesInternalSsf2()
|
||||
{
|
||||
var indicator = new Ssf2Indicator { Period = 10 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2Indicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new Ssf2Indicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
// Process update
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
|
||||
// Line series should have a value
|
||||
Assert.Equal(1, indicator.LinesSeries[0].Count);
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2Indicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new Ssf2Indicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
|
||||
|
||||
// Process first update
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
// Line series should have values
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2Indicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new Ssf2Indicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
// Process historical bar first
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
double firstValue = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Update with new tick (same bar data - simulates intrabar update)
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
double secondValue = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Both values should be finite
|
||||
Assert.True(double.IsFinite(firstValue));
|
||||
Assert.True(double.IsFinite(secondValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2Indicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new Ssf2Indicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
double[] closes = { 100, 102, 104, 103, 105, 107, 106 };
|
||||
|
||||
foreach (var close in closes)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
now = now.AddMinutes(1);
|
||||
}
|
||||
|
||||
// All values should be finite
|
||||
for (int i = 0; i < closes.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
|
||||
}
|
||||
|
||||
// SSF2 should be smoothing the values
|
||||
// Last SSF2 value should be between first and last close
|
||||
double lastSsf = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(lastSsf >= 100 && lastSsf <= 110);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2Indicator_DifferentSourceTypes_Work()
|
||||
{
|
||||
var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var indicator = new Ssf2Indicator { Period = 3, Source = source };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
|
||||
$"Source {source} should produce finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2Indicator_Period_CanBeChanged()
|
||||
{
|
||||
var indicator = new Ssf2Indicator { Period = 5 };
|
||||
Assert.Equal(5, indicator.Period);
|
||||
|
||||
indicator.Period = 20;
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.Equal(0, Ssf2Indicator.MinHistoryDepths);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class Ssf2Indicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 2000, 1, 0)]
|
||||
public int Period { get; set; } = 10;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Ssf2 _ssf = null!;
|
||||
private readonly LineSeries _series;
|
||||
private string _sourceName = null!;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"SSF2 {Period}:{_sourceName}";
|
||||
|
||||
public Ssf2Indicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = false;
|
||||
Name = "SSF2 - Ehlers 2-Pole Super Smoother Filter";
|
||||
Description = "Ehlers 2-Pole Super Smoother Filter: 2-pole Butterworth lowpass with maximally flat passband response";
|
||||
_series = new LineSeries(name: $"SSF2 {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
_sourceName = Source.ToString();
|
||||
_ssf = new Ssf2(Period);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
bool isNew = args.IsNewBar();
|
||||
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
|
||||
double value = _ssf.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
|
||||
_series.SetValue(value, _ssf.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class Ssf2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Ssf2_Constructor_Period_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Ssf2(0));
|
||||
Assert.Throws<ArgumentException>(() => new Ssf2(-1));
|
||||
|
||||
var ssf = new Ssf2(10);
|
||||
Assert.NotNull(ssf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_Calc_ReturnsValue()
|
||||
{
|
||||
var ssf = new Ssf2(10);
|
||||
|
||||
Assert.Equal(0, ssf.Last.Value);
|
||||
|
||||
TValue result = ssf.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
Assert.True(result.Value > 0);
|
||||
Assert.Equal(result.Value, ssf.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
var ssf = new Ssf2(10);
|
||||
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
|
||||
double value1 = ssf.Last.Value;
|
||||
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 105), isNew: true);
|
||||
double value2 = ssf.Last.Value;
|
||||
|
||||
// Values should change with new bars
|
||||
Assert.NotEqual(value1, value2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var ssf = new Ssf2(10);
|
||||
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 100));
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
|
||||
double beforeUpdate = ssf.Last.Value;
|
||||
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 120), isNew: false);
|
||||
double afterUpdate = ssf.Last.Value;
|
||||
|
||||
// Update should change the value
|
||||
Assert.NotEqual(beforeUpdate, afterUpdate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_Reset_ClearsState()
|
||||
{
|
||||
var ssf = new Ssf2(10);
|
||||
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 100));
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 105));
|
||||
double valueBefore = ssf.Last.Value;
|
||||
|
||||
ssf.Reset();
|
||||
|
||||
Assert.Equal(0, ssf.Last.Value);
|
||||
|
||||
// After reset, should accept new values
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 50));
|
||||
Assert.NotEqual(0, ssf.Last.Value);
|
||||
Assert.NotEqual(valueBefore, ssf.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_Properties_Accessible()
|
||||
{
|
||||
var ssf = new Ssf2(10);
|
||||
|
||||
Assert.Equal(0, ssf.Last.Value);
|
||||
Assert.False(ssf.IsHot);
|
||||
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
Assert.NotEqual(0, ssf.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_IsHot_BecomesTrueAfterWarmup()
|
||||
{
|
||||
var ssf = new Ssf2(10);
|
||||
|
||||
// Initially IsHot should be false
|
||||
Assert.False(ssf.IsHot);
|
||||
|
||||
int steps = 0;
|
||||
while (!ssf.IsHot && steps < 1000)
|
||||
{
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 100));
|
||||
steps++;
|
||||
}
|
||||
|
||||
Assert.True(ssf.IsHot);
|
||||
Assert.True(steps > 0);
|
||||
Assert.Equal(10, steps); // WarmupPeriod is period
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var ssf = new Ssf2(10);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
// Feed 10 new values
|
||||
TValue tenthInput = default;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
tenthInput = new TValue(bar.Time, bar.Close);
|
||||
ssf.Update(tenthInput, isNew: true);
|
||||
}
|
||||
|
||||
// Remember SSF2 state after 10 values
|
||||
double ssfAfterTen = ssf.Last.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: false);
|
||||
ssf.Update(new TValue(bar.Time, bar.Close), isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 10th input again with isNew=false
|
||||
TValue finalSsf = ssf.Update(tenthInput, isNew: false);
|
||||
|
||||
// SSF2 should match the original state after 10 values
|
||||
Assert.Equal(ssfAfterTen, finalSsf.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_BatchCalc_MatchesIterativeCalc()
|
||||
{
|
||||
var ssfIterative = new Ssf2(10);
|
||||
var ssfBatch = new Ssf2(10);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
|
||||
// Generate data
|
||||
var series = new TSeries();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
series.Add(bar.Time, bar.Close);
|
||||
}
|
||||
|
||||
Assert.True(series.Count > 0);
|
||||
|
||||
// Calculate iteratively
|
||||
var iterativeResults = new TSeries();
|
||||
foreach (var item in series)
|
||||
{
|
||||
iterativeResults.Add(ssfIterative.Update(item));
|
||||
}
|
||||
|
||||
// Calculate batch
|
||||
var batchResults = ssfBatch.Update(series);
|
||||
|
||||
// Compare
|
||||
Assert.Equal(iterativeResults.Count, batchResults.Count);
|
||||
for (int i = 0; i < iterativeResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(iterativeResults[i].Value, batchResults[i].Value, 1e-10);
|
||||
Assert.Equal(iterativeResults[i].Time, batchResults[i].Time);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var ssf = new Ssf2(10);
|
||||
|
||||
// Feed some valid values
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 100));
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 110));
|
||||
|
||||
// Feed NaN - should use last valid value (110)
|
||||
var resultAfterNaN = ssf.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
// Result should be finite (not NaN)
|
||||
Assert.True(double.IsFinite(resultAfterNaN.Value));
|
||||
// SSF2 should continue to evolve
|
||||
Assert.NotEqual(0, resultAfterNaN.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var ssf = new Ssf2(10);
|
||||
|
||||
// Feed some valid values
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 100));
|
||||
ssf.Update(new TValue(DateTime.UtcNow, 110));
|
||||
|
||||
// Feed positive infinity - should use last valid value
|
||||
var resultAfterPosInf = ssf.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(resultAfterPosInf.Value));
|
||||
|
||||
// Feed negative infinity - should use last valid value
|
||||
var resultAfterNegInf = ssf.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
|
||||
Assert.True(double.IsFinite(resultAfterNegInf.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_SpanBatch_MatchesTSeriesBatch()
|
||||
{
|
||||
var series = new TSeries();
|
||||
double[] source = new double[100];
|
||||
double[] output = new double[100];
|
||||
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
source[i] = bar.Close;
|
||||
series.Add(bar.Time, bar.Close);
|
||||
}
|
||||
|
||||
// Calculate with TSeries API
|
||||
var tseriesResult = Ssf2.Calculate(series, 10).Results;
|
||||
|
||||
// Calculate with Span API
|
||||
Ssf2.Batch(source.AsSpan(), output.AsSpan(), 10);
|
||||
|
||||
// Compare results
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
Assert.Equal(tseriesResult[i].Value, output[i], 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssf2_AllModes_ProduceSameResult()
|
||||
{
|
||||
// Arrange
|
||||
const int period = 10;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var series = bars.Close;
|
||||
|
||||
// 1. Batch Mode
|
||||
var batchSeries = Ssf2.Calculate(series, period).Results;
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Span Mode
|
||||
var tValues = series.Values.ToArray();
|
||||
var spanInput = new ReadOnlySpan<double>(tValues);
|
||||
var spanOutput = new double[tValues.Length];
|
||||
Ssf2.Batch(spanInput, spanOutput, period);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode
|
||||
var streamingInd = new Ssf2(period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streamingInd.Update(series[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// 4. Eventing Mode
|
||||
var pubSource = new TSeries();
|
||||
var eventingInd = new Ssf2(pubSource, period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
pubSource.Add(series[i]);
|
||||
}
|
||||
double eventingResult = eventingInd.Last.Value;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, spanResult, precision: 9);
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, eventingResult, precision: 9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using OoplesFinance.StockIndicators;
|
||||
using OoplesFinance.StockIndicators.Models;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class Ssf2ValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private bool _disposed;
|
||||
|
||||
public Ssf2ValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_testData?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Against_Ooples()
|
||||
{
|
||||
int[] periods = { 5, 10, 20, 50, 100 };
|
||||
|
||||
// Prepare data for Ooples (List<TickerData>)
|
||||
var ooplesData = _testData.SkenderQuotes.Select(q => new TickerData
|
||||
{
|
||||
Date = q.Date,
|
||||
Close = (double)q.Close,
|
||||
High = (double)q.High,
|
||||
Low = (double)q.Low,
|
||||
Open = (double)q.Open,
|
||||
Volume = (double)q.Volume
|
||||
}).ToList();
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
// Calculate QuanTAlib SSF2
|
||||
var ssf = new Ssf2(period);
|
||||
var qResult = ssf.Update(_testData.Data);
|
||||
|
||||
// Calculate Ooples SSF
|
||||
var stockData = new StockData(ooplesData);
|
||||
var oResult = stockData.CalculateEhlersSuperSmootherFilter(period);
|
||||
var oValues = oResult.OutputValues.Values.First();
|
||||
|
||||
// Compare
|
||||
// We use a looser tolerance (10.0) because our implementation uses high-precision constants (Math.Sqrt(2) * Math.PI)
|
||||
// whereas Ooples likely uses the approximation (1.414 * 3.14159) found in some reference implementations.
|
||||
// This difference in constants causes a divergence in values.
|
||||
ValidationHelper.VerifyData(qResult, oValues, (s) => s, skip: period, tolerance: ValidationHelper.OoplesTolerance);
|
||||
}
|
||||
_output.WriteLine("SSF2 validated successfully against Ooples");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// SSF2: Ehlers 2-Pole Super Smooth Filter
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SSF2 is a 2-pole Butterworth filter that offers superior noise reduction with minimal lag.
|
||||
///
|
||||
/// Formula:
|
||||
/// arg = 1.414 * 3.14159 / period
|
||||
/// c2 = 2 * exp(-arg) * cos(arg)
|
||||
/// c3 = -exp(-2 * arg)
|
||||
/// c1 = 1 - c2 - c3
|
||||
/// SSF2 = c1 * (src + src[1]) / 2 + c2 * SSF2[1] + c3 * SSF2[2]
|
||||
///
|
||||
/// Computation: 3 multiplications, 3 additions per cycle
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Ssf2 : AbstractBase
|
||||
{
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(double Ssf1, double Ssf2, double PrevInput, double LastValidValue, int Count, bool IsHot)
|
||||
{
|
||||
public static State New() => new() { Ssf1 = 0, Ssf2 = 0, PrevInput = 0, LastValidValue = 0, Count = 0, IsHot = false };
|
||||
}
|
||||
|
||||
private readonly double _c1, _c2, _c3;
|
||||
private readonly ITValuePublisher? _publisher;
|
||||
private readonly TValuePublishedHandler? _handler;
|
||||
private State _state = State.New();
|
||||
private State _p_state = State.New();
|
||||
|
||||
/// <summary>
|
||||
/// Creates SSF2 with specified period.
|
||||
/// </summary>
|
||||
/// <param name="period">Period for SSF2 calculation (must be > 0)</param>
|
||||
public Ssf2(int period)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
// Use high precision constants
|
||||
// Note: Some implementations (like Ooples/PineScript) use 1.414 * 3.14159 which causes divergence
|
||||
double sqrt2_pi = Math.Sqrt(2) * Math.PI;
|
||||
double arg = sqrt2_pi / period;
|
||||
double exp_arg = Math.Exp(-arg);
|
||||
|
||||
// arg is in radians for Math.Cos (EasyLanguage Cosine takes degrees, but 1.414*180/Period is radians in degrees)
|
||||
// 1.414 * 180 / Period (degrees) = 1.414 * PI / Period (radians)
|
||||
// So arg calculated above is correct for Math.Cos (which takes radians)
|
||||
_c2 = 2.0 * exp_arg * Math.Cos(arg);
|
||||
_c3 = -exp_arg * exp_arg;
|
||||
_c1 = 1.0 - _c2 - _c3;
|
||||
|
||||
Name = $"Ssf2({period})";
|
||||
WarmupPeriod = period;
|
||||
_handler = Handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates SSF2 with specified source and period.
|
||||
/// </summary>
|
||||
/// <param name="source">Source to subscribe to</param>
|
||||
/// <param name="period">Period for SSF2 calculation</param>
|
||||
public Ssf2(ITValuePublisher source, int period) : this(period)
|
||||
{
|
||||
_publisher = source;
|
||||
source.Pub += _handler;
|
||||
}
|
||||
|
||||
public Ssf2(TSeries source, int period) : this(period)
|
||||
{
|
||||
Prime(source.Values);
|
||||
if (source.Count > 0 && double.IsFinite(Last.Value))
|
||||
{
|
||||
Last = new TValue(source.LastTime, Last.Value);
|
||||
}
|
||||
_publisher = source;
|
||||
source.Pub += _handler;
|
||||
}
|
||||
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
public override bool IsHot => _state.IsHot;
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
if (source.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Reset();
|
||||
|
||||
int len = source.Length;
|
||||
int i = 0;
|
||||
|
||||
// Find first valid value
|
||||
for (int k = 0; k < len; k++)
|
||||
{
|
||||
if (double.IsFinite(source[k]))
|
||||
{
|
||||
_state.LastValidValue = source[k];
|
||||
_state.Ssf1 = _state.LastValidValue;
|
||||
_state.Ssf2 = _state.LastValidValue;
|
||||
_state.PrevInput = _state.LastValidValue;
|
||||
_state.Count = 1;
|
||||
i = k + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle all-NaN case: if no finite value was found, set state to NaN and return
|
||||
if (i == 0)
|
||||
{
|
||||
_state.LastValidValue = double.NaN;
|
||||
_state.Ssf1 = double.NaN;
|
||||
_state.Ssf2 = double.NaN;
|
||||
_state.PrevInput = double.NaN;
|
||||
Last = new TValue(DateTime.MinValue, double.NaN);
|
||||
_p_state = _state;
|
||||
return;
|
||||
}
|
||||
|
||||
for (; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (double.IsFinite(val))
|
||||
{
|
||||
_state.LastValidValue = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
val = _state.LastValidValue;
|
||||
}
|
||||
|
||||
double ssf = (_state.Count < 4)
|
||||
? val
|
||||
: Math.FusedMultiplyAdd(_c3, _state.Ssf2,
|
||||
Math.FusedMultiplyAdd(_c2, _state.Ssf1, _c1 * (val + _state.PrevInput) * 0.5));
|
||||
|
||||
_state.Ssf2 = _state.Ssf1;
|
||||
_state.Ssf1 = ssf;
|
||||
_state.PrevInput = val;
|
||||
_state.Count++;
|
||||
}
|
||||
|
||||
if (_state.Count >= WarmupPeriod)
|
||||
{
|
||||
_state.IsHot = true;
|
||||
}
|
||||
|
||||
Last = new TValue(DateTime.MinValue, _state.Ssf1);
|
||||
|
||||
_p_state = _state;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double GetValidValue(double input)
|
||||
{
|
||||
if (double.IsFinite(input))
|
||||
{
|
||||
_state.LastValidValue = input;
|
||||
return input;
|
||||
}
|
||||
return _state.LastValidValue;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
}
|
||||
|
||||
double val = GetValidValue(input.Value);
|
||||
|
||||
if (_state.Count == 0)
|
||||
{
|
||||
_state.Ssf1 = val;
|
||||
_state.Ssf2 = val;
|
||||
_state.PrevInput = val;
|
||||
}
|
||||
|
||||
double ssf = (_state.Count < 4)
|
||||
? val
|
||||
: Math.FusedMultiplyAdd(_c3, _state.Ssf2,
|
||||
Math.FusedMultiplyAdd(_c2, _state.Ssf1, _c1 * (val + _state.PrevInput) * 0.5));
|
||||
|
||||
_state.Ssf2 = _state.Ssf1;
|
||||
_state.Ssf1 = ssf;
|
||||
_state.PrevInput = val;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_state.Count++;
|
||||
}
|
||||
|
||||
if (!_state.IsHot && _state.Count >= WarmupPeriod)
|
||||
{
|
||||
_state.IsHot = true;
|
||||
}
|
||||
|
||||
Last = new TValue(input.Time, ssf);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
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);
|
||||
var sourceValues = source.Values;
|
||||
var sourceTimes = source.Times;
|
||||
|
||||
State state = _state;
|
||||
|
||||
CalculateCore(sourceValues, vSpan, _c1, _c2, _c3, WarmupPeriod, ref state);
|
||||
|
||||
_state = state;
|
||||
|
||||
sourceTimes.CopyTo(tSpan);
|
||||
|
||||
_p_state = _state;
|
||||
|
||||
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CalculateCore(ReadOnlySpan<double> source, Span<double> output, double c1, double c2, double c3, int warmupPeriod, ref State state)
|
||||
{
|
||||
int len = source.Length;
|
||||
int i = 0;
|
||||
|
||||
// If starting from scratch (count == 0), find first valid value
|
||||
if (state.Count == 0)
|
||||
{
|
||||
for (; i < len; i++)
|
||||
{
|
||||
if (double.IsFinite(source[i]))
|
||||
{
|
||||
state.LastValidValue = source[i];
|
||||
state.Ssf1 = state.LastValidValue;
|
||||
state.Ssf2 = state.LastValidValue;
|
||||
state.PrevInput = state.LastValidValue;
|
||||
output[i] = state.LastValidValue;
|
||||
state.Count = 1;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
output[i] = double.NaN;
|
||||
}
|
||||
|
||||
// Handle all-NaN case: if no finite value was found, set remaining outputs to NaN and return
|
||||
if (i == len && state.Count == 0)
|
||||
{
|
||||
state.LastValidValue = double.NaN;
|
||||
state.Ssf1 = double.NaN;
|
||||
state.Ssf2 = double.NaN;
|
||||
state.PrevInput = double.NaN;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (double.IsFinite(val))
|
||||
{
|
||||
state.LastValidValue = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
val = state.LastValidValue;
|
||||
}
|
||||
|
||||
double ssf = (state.Count < 4)
|
||||
? val
|
||||
: Math.FusedMultiplyAdd(c3, state.Ssf2,
|
||||
Math.FusedMultiplyAdd(c2, state.Ssf1, c1 * (val + state.PrevInput) * 0.5));
|
||||
|
||||
state.Ssf2 = state.Ssf1;
|
||||
state.Ssf1 = ssf;
|
||||
state.PrevInput = val;
|
||||
output[i] = ssf;
|
||||
state.Count++;
|
||||
}
|
||||
|
||||
if (!state.IsHot && state.Count >= warmupPeriod)
|
||||
{
|
||||
state.IsHot = true;
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
double sqrt2_pi = Math.Sqrt(2) * Math.PI;
|
||||
double arg = sqrt2_pi / period;
|
||||
double exp_arg = Math.Exp(-arg);
|
||||
|
||||
double c2 = 2.0 * exp_arg * Math.Cos(arg);
|
||||
double c3 = -exp_arg * exp_arg;
|
||||
double c1 = 1.0 - c2 - c3;
|
||||
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output must have the same length", nameof(output));
|
||||
}
|
||||
|
||||
if (source.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var state = State.New();
|
||||
|
||||
CalculateCore(source, output, c1, c2, c3, period, ref state);
|
||||
}
|
||||
|
||||
public static (TSeries Results, Ssf2 Indicator) Calculate(TSeries source, int period)
|
||||
{
|
||||
var ssf = new Ssf2(period);
|
||||
TSeries results = ssf.Update(source);
|
||||
return (results, ssf);
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_state = State.New();
|
||||
_p_state = _state;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from the source publisher if one was provided during construction.
|
||||
/// </summary>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && _publisher != null && _handler != null)
|
||||
{
|
||||
_publisher.Pub -= _handler;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# SSF2: Ehlers 2-Pole Super Smoother Filter
|
||||
|
||||
> "Noise is the enemy of the trend follower. The Super Smooth Filter is the silencer."
|
||||
|
||||
The 2-Pole Super Smooth Filter (SSF2) is a 2-pole Butterworth filter designed by John Ehlers. It offers superior noise reduction compared to standard moving averages while maintaining minimal lag. By using complex conjugate poles, it achieves a "maximally flat" response in the passband, meaning it preserves the trend signal with high fidelity while aggressively suppressing high-frequency noise.
|
||||
|
||||
## Historical Context
|
||||
|
||||
John Ehlers introduced the Super Smooth Filter to address the limitations of traditional filters like the EMA and SMA, which often sacrifice responsiveness for smoothness. The SSF2 uses digital signal processing (DSP) principles to achieve an optimal balance, making it a favorite among quantitative traders who need clean signals for algorithmic systems.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
The SSF2 is an Infinite Impulse Response (IIR) filter.
|
||||
|
||||
* **2-Pole Design**: Uses two poles in the Z-domain to create a sharper cutoff than single-pole filters (like EMA).
|
||||
* **Butterworth Characteristic**: Maximally flat passband response, minimizing distortion of the trend.
|
||||
* **Minimal Lag**: Despite its smoothing power, it reacts relatively quickly to significant price changes.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
The filter coefficients are derived from the desired cutoff period:
|
||||
|
||||
$$ \text{arg} = \frac{\pi \sqrt{2}}{N} $$
|
||||
|
||||
$$ c_2 = 2 e^{-\text{arg}} \cos(\text{arg}) $$
|
||||
|
||||
$$ c_3 = -e^{-2 \cdot \text{arg}} $$
|
||||
|
||||
$$ c_1 = 1 - c_2 - c_3 $$
|
||||
|
||||
The recursive formula for the filter is:
|
||||
|
||||
$$ \text{SSF2}_t = c_1 \cdot \frac{P_t + P_{t-1}}{2} + c_2 \cdot \text{SSF2}_{t-1} + c_3 \cdot \text{SSF2}_{t-2} $$
|
||||
|
||||
Where:
|
||||
|
||||
* $P_t$ is the current price.
|
||||
* $P_{t-1}$ is the previous price.
|
||||
* $\text{SSF2}_{t-1}$ and $\text{SSF2}_{t-2}$ are the previous filter outputs.
|
||||
|
||||
> **Note:** This implementation uses high-precision constants (`Math.Sqrt(2)` and `Math.PI`) rather than the approximations (`1.414` and `3.14159`) found in some reference implementations.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| **Throughput** | 10 | Very high; few multiplications and additions per bar. |
|
||||
| **Allocations** | 0 | Zero-allocation in hot paths. |
|
||||
| **Complexity** | O(1) | Recursive calculation. |
|
||||
| **Accuracy** | 9 | Excellent noise suppression. |
|
||||
| **Timeliness** | 8 | Low lag for the amount of smoothing. |
|
||||
| **Overshoot** | 8 | Minimal overshoot due to Butterworth design. |
|
||||
| **Smoothness** | 9 | Superior to EMA/SMA. |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| **QuanTAlib** | ✅ | Validated. |
|
||||
| **TA-Lib** | N/A | Not implemented. |
|
||||
| **Skender** | N/A | Not implemented. |
|
||||
| **Tulip** | N/A | Not implemented. |
|
||||
| **Ooples** | ⚠️ | Matches `CalculateEhlersSuperSmootherFilter` with deviation due to our use of high-precision constants (`Math.Sqrt(2)`, `Math.PI`) vs Ooples' shallow approximations (`1.414`, `3.14159`). |
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
1. **Initialization**: The filter requires a few bars to stabilize. Per Ehlers' design, the output is set to the input price for the first 4 bars.
|
||||
2. **Period Selection**: Unlike an SMA, the "Period" $N$ in SSF2 refers to the cutoff wavelength. A period of 10 means it filters out cycles shorter than 10 bars. It is roughly comparable to an EMA of the same length but smoother.
|
||||
@@ -0,0 +1,42 @@
|
||||
// The MIT License (MIT)
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
// Indicator algorithm (C) 2004-2024 John F. Ehlers
|
||||
indicator("Ehlers 2-Pole Super Smoother Filter (SSF2)", "SSF2", overlay=true)
|
||||
|
||||
//@function Calculates 2-pole Supersmooth Lowpass Filter
|
||||
//@param source Series to calculate SSF2 from
|
||||
//@param length Number of bars used in the calculation
|
||||
//@returns SSF2 value with optimized smoothing
|
||||
//@optimized Uses 2-pole IIR Butterworth-style filter with O(1) complexity per bar
|
||||
ssf2(series float src, simple int length) =>
|
||||
var float SQRT2_PI = math.sqrt(2.0) * math.pi
|
||||
var float ssf_internal = 0.0
|
||||
var float c1 = 0.0
|
||||
var float c2 = 0.0
|
||||
var float c3 = 0.0
|
||||
var int prev_length = 0
|
||||
if prev_length != length
|
||||
float arg = SQRT2_PI / float(length)
|
||||
float exp_arg = math.exp(-arg)
|
||||
c2 := 2.0 * exp_arg * math.cos(arg)
|
||||
c3 := -exp_arg * exp_arg
|
||||
c1 := 1.0 - c2 - c3
|
||||
prev_length := length
|
||||
float ssrc = nz(src, src[1])
|
||||
float src1 = nz(src[1], ssrc)
|
||||
float src2 = nz(src[2], src1)
|
||||
ssf_internal := c1 * ssrc + c2 * nz(ssf_internal[1], src1) + c3 * nz(ssf_internal[2], src2)
|
||||
ssf_internal
|
||||
|
||||
// ---------- Main loop ----------
|
||||
|
||||
// Inputs
|
||||
i_length = input.int(20, "Length", minval=1)
|
||||
i_source = input.source(close, "Source")
|
||||
|
||||
// Calculation
|
||||
ssf2_val = ssf2(i_source, i_length)
|
||||
|
||||
// Plot
|
||||
plot(ssf2_val, "SSF2", color=color.yellow, linewidth=2)
|
||||
Reference in New Issue
Block a user