mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-24 21:48:03 +00:00
Add SSF-DSP implementation with validation tests and documentation
- Implemented the SSF-DSP (Super Smooth Filter Detrended Synthetic Price) indicator using dual Super Smooth Filters. - Added validation tests to ensure correctness against PineScript implementation and mathematical properties. - Created comprehensive documentation outlining the architecture, mathematical foundation, performance profile, and common pitfalls. - Included batch processing capabilities for efficient calculations on time series data.
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Quantower.Tests;
|
||||
|
||||
public class SineIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SineIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new SineIndicator();
|
||||
|
||||
Assert.Equal(40, indicator.HpPeriod);
|
||||
Assert.Equal(10, indicator.SsfPeriod);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("SINE - Ehlers Sine Wave", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new SineIndicator();
|
||||
|
||||
Assert.Equal(0, SineIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
|
||||
Assert.True(indicator.ShortName.Contains("SINE", StringComparison.Ordinal));
|
||||
Assert.True(indicator.ShortName.Contains("20", StringComparison.Ordinal));
|
||||
Assert.True(indicator.ShortName.Contains("5", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_Initialize_CreatesInternalSine()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (SINE + Zero + Upper + Lower lines)
|
||||
Assert.Equal(4, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
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 SineIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Should not throw an exception
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
// Assert that the indicator still exists (method completed without exception)
|
||||
Assert.NotNull(indicator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
double[] closes = { 100, 102, 105, 103, 107, 110, 108, 112, 115, 113 };
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_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 SineIndicator { HpPeriod = 20, SsfPeriod = 5, 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 SineIndicator_HpPeriod_CanBeChanged()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40 };
|
||||
|
||||
Assert.Equal(40, indicator.HpPeriod);
|
||||
|
||||
indicator.HpPeriod = 20;
|
||||
Assert.Equal(20, indicator.HpPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_SsfPeriod_CanBeChanged()
|
||||
{
|
||||
var indicator = new SineIndicator { SsfPeriod = 10 };
|
||||
|
||||
Assert.Equal(10, indicator.SsfPeriod);
|
||||
|
||||
indicator.SsfPeriod = 5;
|
||||
Assert.Equal(5, indicator.SsfPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_Source_CanBeChanged()
|
||||
{
|
||||
var indicator = new SineIndicator { Source = SourceType.Close };
|
||||
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
|
||||
indicator.Source = SourceType.Open;
|
||||
Assert.Equal(SourceType.Open, indicator.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ShowColdValues_CanBeChanged()
|
||||
{
|
||||
var indicator = new SineIndicator { ShowColdValues = true };
|
||||
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ShortName_UpdatesWhenParametersChange()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
string initialName = indicator.ShortName;
|
||||
|
||||
Assert.True(initialName.Contains("40", StringComparison.Ordinal));
|
||||
Assert.True(initialName.Contains("10", StringComparison.Ordinal));
|
||||
|
||||
indicator.HpPeriod = 20;
|
||||
indicator.SsfPeriod = 5;
|
||||
string updatedName = indicator.ShortName;
|
||||
|
||||
Assert.True(updatedName.Contains("20", StringComparison.Ordinal));
|
||||
Assert.True(updatedName.Contains("5", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ProcessUpdate_IgnoresNonBarUpdates()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
// Process historical bar first
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Process other update reasons - should not throw
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
// Assert that the indicator still exists (method completed without exception)
|
||||
Assert.NotNull(indicator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_LineSeries_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var lineSeries = indicator.LinesSeries[0];
|
||||
|
||||
Assert.Equal("SINE", lineSeries.Name);
|
||||
Assert.Equal(2, lineSeries.Width);
|
||||
Assert.Equal(LineStyle.Solid, lineSeries.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ZeroLine_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var zeroLine = indicator.LinesSeries[1];
|
||||
|
||||
Assert.Equal("Zero", zeroLine.Name);
|
||||
Assert.Equal(1, zeroLine.Width);
|
||||
Assert.Equal(LineStyle.Dash, zeroLine.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_BoundaryLines_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var upperLine = indicator.LinesSeries[2];
|
||||
var lowerLine = indicator.LinesSeries[3];
|
||||
|
||||
Assert.Equal("+1", upperLine.Name);
|
||||
Assert.Equal("-1", lowerLine.Name);
|
||||
Assert.Equal(LineStyle.Dot, upperLine.Style);
|
||||
Assert.Equal(LineStyle.Dot, lowerLine.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_DifferentParameters_Work()
|
||||
{
|
||||
var paramSets = new[] { (10, 3), (20, 5), (40, 10), (80, 20) };
|
||||
|
||||
foreach (var (hpPeriod, ssfPeriod) in paramSets)
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = hpPeriod, SsfPeriod = ssfPeriod };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add enough bars to fill the buffer
|
||||
for (int i = 0; i < hpPeriod + 10; i++)
|
||||
{
|
||||
double close = 100 + (i % 10);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), close, close + 2, close - 2, close);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// Last value should be finite
|
||||
double sineValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(sineValue), $"HP {hpPeriod}, SSF {ssfPeriod} should produce finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ConstantPrice_ProducesBoundedOutput()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add constant price bars
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// Output is normalized to [-1, +1]
|
||||
double sineValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(sineValue >= -1.0 && sineValue <= 1.0,
|
||||
$"SINE value {sineValue} should be in [-1, +1]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_OutputBounded_BetweenNegativeOneAndOne()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add varying price bars
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100 + 20 * Math.Sin(i * 0.2);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
double sineValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(sineValue >= -1.0 && sineValue <= 1.0,
|
||||
$"SINE value {sineValue} should be in [-1, +1]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_OscillatesAroundZero_ForSineWave()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 40, SsfPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var values = new List<double>();
|
||||
|
||||
// Generate sine wave price pattern
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(i * 0.1);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
values.Add(indicator.LinesSeries[0].GetValue(0));
|
||||
}
|
||||
|
||||
// Should have both positive and negative values
|
||||
int positiveCount = values.Count(v => v > 0);
|
||||
int negativeCount = values.Count(v => v < 0);
|
||||
|
||||
Assert.True(positiveCount > 0, "Should have positive SINE values");
|
||||
Assert.True(negativeCount > 0, "Should have negative SINE values");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SineIndicator_ZeroCrossings_IndicateCyclePhase()
|
||||
{
|
||||
var indicator = new SineIndicator { HpPeriod = 20, SsfPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var values = new List<double>();
|
||||
|
||||
// Generate sine wave price pattern
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(i * 0.15);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
values.Add(indicator.LinesSeries[0].GetValue(0));
|
||||
}
|
||||
|
||||
// Count zero crossings
|
||||
int crossings = 0;
|
||||
for (int i = 1; i < values.Count; i++)
|
||||
{
|
||||
if (values[i - 1] * values[i] < 0)
|
||||
{
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
|
||||
// Should have multiple zero crossings for oscillating price
|
||||
Assert.True(crossings >= 3, $"Should have multiple zero crossings, got {crossings}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class SineIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("HP Period", sortIndex: 1, 1, 2000, 1, 0)]
|
||||
public int HpPeriod { get; set; } = 40;
|
||||
|
||||
[InputParameter("SSF Period", sortIndex: 2, 1, 500, 1, 0)]
|
||||
public int SsfPeriod { 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 Sine _sine = null!;
|
||||
private readonly LineSeries _series;
|
||||
private readonly LineSeries _zeroLine;
|
||||
private readonly LineSeries _upperLine;
|
||||
private readonly LineSeries _lowerLine;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"SINE ({HpPeriod},{SsfPeriod})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/sine/Sine.Quantower.cs";
|
||||
|
||||
public SineIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "SINE - Ehlers Sine Wave";
|
||||
Description = "Ehlers' Sine Wave indicator extracts the dominant cycle from price data using High-Pass filter, Super-Smoother, and Hilbert Transform";
|
||||
|
||||
_series = new LineSeries(name: "SINE", color: IndicatorExtensions.Oscillators, width: 2, style: LineStyle.Solid);
|
||||
_zeroLine = new LineSeries(name: "Zero", color: Color.Gray, width: 1, style: LineStyle.Dash);
|
||||
_upperLine = new LineSeries(name: "+1", color: Color.DarkGray, width: 1, style: LineStyle.Dot);
|
||||
_lowerLine = new LineSeries(name: "-1", color: Color.DarkGray, width: 1, style: LineStyle.Dot);
|
||||
AddLineSeries(_series);
|
||||
AddLineSeries(_zeroLine);
|
||||
AddLineSeries(_upperLine);
|
||||
AddLineSeries(_lowerLine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_sine = new Sine(HpPeriod, SsfPeriod);
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
if (args.Reason != UpdateReason.NewBar && args.Reason != UpdateReason.HistoricalBar)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = this.HistoricalData[this.Count - 1, SeekOriginHistory.Begin];
|
||||
double value = _priceSelector(item);
|
||||
var time = this.HistoricalData.Time();
|
||||
|
||||
var input = new TValue(time, value);
|
||||
TValue result = _sine.Update(input, args.IsNewBar());
|
||||
|
||||
_series.SetValue(result.Value, _sine.IsHot, ShowColdValues);
|
||||
_zeroLine.SetValue(0.0);
|
||||
_upperLine.SetValue(1.0);
|
||||
_lowerLine.SetValue(-1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
using Xunit;
|
||||
|
||||
public class SineTests
|
||||
{
|
||||
private const double Tolerance = 1e-9;
|
||||
private readonly GBM _gbm;
|
||||
|
||||
public SineTests()
|
||||
{
|
||||
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
}
|
||||
|
||||
private TBarSeries GenerateBars(int count)
|
||||
{
|
||||
return _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromDays(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstructorDefaults()
|
||||
{
|
||||
var sine = new Sine();
|
||||
Assert.Equal("SINE", sine.Name);
|
||||
Assert.Equal(40, sine.HpPeriod);
|
||||
Assert.Equal(10, sine.SsfPeriod);
|
||||
Assert.Equal(48, sine.WarmupPeriod); // max(40, 10) + 8
|
||||
Assert.False(sine.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstructorCustomParameters()
|
||||
{
|
||||
var sine = new Sine(hpPeriod: 20, ssfPeriod: 5);
|
||||
Assert.Equal(20, sine.HpPeriod);
|
||||
Assert.Equal(5, sine.SsfPeriod);
|
||||
Assert.Equal(28, sine.WarmupPeriod); // max(20, 5) + 8
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstructorValidation_ThrowsOnInvalidHpPeriod()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sine(hpPeriod: 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sine(hpPeriod: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstructorValidation_ThrowsOnInvalidSsfPeriod()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sine(ssfPeriod: 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sine(ssfPeriod: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Update_ReturnsValidRange()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var bars = GenerateBars(200);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
var result = sine.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(result.Value >= -1.0 && result.Value <= 1.0,
|
||||
$"Sine value {result.Value} out of range [-1, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_IsHot_AfterWarmup()
|
||||
{
|
||||
var sine = new Sine(hpPeriod: 20, ssfPeriod: 5);
|
||||
var bars = GenerateBars(50);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
sine.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
|
||||
if (i + 1 < sine.WarmupPeriod)
|
||||
{
|
||||
Assert.False(sine.IsHot, $"Should not be hot at index {i}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(sine.IsHot, $"Should be hot at index {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_IsNew_AdvancesState()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var input = new TValue(DateTime.UtcNow, 100.0);
|
||||
|
||||
var result1 = sine.Update(input, isNew: true);
|
||||
var result2 = sine.Update(new TValue(DateTime.UtcNow.AddDays(1), 101.0), isNew: true);
|
||||
|
||||
// With isNew=true, each call should advance state
|
||||
// Values might be the same early on, but state should advance
|
||||
Assert.NotEqual(result1.Time, result2.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_IsNew_False_UpdatesCurrentBar()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var bars = GenerateBars(60);
|
||||
|
||||
// Process first 50 bars normally
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sine.Update(new TValue(bars[i].Time, bars[i].Close), isNew: true);
|
||||
}
|
||||
|
||||
// Get result at bar 50
|
||||
var newBarResult = sine.Update(new TValue(bars[50].Time, bars[50].Close), isNew: true);
|
||||
|
||||
// Reset and replay to bar 49, then update bar 50 with different value
|
||||
var sine2 = new Sine();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sine2.Update(new TValue(bars[i].Time, bars[i].Close), isNew: true);
|
||||
}
|
||||
|
||||
// First update bar 50
|
||||
sine2.Update(new TValue(bars[50].Time, bars[50].Close), isNew: true);
|
||||
|
||||
// Update same bar with different value (bar correction)
|
||||
var correctedResult = sine2.Update(new TValue(bars[50].Time, bars[50].Close * 1.1), isNew: false);
|
||||
|
||||
// Results should differ due to different input
|
||||
Assert.NotEqual(newBarResult.Value, correctedResult.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Reset_ClearsState()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var bars = GenerateBars(100);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
sine.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
Assert.True(sine.IsHot);
|
||||
|
||||
sine.Reset();
|
||||
|
||||
Assert.False(sine.IsHot);
|
||||
Assert.Equal(0, sine.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_TSeries_Update()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
var series = new TSeries(100);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
series.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var sine = new Sine();
|
||||
var result = sine.Update(series);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
|
||||
// Verify all values are in range
|
||||
foreach (var val in result)
|
||||
{
|
||||
Assert.True(val.Value >= -1.0 && val.Value <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_StaticCalculate_TSeries()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
var series = new TSeries(100);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
series.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var result = Sine.Calculate(series);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_StaticCalculate_WithCustomParams()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
var series = new TSeries(100);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
series.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var result = Sine.Calculate(series, hpPeriod: 20, ssfPeriod: 5);
|
||||
|
||||
Assert.Equal(100, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Chaining_Works()
|
||||
{
|
||||
var source = new Sma(10);
|
||||
var sine = new Sine(source);
|
||||
|
||||
bool eventFired = false;
|
||||
sine.Pub += (object? sender, in TValueEventArgs args) => eventFired = true;
|
||||
|
||||
var input = new TValue(DateTime.UtcNow, 100.0);
|
||||
source.Update(input);
|
||||
|
||||
Assert.True(eventFired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_EmptyTSeries_ReturnsEmpty()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var empty = new TSeries();
|
||||
var result = sine.Update(empty);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Streaming_MatchesBatch()
|
||||
{
|
||||
var bars = GenerateBars(200);
|
||||
var series = new TSeries(200);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
series.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Streaming calculation
|
||||
var streamingSine = new Sine();
|
||||
var streamingResults = new List<double>();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
var result = streamingSine.Update(new TValue(bar.Time, bar.Close));
|
||||
streamingResults.Add(result.Value);
|
||||
}
|
||||
|
||||
// Batch calculation
|
||||
var batchResult = Sine.Calculate(series);
|
||||
|
||||
// Compare last 100 values (after warmup)
|
||||
for (int i = 100; i < 200; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batchResult[i].Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_NaN_HandledGracefully()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var bars = GenerateBars(60);
|
||||
|
||||
// Process some bars
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
sine.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
|
||||
// Feed NaN - should substitute with last valid value
|
||||
var nanResult = sine.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
// Result should NOT be NaN (last-valid substitution) and in valid range
|
||||
Assert.False(double.IsNaN(nanResult.Value), "NaN should not propagate");
|
||||
Assert.True(nanResult.Value >= -1.0 && nanResult.Value <= 1.0,
|
||||
$"Value {nanResult.Value} should be in [-1, 1]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_Prime_InitializesState()
|
||||
{
|
||||
var bars = GenerateBars(100);
|
||||
var primeData = bars.Select(b => b.Close).ToArray();
|
||||
|
||||
var sine = new Sine();
|
||||
sine.Prime(primeData);
|
||||
|
||||
Assert.True(sine.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_WithCyclingData_ProducesOscillation()
|
||||
{
|
||||
var sine = new Sine(hpPeriod: 20, ssfPeriod: 5);
|
||||
|
||||
// Generate sinusoidal price data
|
||||
var results = new List<double>();
|
||||
var baseTime = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
// Create a price with embedded 30-bar cycle
|
||||
double price = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / 30.0);
|
||||
var result = sine.Update(new TValue(baseTime.AddDays(i), price));
|
||||
results.Add(result.Value);
|
||||
}
|
||||
|
||||
// After warmup, check that we have both positive and negative values
|
||||
var afterWarmup = results.Skip(30).ToList();
|
||||
Assert.True(afterWarmup.Any(v => v > 0.5), "Should have positive cycle values");
|
||||
Assert.True(afterWarmup.Any(v => v < -0.5), "Should have negative cycle values");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_ConstantInput_ProducesValidOutput()
|
||||
{
|
||||
var sine = new Sine();
|
||||
var baseTime = DateTime.UtcNow;
|
||||
|
||||
// Feed constant values
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var result = sine.Update(new TValue(baseTime.AddDays(i), 100.0));
|
||||
// Output should always be in valid range regardless of input
|
||||
Assert.True(result.Value >= -1.0 && result.Value <= 1.0,
|
||||
$"Value {result.Value} out of range at index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sine_TrendingInput_ProducesValidOutput()
|
||||
{
|
||||
var sine = new Sine(hpPeriod: 40, ssfPeriod: 10);
|
||||
var baseTime = DateTime.UtcNow;
|
||||
|
||||
// Feed trending data (very low frequency)
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
double price = 100.0 + i * 0.1; // Slow uptrend
|
||||
var result = sine.Update(new TValue(baseTime.AddDays(i), price));
|
||||
// Output should always be in valid range regardless of input
|
||||
Assert.True(result.Value >= -1.0 && result.Value <= 1.0,
|
||||
$"Value {result.Value} out of range at index {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Ehlers Sine Wave (SINE) - Cycle extraction using Hilbert Transform
|
||||
// Uses High-Pass filter + Super-Smoother + Hilbert Transform to extract sine wave
|
||||
// Based on John Ehlers' "Cybernetic Analysis for Stocks and Futures"
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Ehlers Sine Wave indicator extracts the dominant cycle from price data.
|
||||
/// Uses a High-Pass filter for detrending, Super-Smoother for noise reduction,
|
||||
/// and Hilbert Transform FIR for quadrature component extraction.
|
||||
/// Output ranges from -1.0 to +1.0 representing the normalized sine wave.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Sine : AbstractBase
|
||||
{
|
||||
private readonly int _hpPeriod;
|
||||
private readonly int _ssfPeriod;
|
||||
private readonly RingBuffer _srcBuffer;
|
||||
private readonly RingBuffer _hpBuffer;
|
||||
private readonly RingBuffer _filtBuffer;
|
||||
|
||||
// High-Pass filter coefficient
|
||||
private readonly double _alphaHP;
|
||||
|
||||
// Super-Smoother coefficients
|
||||
private readonly double _c1, _c2, _c3;
|
||||
|
||||
// Hilbert FIR coefficients
|
||||
private const double H1 = 0.0962;
|
||||
private const double H2 = 0.5769;
|
||||
|
||||
// State tracking
|
||||
private int _count;
|
||||
|
||||
public int HpPeriod => _hpPeriod;
|
||||
public int SsfPeriod => _ssfPeriod;
|
||||
public override bool IsHot => _count >= WarmupPeriod;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Ehlers Sine Wave indicator.
|
||||
/// </summary>
|
||||
/// <param name="hpPeriod">High-Pass filter period for detrending (default: 40)</param>
|
||||
/// <param name="ssfPeriod">Super-Smoother filter period for smoothing (default: 10)</param>
|
||||
public Sine(int hpPeriod = 40, int ssfPeriod = 10)
|
||||
{
|
||||
if (hpPeriod < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(hpPeriod), "High-Pass period must be >= 1");
|
||||
}
|
||||
if (ssfPeriod < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ssfPeriod), "Super-Smoother period must be >= 1");
|
||||
}
|
||||
|
||||
_hpPeriod = hpPeriod;
|
||||
_ssfPeriod = ssfPeriod;
|
||||
Name = "SINE";
|
||||
WarmupPeriod = Math.Max(hpPeriod, ssfPeriod) + 8; // +8 for Hilbert lookback
|
||||
|
||||
// High-Pass filter coefficient
|
||||
double angHP = 2.0 * Math.PI / hpPeriod;
|
||||
_alphaHP = (1.0 - Math.Sin(angHP)) / Math.Cos(angHP);
|
||||
|
||||
// Super-Smoother coefficients (2-pole Butterworth)
|
||||
double angSSF = Math.Sqrt(2.0) * Math.PI / ssfPeriod;
|
||||
double aSSF = Math.Exp(-angSSF);
|
||||
double bSSF = 2.0 * aSSF * Math.Cos(angSSF);
|
||||
_c2 = bSSF;
|
||||
_c3 = -aSSF * aSSF;
|
||||
_c1 = 1.0 - _c2 - _c3;
|
||||
|
||||
// Buffers for historical values
|
||||
_srcBuffer = new RingBuffer(2); // src[0], src[1]
|
||||
_hpBuffer = new RingBuffer(2); // hp[0], hp[1]
|
||||
_filtBuffer = new RingBuffer(8); // filt[0..7] for Hilbert
|
||||
|
||||
_count = 0;
|
||||
Last = new TValue(DateTime.UtcNow, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a chained Sine indicator.
|
||||
/// </summary>
|
||||
public Sine(ITValuePublisher source, int hpPeriod = 40, int ssfPeriod = 10) : this(hpPeriod, ssfPeriod)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
source.Pub += HandleInput;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void HandleInput(object? sender, in TValueEventArgs e)
|
||||
{
|
||||
Update(e.Value, e.IsNew);
|
||||
}
|
||||
|
||||
// Last valid value for NaN substitution
|
||||
private double _lastValidValue;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
double src = input.Value;
|
||||
|
||||
// Handle NaN/Infinity: substitute with last valid value
|
||||
if (!double.IsFinite(src))
|
||||
{
|
||||
src = _lastValidValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastValidValue = src;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_srcBuffer.Add(src);
|
||||
_count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_srcBuffer.UpdateNewest(src);
|
||||
}
|
||||
|
||||
// High-Pass filter: hp = 0.5 * (1 + α) * (src - src[1]) + α * hp[1]
|
||||
double src1 = _srcBuffer.Count > 1 ? _srcBuffer[0] : 0;
|
||||
double hp1 = _hpBuffer.Count > 0 ? _hpBuffer[^1] : 0;
|
||||
double hp = Math.FusedMultiplyAdd(0.5 * (1.0 + _alphaHP), src - src1, _alphaHP * hp1);
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_hpBuffer.Add(hp);
|
||||
}
|
||||
else
|
||||
{
|
||||
_hpBuffer.UpdateNewest(hp);
|
||||
}
|
||||
|
||||
// Super-Smoother: filt = c1 * (hp + hp[1]) / 2 + c2 * filt[1] + c3 * filt[2]
|
||||
double hp1b = _hpBuffer.Count > 1 ? _hpBuffer[0] : hp;
|
||||
double filt1 = _filtBuffer.Count > 0 ? _filtBuffer[^1] : 0;
|
||||
double filt2 = _filtBuffer.Count > 1 ? _filtBuffer[^2] : 0;
|
||||
double filt = Math.FusedMultiplyAdd(_c1, (hp + hp1b) / 2.0,
|
||||
Math.FusedMultiplyAdd(_c2, filt1, _c3 * filt2));
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_filtBuffer.Add(filt);
|
||||
}
|
||||
else
|
||||
{
|
||||
_filtBuffer.UpdateNewest(filt);
|
||||
}
|
||||
|
||||
// Hilbert Transform for quadrature component Q
|
||||
// Q = 0.0962 * filt[3] + 0.5769 * filt[1] - 0.5769 * filt[5] - 0.0962 * filt[7]
|
||||
// Using ^N for from-end indexing: ^1 = newest, ^2 = second newest, etc.
|
||||
double filt1q = _filtBuffer.Count > 1 ? _filtBuffer[^2] : 0;
|
||||
double filt3 = _filtBuffer.Count > 3 ? _filtBuffer[^4] : 0;
|
||||
double filt5 = _filtBuffer.Count > 5 ? _filtBuffer[^6] : 0;
|
||||
double filt7 = _filtBuffer.Count > 7 ? _filtBuffer[^8] : 0;
|
||||
|
||||
double Q = Math.FusedMultiplyAdd(H1, filt3,
|
||||
Math.FusedMultiplyAdd(H2, filt1q,
|
||||
Math.FusedMultiplyAdd(-H2, filt5, -H1 * filt7)));
|
||||
|
||||
// In-phase component I = filt (current smoothed value)
|
||||
double I = filt;
|
||||
|
||||
// Power and normalization
|
||||
double pwr = (I * I) + (Q * Q);
|
||||
double sineWave = pwr < double.Epsilon ? 0.0 : I / Math.Sqrt(pwr);
|
||||
|
||||
// Clamp to [-1, 1]
|
||||
sineWave = Math.Clamp(sineWave, -1.0, 1.0);
|
||||
|
||||
Last = new TValue(input.Time, sineWave);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Sine for an entire TSeries.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
// Reset and process each value
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var result = Update(source[i], true);
|
||||
tSpan[i] = source.Times[i];
|
||||
vSpan[i] = result.Value;
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Sine indicator and calculates for the source series.
|
||||
/// </summary>
|
||||
public static TSeries Calculate(TSeries source, int hpPeriod = 40, int ssfPeriod = 10)
|
||||
{
|
||||
var sine = new Sine(hpPeriod, ssfPeriod);
|
||||
return sine.Update(source);
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_srcBuffer.Clear();
|
||||
_hpBuffer.Clear();
|
||||
_filtBuffer.Clear();
|
||||
_count = 0;
|
||||
Last = new TValue(DateTime.UtcNow, 0);
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
TimeSpan interval = step ?? TimeSpan.FromDays(1);
|
||||
DateTime baseTime = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(baseTime + (interval * i), source[i]), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user