SIMD Refactor: Merge simd-dev into dev (#55)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
Co-authored-by: Warp <agent@warp.dev>
This commit is contained in:
Miha Kralj
2026-01-18 19:02:03 -08:00
committed by GitHub
co-authored by Claude Opus 4.5 aider Warp
parent 5bcdf8d614
commit 86fe32a682
1750 changed files with 198235 additions and 80539 deletions
@@ -0,0 +1,40 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Quantower.Tests;
public class HtitIndicatorTests
{
[Fact]
public void Indicator_Initializes_Correctly()
{
var indicator = new HtitIndicator();
indicator.Initialize();
Assert.Equal("HTIT - Ehlers Hilbert Transform Instantaneous Trend", indicator.Name);
Assert.StartsWith("HTIT", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("Close", indicator.ShortName, StringComparison.Ordinal);
Assert.Equal(0, HtitIndicator.MinHistoryDepths);
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void Indicator_Updates_Correctly()
{
var indicator = new HtitIndicator();
indicator.Initialize();
// Warmup
for (int i = 0; i < 100; i++)
{
var time = DateTime.UtcNow.AddMinutes(i);
indicator.HistoricalData.AddBar(time, 100 + i, 100 + i, 100 + i, 100 + i);
var args = new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(args);
}
// Check if value is set (should be non-zero after warmup)
var result = indicator.LinesSeries[0].GetValue();
Assert.NotEqual(0, result);
Assert.False(double.IsNaN(result));
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class HtitIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 2000, 1, 0)]
public int Period { get; set; } = 50; // Not used in calculation but kept for consistency
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Htit _htit = null!;
private readonly LineSeries _series;
private string _sourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"HTIT:{_sourceName}";
public HtitIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "HTIT - Ehlers Hilbert Transform Instantaneous Trend";
Description = "Ehlers Hilbert Transform Instantaneous Trend";
_series = new LineSeries(name: "HTIT", color: Color.Orange, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_priceSelector = Source.GetPriceSelector();
_sourceName = Source.ToString();
_htit = new Htit();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
bool isNew = args.IsNewBar();
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
double value = _htit.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew).Value;
_series.SetValue(value, _htit.IsHot, ShowColdValues);
}
}
+195
View File
@@ -0,0 +1,195 @@
namespace QuanTAlib.Tests;
public class HtitTests
{
private readonly GBM _gbm;
public HtitTests()
{
_gbm = new GBM();
}
[Fact]
public void IsHot_BecomesTrue_AfterWarmup()
{
var htit = new Htit();
for (int i = 0; i < 12; i++)
{
Assert.False(htit.IsHot);
htit.Update(new TValue(DateTime.UtcNow.Ticks, 100.0));
}
Assert.True(htit.IsHot);
}
[Fact]
public void Update_Matches_Calculate()
{
var htit = new Htit();
var data = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
var series = data;
var resultSeries = htit.Update(series);
// Reset and calculate streaming
htit.Reset();
var streamingResults = new List<double>();
foreach (var item in data)
{
streamingResults.Add(htit.Update(item).Value);
}
for (int i = 0; i < resultSeries.Count; i++)
{
Assert.Equal(resultSeries.Values[i], streamingResults[i], 1e-9);
}
}
[Fact]
public void Calculate_Span_Matches_Update()
{
var htit = new Htit();
var data = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)).Close;
var series = data;
var resultSeries = htit.Update(series);
var spanInput = data.Values.ToArray();
var spanOutput = new double[spanInput.Length];
Htit.Calculate(spanInput, spanOutput);
for (int i = 0; i < resultSeries.Count; i++)
{
Assert.Equal(resultSeries.Values[i], spanOutput[i], 1e-9);
}
}
[Fact]
public void Handles_NaN()
{
var htit = new Htit();
htit.Update(new TValue(DateTime.UtcNow.Ticks, 100.0));
htit.Update(new TValue(DateTime.UtcNow.Ticks, double.NaN));
Assert.Equal(100.0, htit.Last.Value);
}
[Fact]
public void Htit_Calc_IsNew_AcceptsParameter()
{
var htit = new Htit();
htit.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
Assert.Equal(100, htit.Last.Value);
}
[Fact]
public void Htit_Reset_ClearsState()
{
var htit = new Htit();
htit.Update(new TValue(DateTime.UtcNow, 100));
htit.Update(new TValue(DateTime.UtcNow, 110));
htit.Reset();
Assert.True(double.IsNaN(htit.Last.Value));
Assert.False(htit.IsHot);
}
[Fact]
public void Htit_IterativeCorrections_RestoreToOriginalState()
{
var htit = new Htit();
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
// Feed 20 new values (needs > 12 for warmup)
TValue lastInput = default;
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next(isNew: true);
lastInput = new TValue(bar.Time, bar.Close);
htit.Update(lastInput, isNew: true);
}
// Remember state after 20 values
double valueAfterTwenty = htit.Last.Value;
// Generate 9 corrections with isNew=false (different values)
for (int i = 0; i < 9; i++)
{
var bar = gbm.Next(isNew: false);
htit.Update(new TValue(bar.Time, bar.Close), isNew: false);
}
// Feed the remembered 20th input again with isNew=false
TValue finalValue = htit.Update(lastInput, isNew: false);
// Should match the original state after 20 values
Assert.Equal(valueAfterTwenty, finalValue.Value, 1e-9);
}
[Fact]
public void Htit_SpanCalc_ValidatesInput()
{
double[] source = [1, 2, 3, 4, 5];
double[] wrongSizeOutput = new double[3];
Assert.Throws<ArgumentException>(() => Htit.Calculate(source.AsSpan(), wrongSizeOutput.AsSpan()));
}
[Fact]
public void Htit_SpanCalc_HandlesNaN()
{
double[] source = [100, 110, double.NaN, 120, 130];
double[] output = new double[5];
Htit.Calculate(source.AsSpan(), output.AsSpan());
foreach (var val in output)
{
Assert.True(double.IsFinite(val));
}
}
[Fact]
public void Htit_AllModes_ProduceSameResult()
{
// Arrange
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
// 1. Batch Mode
var batchSeries = Htit.Batch(series);
double expected = batchSeries.Last.Value;
// 2. Span Mode
var tValues = series.Values.ToArray();
var spanInput = new ReadOnlySpan<double>(tValues);
var spanOutput = new double[tValues.Length];
Htit.Calculate(spanInput, spanOutput);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Htit();
for (int i = 0; i < series.Count; i++)
{
streamingInd.Update(series[i]);
}
double streamingResult = streamingInd.Last.Value;
// 4. Eventing Mode
var pubSource = new TSeries();
var eventingInd = new Htit(pubSource);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
Assert.Equal(expected, eventingResult, precision: 9);
}
}
@@ -0,0 +1,164 @@
using Skender.Stock.Indicators;
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using TALib;
namespace QuanTAlib.Tests;
public sealed class HtitValidationTests : IDisposable
{
private readonly ValidationTestData _data;
private bool _disposed;
public HtitValidationTests()
{
_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 HTIT
var input = _data.RawData.Span;
var output = new double[input.Length];
var retCode = TALib.Functions.HtTrendline(input, 0..^0, output, out var outRange);
Assert.Equal(Core.RetCode.Success, retCode);
// Calculate QuanTAlib HTIT
var htit = new Htit();
var quantalibResults = htit.Update(_data.Data);
// Compare results
// TA-Lib HT_TRENDLINE has a lookback of 63
for (int i = quantalibResults.Count - 100; i < quantalibResults.Count; i++)
{
if (i >= outRange.Start.Value)
{
double talibValue = output[i - outRange.Start.Value];
double quantalibValue = quantalibResults.Values[i];
Assert.Equal(talibValue, quantalibValue, ValidationHelper.TalibTolerance);
}
}
}
[Fact]
public void Validate_Skender_Batch()
{
// Calculate Skender HTIT
var skenderResults = _data.SkenderQuotes.GetHtTrendline().ToList();
// Calculate QuanTAlib HTIT
var htit = new Htit();
var series = _data.Data;
var quantalibResults = htit.Update(series);
// Compare results
// Skip warmup period (Skender needs 100 periods for convergence, but we can check after 50)
for (int i = quantalibResults.Count - 100; i < quantalibResults.Count; i++)
{
double skenderValue = skenderResults[i].Trendline ?? double.NaN;
double quantalibValue = quantalibResults.Values[i];
if (!double.IsNaN(skenderValue))
{
// Skender implementation differs slightly (~0.32%) from TA-Lib/QuanTAlib.
// QuanTAlib matches TA-Lib (reference) with 1e-6 precision.
// The divergence in Skender is likely due to implementation details or smoothing differences.
double diff = Math.Abs(skenderValue - quantalibValue);
double relError = diff / skenderValue;
Assert.True(relError < ValidationHelper.RelativeTolerance, $"Relative error {relError} too high at index {i}");
}
}
}
[Fact]
public void Validate_Skender_Streaming()
{
// Calculate Skender HTIT
var skenderResults = _data.SkenderQuotes.GetHtTrendline().ToList();
// Calculate QuanTAlib HTIT Streaming
var htit = new Htit();
var streamingResults = new List<double>();
foreach (var item in _data.Data)
{
streamingResults.Add(htit.Update(item).Value);
}
// Compare results
for (int i = streamingResults.Count - 100; i < streamingResults.Count; i++)
{
double skenderValue = skenderResults[i].Trendline ?? double.NaN;
double quantalibValue = streamingResults[i];
if (!double.IsNaN(skenderValue))
{
// Skender implementation differs slightly (~0.32%) from TA-Lib/QuanTAlib
double diff = Math.Abs(skenderValue - quantalibValue);
double relError = diff / skenderValue;
Assert.True(relError < ValidationHelper.RelativeTolerance, $"Relative error {relError} too high at index {i}");
}
}
}
[Fact]
public void Validate_Ooples()
{
// Prepare data for Ooples
var ooplesData = _data.SkenderQuotes.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
}).ToList();
// Calculate Ooples HTIT
var stockData = new StockData(ooplesData);
var oResult = stockData.CalculateEhlersInstantaneousTrendlineV1();
var oValues = oResult.OutputValues["Eit"];
// Calculate QuanTAlib HTIT
var htit = new Htit();
var quantalibResults = htit.Update(_data.Data);
// Compare results
// Ooples might have different warmup or calculation details
// We'll check for correlation or close values after warmup
for (int i = quantalibResults.Count - 100; i < quantalibResults.Count; i++)
{
double ooplesValue = oValues[i];
double quantalibValue = quantalibResults.Values[i];
// Ooples V1 differs slightly (~0.25%) from TA-Lib/QuanTAlib.
// QuanTAlib matches TA-Lib (reference) with 1e-6 precision.
double diff = Math.Abs(ooplesValue - quantalibValue);
double relError = diff / ooplesValue;
Assert.True(relError < ValidationHelper.RelativeTolerance, $"Relative error {relError} too high at index {i}");
}
}
}
+478
View File
@@ -0,0 +1,478 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// HTIT: Ehlers Hilbert Transform Instantaneous Trend
/// A trend-following indicator that uses the Hilbert Transform to measure the dominant cycle period
/// and compute an instantaneous trendline. It adapts to market cycles to reduce lag while maintaining smoothness.
/// </summary>
/// <remarks>
/// Sources:
/// https://github.com/mihakralj/pinescript/blob/main/indicators/trends_IIR/htit.md
/// https://dotnet.stockindicators.dev/indicators/HtTrendline/
/// </remarks>
[SkipLocalsInit]
public sealed class Htit : AbstractBase
{
public override bool IsHot => _state.Index >= WarmupPeriod;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double I2, double Q2, double Re, double Im,
double Period, double SmoothPeriod,
double LastValidPrice, int Index
)
{
// Initialize LastValidPrice to NaN to detect first valid price
public State() : this(0, 0, 0, 0, 0, 0, double.NaN, 0) { }
}
private State _state;
private State _p_state;
private readonly RingBuffer _priceBuffer;
private readonly RingBuffer _smoothBuffer;
private readonly RingBuffer _detrenderBuffer;
private readonly RingBuffer _i1Buffer;
private readonly RingBuffer _q1Buffer;
private readonly RingBuffer _itBuffer;
private readonly TValuePublishedHandler _handler;
// High-precision constants
private const double c1 = 5.0 / 52.0; // ~0.09615385
private const double c2 = 15.0 / 26.0; // ~0.57692308
private const double adjSlope = 3.0 / 40.0; // 0.075
private const double adjIntercept = 27.0 / 50.0; // 0.54
private const double TwoPi = 2.0 * Math.PI;
private const double MinDeltaRadians = Math.PI / 180.0; // 1 degree in radians
public Htit()
{
Name = "Htit";
WarmupPeriod = 12;
_handler = Handle;
// Initialize buffers with size 8 (power of 2) for consistency with Calculate optimization
// except priceBuffer which needs to be larger for IT calculation
_priceBuffer = new RingBuffer(64); // Needs to hold enough history for IT calculation (up to 50 bars)
_smoothBuffer = new RingBuffer(8);
_detrenderBuffer = new RingBuffer(8);
_i1Buffer = new RingBuffer(8);
_q1Buffer = new RingBuffer(8);
_itBuffer = new RingBuffer(8);
Init();
}
public Htit(ITValuePublisher source) : this()
{
source.Pub += _handler;
}
private void Init()
{
Reset();
}
public override void Reset()
{
_state = new State();
_p_state = new State();
_priceBuffer.Clear();
_smoothBuffer.Clear();
_detrenderBuffer.Clear();
_i1Buffer.Clear();
_q1Buffer.Clear();
_itBuffer.Clear();
Last = new TValue(DateTime.MinValue, double.NaN);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double Step(double price, bool isNew)
{
if (isNew)
{
_p_state = _state;
_state.Index++;
}
else
{
_state = _p_state;
}
// Handle non-finite input: skip processing if no valid price seen yet
if (!double.IsFinite(price))
{
// If we haven't seen a valid price yet, return NaN (early exit)
if (double.IsNaN(_state.LastValidPrice))
{
return double.NaN;
}
// Otherwise, use the last valid price
price = _state.LastValidPrice;
}
else
{
_state.LastValidPrice = price;
}
_priceBuffer.Add(price, isNew);
// Need enough data for smooth calculation (4 bars) + detrender (7 bars total lag)
if (_state.Index < 7)
{
// During warmup, propagate NaN if input is NaN
_smoothBuffer.Add(price, isNew);
_detrenderBuffer.Add(0, isNew);
_i1Buffer.Add(0, isNew);
_q1Buffer.Add(0, isNew);
_itBuffer.Add(price, isNew);
return price; // May be NaN if no valid input yet
}
// 1. Smooth Price using FMA for precision
// smooth = (4*Price + 3*Price[1] + 2*Price[2] + Price[3]) / 10
double smooth = Math.FusedMultiplyAdd(4.0, _priceBuffer[^1],
Math.FusedMultiplyAdd(3.0, _priceBuffer[^2],
Math.FusedMultiplyAdd(2.0, _priceBuffer[^3], _priceBuffer[^4]))) * 0.1;
_smoothBuffer.Add(smooth, isNew);
// 2. Detrender
// In streaming, we use previous period from state
double prevPeriod = _p_state.Period;
double adj = (adjSlope * prevPeriod) + adjIntercept;
// Use FMA for detrender calculation
double detrender = Math.FusedMultiplyAdd(c1, _smoothBuffer[^1],
Math.FusedMultiplyAdd(c2, _smoothBuffer[^3],
Math.FusedMultiplyAdd(-c2, _smoothBuffer[^5], -c1 * _smoothBuffer[^7]))) * adj;
_detrenderBuffer.Add(detrender, isNew);
// 3. In-Phase and Quadrature using FMA
double q1 = Math.FusedMultiplyAdd(c1, _detrenderBuffer[^1],
Math.FusedMultiplyAdd(c2, _detrenderBuffer[^3],
Math.FusedMultiplyAdd(-c2, _detrenderBuffer[^5], -c1 * _detrenderBuffer[^7]))) * adj;
double i1 = _detrenderBuffer[^4];
_q1Buffer.Add(q1, isNew);
_i1Buffer.Add(i1, isNew);
// 4. Advance phases by 90 degrees using FMA
double jI = Math.FusedMultiplyAdd(c1, _i1Buffer[^1],
Math.FusedMultiplyAdd(c2, _i1Buffer[^3],
Math.FusedMultiplyAdd(-c2, _i1Buffer[^5], -c1 * _i1Buffer[^7]))) * adj;
double jQ = Math.FusedMultiplyAdd(c1, _q1Buffer[^1],
Math.FusedMultiplyAdd(c2, _q1Buffer[^3],
Math.FusedMultiplyAdd(-c2, _q1Buffer[^5], -c1 * _q1Buffer[^7]))) * adj;
// 5. Phasor addition
double i2_val = i1 - jQ;
double q2_val = q1 + jI;
// Smooth i2, q2 (using FMA for precision)
_state.I2 = Math.FusedMultiplyAdd(0.2, i2_val, 0.8 * _p_state.I2);
_state.Q2 = Math.FusedMultiplyAdd(0.2, q2_val, 0.8 * _p_state.Q2);
// 6. Homodyne Discriminator
double re_val = Math.FusedMultiplyAdd(_state.I2, _p_state.I2, _state.Q2 * _p_state.Q2);
double im_val = Math.FusedMultiplyAdd(_state.I2, _p_state.Q2, -_state.Q2 * _p_state.I2);
// Smooth re, im (using FMA)
_state.Re = Math.FusedMultiplyAdd(0.2, re_val, 0.8 * _p_state.Re);
_state.Im = Math.FusedMultiplyAdd(0.2, im_val, 0.8 * _p_state.Im);
// 7. Calculate Period
double angle = Math.Atan2(_state.Im, _state.Re);
double period = Math.Abs(angle) > MinDeltaRadians
? TwoPi / Math.Abs(angle)
: _p_state.Period;
// Adjust period to thresholds
if (prevPeriod > 0)
{
double cap = 1.5 * prevPeriod;
double floor = 0.67 * prevPeriod;
if (period > cap) period = cap;
if (period < floor) period = floor;
}
if (period < 6) period = 6;
if (period > 50) period = 50;
// Smooth the period (using FMA)
_state.Period = Math.FusedMultiplyAdd(0.2, period, 0.8 * prevPeriod);
_state.SmoothPeriod = Math.FusedMultiplyAdd(0.33, _state.Period, 0.67 * _p_state.SmoothPeriod);
// 8. Instantaneous Trend
int dcPeriods = (int)(double.IsNaN(_state.SmoothPeriod) ? 0 : _state.SmoothPeriod + 0.5);
double sumPr = 0;
int count = 0;
// Sum price over dcPeriods
for (int d = 0; d < dcPeriods; d++)
{
// Check if we have enough history
if (d < _priceBuffer.Count)
{
sumPr += _priceBuffer[^(d + 1)];
count++;
}
}
double it = count > 0 ? sumPr / count : price;
_itBuffer.Add(it, isNew);
// 9. Final Trendline
// Need at least 12 bars total (Index > 11) to have valid IT history for smoothing
if (_state.Index >= 12)
{
// NaN will propagate if IT buffer contains NaN
return (4.0 * _itBuffer[^1] + 3.0 * _itBuffer[^2] + 2.0 * _itBuffer[^3] + _itBuffer[^4]) * 0.1;
}
return price; // May be NaN if no valid input yet
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
double val = Step(input.Value, isNew);
Last = new TValue(input.Time, val);
PubEvent(Last, isNew);
return Last;
}
/// <summary>
/// Updates the indicator with a TSeries (batch mode).
/// This method processes each value through the streaming Update method,
/// maintaining full state for subsequent streaming updates.
/// For high-performance batch-only processing, use the static Calculate method instead.
/// </summary>
/// <param name="source">Input time series</param>
/// <returns>Output time series with HTIT values</returns>
public override TSeries Update(TSeries source)
{
if (source.Count == 0) return new TSeries([], []);
int len = source.Count;
var v = new List<double>(len);
var t = new List<long>(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);
}
private void Handle(object? sender, in TValueEventArgs args)
{
Update(args.Value, args.IsNew);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (var value in source)
{
Step(value, isNew: true);
}
}
public static TSeries Batch(TSeries source)
{
var htit = new Htit();
return htit.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> output)
{
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length", nameof(output));
if (source.Length == 0) return;
// Stack allocate buffers
// priceBuffer needs to be larger for IT calculation (up to 50 bars)
// Using 64 (power of 2) for efficient masking
Span<double> priceBuffer = stackalloc double[64];
Span<double> smoothBuffer = stackalloc double[8];
Span<double> detrenderBuffer = stackalloc double[8];
Span<double> i1Buffer = stackalloc double[8];
Span<double> q1Buffer = stackalloc double[8];
Span<double> itBuffer = stackalloc double[8];
int pIdx = 0; // Index for priceBuffer (mask 63)
int sIdx = 0; // Index for other buffers (mask 7)
int count = 0;
// State variables
double i2 = 0, q2 = 0, re = 0, im = 0;
double period = 0, smoothPeriod = 0;
// Initialize to NaN to detect first valid price
double lastValidPrice = double.NaN;
// Previous state variables
double p_i2 = 0, p_q2 = 0, p_re = 0, p_im = 0;
double p_period = 0, p_smoothPeriod = 0;
const int Mask63 = 63;
const int Mask7 = 7;
for (int i = 0; i < source.Length; i++)
{
double price = source[i];
// Handle non-finite input: skip processing if no valid price seen yet
if (!double.IsFinite(price))
{
// If we haven't seen a valid price yet, output NaN
if (double.IsNaN(lastValidPrice))
{
output[i] = double.NaN;
continue;
}
// Otherwise, use the last valid price
price = lastValidPrice;
}
else
{
lastValidPrice = price;
}
// Update circular buffer indices
pIdx = (pIdx + 1) & Mask63;
sIdx = (sIdx + 1) & Mask7;
count++;
priceBuffer[pIdx] = price;
if (count > 6)
{
// 1. Smooth Price using FMA
double smooth = Math.FusedMultiplyAdd(4.0, priceBuffer[pIdx],
Math.FusedMultiplyAdd(3.0, priceBuffer[(pIdx - 1) & Mask63],
Math.FusedMultiplyAdd(2.0, priceBuffer[(pIdx - 2) & Mask63],
priceBuffer[(pIdx - 3) & Mask63]))) * 0.1;
smoothBuffer[sIdx] = smooth;
// 2. Detrender
double adj = (adjSlope * p_period) + adjIntercept;
// Use FMA for detrender
double detrender = Math.FusedMultiplyAdd(c1, smoothBuffer[sIdx],
Math.FusedMultiplyAdd(c2, smoothBuffer[(sIdx - 2) & Mask7],
Math.FusedMultiplyAdd(-c2, smoothBuffer[(sIdx - 4) & Mask7],
-c1 * smoothBuffer[(sIdx - 6) & Mask7]))) * adj;
detrenderBuffer[sIdx] = detrender;
// 3. In-Phase and Quadrature using FMA
double q1 = Math.FusedMultiplyAdd(c1, detrender,
Math.FusedMultiplyAdd(c2, detrenderBuffer[(sIdx - 2) & Mask7],
Math.FusedMultiplyAdd(-c2, detrenderBuffer[(sIdx - 4) & Mask7],
-c1 * detrenderBuffer[(sIdx - 6) & Mask7]))) * adj;
q1Buffer[sIdx] = q1;
double i1 = detrenderBuffer[(sIdx - 3) & Mask7];
i1Buffer[sIdx] = i1;
// 4. Advance phases using FMA
double jI = Math.FusedMultiplyAdd(c1, i1,
Math.FusedMultiplyAdd(c2, i1Buffer[(sIdx - 2) & Mask7],
Math.FusedMultiplyAdd(-c2, i1Buffer[(sIdx - 4) & Mask7],
-c1 * i1Buffer[(sIdx - 6) & Mask7]))) * adj;
double jQ = Math.FusedMultiplyAdd(c1, q1,
Math.FusedMultiplyAdd(c2, q1Buffer[(sIdx - 2) & Mask7],
Math.FusedMultiplyAdd(-c2, q1Buffer[(sIdx - 4) & Mask7],
-c1 * q1Buffer[(sIdx - 6) & Mask7]))) * adj;
// 5. Phasor addition
double i2_val = i1 - jQ;
double q2_val = q1 + jI;
i2 = Math.FusedMultiplyAdd(0.2, i2_val, 0.8 * p_i2);
q2 = Math.FusedMultiplyAdd(0.2, q2_val, 0.8 * p_q2);
// 6. Homodyne Discriminator
double re_val = Math.FusedMultiplyAdd(i2, p_i2, q2 * p_q2);
double im_val = Math.FusedMultiplyAdd(i2, p_q2, -q2 * p_i2);
re = Math.FusedMultiplyAdd(0.2, re_val, 0.8 * p_re);
im = Math.FusedMultiplyAdd(0.2, im_val, 0.8 * p_im);
// 7. Calculate Period
double angle = Math.Atan2(im, re);
double newPeriod = Math.Abs(angle) > MinDeltaRadians
? TwoPi / Math.Abs(angle)
: p_period;
if (p_period > 0)
{
double cap = 1.5 * p_period;
double floor = 0.67 * p_period;
if (newPeriod > cap) newPeriod = cap;
if (newPeriod < floor) newPeriod = floor;
}
if (newPeriod < 6) newPeriod = 6;
if (newPeriod > 50) newPeriod = 50;
period = Math.FusedMultiplyAdd(0.2, newPeriod, 0.8 * p_period);
smoothPeriod = Math.FusedMultiplyAdd(0.33, period, 0.67 * p_smoothPeriod);
// 8. Instantaneous Trend
double safeSmooth = double.IsNaN(smoothPeriod) ? 0 : smoothPeriod;
int dcPeriods = (int)(safeSmooth + 0.5);
double sumPr = 0;
int prCount = 0;
for (int d = 0; d < dcPeriods; d++)
{
if (d < count)
{
sumPr += priceBuffer[(pIdx - d) & Mask63];
prCount++;
}
}
double it = prCount > 0 ? sumPr / prCount : price;
itBuffer[sIdx] = it;
// 9. Final Trendline using FMA
output[i] = count >= 12
? Math.FusedMultiplyAdd(4.0, itBuffer[sIdx],
Math.FusedMultiplyAdd(3.0, itBuffer[(sIdx - 1) & Mask7],
Math.FusedMultiplyAdd(2.0, itBuffer[(sIdx - 2) & Mask7],
itBuffer[(sIdx - 3) & Mask7]))) * 0.1
: price;
// Update previous state
p_i2 = i2;
p_q2 = q2;
p_re = re;
p_im = im;
p_period = period;
p_smoothPeriod = smoothPeriod;
}
else
{
// Initialization - propagate NaN if no valid price yet
smoothBuffer[sIdx] = price;
detrenderBuffer[sIdx] = 0;
i1Buffer[sIdx] = 0;
q1Buffer[sIdx] = 0;
itBuffer[sIdx] = price;
output[i] = price; // May be NaN if no valid input yet
// Reset state variables
p_i2 = 0; p_q2 = 0; p_re = 0; p_im = 0;
p_period = 0; p_smoothPeriod = 0;
}
}
}
}
+260
View File
@@ -0,0 +1,260 @@
# HTIT: Hilbert Transform Instantaneous Trend
> "John Ehlers brought rocket science to trading. Literally. HTIT uses signal processing to find the trend by removing the cycle. It's not smoothing; it's extraction."
HTIT (Hilbert Transform Instantaneous Trend) is a trend-following indicator that doesn't rely on simple averaging. Instead, it uses the Hilbert Transform to measure the dominant cycle period of the market and then computes a trendline that filters out that specific cycle. It adapts to the market's rhythm rather than imposing a fixed period.
## Historical Context
John Ehlers, a pioneer in applying DSP to trading, introduced this in his book *Rocket Science for Traders*. He recognized that markets have cyclic components (noise) and trend components. By identifying the cycle, you can mathematically subtract it to reveal the pure trend.
Most trend indicators (SMA, EMA) are low-pass filters: they let low frequencies (trend) pass and block high frequencies (noise). The problem is that "noise" in markets isn't random white noise; it's often cyclic. A fixed-period SMA might filter out a 10-day cycle perfectly but amplify a 20-day cycle. HTIT solves this by measuring the cycle first, then tuning the filter to kill exactly that frequency.
## Architecture & Physics
This is a complex, multi-stage signal processing pipeline. It's not just a formula; it's a machine.
1. **Smooth**: 4-bar WMA to remove high-frequency noise (Nyquist limit).
2. **Detrend**: High-pass filter to remove the DC component (trend) temporarily to isolate the cycle.
3. **Hilbert Transform**: Compute In-Phase (I) and Quadrature (Q) components.
4. **Period Measurement**: Use the phase rate of change (Homodyne Discriminator) to measure the dominant cycle period.
5. **Trend Extraction**: Average the price over the measured dominant cycle period to cancel out the cycle.
6. **Post-Smoothing**: 4-bar WMA on the extracted trend for final polish.
The "physics" here is cancellation. If you average a sine wave over exactly one period, the result is zero. If you average Price (Trend + Cycle) over exactly one cycle period, the Cycle cancels out, leaving only the Trend.
## Mathematical Foundation
### 1. Pre-Smoothing
A 4-tap FIR filter removes high-frequency noise to prevent aliasing before the Hilbert Transform.
$$ \text{Smooth}_t = \frac{4 P_t + 3 P_{t-1} + 2 P_{t-2} + P_{t-3}}{10} $$
### 2. Hilbert Transform & Detrending
The signal is detrended and split into In-Phase ($I$) and Quadrature ($Q$) components using a 7-tap Hilbert Transform. The coefficients are optimized for market cycles (10-40 bars).
$$ \text{Adj} = 0.075 \cdot \text{Period}_{t-1} + 0.54 $$
$$ \text{Detrender}_t = \left( \frac{5}{52} S_t + \frac{15}{26} S_{t-2} - \frac{15}{26} S_{t-4} - \frac{5}{52} S_{t-6} \right) \cdot \text{Adj} $$
$$ Q_t = \left( \frac{5}{52} D_t + \frac{15}{26} D_{t-2} - \frac{15}{26} D_{t-4} - \frac{5}{52} D_{t-6} \right) \cdot \text{Adj} $$
$$ I_t = D_{t-3} $$
### 3. Homodyne Discriminator
The phase rate of change is calculated using the complex conjugate product of the current and previous phasors. This is the "Homodyne Discriminator" - a fancy radio term for "measuring frequency by comparing a signal to a delayed version of itself."
$$ \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}) $$
The period is derived from the phase angle of this complex product:
$$ \text{Period}_t = \frac{2\pi}{\arctan\left(\frac{\text{Im}_t}{\text{Re}_t}\right)} $$
The period is constrained to [6, 50] bars and smoothed.
### 4. Instantaneous Trend
The trend is extracted by averaging the price over the measured dominant cycle period. This is the magic step.
$$ \text{IT}_t = \frac{1}{\text{DC}} \sum_{i=0}^{\text{DC}-1} P_{t-i} $$
Where $\text{DC}$ is the integer part of the smoothed dominant cycle period.
### 5. Final Output
The Instantaneous Trend is smoothed again using the same 4-bar WMA to remove any residual stepping artifacts from the integer period changes.
$$ \text{HTIT}_t = \frac{4 \text{IT}_t + 3 \text{IT}_{t-1} + 2 \text{IT}_{t-2} + \text{IT}_{t-3}}{10} $$
## Mathematical Precision & Implementation Philosophy
Like our MAMA implementation, QuanTAlib's HTIT prioritizes mathematical correctness over blind porting.
| Aspect | Other Libraries | QuanTAlib | Rationale |
| :----------------------- | :----------------- | :---------------------- | :-------------------------------------------- |
| **Hilbert Coefficients** | `0.0962`, `0.5769` | `5.0/52.0`, `15.0/26.0` | Exact fractions avoid rounding accumulation |
| **Adjustment Slope** | `0.075` | `3.0/40.0` | Preserves rational arithmetic precision |
| **Adjustment Intercept** | `0.54` | `27.0/50.0` | Ditto |
| **Arctangent Function** | `atan(y/x)` | `atan2(y, x)` | Proper quadrant handling, no division by zero |
| **Period Calculation** | `360/atan(...)` | `2π/atan2(...)` | Mathematically correct radians |
We use `atan2` for robust phase calculation and maintain full double precision throughout the pipeline.
## Performance Profile
HTIT is computationally heavier than a simple MA but lighter than MAMA. The main cost is the loop for the Instantaneous Trend calculation, which sums up to 50 past prices.
### Operation Count (Streaming Mode, Scalar)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| **Stage 1: Pre-Smoothing (4-tap FIR)** | | | |
| MUL | 4 | 3 | 12 |
| ADD | 3 | 1 | 3 |
| **Stage 2: Detrender (7-tap Hilbert)** | | | |
| MUL | 4 | 3 | 12 |
| ADD/SUB | 3 | 1 | 3 |
| **Stage 3: Q Hilbert Transform** | | | |
| MUL | 4 | 3 | 12 |
| ADD/SUB | 3 | 1 | 3 |
| **Stage 4: I2/Q2 Smoothing** | | | |
| FMA | 2 | 4 | 8 |
| **Stage 5: Homodyne Discriminator** | | | |
| MUL | 4 | 3 | 12 |
| ADD/SUB | 2 | 1 | 2 |
| **Stage 6: Period Calculation** | | | |
| ATAN2 | 1 | 50 | 50 |
| DIV | 1 | 15 | 15 |
| CMP (clamp) | 2 | 1 | 2 |
| **Stage 7: Period Smoothing** | | | |
| FMA | 1 | 4 | 4 |
| **Stage 8: Instantaneous Trend (O(N) sum)** | | | |
| ADD | ~25 avg | 1 | ~25 |
| DIV | 1 | 15 | 15 |
| **Stage 9: Final 4-tap Smoothing** | | | |
| MUL | 4 | 3 | 12 |
| ADD | 3 | 1 | 3 |
| **Total** | | | **~193 cycles** |
**Dominant costs:**
- ATAN2 (50 cycles, 26%) — phase measurement for homodyne discriminator
- IT summation loop (~25 cycles avg, 13%) — O(N) complexity where N = dcPeriod (6-50)
**Note:** The IT loop iterates `dcPeriod` times (6-50 bars). The estimate above uses 25 as the average. Worst case (dcPeriod=50) adds ~50 cycles total.
### Batch Mode (SIMD Analysis)
HTIT is **not SIMD-parallelizable** across bars due to:
1. Recursive feedback in Hilbert transforms (I2, Q2 depend on previous values)
2. Period-dependent IT summation loop (variable iteration count)
3. Homodyne discriminator state dependencies
**Per-bar optimization with FMA:** The 4-tap smoothing stages and Hilbert transforms could benefit from FMA, saving ~4-8 cycles per bar.
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Extracts trend by mathematically canceling the dominant cycle |
| **Timeliness** | 7/10 | Adapts period, but IT averaging introduces inherent lag |
| **Overshoot** | 8/10 | Generally stable; double WMA reduces oscillation |
| **Smoothness** | 9/10 | Very smooth trendline due to dual 4-tap WMA stages |
## Validation
Validated against TA-Lib, Skender, and Ooples.
| Library | Status | Notes |
| :------------ | :----------- | :--------------------------------------------------------------- |
| **QuanTAlib** | ✅ Reference | Mathematically correct implementation. |
| **TA-Lib** | ✅ | Matches `HtTrendline` exactly (1e-9 precision). |
| **Skender** | ⚠️ | Matches `GetHtTrendline` (~0.32% diff). |
| **Ooples** | ⚠️ | Matches `CalculateEhlersInstantaneousTrendlineV1` (~0.25% diff). |
The differences with Skender and Ooples arise from:
1. **Initialization**: How the first few bars are handled.
2. **Precision**: Hardcoded decimals vs exact fractions.
3. **Period Constraints**: How strictly the [6, 50] bounds are enforced during intermediate steps.
## C# Implementation Considerations
### State Management
HTIT uses a compact record struct for Hilbert Transform state tracking:
```csharp
[StructLayout(LayoutKind.Auto)]
private record struct State(
double I2, double Q2, double Re, double Im,
double Period, double SmoothPeriod,
double LastValidPrice, int Index
);
```
Bar correction uses simple state copy (no RingBuffer snapshot needed for state struct):
```csharp
if (isNew) { _p_state = _state; _state.Index++; }
else { _state = _p_state; }
```
### Multiple RingBuffers
HTIT maintains six separate circular buffers for the multi-stage pipeline:
```csharp
private readonly RingBuffer _priceBuffer; // 64 elements (for IT sum)
private readonly RingBuffer _smoothBuffer; // 8 elements
private readonly RingBuffer _detrenderBuffer; // 8 elements
private readonly RingBuffer _i1Buffer; // 8 elements
private readonly RingBuffer _q1Buffer; // 8 elements
private readonly RingBuffer _itBuffer; // 8 elements
```
The price buffer is larger (64) to support IT calculation over up to 50 bars.
### Precomputed Constants
High-precision rational constants avoid rounding accumulation:
```csharp
private const double c1 = 5.0 / 52.0; // ~0.09615385
private const double c2 = 15.0 / 26.0; // ~0.57692308
private const double adjSlope = 3.0 / 40.0; // 0.075
private const double adjIntercept = 27.0 / 50.0; // 0.54
private const double TwoPi = 2.0 * Math.PI;
```
### FMA Usage
Smoothing operations use FusedMultiplyAdd for precision:
```csharp
_state.I2 = Math.FusedMultiplyAdd(0.2, i2_val, 0.8 * _p_state.I2);
_state.Q2 = Math.FusedMultiplyAdd(0.2, q2_val, 0.8 * _p_state.Q2);
_state.Re = Math.FusedMultiplyAdd(0.2, re_val, 0.8 * _p_state.Re);
_state.Period = Math.FusedMultiplyAdd(0.2, period, 0.8 * prevPeriod);
```
### Stack-Allocated Calculate Method
The static `Calculate(Span)` method uses stackalloc for zero-allocation batch processing:
```csharp
Span<double> priceBuffer = stackalloc double[64];
Span<double> smoothBuffer = stackalloc double[8];
// ... etc
const int Mask63 = 63; // Power-of-2 masking for circular index
const int Mask7 = 7;
```
### Memory Layout
| Field | Type | Size | Purpose |
| :--- | :--- | :---: | :--- |
| `_priceBuffer` | RingBuffer | ~8B+512B | Price history (64×8B) |
| `_smoothBuffer` | RingBuffer | ~8B+64B | Smoothed prices (8×8B) |
| `_detrenderBuffer` | RingBuffer | ~8B+64B | Detrender output |
| `_i1Buffer` | RingBuffer | ~8B+64B | In-phase component |
| `_q1Buffer` | RingBuffer | ~8B+64B | Quadrature component |
| `_itBuffer` | RingBuffer | ~8B+64B | Instantaneous trend |
| `_state` | State | ~64B | Current Hilbert state |
| `_p_state` | State | ~64B | Previous state for rollback |
| **Total** | | **~960B** | Per indicator instance |
### Numerical Robustness
Uses `Math.Atan2` for proper quadrant handling in phase calculation, avoiding division-by-zero issues that plague `atan(y/x)` implementations.
### Common Pitfalls
1. **Warmup**: This indicator needs significant warmup (at least 12 bars, ideally 50+) for the feedback loops (period smoothing) to stabilize. Don't trust the first 50 bars.
2. **Lag**: While it adapts, the trendline still lags because it's essentially a dynamic SMA. The advantage is that the period is optimal for the current market condition, not that it has zero lag.
3. **Complexity**: Debugging this is a nightmare. Trust the math.
4. **Ranging Markets**: In a pure range, the "trend" should be flat. HTIT handles this well because the cycle cancellation works best when the cycle is clear.
+63
View File
@@ -0,0 +1,63 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Hilbert Trendline (HTIT)", "HTIT", overlay=true)
//@function Calculates the Hilbert Transform Instantaneous Trendline (HTIT)
//@doc https://github.com/mihakralj/pinescript/blob/main/indicators/trends_IIR/htit.md
//@param source Series to calculate HTIT from
//@returns HTIT value using Hilbert Transform with adaptive period estimation
//@optimized Uses Hilbert Transform quadrature components for O(1) complexity per bar
htit(series float source) =>
var float price = na
var float smooth = na
var float detrender = 0.0
var float I1 = 0.0
var float Q1 = 0.0
var float I2 = 0.0
var float Q2 = 0.0
var float Re = 0.0
var float Im = 0.0
var float periodEst = 10.0
var float iTrend = na
var float iTrend1 = na
var float iTrend2 = na
float result = na
price := (4 * source + 3 * source[1] + 2 * source[2] + source[3]) / 10
smooth := (4 * price + 3 * price[1] + 2 * price[2] + price[3]) / 10
float padAdj = 0.075 * periodEst + 0.54
detrender := (0.0962 * smooth + 0.5769 * smooth[2] - 0.5769 * smooth[4] - 0.0962 * smooth[6]) * padAdj
I1 := nz(detrender[3])
Q1 := (0.0962 * detrender + 0.5769 * detrender[2] - 0.5769 * detrender[4] - 0.0962 * detrender[6]) * padAdj
float jI = (0.0962 * I1 + 0.5769 * I1[2] - 0.5769 * I1[4] - 0.0962 * I1[6]) * padAdj
float jQ = (0.0962 * Q1 + 0.5769 * Q1[2] - 0.5769 * Q1[4] - 0.0962 * Q1[6]) * padAdj
I2 := 0.2 * (I1 - jQ) + 0.8 * nz(I2[1])
Q2 := 0.2 * (Q1 + jI) + 0.8 * nz(Q2[1])
Re := 0.2 * (I2 * nz(I2[1]) + Q2 * nz(Q2[1])) + 0.8 * nz(Re[1])
Im := 0.2 * (I2 * nz(Q2[1]) - Q2 * nz(I2[1])) + 0.8 * nz(Im[1])
float newP = Im != 0 and Re != 0 ? 2 * math.pi / math.atan(Im / Re) : periodEst
periodEst := math.max(6, math.min(50, 0.2 * newP + 0.8 * periodEst))
float angle = I1 != 0 ? math.atan(Q1 / I1) : math.pi / 2 * math.sign(Q1)
angle += I1 < 0 ? math.pi : Q1 < 0 and I1 > 0 ? 2 * math.pi : 0
angle := angle % (2 * math.pi)
float trendPower = math.sqrt(I1 * I1 + Q1 * Q1)
float newITrendComponent = smooth + 0.07 * trendPower * math.sin(angle)
float currentITrend2 = nz(iTrend1[1], smooth)
float currentITrend1 = nz(iTrend[1], smooth)
float currentITrend = 0.9 * newITrendComponent + 1.1 * currentITrend1 - 1.0 * currentITrend2
iTrend2 := currentITrend1
iTrend1 := currentITrend
iTrend := currentITrend
result := currentITrend
result
// ---------- Main loop ----------
// Inputs
i_source = input.source(close, "Source")
// Calculation
htit_value = htit(i_source)
// Plot
plot(htit_value, "HTIT", color=color.yellow, linewidth=2)