Files
QuanTAlib/lib/trends/htit/Htit.cs
T
Miha Kralj 5c3b3fbab4 Refactor indicators to support optional time step in Prime method
- Updated the Prime method signature in multiple indicators (Jma, Kama, Lsma, Mama, Mgdi, Pwma, Rma, Sma, Ssf, Super, T3, Tema, Trima, Usf, Vidya, Wma, Atr) to accept an optional TimeSpan parameter for improved flexibility.
- Added unit tests for Lsma to verify Dispose functionality, ensuring proper unsubscription from the source and thread safety.
- Enhanced Mama and Wma classes to handle non-finite inputs gracefully and added checks for valid parameters in constructors.
- Introduced additional tests for T3 to validate constructor behavior with invalid volume factors.
- Ensured all indicators maintain consistent behavior when handling edge cases, such as empty buffers and non-finite values.
2025-12-28 15:14:07 -08:00

461 lines
16 KiB
C#

using System;
using System.Collections.Generic;
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 = default;
_p_state = default;
_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
// smooth = (4*Price + 3*Price[1] + 2*Price[2] + Price[3]) / 10
double smooth = (4.0 * _priceBuffer[^1] + 3.0 * _priceBuffer[^2] + 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;
double detrender = (c1 * _smoothBuffer[^1] + c2 * _smoothBuffer[^3] - c2 * _smoothBuffer[^5] - c1 * _smoothBuffer[^7]) * adj;
_detrenderBuffer.Add(detrender, isNew);
// 3. In-Phase and Quadrature
double q1 = (c1 * _detrenderBuffer[^1] + c2 * _detrenderBuffer[^3] - 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
double jI = (c1 * _i1Buffer[^1] + c2 * _i1Buffer[^3] - c2 * _i1Buffer[^5] - c1 * _i1Buffer[^7]) * adj;
double jQ = (c1 * _q1Buffer[^1] + c2 * _q1Buffer[^3] - c2 * _q1Buffer[^5] - c1 * _q1Buffer[^7]) * adj;
// 5. Phasor addition
double i2_val = i1 - jQ;
double q2_val = q1 + jI;
// Smooth i2, q2
_state.I2 = 0.2 * i2_val + 0.8 * _p_state.I2;
_state.Q2 = 0.2 * q2_val + 0.8 * _p_state.Q2;
// 6. Homodyne Discriminator
double re_val = (_state.I2 * _p_state.I2) + (_state.Q2 * _p_state.Q2);
double im_val = (_state.I2 * _p_state.Q2) - (_state.Q2 * _p_state.I2);
// Smooth re, im
_state.Re = 0.2 * re_val + 0.8 * _p_state.Re;
_state.Im = 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
_state.Period = 0.2 * period + 0.8 * prevPeriod;
_state.SmoothPeriod = 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;
}
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, TValueEventArgs args)
{
Update(args.Value, args.IsNew);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (var value in source)
{
Step(value, 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
double smooth = (4.0 * priceBuffer[pIdx] +
3.0 * priceBuffer[(pIdx - 1) & Mask63] +
2.0 * priceBuffer[(pIdx - 2) & Mask63] +
priceBuffer[(pIdx - 3) & Mask63]) * 0.1;
smoothBuffer[sIdx] = smooth;
// 2. Detrender
double adj = (adjSlope * p_period) + adjIntercept;
double detrender = (c1 * smoothBuffer[sIdx] +
c2 * smoothBuffer[(sIdx - 2) & Mask7] -
c2 * smoothBuffer[(sIdx - 4) & Mask7] -
c1 * smoothBuffer[(sIdx - 6) & Mask7]) * adj;
detrenderBuffer[sIdx] = detrender;
// 3. In-Phase and Quadrature
double q1 = (c1 * detrender +
c2 * detrenderBuffer[(sIdx - 2) & Mask7] -
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
double jI = (c1 * i1 +
c2 * i1Buffer[(sIdx - 2) & Mask7] -
c2 * i1Buffer[(sIdx - 4) & Mask7] -
c1 * i1Buffer[(sIdx - 6) & Mask7]) * adj;
double jQ = (c1 * q1 +
c2 * q1Buffer[(sIdx - 2) & Mask7] -
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 = 0.2 * i2_val + 0.8 * p_i2;
q2 = 0.2 * q2_val + 0.8 * p_q2;
// 6. Homodyne Discriminator
double re_val = (i2 * p_i2) + (q2 * p_q2);
double im_val = (i2 * p_q2) - (q2 * p_i2);
re = 0.2 * re_val + 0.8 * p_re;
im = 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 = 0.2 * newPeriod + 0.8 * p_period;
smoothPeriod = 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
output[i] = count >= 12
? (4.0 * itBuffer[sIdx] +
3.0 * itBuffer[(sIdx - 1) & Mask7] +
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;
}
}
}
}