mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-25 22:08:05 +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,295 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Quantower.Tests;
|
||||
|
||||
public class HtSineIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void HtSineIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("HT_SINE - Hilbert Transform SineWave", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_MinHistoryDepths_Equals63()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
|
||||
Assert.Equal(63, HtSineIndicator.MinHistoryDepths);
|
||||
Assert.Equal(63, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_ShortName_IsHtSine()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
|
||||
Assert.Equal("HT_SINE", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_Initialize_CreatesInternalIndicator()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (Sine + LeadSine + Zero lines)
|
||||
Assert.Equal(3, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
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 HtSineIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
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 HtSineIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
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 HtSineIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
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 sine 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 HtSineIndicator_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 HtSineIndicator { 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 HtSineIndicator_Source_CanBeChanged()
|
||||
{
|
||||
var indicator = new HtSineIndicator { Source = SourceType.Close };
|
||||
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
|
||||
indicator.Source = SourceType.Open;
|
||||
Assert.Equal(SourceType.Open, indicator.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_ShowColdValues_CanBeChanged()
|
||||
{
|
||||
var indicator = new HtSineIndicator { ShowColdValues = true };
|
||||
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_SineSeries_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var sineSeries = indicator.LinesSeries[0];
|
||||
|
||||
Assert.Equal("Sine", sineSeries.Name);
|
||||
Assert.Equal(2, sineSeries.Width);
|
||||
Assert.Equal(LineStyle.Solid, sineSeries.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_LeadSineSeries_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var leadSineSeries = indicator.LinesSeries[1];
|
||||
|
||||
Assert.Equal("LeadSine", leadSineSeries.Name);
|
||||
Assert.Equal(1, leadSineSeries.Width);
|
||||
Assert.Equal(LineStyle.Solid, leadSineSeries.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_ZeroLine_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var zeroLine = indicator.LinesSeries[2];
|
||||
|
||||
Assert.Equal("Zero", zeroLine.Name);
|
||||
Assert.Equal(1, zeroLine.Width);
|
||||
Assert.Equal(LineStyle.Dash, zeroLine.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_BothOutputs_ProducedAfterWarmup()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Add enough bars to pass warmup (63 bars)
|
||||
for (int i = 0; i < 70; 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));
|
||||
}
|
||||
|
||||
// Both sine and leadsine should have values
|
||||
double sineValue = indicator.LinesSeries[0].GetValue(0);
|
||||
double leadSineValue = indicator.LinesSeries[1].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(sineValue), "Sine should produce finite value");
|
||||
Assert.True(double.IsFinite(leadSineValue), "LeadSine should produce finite value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_OutputsInRangeMinusOneToOne()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Generate enough data
|
||||
for (int i = 0; i < 100; 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));
|
||||
}
|
||||
|
||||
// Check all values are in range [-1, 1]
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double sineValue = indicator.LinesSeries[0].GetValue(99 - i);
|
||||
double leadSineValue = indicator.LinesSeries[1].GetValue(99 - i);
|
||||
|
||||
Assert.InRange(sineValue, -1.0, 1.0);
|
||||
Assert.InRange(leadSineValue, -1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_LeadSineLeadsSine()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var sineValues = new List<double>();
|
||||
var leadSineValues = new List<double>();
|
||||
|
||||
// Generate cyclic price pattern
|
||||
for (int i = 0; i < 100; 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));
|
||||
sineValues.Add(indicator.LinesSeries[0].GetValue(0));
|
||||
leadSineValues.Add(indicator.LinesSeries[1].GetValue(0));
|
||||
}
|
||||
|
||||
// LeadSine should generally cross zero before Sine (phase lead)
|
||||
// Count zero crossings where LeadSine leads
|
||||
int leadsCount = 0;
|
||||
for (int i = 70; i < sineValues.Count - 1; i++)
|
||||
{
|
||||
// Check if LeadSine crossed zero in this bar
|
||||
bool leadCrossed = (leadSineValues[i - 1] <= 0 && leadSineValues[i] > 0) ||
|
||||
(leadSineValues[i - 1] >= 0 && leadSineValues[i] < 0);
|
||||
if (leadCrossed)
|
||||
{
|
||||
leadsCount++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(leadsCount >= 0, "LeadSine should have zero crossings");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSineIndicator_SourceCodeLink_PointsToGitHub()
|
||||
{
|
||||
var indicator = new HtSineIndicator();
|
||||
|
||||
Assert.Contains("github.com/mihakralj/QuanTAlib", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("HtSine.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class HtSineIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private HtSine _htSine = null!;
|
||||
private readonly LineSeries _sineSeries;
|
||||
private readonly LineSeries _leadSineSeries;
|
||||
private readonly LineSeries _zeroLine;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 63;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => "HT_SINE";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/ht_sine/HtSine.Quantower.cs";
|
||||
|
||||
public HtSineIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "HT_SINE - Hilbert Transform SineWave";
|
||||
Description = "Hilbert Transform SineWave indicator showing Sine and LeadSine for cycle timing";
|
||||
|
||||
_sineSeries = new LineSeries(name: "Sine", color: IndicatorExtensions.Oscillators, width: 2, style: LineStyle.Solid);
|
||||
_leadSineSeries = new LineSeries(name: "LeadSine", color: Color.Orange, width: 1, style: LineStyle.Solid);
|
||||
_zeroLine = new LineSeries(name: "Zero", color: Color.Gray, width: 1, style: LineStyle.Dash);
|
||||
AddLineSeries(_sineSeries);
|
||||
AddLineSeries(_leadSineSeries);
|
||||
AddLineSeries(_zeroLine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_htSine = new HtSine();
|
||||
_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 = _htSine.Update(input, args.IsNewBar());
|
||||
|
||||
_sineSeries.SetValue(result.Value, _htSine.IsHot, ShowColdValues);
|
||||
_leadSineSeries.SetValue(_htSine.LeadSine, _htSine.IsHot, ShowColdValues);
|
||||
_zeroLine.SetValue(0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class HtSineTests
|
||||
{
|
||||
private const double Tolerance = 1e-9;
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsProperties()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
Assert.Equal("HtSine", htSine.Name);
|
||||
Assert.False(htSine.IsHot);
|
||||
Assert.Equal(63, htSine.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullSource_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new HtSine(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidSource_Subscribes()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var htSine = new HtSine(source);
|
||||
|
||||
source.Add(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.NotEqual(default, htSine.Last);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basic Calculation Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsValidTValue()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
var result = htSine.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AfterWarmup_IsHotTrue()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
htSine.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
Assert.True(htSine.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_SineInBoundedRange()
|
||||
{
|
||||
// Sine values should be between -1 and +1
|
||||
var htSine = new HtSine();
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
htSine.Update(new TValue(bar.Time, bar.Close));
|
||||
|
||||
if (htSine.IsHot)
|
||||
{
|
||||
Assert.InRange(htSine.Last.Value, -1.0, 1.0);
|
||||
Assert.InRange(htSine.LeadSine, -1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_LeadSineIsAccessible()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.1) * 10));
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(htSine.LeadSine));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_SineWaveInput_DetectsCycle()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
// Feed a perfect sine wave with known period
|
||||
const int period = 20;
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / period);
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
|
||||
// Should have detected a cycle and produce valid output
|
||||
Assert.True(htSine.IsHot);
|
||||
Assert.InRange(htSine.Last.Value, -1.0, 1.0);
|
||||
Assert.InRange(htSine.LeadSine, -1.0, 1.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bar Correction Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
// Build some history first
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.1), isNew: true);
|
||||
}
|
||||
var first = htSine.Last.Value;
|
||||
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 200.0), isNew: true);
|
||||
var second = htSine.Last.Value;
|
||||
|
||||
// Values should be different after processing different prices
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_ReplacesCurrentBar()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
// Build some history first
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.1), isNew: true);
|
||||
}
|
||||
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 150.0), isNew: true);
|
||||
var beforeCorrection = htSine.Last.Value;
|
||||
|
||||
// Correct the bar with a significantly different value
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 50.0), isNew: false);
|
||||
var afterCorrection = htSine.Last.Value;
|
||||
|
||||
Assert.NotEqual(beforeCorrection, afterCorrection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_MultipleCorrections_RestoresToSnapshot()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
// Build some history
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.1), isNew: true);
|
||||
}
|
||||
|
||||
// Add a new bar
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 150.0), isNew: true);
|
||||
var originalValue = htSine.Last.Value;
|
||||
|
||||
// Correct multiple times
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 160.0), isNew: false);
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 140.0), isNew: false);
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(100), 150.0), isNew: false);
|
||||
var restoredValue = htSine.Last.Value;
|
||||
|
||||
Assert.Equal(originalValue, restoredValue, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.True(htSine.IsHot);
|
||||
|
||||
htSine.Reset();
|
||||
|
||||
Assert.False(htSine.IsHot);
|
||||
Assert.Equal(default, htSine.Last);
|
||||
Assert.Equal(0, htSine.LeadSine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_AllowsReuse()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
// First run
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(i * 0.1);
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
var firstResult = htSine.Last.Value;
|
||||
|
||||
htSine.Reset();
|
||||
|
||||
// Second run with same data
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(i * 0.1);
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
var secondResult = htSine.Last.Value;
|
||||
|
||||
Assert.Equal(firstResult, secondResult, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NaN/Infinity Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_NaN_UsesLastValidValue()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
htSine.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.NaN));
|
||||
var afterNaN = htSine.Last.Value;
|
||||
|
||||
Assert.True(double.IsFinite(afterNaN));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Infinity_UsesLastValidValue()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
htSine.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.PositiveInfinity));
|
||||
|
||||
Assert.True(double.IsFinite(htSine.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NegativeInfinity_UsesLastValidValue()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
htSine.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.NegativeInfinity));
|
||||
|
||||
Assert.True(double.IsFinite(htSine.Last.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consistency Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(42)]
|
||||
[InlineData(123)]
|
||||
[InlineData(999)]
|
||||
public void Update_StreamingMatchesBatch(int seed)
|
||||
{
|
||||
const int dataLen = 200;
|
||||
|
||||
var gbm = new GBM(seed: seed);
|
||||
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Streaming
|
||||
var streaming = new HtSine();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
streaming.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Batch via TSeries
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var batch = HtSine.Calculate(tSeries);
|
||||
|
||||
// Compare last values
|
||||
Assert.Equal(batch[^1].Value, streaming.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_MatchesStreaming()
|
||||
{
|
||||
const int dataLen = 200;
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Streaming
|
||||
var streaming = new HtSine();
|
||||
var streamingSine = new double[dataLen];
|
||||
var streamingLeadSine = new double[dataLen];
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
streaming.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
streamingSine[i] = streaming.Last.Value;
|
||||
streamingLeadSine[i] = streaming.LeadSine;
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] source = new double[dataLen];
|
||||
double[] batchSine = new double[dataLen];
|
||||
double[] batchLeadSine = new double[dataLen];
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
source[i] = bars[i].Close;
|
||||
}
|
||||
|
||||
HtSine.Batch(source, batchSine, batchLeadSine);
|
||||
|
||||
// Compare all values
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
Assert.Equal(streamingSine[i], batchSine[i], Tolerance);
|
||||
Assert.Equal(streamingLeadSine[i], batchLeadSine[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Span API Tests
|
||||
|
||||
[Fact]
|
||||
public void Batch_ValidatesSineLengthMismatch()
|
||||
{
|
||||
double[] source = new double[100];
|
||||
double[] sine = new double[50];
|
||||
double[] leadSine = new double[100];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => HtSine.Batch(source, sine, leadSine));
|
||||
Assert.Equal("sine", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ValidatesLeadSineLengthMismatch()
|
||||
{
|
||||
double[] source = new double[100];
|
||||
double[] sine = new double[100];
|
||||
double[] leadSine = new double[50];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => HtSine.Batch(source, sine, leadSine));
|
||||
Assert.Equal("leadSine", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptyArrays_NoException()
|
||||
{
|
||||
double[] source = [];
|
||||
double[] sine = [];
|
||||
double[] leadSine = [];
|
||||
|
||||
var ex = Record.Exception(() => HtSine.Batch(source, sine, leadSine));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_HandlesNaN()
|
||||
{
|
||||
double[] source = { 100, 101, double.NaN, 103, 104, 105, 106, 107, 108, 109 };
|
||||
double[] sine = new double[10];
|
||||
double[] leadSine = new double[10];
|
||||
|
||||
HtSine.Batch(source, sine, leadSine);
|
||||
|
||||
foreach (double v in sine)
|
||||
{
|
||||
Assert.True(double.IsFinite(v));
|
||||
}
|
||||
foreach (double v in leadSine)
|
||||
{
|
||||
Assert.True(double.IsFinite(v));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Chaining Tests
|
||||
|
||||
[Fact]
|
||||
public void Chaining_PropagatesUpdates()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var htSine = new HtSine(source);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.1) * 10));
|
||||
}
|
||||
|
||||
Assert.True(htSine.IsHot);
|
||||
Assert.True(double.IsFinite(htSine.Last.Value));
|
||||
Assert.True(double.IsFinite(htSine.LeadSine));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Phase Lead Tests
|
||||
|
||||
[Fact]
|
||||
public void LeadSine_IsPhaseShifted()
|
||||
{
|
||||
// LeadSine should be sin(phase + π/4), which means it leads by 45°
|
||||
var htSine = new HtSine();
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
htSine.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Both should be valid after warmup
|
||||
Assert.True(double.IsFinite(htSine.Last.Value));
|
||||
Assert.True(double.IsFinite(htSine.LeadSine));
|
||||
|
||||
// They should generally be different (unless at specific phase points)
|
||||
// We just verify both are in valid range
|
||||
Assert.InRange(htSine.Last.Value, -1.0, 1.0);
|
||||
Assert.InRange(htSine.LeadSine, -1.0, 1.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantSeries_ProducesValidOutput()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
|
||||
}
|
||||
|
||||
// Should produce valid (finite) output even for constant input
|
||||
Assert.True(double.IsFinite(htSine.Last.Value));
|
||||
Assert.True(double.IsFinite(htSine.LeadSine));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_StepChange_HandlesGracefully()
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
|
||||
// Constant series then step change
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
|
||||
}
|
||||
for (int i = 100; i < 200; i++)
|
||||
{
|
||||
htSine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 200.0));
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(htSine.Last.Value));
|
||||
Assert.InRange(htSine.Last.Value, -1.0, 1.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Xunit;
|
||||
using TALib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class HtSineValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _data;
|
||||
private bool _disposed;
|
||||
|
||||
public HtSineValidationTests()
|
||||
{
|
||||
_data = new ValidationTestData(5000);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_data?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TaLib()
|
||||
{
|
||||
// Calculate TA-Lib HtSine
|
||||
var input = _data.RawData.Span;
|
||||
var outSine = new double[input.Length];
|
||||
var outLeadSine = new double[input.Length];
|
||||
var retCode = TALib.Functions.HtSine(input, 0..^0, outSine, outLeadSine, out var outRange);
|
||||
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
// Calculate QuanTAlib HtSine
|
||||
var htSine = new HtSine();
|
||||
var quantalibResults = htSine.Update(_data.Data);
|
||||
var quantLeadSine = new List<double>();
|
||||
|
||||
// Get LeadSine values by re-running
|
||||
var htSine2 = new HtSine();
|
||||
foreach (var tv in _data.Data)
|
||||
{
|
||||
htSine2.Update(tv);
|
||||
quantLeadSine.Add(htSine2.LeadSine);
|
||||
}
|
||||
|
||||
// Compare results - TA-Lib HT_SINE has a lookback of 63
|
||||
int outLength = outRange.End.Value - outRange.Start.Value;
|
||||
for (int i = quantalibResults.Count - 100; i < quantalibResults.Count; i++)
|
||||
{
|
||||
int talibIdx = i - outRange.Start.Value;
|
||||
if (talibIdx >= 0 && talibIdx < outLength)
|
||||
{
|
||||
double talibSineValue = outSine[talibIdx];
|
||||
double talibLeadSineValue = outLeadSine[talibIdx];
|
||||
double quantalibSineValue = quantalibResults.Values[i];
|
||||
double quantalibLeadSineValue = quantLeadSine[i];
|
||||
Assert.Equal(talibSineValue, quantalibSineValue, ValidationHelper.TalibTolerance);
|
||||
Assert.Equal(talibLeadSineValue, quantalibLeadSineValue, ValidationHelper.TalibTolerance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TaLib_Streaming()
|
||||
{
|
||||
// Calculate TA-Lib HtSine
|
||||
var input = _data.RawData.Span;
|
||||
var outSine = new double[input.Length];
|
||||
var outLeadSine = new double[input.Length];
|
||||
var retCode = TALib.Functions.HtSine(input, 0..^0, outSine, outLeadSine, out var outRange);
|
||||
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
// Calculate QuanTAlib HtSine Streaming
|
||||
var htSine = new HtSine();
|
||||
var streamingSine = new List<double>();
|
||||
var streamingLeadSine = new List<double>();
|
||||
|
||||
foreach (var item in _data.Data)
|
||||
{
|
||||
htSine.Update(item);
|
||||
streamingSine.Add(htSine.Last.Value);
|
||||
streamingLeadSine.Add(htSine.LeadSine);
|
||||
}
|
||||
|
||||
// Compare results
|
||||
int outLength = outRange.End.Value - outRange.Start.Value;
|
||||
for (int i = streamingSine.Count - 100; i < streamingSine.Count; i++)
|
||||
{
|
||||
int talibIdx = i - outRange.Start.Value;
|
||||
if (talibIdx >= 0 && talibIdx < outLength)
|
||||
{
|
||||
double talibSineValue = outSine[talibIdx];
|
||||
double talibLeadSineValue = outLeadSine[talibIdx];
|
||||
double quantalibSineValue = streamingSine[i];
|
||||
double quantalibLeadSineValue = streamingLeadSine[i];
|
||||
Assert.Equal(talibSineValue, quantalibSineValue, ValidationHelper.TalibTolerance);
|
||||
Assert.Equal(talibLeadSineValue, quantalibLeadSineValue, ValidationHelper.TalibTolerance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HtSine_Lookback_MatchesTalib()
|
||||
{
|
||||
int talibLookback = TALib.Functions.HtSineLookback();
|
||||
var htSine = new HtSine();
|
||||
|
||||
Assert.Equal(talibLookback, htSine.WarmupPeriod);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// HT_SINE: Hilbert Transform - SineWave indicator that uses the Hilbert Transform
|
||||
/// to compute the sine of the dominant cycle phase. Returns both Sine and LeadSine
|
||||
/// (45° phase lead) for cycle timing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Hilbert Transform SineWave indicator identifies the dominant market cycle
|
||||
/// and outputs the sine of the current phase angle. The LeadSine provides a 45°
|
||||
/// phase lead for early signal detection.
|
||||
///
|
||||
/// Key Features:
|
||||
/// - Oscillates between -1 and +1
|
||||
/// - Crossover of Sine/LeadSine indicates cycle turning points
|
||||
/// - Sine crossing LeadSine from below = potential buy
|
||||
/// - Sine crossing LeadSine from above = potential sell
|
||||
/// - Works best in ranging/cycling markets
|
||||
///
|
||||
/// Reference: John Ehlers' "Rocket Science for Traders", TA-Lib implementation
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class HtSine : AbstractBase
|
||||
{
|
||||
private const int LOOKBACK = 63; // 31 + 32 for TA-Lib compatibility
|
||||
private const int SMOOTH_PRICE_SIZE = 50;
|
||||
private const int CIRC_BUFFER_SIZE = 44; // 4 * 11 for Hilbert transform
|
||||
private const int PRICE_HISTORY_SIZE = 64; // Must hold at least LOOKBACK prices
|
||||
|
||||
// Hilbert transform constants (TA-Lib exact values)
|
||||
private const double A_CONST = 0.0962;
|
||||
private const double B_CONST = 0.5769;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current LeadSine value (45° phase lead).
|
||||
/// </summary>
|
||||
public double LeadSine { get; private set; }
|
||||
|
||||
// Hilbert buffer keys (matching TA-Lib HTHelper.HilbertKeys)
|
||||
private const int KEY_DETRENDER = 6;
|
||||
private const int KEY_Q1 = 17;
|
||||
private const int KEY_JI = 28;
|
||||
private const int KEY_JQ = 39;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double PrevI2, double PrevQ2, double Re, double Im,
|
||||
double Period, double SmoothPeriod, double DcPhase,
|
||||
double I1ForOddPrev3, double I1ForEvenPrev3,
|
||||
double I1ForOddPrev2, double I1ForEvenPrev2,
|
||||
double PeriodWMASub, double PeriodWMASum, double TrailingWMAValue,
|
||||
int TrailingWMAIdx, int HilbertIdx, int SmoothPriceIdx,
|
||||
double LastValidPrice, int Today, bool WmaInitialized
|
||||
)
|
||||
{
|
||||
public State() : this(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, double.NaN, 0, false) { }
|
||||
}
|
||||
|
||||
private State _state;
|
||||
private State _p_state;
|
||||
|
||||
private readonly double[] _circBuffer;
|
||||
private readonly double[] _p_circBuffer;
|
||||
private readonly double[] _smoothPrice;
|
||||
private readonly double[] _p_smoothPrice;
|
||||
private readonly double[] _priceHistory;
|
||||
private readonly double[] _p_priceHistory;
|
||||
|
||||
private readonly TValuePublishedHandler _handler;
|
||||
|
||||
public override bool IsHot => _state.Today >= LOOKBACK;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Hilbert Transform SineWave indicator.
|
||||
/// </summary>
|
||||
public HtSine()
|
||||
{
|
||||
Name = "HtSine";
|
||||
WarmupPeriod = LOOKBACK;
|
||||
_handler = Handle;
|
||||
|
||||
_circBuffer = new double[CIRC_BUFFER_SIZE];
|
||||
_p_circBuffer = new double[CIRC_BUFFER_SIZE];
|
||||
_smoothPrice = new double[SMOOTH_PRICE_SIZE];
|
||||
_p_smoothPrice = new double[SMOOTH_PRICE_SIZE];
|
||||
_priceHistory = new double[PRICE_HISTORY_SIZE];
|
||||
_p_priceHistory = new double[PRICE_HISTORY_SIZE];
|
||||
|
||||
Init();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a chained Hilbert Transform SineWave indicator.
|
||||
/// </summary>
|
||||
/// <param name="source">The source indicator to chain from.</param>
|
||||
public HtSine(ITValuePublisher source) : this()
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
source.Pub += _handler;
|
||||
}
|
||||
|
||||
private void Init()
|
||||
{
|
||||
Reset();
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_state = new State();
|
||||
_p_state = new State();
|
||||
|
||||
Array.Clear(_circBuffer);
|
||||
Array.Clear(_p_circBuffer);
|
||||
Array.Clear(_smoothPrice);
|
||||
Array.Clear(_p_smoothPrice);
|
||||
Array.Clear(_priceHistory);
|
||||
Array.Clear(_p_priceHistory);
|
||||
|
||||
LeadSine = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void Handle(object? sender, in TValueEventArgs e)
|
||||
{
|
||||
Update(e.Value, e.IsNew);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void DoHilbertTransform(
|
||||
Span<double> buffer, int baseKey, double input, bool isOdd, int hilbertIdx, double adjustedPrevPeriod)
|
||||
{
|
||||
double hilbertTempT = A_CONST * input;
|
||||
int hilbertIndex = baseKey - (isOdd ? 6 : 3) + hilbertIdx;
|
||||
int prevIndex = baseKey + (isOdd ? 1 : 2);
|
||||
int prevInputIndex = baseKey + (isOdd ? 3 : 4);
|
||||
|
||||
buffer[baseKey] = -buffer[hilbertIndex];
|
||||
buffer[hilbertIndex] = hilbertTempT;
|
||||
buffer[baseKey] += hilbertTempT;
|
||||
buffer[baseKey] -= buffer[prevIndex];
|
||||
buffer[prevIndex] = B_CONST * buffer[prevInputIndex];
|
||||
buffer[baseKey] += buffer[prevIndex];
|
||||
buffer[prevInputIndex] = input;
|
||||
buffer[baseKey] *= adjustedPrevPeriod;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CalcHilbertOdd(
|
||||
Span<double> buffer, double smoothedValue, int hilbertIdx, double adjustedPrevPeriod,
|
||||
out double i1ForEvenPrev3, double prevQ2, double prevI2, double i1ForOddPrev3,
|
||||
ref double i1ForEvenPrev2, out double q2, out double i2)
|
||||
{
|
||||
DoHilbertTransform(buffer, KEY_DETRENDER, smoothedValue, true, hilbertIdx, adjustedPrevPeriod);
|
||||
double input = buffer[KEY_DETRENDER];
|
||||
DoHilbertTransform(buffer, KEY_Q1, input, true, hilbertIdx, adjustedPrevPeriod);
|
||||
DoHilbertTransform(buffer, KEY_JI, i1ForOddPrev3, true, hilbertIdx, adjustedPrevPeriod);
|
||||
double input1 = buffer[KEY_Q1];
|
||||
DoHilbertTransform(buffer, KEY_JQ, input1, true, hilbertIdx, adjustedPrevPeriod);
|
||||
|
||||
q2 = 0.2 * (buffer[KEY_Q1] + buffer[KEY_JI]) + 0.8 * prevQ2;
|
||||
i2 = 0.2 * (i1ForOddPrev3 - buffer[KEY_JQ]) + 0.8 * prevI2;
|
||||
|
||||
// The variable I1 is the detrender delayed for 3 price bars.
|
||||
i1ForEvenPrev3 = i1ForEvenPrev2;
|
||||
i1ForEvenPrev2 = buffer[KEY_DETRENDER];
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CalcHilbertEven(
|
||||
Span<double> buffer, double smoothedValue, ref int hilbertIdx, double adjustedPrevPeriod,
|
||||
double i1ForEvenPrev3, double prevQ2, double prevI2, out double i1ForOddPrev3,
|
||||
ref double i1ForOddPrev2, out double q2, out double i2)
|
||||
{
|
||||
DoHilbertTransform(buffer, KEY_DETRENDER, smoothedValue, false, hilbertIdx, adjustedPrevPeriod);
|
||||
double input = buffer[KEY_DETRENDER];
|
||||
DoHilbertTransform(buffer, KEY_Q1, input, false, hilbertIdx, adjustedPrevPeriod);
|
||||
DoHilbertTransform(buffer, KEY_JI, i1ForEvenPrev3, false, hilbertIdx, adjustedPrevPeriod);
|
||||
double input1 = buffer[KEY_Q1];
|
||||
DoHilbertTransform(buffer, KEY_JQ, input1, false, hilbertIdx, adjustedPrevPeriod);
|
||||
|
||||
if (++hilbertIdx == 3)
|
||||
{
|
||||
hilbertIdx = 0;
|
||||
}
|
||||
|
||||
q2 = 0.2 * (buffer[KEY_Q1] + buffer[KEY_JI]) + 0.8 * prevQ2;
|
||||
i2 = 0.2 * (i1ForEvenPrev3 - buffer[KEY_JQ]) + 0.8 * prevI2;
|
||||
|
||||
// The variable i1 is the detrender delayed for 3 price bars.
|
||||
i1ForOddPrev3 = i1ForOddPrev2;
|
||||
i1ForOddPrev2 = buffer[KEY_DETRENDER];
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CalcSmoothedPeriod(
|
||||
ref double re, double i2, double q2, ref double prevI2, ref double prevQ2, ref double im, ref double period)
|
||||
{
|
||||
re = Math.FusedMultiplyAdd(0.2, i2 * prevI2 + q2 * prevQ2, 0.8 * re);
|
||||
im = Math.FusedMultiplyAdd(0.2, i2 * prevQ2 - q2 * prevI2, 0.8 * im);
|
||||
|
||||
prevQ2 = q2;
|
||||
prevI2 = i2;
|
||||
|
||||
double tempReal1 = period;
|
||||
if (im != 0.0 && re != 0.0)
|
||||
{
|
||||
double angle = Math.Atan(im / re);
|
||||
if (angle != 0.0)
|
||||
{
|
||||
period = (2.0 * Math.PI) / angle;
|
||||
}
|
||||
}
|
||||
|
||||
double tempReal2 = 1.5 * tempReal1;
|
||||
period = Math.Min(period, tempReal2);
|
||||
|
||||
tempReal2 = 0.67 * tempReal1;
|
||||
period = Math.Max(period, tempReal2);
|
||||
|
||||
period = Math.Clamp(period, 6.0, 50.0);
|
||||
period = Math.FusedMultiplyAdd(0.2, period, 0.8 * tempReal1);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double ComputeDcPhase(ReadOnlySpan<double> smoothPrice, double smoothPeriod, int smoothPriceIdx, int bufferSize)
|
||||
{
|
||||
int dcPeriodInt = (int)(smoothPeriod + 0.5);
|
||||
double realPart = 0.0;
|
||||
double imagPart = 0.0;
|
||||
|
||||
int idx = smoothPriceIdx;
|
||||
for (int i = 0; i < dcPeriodInt; i++)
|
||||
{
|
||||
double tempReal = i * 2.0 * Math.PI / dcPeriodInt;
|
||||
double tempReal2 = smoothPrice[idx];
|
||||
realPart += Math.Sin(tempReal) * tempReal2;
|
||||
imagPart += Math.Cos(tempReal) * tempReal2;
|
||||
|
||||
idx = idx == 0 ? bufferSize - 1 : idx - 1;
|
||||
}
|
||||
|
||||
double dcPhase;
|
||||
double absImagPart = Math.Abs(imagPart);
|
||||
if (absImagPart > 0.0)
|
||||
{
|
||||
dcPhase = Math.Atan(realPart / imagPart) * (180.0 / Math.PI);
|
||||
}
|
||||
else if (absImagPart <= 0.01)
|
||||
{
|
||||
if (realPart < 0.0)
|
||||
{
|
||||
dcPhase = -90.0;
|
||||
}
|
||||
else if (realPart > 0.0)
|
||||
{
|
||||
dcPhase = 90.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
dcPhase = 0.0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
dcPhase = 0.0;
|
||||
}
|
||||
|
||||
// Adjustments
|
||||
dcPhase += 90.0;
|
||||
dcPhase += 360.0 / smoothPeriod; // Compensate for WMA lag
|
||||
|
||||
if (imagPart < 0.0)
|
||||
{
|
||||
dcPhase += 180.0;
|
||||
}
|
||||
|
||||
if (dcPhase > 315.0)
|
||||
{
|
||||
dcPhase -= 360.0;
|
||||
}
|
||||
|
||||
return dcPhase;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private (double sine, double leadSine) Step(double price, bool isNew)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
Array.Copy(_circBuffer, _p_circBuffer, CIRC_BUFFER_SIZE);
|
||||
Array.Copy(_smoothPrice, _p_smoothPrice, SMOOTH_PRICE_SIZE);
|
||||
Array.Copy(_priceHistory, _p_priceHistory, PRICE_HISTORY_SIZE);
|
||||
_state.Today++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
Array.Copy(_p_circBuffer, _circBuffer, CIRC_BUFFER_SIZE);
|
||||
Array.Copy(_p_smoothPrice, _smoothPrice, SMOOTH_PRICE_SIZE);
|
||||
Array.Copy(_p_priceHistory, _priceHistory, PRICE_HISTORY_SIZE);
|
||||
}
|
||||
|
||||
// Local copy of state for struct promotion (AGENTS.md §2.5)
|
||||
var s = _state;
|
||||
|
||||
// Handle non-finite input
|
||||
if (!double.IsFinite(price))
|
||||
{
|
||||
if (double.IsNaN(s.LastValidPrice))
|
||||
{
|
||||
return (double.NaN, double.NaN);
|
||||
}
|
||||
price = s.LastValidPrice;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.LastValidPrice = price;
|
||||
}
|
||||
|
||||
int today = s.Today - 1;
|
||||
|
||||
// WMA initialization phase (first 34 + 3 bars)
|
||||
if (today < 37)
|
||||
{
|
||||
// Store prices for WMA initialization
|
||||
if (today >= 0)
|
||||
{
|
||||
_priceHistory[today % PRICE_HISTORY_SIZE] = price;
|
||||
}
|
||||
|
||||
// Initialize WMA (TA-Lib pattern: unrolled first 3, then loop for period)
|
||||
if (today == 36)
|
||||
{
|
||||
// Now we have enough data to initialize WMA
|
||||
double tempReal = _priceHistory[0];
|
||||
s.PeriodWMASub = tempReal;
|
||||
s.PeriodWMASum = tempReal;
|
||||
|
||||
tempReal = _priceHistory[1];
|
||||
s.PeriodWMASub += tempReal;
|
||||
s.PeriodWMASum += tempReal * 2.0;
|
||||
|
||||
tempReal = _priceHistory[2];
|
||||
s.PeriodWMASub += tempReal;
|
||||
s.PeriodWMASum += tempReal * 3.0;
|
||||
|
||||
s.TrailingWMAValue = 0.0;
|
||||
s.TrailingWMAIdx = 0;
|
||||
|
||||
// Process remaining bars in period (34 iterations)
|
||||
for (int i = 0; i < 34; i++)
|
||||
{
|
||||
int priceIdx = 3 + i;
|
||||
double priceVal = _priceHistory[priceIdx];
|
||||
|
||||
s.PeriodWMASub += priceVal;
|
||||
s.PeriodWMASub -= s.TrailingWMAValue;
|
||||
s.PeriodWMASum += priceVal * 4.0;
|
||||
s.TrailingWMAValue = _priceHistory[s.TrailingWMAIdx++];
|
||||
|
||||
s.PeriodWMASum -= s.PeriodWMASub;
|
||||
}
|
||||
}
|
||||
|
||||
_state = s;
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
// Calculate smoothed price using WMA
|
||||
double adjustedPrevPeriod = 0.075 * s.Period + 0.54;
|
||||
|
||||
s.PeriodWMASub += price;
|
||||
s.PeriodWMASub -= s.TrailingWMAValue;
|
||||
s.PeriodWMASum += price * 4.0;
|
||||
|
||||
// Get trailing value (TA-Lib uses a linear trailing index)
|
||||
int trailIdx = s.TrailingWMAIdx % PRICE_HISTORY_SIZE;
|
||||
s.TrailingWMAValue = _priceHistory[trailIdx];
|
||||
s.TrailingWMAIdx++;
|
||||
|
||||
int historyIdx = today % PRICE_HISTORY_SIZE;
|
||||
_priceHistory[historyIdx] = price;
|
||||
|
||||
double smoothedValue = s.PeriodWMASum * 0.1;
|
||||
s.PeriodWMASum -= s.PeriodWMASub;
|
||||
|
||||
// Store smoothed value
|
||||
_smoothPrice[s.SmoothPriceIdx] = smoothedValue;
|
||||
|
||||
// Extract fields for ref/out parameters
|
||||
int hilbertIdx = s.HilbertIdx;
|
||||
double i1ForOddPrev2 = s.I1ForOddPrev2;
|
||||
double i1ForEvenPrev2 = s.I1ForEvenPrev2;
|
||||
double re = s.Re;
|
||||
double im = s.Im;
|
||||
double prevI2 = s.PrevI2;
|
||||
double prevQ2 = s.PrevQ2;
|
||||
double period = s.Period;
|
||||
|
||||
// Perform Hilbert Transform (alternating odd/even)
|
||||
double q2, i2;
|
||||
if (today % 2 == 0)
|
||||
{
|
||||
// Even bar
|
||||
CalcHilbertEven(_circBuffer, smoothedValue, ref hilbertIdx, adjustedPrevPeriod,
|
||||
s.I1ForEvenPrev3, prevQ2, prevI2, out double i1ForOddPrev3,
|
||||
ref i1ForOddPrev2, out q2, out i2);
|
||||
s.I1ForOddPrev3 = i1ForOddPrev3;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd bar
|
||||
CalcHilbertOdd(_circBuffer, smoothedValue, hilbertIdx, adjustedPrevPeriod,
|
||||
out double i1ForEvenPrev3, prevQ2, prevI2, s.I1ForOddPrev3,
|
||||
ref i1ForEvenPrev2, out q2, out i2);
|
||||
s.I1ForEvenPrev3 = i1ForEvenPrev3;
|
||||
}
|
||||
|
||||
// Write back ref parameters
|
||||
s.HilbertIdx = hilbertIdx;
|
||||
s.I1ForOddPrev2 = i1ForOddPrev2;
|
||||
s.I1ForEvenPrev2 = i1ForEvenPrev2;
|
||||
|
||||
// Calculate smoothed period
|
||||
CalcSmoothedPeriod(ref re, i2, q2, ref prevI2, ref prevQ2, ref im, ref period);
|
||||
|
||||
// Write back ref parameters
|
||||
s.Re = re;
|
||||
s.Im = im;
|
||||
s.PrevI2 = prevI2;
|
||||
s.PrevQ2 = prevQ2;
|
||||
s.Period = period;
|
||||
|
||||
s.SmoothPeriod = Math.FusedMultiplyAdd(0.33, period, 0.67 * s.SmoothPeriod);
|
||||
|
||||
// Calculate DC Phase
|
||||
s.DcPhase = ComputeDcPhase(_smoothPrice, s.SmoothPeriod, s.SmoothPriceIdx, SMOOTH_PRICE_SIZE);
|
||||
|
||||
// Update smooth price index
|
||||
s.SmoothPriceIdx = (s.SmoothPriceIdx + 1) % SMOOTH_PRICE_SIZE;
|
||||
|
||||
// Write back state
|
||||
_state = s;
|
||||
|
||||
// Calculate sine and leadsine from DCPhase
|
||||
double sine = Math.Sin(s.DcPhase * (Math.PI / 180.0));
|
||||
double leadSine = Math.Sin((s.DcPhase + 45.0) * (Math.PI / 180.0));
|
||||
|
||||
return (sine, leadSine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
var (sine, leadSine) = Step(input.Value, isNew);
|
||||
LeadSine = leadSine;
|
||||
Last = new TValue(input.Time, sine);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return new TSeries([], []);
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var result = Update(new TValue(source.Times[i], source.Values[i]));
|
||||
t.Add(result.Time);
|
||||
v.Add(result.Value);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
foreach (double value in source)
|
||||
{
|
||||
Update(new TValue(DateTime.UtcNow, value));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates HT_SINE for a time series.
|
||||
/// </summary>
|
||||
public static TSeries Calculate(TSeries source)
|
||||
{
|
||||
var htSine = new HtSine();
|
||||
return htSine.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates HT_SINE in-place using pre-allocated output spans.
|
||||
/// </summary>
|
||||
/// <param name="source">Input price data.</param>
|
||||
/// <param name="sine">Output span for Sine values.</param>
|
||||
/// <param name="leadSine">Output span for LeadSine values.</param>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> sine, Span<double> leadSine)
|
||||
{
|
||||
if (source.Length != sine.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and sine must have the same length", nameof(sine));
|
||||
}
|
||||
if (source.Length != leadSine.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and leadSine must have the same length", nameof(leadSine));
|
||||
}
|
||||
|
||||
int len = source.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var htSine = new HtSine();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
htSine.Update(new TValue(DateTime.UtcNow, source[i]));
|
||||
sine[i] = htSine.Last.Value;
|
||||
leadSine[i] = htSine.LeadSine;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
# HT_SINE: Hilbert Transform - SineWave
|
||||
|
||||
> "The Hilbert Transform gives us the phase of the dominant cycle—knowing when to buy and sell becomes a matter of trigonometry."
|
||||
|
||||
HT_SINE applies the Hilbert Transform to extract the dominant market cycle and outputs the sine of the current phase angle. The indicator produces two outputs: **Sine** (current phase) and **LeadSine** (45° phase lead), enabling traders to identify cycle turning points before they occur. Crossovers between Sine and LeadSine signal potential reversals in ranging markets.
|
||||
|
||||
## Historical Context
|
||||
|
||||
John Ehlers introduced the Hilbert Transform indicator in his 2001 book *Rocket Science for Traders*, later refining it in *Cycle Analytics for Traders* (2013). The Hilbert Transform originates from signal processing, where it creates an analytic signal by generating a 90° phase-shifted version of the input. This quadrature relationship enables measurement of instantaneous phase and frequency.
|
||||
|
||||
The HT_SINE indicator represents Ehlers' adaptation of the Hilbert Transform for financial markets. Unlike simple oscillators that assume fixed periodicity, HT_SINE dynamically measures the dominant cycle period using homodyne discrimination—a technique borrowed from radio engineering. The 45° phase lead of LeadSine anticipates turning points by approximately 1/8 of the cycle period, providing early warning of reversals.
|
||||
|
||||
TA-Lib implements a version of this indicator matching Ehlers' published specifications. This implementation validates against TA-Lib's output within floating-point tolerance.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. WMA Price Smoothing
|
||||
|
||||
The algorithm begins with weighted moving average smoothing:
|
||||
|
||||
$$
|
||||
\text{SmoothPrice}_t = \frac{4 \cdot P_t + 3 \cdot P_{t-1} + 2 \cdot P_{t-2} + P_{t-3}}{10}
|
||||
$$
|
||||
|
||||
This 4-bar WMA provides initial noise rejection without excessive lag. The weights (4, 3, 2, 1) sum to 10, centering the filter approximately 1.5 bars back.
|
||||
|
||||
### 2. Bandwidth Calculation
|
||||
|
||||
The Hilbert Transform coefficients scale with the measured cycle period:
|
||||
|
||||
$$
|
||||
\text{Bandwidth}_t = 0.075 \cdot \text{SmoothPeriod}_{t-1} + 0.54
|
||||
$$
|
||||
|
||||
This adaptive bandwidth widens for longer cycles and narrows for shorter ones, maintaining filter stability across varying market conditions.
|
||||
|
||||
### 3. Hilbert Transform Cascade
|
||||
|
||||
The transform applies Ehlers' specialized coefficients in a cascade:
|
||||
|
||||
$$
|
||||
A = 0.0962, \quad B = 0.5769
|
||||
$$
|
||||
|
||||
**Detrender:**
|
||||
$$
|
||||
D_t = (A \cdot \text{SP}_t + B \cdot \text{SP}_{t-2} - B \cdot \text{SP}_{t-4} - A \cdot \text{SP}_{t-6}) \cdot \text{BW}
|
||||
$$
|
||||
|
||||
**Quadrature (Q1):**
|
||||
$$
|
||||
Q1_t = (A \cdot D_t + B \cdot D_{t-2} - B \cdot D_{t-4} - A \cdot D_{t-6}) \cdot \text{BW}
|
||||
$$
|
||||
|
||||
**In-Phase (I1):**
|
||||
$$
|
||||
I1_t = D_{t-3}
|
||||
$$
|
||||
|
||||
**jI (Hilbert of I1):**
|
||||
$$
|
||||
jI_t = (A \cdot I1_t + B \cdot I1_{t-2} - B \cdot I1_{t-4} - A \cdot I1_{t-6}) \cdot \text{BW}
|
||||
$$
|
||||
|
||||
**jQ (Hilbert of Q1):**
|
||||
$$
|
||||
jQ_t = (A \cdot Q1_t + B \cdot Q1_{t-2} - B \cdot Q1_{t-4} - A \cdot Q1_{t-6}) \cdot \text{BW}
|
||||
$$
|
||||
|
||||
### 4. Phasor Components
|
||||
|
||||
The in-phase and quadrature components combine:
|
||||
|
||||
$$
|
||||
I2_t = I1_t - jQ_t
|
||||
$$
|
||||
|
||||
$$
|
||||
Q2_t = Q1_t + jI_t
|
||||
$$
|
||||
|
||||
These are smoothed with a 0.2/0.8 EMA:
|
||||
|
||||
$$
|
||||
I2_t \leftarrow 0.2 \cdot I2_t + 0.8 \cdot I2_{t-1}
|
||||
$$
|
||||
|
||||
$$
|
||||
Q2_t \leftarrow 0.2 \cdot Q2_t + 0.8 \cdot Q2_{t-1}
|
||||
$$
|
||||
|
||||
### 5. Homodyne Discriminator
|
||||
|
||||
Period measurement uses cross-correlation of consecutive phasors:
|
||||
|
||||
$$
|
||||
\text{Re}_t = I2_t \cdot I2_{t-1} + Q2_t \cdot Q2_{t-1}
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{Im}_t = I2_t \cdot Q2_{t-1} - Q2_t \cdot I2_{t-1}
|
||||
$$
|
||||
|
||||
Smoothed with 0.2/0.8 EMA:
|
||||
|
||||
$$
|
||||
\text{Re}_t \leftarrow 0.2 \cdot \text{Re}_t + 0.8 \cdot \text{Re}_{t-1}
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{Im}_t \leftarrow 0.2 \cdot \text{Im}_t + 0.8 \cdot \text{Im}_{t-1}
|
||||
$$
|
||||
|
||||
The instantaneous period:
|
||||
|
||||
$$
|
||||
\text{Period}_t = \begin{cases}
|
||||
\frac{2\pi}{\arctan2(\text{Im}_t, \text{Re}_t)} & \text{if angle} \neq 0 \\
|
||||
\text{Period}_{t-1} & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
### 6. Period Clamping and Smoothing
|
||||
|
||||
$$
|
||||
\text{Period}_t = \text{clamp}(\text{Period}_t, 6, 50)
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{SmoothPeriod}_t = 0.33 \cdot \text{Period}_t + 0.67 \cdot \text{SmoothPeriod}_{t-1}
|
||||
$$
|
||||
|
||||
### 7. Phase and Output
|
||||
|
||||
Phase angle from the phasor:
|
||||
|
||||
$$
|
||||
\phi_t = \arctan2(Q2_t, I2_t)
|
||||
$$
|
||||
|
||||
Final outputs:
|
||||
|
||||
$$
|
||||
\text{Sine}_t = \sin(\phi_t)
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{LeadSine}_t = \sin\left(\phi_t + \frac{\pi}{4}\right)
|
||||
$$
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Analytic Signal Theory
|
||||
|
||||
The Hilbert Transform $\mathcal{H}$ creates a 90° phase shift:
|
||||
|
||||
$$
|
||||
\hat{x}(t) = \mathcal{H}[x(t)]
|
||||
$$
|
||||
|
||||
The analytic signal combines original and transformed:
|
||||
|
||||
$$
|
||||
z(t) = x(t) + j\hat{x}(t) = A(t)e^{j\phi(t)}
|
||||
$$
|
||||
|
||||
where $A(t)$ is instantaneous amplitude and $\phi(t)$ is instantaneous phase.
|
||||
|
||||
### Discrete Approximation
|
||||
|
||||
Ehlers' discrete Hilbert Transform uses a specialized FIR structure with coefficients A and B that approximate the continuous transform's frequency response over the 6-50 bar period range typical of market cycles.
|
||||
|
||||
### LeadSine Phase Relationship
|
||||
|
||||
The 45° ($\pi/4$ radians) phase lead means:
|
||||
|
||||
$$
|
||||
\text{LeadSine} = \sin(\phi + 45°) = \frac{\sqrt{2}}{2}(\sin\phi + \cos\phi)
|
||||
$$
|
||||
|
||||
This advance equals 1/8 of a full cycle. For a 32-bar cycle, LeadSine leads by 4 bars.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, Scalar)
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| MUL | 32 | 3 | 96 |
|
||||
| ADD/SUB | 24 | 1 | 24 |
|
||||
| Buffer access | 28 | 1 | 28 |
|
||||
| ATAN2 | 2 | 50 | 100 |
|
||||
| SIN | 2 | 50 | 100 |
|
||||
| State EMA (×6) | 6 | 4 | 24 |
|
||||
| **Total** | — | — | **~372 cycles** |
|
||||
|
||||
Dominant cost: trigonometric functions (ATAN2, SIN). The recursive nature of the Hilbert Transform cascade prevents SIMD vectorization in streaming mode.
|
||||
|
||||
### State Memory
|
||||
|
||||
| Component | Size |
|
||||
| :--- | :---: |
|
||||
| Ring buffers (4 × 8 doubles) | 256 bytes |
|
||||
| State record (Period, SmoothPeriod, I2, Q2, Re, Im, PrevI2, PrevQ2, Price1-3, Count, LastValid) | 104 bytes |
|
||||
| Previous state (snapshot) | 104 bytes |
|
||||
| Buffer snapshots (4 × 8 doubles) | 256 bytes |
|
||||
| **Total per instance** | **~720 bytes** |
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 9/10 | Matches TA-Lib output within 1e-9 tolerance |
|
||||
| **Timeliness** | 7/10 | 45° lead via LeadSine; warmup requires 63 bars |
|
||||
| **Overshoot** | 6/10 | Bounded to [-1, +1]; phase errors during trend transitions |
|
||||
| **Smoothness** | 8/10 | Multiple EMAs in cascade provide good noise rejection |
|
||||
| **Cycle Fidelity** | 8/10 | Accurate in ranging markets; degrades in strong trends |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **TA-Lib** | ✅ | Matches `TALib.Functions.HtSine()` for both Sine and LeadSine outputs |
|
||||
| **Skender** | N/A | No HT_SINE implementation |
|
||||
| **Tulip** | N/A | No HT_SINE implementation |
|
||||
| **Ooples** | N/A | No HT_SINE implementation |
|
||||
| **PineScript** | ✅ | Matches `ht_sine.pine` reference within floating-point tolerance |
|
||||
|
||||
Validation confirms:
|
||||
1. Lookback period = 63 bars (matches TA-Lib)
|
||||
2. Both outputs bounded to [-1, +1]
|
||||
3. LeadSine consistently leads Sine by π/4 radians
|
||||
4. Period measurement stable in 6-50 bar range
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Trend Mode Failure**: HT_SINE assumes cyclic behavior. In strong trends, the indicator produces unreliable signals. Combine with trend detection (e.g., `HT_TRENDMODE`) to filter signals.
|
||||
|
||||
2. **Warmup Period**: The 63-bar warmup is substantial. First 63 values should be ignored; `IsHot = false` during this period.
|
||||
|
||||
3. **Period Clamping**: Cycles outside 6-50 bars get clamped, distorting phase measurement. Markets with very long cycles (weekly/monthly) may not suit HT_SINE.
|
||||
|
||||
4. **Crossover Interpretation**: Sine crossing LeadSine from below suggests a cycle trough (buy); crossing from above suggests a peak (sell). However, this assumes price follows the extracted cycle.
|
||||
|
||||
5. **Phase Discontinuities**: Phase wraps at ±π, causing potential signal jumps. The sine function naturally handles this, but raw phase values require unwrapping for derivative calculations.
|
||||
|
||||
6. **Bar Correction**: When updating the same bar (`isNew = false`), all ring buffers and state must rollback. The implementation uses snapshot arrays for this; incorrect `isNew` usage corrupts 8 bars of filter memory.
|
||||
|
||||
7. **Memory Footprint**: At ~720 bytes per instance, HT_SINE is memory-heavy compared to simple oscillators. Monitor allocation when running many instances.
|
||||
|
||||
## API Usage
|
||||
|
||||
```csharp
|
||||
// Streaming mode
|
||||
var htSine = new HtSine();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
TValue result = htSine.Update(new TValue(bar.Time, bar.Close), isNew: true);
|
||||
if (htSine.IsHot)
|
||||
{
|
||||
double sine = result.Value;
|
||||
double leadSine = htSine.LeadSine;
|
||||
|
||||
// Crossover detection
|
||||
if (prevSine < prevLeadSine && sine > leadSine)
|
||||
{
|
||||
// Potential sell signal (peak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bar correction (same bar, updated price)
|
||||
TValue corrected = htSine.Update(new TValue(bar.Time, newClose), isNew: false);
|
||||
|
||||
// Batch mode with dual outputs
|
||||
Span<double> sine = stackalloc double[closes.Length];
|
||||
Span<double> leadSine = stackalloc double[closes.Length];
|
||||
HtSine.Batch(closes, sine, leadSine);
|
||||
|
||||
// TSeries mode
|
||||
TSeries output = HtSine.Calculate(closePrices);
|
||||
// Note: LeadSine only available in streaming mode
|
||||
|
||||
// Chaining
|
||||
var source = new Ema(10);
|
||||
var htSine = new HtSine(source);
|
||||
// htSine automatically subscribes to source.Pub events
|
||||
```
|
||||
|
||||
## Trading Signals
|
||||
|
||||
### Primary Crossover Strategy
|
||||
|
||||
1. **Buy Signal**: Sine crosses above LeadSine (from below)
|
||||
2. **Sell Signal**: Sine crosses below LeadSine (from above)
|
||||
|
||||
### Confirmation Filters
|
||||
|
||||
- Filter signals when both lines are near zero (flat cycle)
|
||||
- Avoid signals when Sine and LeadSine are nearly parallel (trend mode)
|
||||
- Combine with volume or momentum confirmation
|
||||
|
||||
### Exit Strategy
|
||||
|
||||
- Exit longs when Sine peaks (approaches +1 then reverses)
|
||||
- Exit shorts when Sine troughs (approaches -1 then reverses)
|
||||
|
||||
## References
|
||||
|
||||
- Ehlers, J. (2001). *Rocket Science for Traders*. Wiley.
|
||||
- Ehlers, J. (2013). *Cycle Analytics for Traders*. Wiley.
|
||||
- TA-Lib: `TALib.Functions.HtSine()`
|
||||
- PineScript reference: `lib/cycles/ht_sine/ht_sine.pine`
|
||||
Reference in New Issue
Block a user