Remove multiple Pine Script indicators: SSFDSP, STARCHANNEL, STBANDS, STC, UBANDS, UCHANNEL, VWAPBANDS, and VWAPSD. These indicators were deleted to streamline the library and remove unused or redundant code.

This commit is contained in:
Miha Kralj
2026-02-20 18:44:56 -08:00
parent 3dd05f23e4
commit cbeefc9d64
283 changed files with 23963 additions and 3838 deletions
@@ -0,0 +1,80 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class HoltIndicatorTests
{
[Fact]
public void HoltIndicator_Constructor_SetsDefaults()
{
var indicator = new HoltIndicator();
Assert.Equal(10, indicator.Period);
Assert.Equal(0, indicator.Gamma);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("HOLT - Holt Exponential Moving Average", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void HoltIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new HoltIndicator { Period = 20 };
Assert.Equal(0, HoltIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void HoltIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new HoltIndicator { Period = 15 };
Assert.Contains("HOLT", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void HoltIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new HoltIndicator { Period = 10, Gamma = 0.3 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void HoltIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new HoltIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void HoltIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new HoltIndicator { Period = 3 };
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);
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public class HoltIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 10;
[InputParameter("Gamma", sortIndex: 2, 0.0, 1.0, 0.01, 2)]
public double Gamma { get; set; } = 0;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Holt _holt = null!;
protected LineSeries Series;
protected string SourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"HOLT {Period}:{SourceName}";
public HoltIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "HOLT - Holt Exponential Moving Average";
Description = "Double exponential smoothing tracking level and trend";
Series = new LineSeries(name: $"HOLT {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
_holt = new Holt(Period, Gamma);
SourceName = Source.ToString();
_priceSelector = Source.GetPriceSelector();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
TValue result = _holt.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
Series.SetValue(result.Value, _holt.IsHot, ShowColdValues);
}
}
+429
View File
@@ -0,0 +1,429 @@
namespace QuanTAlib.Tests;
public class HoltTests
{
private readonly GBM _gbm = new(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42);
// === A) Constructor validation ===
[Fact]
public void Holt_ZeroPeriod_Throws()
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Holt(0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Holt_NegativePeriod_Throws()
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Holt(-1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Holt_GammaTooLow_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Holt(10, gamma: -0.1));
Assert.Equal("gamma", ex.ParamName);
}
[Fact]
public void Holt_GammaTooHigh_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Holt(10, gamma: 1.1));
Assert.Equal("gamma", ex.ParamName);
}
[Fact]
public void Holt_ValidConstruction_SetsName()
{
var holt = new Holt(10);
Assert.Equal("Holt(10)", holt.Name);
}
[Fact]
public void Holt_ValidConstruction_WithGamma_SetsName()
{
var holt = new Holt(10, gamma: 0.3);
Assert.Equal("Holt(10,0.30)", holt.Name);
}
// === B) Basic calculation ===
[Fact]
public void Holt_Update_ReturnsTValue()
{
var holt = new Holt(10);
TValue result = holt.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Holt_FirstBar_ReturnsInput()
{
var holt = new Holt(10);
TValue result = holt.Update(new TValue(DateTime.UtcNow, 42.0));
Assert.Equal(42.0, result.Value, 10);
}
[Fact]
public void Holt_Last_IsAccessible()
{
var holt = new Holt(10);
holt.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(holt.Last.Value));
}
// === C) State + bar correction ===
[Fact]
public void Holt_IsNew_True_AdvancesState()
{
var holt = new Holt(5);
var bars = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
for (int i = 0; i < series.Count; i++)
{
holt.Update(series[i]);
}
double valueAfterAll = holt.Last.Value;
holt.Update(new TValue(DateTime.UtcNow, 999.0), isNew: true);
Assert.NotEqual(valueAfterAll, holt.Last.Value);
}
[Fact]
public void Holt_IsNew_False_RollsBack()
{
var holt = new Holt(5);
var bars = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
for (int i = 0; i < series.Count; i++)
{
holt.Update(series[i]);
}
double baseline = holt.Last.Value;
holt.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
_ = holt.Last.Value;
// Correction should produce a different value from baseline (999 != last close)
// But the state should have been rolled back first
holt.Update(series[^1], isNew: false);
Assert.Equal(baseline, holt.Last.Value, 10);
}
[Fact]
public void Holt_IterativeCorrections_Restore()
{
var holt = new Holt(5);
var bars = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
for (int i = 0; i < series.Count - 1; i++)
{
holt.Update(series[i]);
}
// Feed last bar as new
holt.Update(series[^1], isNew: true);
double expected = holt.Last.Value;
// Correct it multiple times
for (int j = 0; j < 5; j++)
{
holt.Update(series[^1], isNew: false);
}
Assert.Equal(expected, holt.Last.Value, 10);
}
[Fact]
public void Holt_Reset_ClearsState()
{
var holt = new Holt(10);
var bars = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
for (int i = 0; i < series.Count; i++)
{
holt.Update(series[i]);
}
Assert.True(holt.IsHot);
holt.Reset();
Assert.False(holt.IsHot);
Assert.Equal(default, holt.Last);
}
// === D) Warmup/convergence ===
[Fact]
public void Holt_IsHot_FlipsAtWarmup()
{
var holt = new Holt(10);
var bars = _gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
for (int i = 0; i < 9; i++)
{
holt.Update(series[i]);
Assert.False(holt.IsHot);
}
holt.Update(series[9]);
Assert.True(holt.IsHot);
}
[Fact]
public void Holt_WarmupPeriod_MatchesPeriod()
{
var holt = new Holt(15);
Assert.Equal(15, holt.WarmupPeriod);
}
// === E) Robustness ===
[Fact]
public void Holt_NaN_UsesLastValid()
{
var holt = new Holt(5);
holt.Update(new TValue(DateTime.UtcNow, 100.0));
holt.Update(new TValue(DateTime.UtcNow, 101.0));
_ = holt.Last.Value;
holt.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(holt.Last.Value));
}
[Fact]
public void Holt_Infinity_UsesLastValid()
{
var holt = new Holt(5);
holt.Update(new TValue(DateTime.UtcNow, 100.0));
holt.Update(new TValue(DateTime.UtcNow, 101.0));
holt.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(holt.Last.Value));
}
[Fact]
public void Holt_BatchNaN_Safe()
{
double[] src = [100, double.NaN, 102, double.NaN, 104];
double[] dst = new double[5];
Holt.Batch(src, dst, 3);
for (int i = 0; i < dst.Length; i++)
{
Assert.True(double.IsFinite(dst[i]), $"dst[{i}] is not finite");
}
}
// === F) Consistency (4 modes) ===
[Fact]
public void Holt_AllModes_Match()
{
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
int period = 10;
// Mode 1: Streaming
var holtStream = new Holt(period);
for (int i = 0; i < series.Count; i++)
{
holtStream.Update(series[i]);
}
double streamResult = holtStream.Last.Value;
// Mode 2: Batch TSeries
TSeries batchResult = Holt.Batch(series, period);
double batchLast = batchResult[^1].Value;
// Mode 3: Span
double[] src = new double[series.Count];
double[] dst = new double[series.Count];
for (int i = 0; i < series.Count; i++)
{
src[i] = series[i].Value;
}
Holt.Batch(src, dst, period);
double spanLast = dst[^1];
// Mode 4: Event-based
var holtEvent = new Holt(period);
double eventResult = 0;
holtEvent.Pub += (object? s, in TValueEventArgs e) => { eventResult = e.Value.Value; };
for (int i = 0; i < series.Count; i++)
{
holtEvent.Update(series[i]);
}
Assert.Equal(streamResult, batchLast, 10);
Assert.Equal(streamResult, spanLast, 10);
Assert.Equal(streamResult, eventResult, 10);
}
// === G) Span API tests ===
[Fact]
public void Holt_Span_MismatchedLength_Throws()
{
double[] src = [1, 2, 3];
double[] dst = new double[2];
var ex = Assert.Throws<ArgumentException>(() => Holt.Batch(src, dst, 5));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Holt_Span_EmptyInput_NoOutput()
{
double[] src = [];
double[] dst = [];
Holt.Batch(src, dst, 5);
Assert.Empty(dst);
}
[Fact]
public void Holt_Span_ZeroPeriod_Throws()
{
double[] src = [1, 2, 3];
double[] dst = new double[3];
Assert.Throws<ArgumentOutOfRangeException>(() => Holt.Batch(src, dst, 0));
}
[Fact]
public void Holt_Span_MatchesTSeries()
{
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
int period = 10;
TSeries bts = Holt.Batch(series, period);
double[] src = new double[series.Count];
double[] dst = new double[series.Count];
for (int i = 0; i < series.Count; i++)
{
src[i] = series[i].Value;
}
Holt.Batch(src, dst, period);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(bts[i].Value, dst[i], 10);
}
}
// === H) Chainability ===
[Fact]
public void Holt_Pub_Fires()
{
var holt = new Holt(5);
int count = 0;
holt.Pub += (object? s, in TValueEventArgs e) => { count++; };
holt.Update(new TValue(DateTime.UtcNow, 100.0));
holt.Update(new TValue(DateTime.UtcNow, 101.0));
Assert.Equal(2, count);
}
[Fact]
public void Holt_EventChaining_Works()
{
var holt1 = new Holt(5);
var holt2 = new Holt(holt1, 3);
holt1.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(holt2.Last.Value));
}
// === Holt-specific tests ===
[Fact]
public void Holt_ConstantInput_ConvergesToLevel()
{
var holt = new Holt(10);
double constant = 50.0;
for (int i = 0; i < 200; i++)
{
holt.Update(new TValue(DateTime.UtcNow, constant));
}
// With constant input, trend -> 0, level -> constant, output -> constant
Assert.Equal(constant, holt.Last.Value, 6);
}
[Fact]
public void Holt_Calculate_ReturnsHotInstance()
{
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var (results, indicator) = Holt.Calculate(bars.Close, 10);
Assert.True(indicator.IsHot);
Assert.Equal(100, results.Count);
}
[Fact]
public void Holt_Prime_SetsState()
{
var bars = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
var holtPrimed = new Holt(10);
double[] values = new double[series.Count];
for (int i = 0; i < series.Count; i++)
{
values[i] = series[i].Value;
}
holtPrimed.Prime(values);
var holtStreamed = new Holt(10);
for (int i = 0; i < series.Count; i++)
{
holtStreamed.Update(series[i]);
}
Assert.Equal(holtStreamed.Last.Value, holtPrimed.Last.Value, 10);
}
[Fact]
public void Holt_GammaZero_EqualsAutoGamma()
{
var bars = _gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
var holt0 = new Holt(10, gamma: 0);
var holtAuto = new Holt(10);
for (int i = 0; i < series.Count; i++)
{
holt0.Update(series[i]);
holtAuto.Update(series[i]);
}
Assert.Equal(holt0.Last.Value, holtAuto.Last.Value, 15);
}
[Fact]
public void Holt_DifferentGamma_ProducesDifferentOutput()
{
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
var holt1 = new Holt(10, gamma: 0.1);
var holt2 = new Holt(10, gamma: 0.9);
for (int i = 0; i < series.Count; i++)
{
holt1.Update(series[i]);
holt2.Update(series[i]);
}
Assert.NotEqual(holt1.Last.Value, holt2.Last.Value);
}
}
@@ -0,0 +1,171 @@
namespace QuanTAlib.Tests;
public class HoltValidationTests
{
private readonly GBM _gbm = new(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42);
private readonly TSeries _series;
public HoltValidationTests()
{
var bars = _gbm.Fetch(5000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
_series = bars.Close;
}
/// <summary>
/// Validates against holt.pine reference implementation logic.
/// Manually computes Holt using the same equations.
/// </summary>
[Fact]
public void Holt_MatchesPineScriptReference()
{
int period = 10;
double alpha = 2.0 / (period + 1.0);
double gamma = alpha; // gamma=0 means use alpha
var holt = new Holt(period);
double level = 0;
double trend = 0;
bool initialized = false;
for (int i = 0; i < _series.Count; i++)
{
holt.Update(_series[i]);
double src = _series[i].Value;
if (!initialized)
{
level = src;
trend = 0;
initialized = true;
}
else
{
double prevLevel = level;
level = (alpha * src) + ((1.0 - alpha) * (prevLevel + trend));
trend = (gamma * (level - prevLevel)) + ((1.0 - gamma) * trend);
}
double expected = initialized && i > 0 ? level + trend : src;
Assert.Equal(expected, holt.Last.Value, 9);
}
}
/// <summary>
/// Validates that constant input converges to the constant value.
/// Level → constant, trend → 0, output → constant.
/// </summary>
[Fact]
public void Holt_ConstantInput_ConvergesToValue()
{
double constant = 75.0;
var holt = new Holt(20);
for (int i = 0; i < 500; i++)
{
holt.Update(new TValue(DateTime.UtcNow, constant));
}
Assert.Equal(constant, holt.Last.Value, 6);
}
/// <summary>
/// Validates deterministic output with same seed.
/// </summary>
[Fact]
public void Holt_Deterministic_SameSeed()
{
int period = 10;
var holt1 = new Holt(period);
var holt2 = new Holt(period);
for (int i = 0; i < _series.Count; i++)
{
holt1.Update(_series[i]);
holt2.Update(_series[i]);
}
Assert.Equal(holt1.Last.Value, holt2.Last.Value, 15);
}
/// <summary>
/// Validates that batch and streaming produce identical results.
/// </summary>
[Fact]
public void Holt_BatchAndStreaming_Match()
{
int period = 15;
var holtStream = new Holt(period);
for (int i = 0; i < _series.Count; i++)
{
holtStream.Update(_series[i]);
}
TSeries batchResult = Holt.Batch(_series, period);
Assert.Equal(holtStream.Last.Value, batchResult[^1].Value, 10);
}
/// <summary>
/// Validates that different gamma values produce different outputs.
/// </summary>
[Fact]
public void Holt_DifferentGamma_DifferentOutputs()
{
var bars = _gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
var holt1 = new Holt(10, gamma: 0.1);
var holt2 = new Holt(10, gamma: 0.9);
int differenceCount = 0;
for (int i = 0; i < series.Count; i++)
{
holt1.Update(series[i]);
holt2.Update(series[i]);
if (i > 20)
{
double diff = Math.Abs(holt1.Last.Value - holt2.Last.Value);
if (diff > 1e-10)
{
differenceCount++;
}
}
}
Assert.True(differenceCount > 100, $"Expected >100 different values, got {differenceCount}");
}
/// <summary>
/// Validates that different periods produce different outputs.
/// </summary>
[Fact]
public void Holt_DifferentPeriods_DifferentOutputs()
{
var bars = _gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
var holt5 = new Holt(5);
var holt50 = new Holt(50);
int differenceCount = 0;
for (int i = 0; i < series.Count; i++)
{
holt5.Update(series[i]);
holt50.Update(series[i]);
if (i > 50)
{
double diff = Math.Abs(holt5.Last.Value - holt50.Last.Value);
if (diff > 1e-10)
{
differenceCount++;
}
}
}
Assert.True(differenceCount > 100, $"Expected >100 different values, got {differenceCount}");
}
}
+395
View File
@@ -0,0 +1,395 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// HOLT: Holt Exponential Moving Average (Double Exponential Smoothing)
/// </summary>
/// <remarks>
/// Holt's (1957) double exponential smoothing tracks both level and trend,
/// producing a 1-step-ahead forecast that adapts to trending data.
///
/// Calculation:
/// <c>L_t = α·y_t + (1-α)·(L_{t-1} + B_{t-1})</c> (Level)
/// <c>B_t = γ·(L_t - L_{t-1}) + (1-γ)·B_{t-1}</c> (Trend)
/// <c>HOLT_t = L_t + B_t</c> (1-step-ahead forecast)
///
/// When gamma=0, degenerates to standard EMA (no trend correction).
/// When gamma=alpha, provides balanced level/trend tracking.
/// </remarks>
/// <seealso href="Holt.md">Detailed documentation</seealso>
/// <seealso href="holt.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Holt : AbstractBase
{
[StructLayout(LayoutKind.Auto)]
private record struct State(double Level, double Trend, int Count, bool IsHot, bool Initialized)
{
public static State New() => new() { Level = 0, Trend = 0, Count = 0, IsHot = false, Initialized = false };
}
private readonly double _alpha;
private readonly double _decay;
private readonly double _gamma;
private readonly double _gammaDecay;
private State _state = State.New();
private State _p_state = State.New();
private double _lastValidValue;
private double _p_lastValidValue;
/// <summary>
/// Creates Holt with specified period and trend smoothing factor.
/// Alpha = 2 / (period + 1). Gamma defaults to alpha when 0.
/// </summary>
/// <param name="period">Smoothing period (must be &gt; 0)</param>
/// <param name="gamma">Trend smoothing factor [0..1]. 0 = auto (uses alpha)</param>
public Holt(int period, double gamma = 0)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(period);
if (gamma < 0 || gamma > 1)
{
throw new ArgumentException("Gamma must be between 0 and 1", nameof(gamma));
}
_alpha = 2.0 / (period + 1.0);
_decay = 1.0 - _alpha;
_gamma = gamma > 0 ? gamma : _alpha;
_gammaDecay = 1.0 - _gamma;
Name = gamma > 0 ? $"Holt({period},{gamma:F2})" : $"Holt({period})";
WarmupPeriod = period;
}
/// <summary>
/// Creates Holt with specified source and parameters.
/// Subscribes to source.Pub event.
/// </summary>
public Holt(ITValuePublisher source, int period, double gamma = 0) : this(period, gamma)
{
source.Pub += Handle;
}
/// <summary>
/// Creates Holt from a TSeries source with specified parameters.
/// Primes from history and subscribes to source.Pub event.
/// </summary>
public Holt(TSeries source, int period, double gamma = 0) : this(period, gamma)
{
Prime(source.Values);
if (source.Count > 0)
{
Last = new TValue(source.LastTime, Last.Value);
}
source.Pub += Handle;
}
/// <summary>
/// True when the Holt indicator has received enough data for valid output.
/// </summary>
public override bool IsHot => _state.IsHot;
private const int StackAllocThreshold = 512;
/// <summary>
/// Initializes the indicator state using the provided history.
/// </summary>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
if (source.Length == 0)
{
return;
}
_state = State.New();
_p_state = State.New();
_lastValidValue = 0;
_p_lastValidValue = 0;
int len = source.Length;
bool foundValid = false;
for (int k = 0; k < len; k++)
{
if (double.IsFinite(source[k]))
{
_lastValidValue = source[k];
foundValid = true;
break;
}
}
if (!foundValid)
{
Last = new TValue(DateTime.MinValue, double.NaN);
_p_state = _state;
_p_lastValidValue = _lastValidValue;
return;
}
double[]? rented = len > StackAllocThreshold ? ArrayPool<double>.Shared.Rent(len) : null;
Span<double> tempOutput = rented != null
? rented.AsSpan(0, len)
: stackalloc double[len];
try
{
CalculateCore(source, tempOutput, _alpha, _decay, _gamma, _gammaDecay, WarmupPeriod, ref _state, ref _lastValidValue);
Last = new TValue(DateTime.MinValue, tempOutput[len - 1]);
_p_state = _state;
_p_lastValidValue = _lastValidValue;
}
finally
{
if (rented != null)
{
ArrayPool<double>.Shared.Return(rented);
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input)
{
if (double.IsFinite(input))
{
_lastValidValue = input;
return input;
}
return _lastValidValue;
}
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
_p_lastValidValue = _lastValidValue;
}
else
{
_state = _p_state;
_lastValidValue = _p_lastValidValue;
}
double val = GetValidValue(input.Value);
val = Compute(val, _alpha, _decay, _gamma, _gammaDecay, WarmupPeriod, ref _state);
Last = new TValue(input.Time, val);
PubEvent(Last, isNew);
return Last;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
var sourceValues = source.Values;
var sourceTimes = source.Times;
State state = _state;
double lastValidValue = _lastValidValue;
CalculateCore(sourceValues, vSpan, _alpha, _decay, _gamma, _gammaDecay, WarmupPeriod, ref state, ref lastValidValue);
_state = state;
_lastValidValue = lastValidValue;
sourceTimes.CopyTo(tSpan);
_p_state = _state;
_p_lastValidValue = _lastValidValue;
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
/// <summary>
/// Core computation: Holt double exponential smoothing.
/// Level and trend equations use FMA for precision.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static double Compute(double input, double alpha, double decay, double gamma, double gammaDecay, int warmup, ref State state)
{
if (!state.Initialized)
{
// First bar: initialize level to input, trend to 0
state.Level = input;
state.Trend = 0;
state.Initialized = true;
state.Count = 1;
if (warmup <= 1)
{
state.IsHot = true;
}
return input;
}
double prevLevel = state.Level;
// Level: alpha * input + (1 - alpha) * (prevLevel + trend)
// = FMA(alpha, input, decay * (prevLevel + trend))
state.Level = Math.FusedMultiplyAdd(alpha, input, decay * (prevLevel + state.Trend));
// Trend: gamma * (level - prevLevel) + (1 - gamma) * trend
// = FMA(gamma, level - prevLevel, gammaDecay * trend)
state.Trend = Math.FusedMultiplyAdd(gamma, state.Level - prevLevel, gammaDecay * state.Trend);
state.Count++;
if (!state.IsHot && state.Count >= warmup)
{
state.IsHot = true;
}
// Output: level + trend (1-step-ahead forecast)
return state.Level + state.Trend;
}
/// <summary>
/// Core batch calculation with NaN handling.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
private static void CalculateCore(ReadOnlySpan<double> source, Span<double> output,
double alpha, double decay, double gamma, double gammaDecay,
int warmup, ref State state, ref double lastValidValue)
{
int len = source.Length;
ref double srcRef = ref MemoryMarshal.GetReference(source);
ref double outRef = ref MemoryMarshal.GetReference(output);
for (int i = 0; i < len; i++)
{
double val = Unsafe.Add(ref srcRef, i);
if (!double.IsFinite(val))
{
val = lastValidValue;
}
else
{
lastValidValue = val;
}
if (!state.Initialized)
{
state.Level = val;
state.Trend = 0;
state.Initialized = true;
state.Count = 1;
if (warmup <= 1)
{
state.IsHot = true;
}
Unsafe.Add(ref outRef, i) = val;
continue;
}
double prevLevel = state.Level;
state.Level = Math.FusedMultiplyAdd(alpha, val, decay * (prevLevel + state.Trend));
state.Trend = Math.FusedMultiplyAdd(gamma, state.Level - prevLevel, gammaDecay * state.Trend);
state.Count++;
if (!state.IsHot && state.Count >= warmup)
{
state.IsHot = true;
}
Unsafe.Add(ref outRef, i) = state.Level + state.Trend;
}
}
/// <summary>
/// Calculates Holt for the entire series using a new instance.
/// </summary>
public static TSeries Batch(TSeries source, int period, double gamma = 0)
{
var holt = new Holt(period, gamma);
return holt.Update(source);
}
/// <summary>
/// Calculates Holt in-place using pre-allocated output span. Zero-allocation.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period, double gamma = 0)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(period);
if (gamma < 0 || gamma > 1)
{
throw new ArgumentException("Gamma must be between 0 and 1", nameof(gamma));
}
if (source.Length == 0)
{
return;
}
double alpha = 2.0 / (period + 1.0);
double decay = 1.0 - alpha;
double g = gamma > 0 ? gamma : alpha;
double gDecay = 1.0 - g;
var state = State.New();
double lastValid = 0;
bool foundValid = false;
for (int k = 0; k < source.Length; k++)
{
if (double.IsFinite(source[k]))
{
lastValid = source[k];
foundValid = true;
break;
}
}
if (!foundValid)
{
output.Fill(double.NaN);
return;
}
CalculateCore(source, output, alpha, decay, g, gDecay, period, ref state, ref lastValid);
}
/// <summary>
/// Runs a high-performance batch and returns a hot Holt instance.
/// </summary>
public static (TSeries Results, Holt Indicator) Calculate(TSeries source, int period, double gamma = 0)
{
var holt = new Holt(period, gamma);
TSeries results = holt.Update(source);
return (results, holt);
}
/// <summary>
/// Resets the Holt filter state.
/// </summary>
public override void Reset()
{
_state = State.New();
_p_state = _state;
_lastValidValue = 0;
_p_lastValidValue = 0;
Last = default;
}
}
+94
View File
@@ -0,0 +1,94 @@
# HOLT: Holt Exponential Moving Average
> "Single smoothing tracks level. Double smoothing tracks trend. The elegance is not in complexity but in the admission that yesterday's direction matters." — Charles C. Holt (1957)
## Overview
Holt's exponential smoothing extends simple exponential smoothing (EMA) by adding a second equation that explicitly tracks the local trend. The result is a 1-step-ahead forecast that adapts to both the level and direction of the time series. When applied to financial data, HOLT produces a trend-following line that anticipates price continuation rather than merely reacting to it.
## Historical Context
Charles C. Holt published the method in 1957 at the Carnegie Institute of Technology, though the work remained an unpublished ONR report until 2004. The method was independently popularized by Peter Winters (who added seasonality, creating Holt-Winters), but the core two-equation system belongs to Holt. In forecasting literature, this is "double exponential smoothing" — not to be confused with DEMA (which is a different construct using two cascaded EMAs with lag compensation).
The crucial difference from DEMA: Holt explicitly decomposes the signal into level and trend components, then recombines them for forecasting. DEMA applies algebraic lag correction without explicit trend modeling.
## Mathematical Foundation
### Level Equation
$$L_t = \alpha \cdot y_t + (1 - \alpha) \cdot (L_{t-1} + B_{t-1})$$
The level $L_t$ is a weighted average of the current observation and the previous level-plus-trend forecast.
### Trend Equation
$$B_t = \gamma \cdot (L_t - L_{t-1}) + (1 - \gamma) \cdot B_{t-1}$$
The trend $B_t$ is a weighted average of the observed level change and the previous trend estimate.
### Output (1-Step-Ahead Forecast)
$$\text{HOLT}_t = L_t + B_t$$
### Parameter Mapping
| Parameter | Formula | Default | Range |
|-----------|---------|---------|-------|
| Alpha ($\alpha$) | $2 / (\text{period} + 1)$ | period=10 → 0.1818 | (0, 1) |
| Gamma ($\gamma$) | User-specified or $\alpha$ | 0 (auto = $\alpha$) | [0, 1] |
### Special Cases
- **$\gamma = 0$ (auto):** Uses $\gamma = \alpha$, providing balanced level/trend tracking
- **$\gamma \to 0$ (manual):** Trend component freezes; degenerates toward pure EMA
- **$\gamma \to 1$:** Trend reacts instantly to level changes; high noise sensitivity
### Initialization
- **Bar 1:** $L_1 = y_1$, $B_1 = 0$, output $= y_1$
- Subsequent bars use the full equations above
## Interpretation
- **Trend following:** When HOLT is above/below price, the trend component provides a directional bias
- **Crossover signals:** HOLT crossing price suggests trend reversal
- **Lead indicator:** Unlike EMA, HOLT anticipates continuation via the trend term
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `period` | int | 10 | Lookback period for alpha calculation |
| `gamma` | double | 0 | Trend smoothing factor; 0 = auto (uses alpha) |
## Performance Profile
| Metric | Value |
|--------|-------|
| Time complexity | O(1) per bar |
| Space complexity | O(1) — level + trend only |
| Allocations | Zero in Update hot path |
| FMA usage | Level and trend equations |
| SIMD potential | Limited (serial dependency) |
| Warmup period | Same as `period` parameter |
## Limitations
1. **Trend overshoot:** In ranging markets, the trend component causes systematic bias (output overshoots actual price during reversals)
2. **Gamma sensitivity:** Small gamma changes dramatically alter behavior; requires careful tuning
3. **No mean reversion:** The additive trend model assumes perpetual directional movement
4. **Initialization sensitivity:** First-bar seeding (level=price, trend=0) means early outputs are biased
5. **Not a filter:** Unlike Butterworth or SSF, Holt has no defined frequency response — it is a forecasting model applied as a filter
## References
- Holt, C. C. (1957). "Forecasting Seasonals and Trends by Exponentially Weighted Moving Averages." ONR Research Memorandum No. 52, Carnegie Institute of Technology.
- Holt, C. C. (2004). "Forecasting Seasonals and Trends by Exponentially Weighted Moving Averages." International Journal of Forecasting, 20(1), 510. (Republication of the 1957 report)
- Gardner, E. S. (1985). "Exponential Smoothing: The State of the Art." Journal of Forecasting, 4(1), 128.
- Hyndman, R. J. & Athanasopoulos, G. (2021). "Forecasting: Principles and Practice." 3rd ed., OTexts.
## See Also
- [EMA](../ema/Ema.md) — Single exponential smoothing (level only)
- [DEMA](../dema/Dema.md) — Double EMA with algebraic lag correction (different approach)
- [TEMA](../tema/Tema.md) — Triple EMA cascade
+52
View File
@@ -0,0 +1,52 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Holt Exponential Moving Average (HOLT)", "HOLT", overlay=true)
//@function Calculates Holt EMA using double exponential smoothing (level + trend)
//@param source Series to smooth
//@param period Lookback period (determines alpha = 2/(period+1))
//@param gamma Trend smoothing factor (0..1). Default: same as alpha
//@returns Holt EMA value (level + trend) from first bar
//@description Holt's (1957) double exponential smoothing tracks both level and trend.
// Level equation: L_t = alpha * y_t + (1 - alpha) * (L_{t-1} + B_{t-1})
// Trend equation: B_t = gamma * (L_t - L_{t-1}) + (1 - gamma) * B_{t-1}
// Output: HOLT_t = L_t + B_t (1-step-ahead forecast)
// When gamma=0, degenerates to standard EMA (no trend correction).
// When gamma=alpha, provides balanced level/trend tracking.
holt(series float source, simple int period, simple float gamma=0) =>
if period <= 0
runtime.error("Period must be greater than 0")
float alpha = 2.0 / (period + 1)
float g = gamma > 0 ? gamma : alpha
var float level = na
var float trend = 0.0
var float result = source
if na(level)
// First bar: initialize level to source, trend to 0
level := source
trend := 0.0
result := source
else
float prevLevel = level
// Level: alpha * source + (1 - alpha) * (prevLevel + trend)
level := alpha * source + (1.0 - alpha) * (prevLevel + trend)
// Trend: gamma * (level - prevLevel) + (1 - gamma) * trend
trend := g * (level - prevLevel) + (1.0 - g) * trend
// Output: level + trend (1-step-ahead forecast)
result := level + trend
result
// ---------- Main loop ----------
// Inputs
i_period = input.int(10, "Period", minval=1)
i_gamma = input.float(0, "Gamma (0 = auto)", minval=0, maxval=1, step=0.01)
i_source = input.source(close, "Source")
// Calculation
holt_value = holt(i_source, period=i_period, gamma=i_gamma)
// Plot
plot(holt_value, "HOLT", color=color.yellow, linewidth=2)