Add TRAMA implementation and comprehensive tests

- Implemented the TRAMA (Trend Regularity Adaptive Moving Average) class with adaptive EMA logic.
- Added unit tests for TRAMA functionality, including constructor validation, basic calculations, state management, and robustness checks.
- Created validation tests to ensure consistency across different modes of operation (streaming, batch, and static calculations).
- Enhanced documentation for TRAMA, including performance profiles and quality metrics.
- Updated workspace configuration by removing unnecessary folder references.
This commit is contained in:
Miha Kralj
2026-02-21 20:45:38 -08:00
parent 90d5638008
commit 7253f61299
199 changed files with 29577 additions and 234 deletions
+167
View File
@@ -0,0 +1,167 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class HwmaIndicatorTests
{
[Fact]
public void HwmaIndicator_Constructor_SetsDefaults()
{
var indicator = new HwmaIndicator();
Assert.Equal(10, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("HWMA - Holt-Winters Moving Average", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void HwmaIndicator_MinHistoryDepths_ReturnsZero()
{
var indicator = new HwmaIndicator { Period = 20 };
Assert.Equal(0, HwmaIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void HwmaIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new HwmaIndicator { Period = 15 };
Assert.Contains("HWMA", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void HwmaIndicator_Initialize_CreatesInternalHwma()
{
var indicator = new HwmaIndicator { Period = 10 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void HwmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new HwmaIndicator { Period = 3 };
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 HwmaIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new HwmaIndicator { Period = 3 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
// Process first update
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
// Line series should have values
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void HwmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new HwmaIndicator { Period = 3 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
// Process historical bar first
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
// Update with new tick (same bar data - simulates intrabar update)
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
// Both values should be finite
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void HwmaIndicator_MultipleUpdates_ProducesCorrectHwmaSequence()
{
var indicator = new HwmaIndicator { Period = 3 };
indicator.Initialize();
var now = DateTime.UtcNow;
double[] closes = { 100, 102, 104, 103, 105, 107, 106 };
foreach (var close in closes)
{
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
now = now.AddMinutes(1);
}
// All values should be finite
for (int i = 0; i < closes.Length; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
}
// HWMA should be smoothing the values
double lastHwma = indicator.LinesSeries[0].GetValue(0);
Assert.True(lastHwma >= 100 && lastHwma <= 115); // Slight margin for overshoot
}
[Fact]
public void HwmaIndicator_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 HwmaIndicator { Period = 3, 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 HwmaIndicator_Period_CanBeChanged()
{
var indicator = new HwmaIndicator { Period = 5 };
Assert.Equal(5, indicator.Period);
indicator.Period = 20;
Assert.Equal(20, indicator.Period);
Assert.Equal(0, HwmaIndicator.MinHistoryDepths);
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public class HwmaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 10;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Hwma ma = 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 => $"HWMA {Period}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_IIR/hwma/Hwma.Quantower.cs";
public HwmaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "HWMA - Holt-Winters Moving Average";
Description = "Triple exponential smoothing with level, velocity, and acceleration components";
Series = new LineSeries(name: $"HWMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
ma = new Hwma(Period);
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 = ma.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
Series.SetValue(result.Value, ma.IsHot, ShowColdValues);
}
}
+438
View File
@@ -0,0 +1,438 @@
namespace QuanTAlib.Tests;
public class HwmaTests
{
[Fact]
public void Hwma_Constructor_ValidatesInput()
{
var ex1 = Assert.Throws<ArgumentException>(() => new Hwma(0));
Assert.Equal("period", ex1.ParamName);
var ex2 = Assert.Throws<ArgumentException>(() => new Hwma(-1));
Assert.Equal("period", ex2.ParamName);
var hwma = new Hwma(10);
Assert.NotNull(hwma);
}
[Fact]
public void Hwma_AlphaConstructor_ValidatesInput()
{
var ex1 = Assert.Throws<ArgumentException>(() => new Hwma(0.0, 0.1, 0.1));
Assert.Equal("alpha", ex1.ParamName);
var ex2 = Assert.Throws<ArgumentException>(() => new Hwma(1.5, 0.1, 0.1));
Assert.Equal("alpha", ex2.ParamName);
var ex3 = Assert.Throws<ArgumentException>(() => new Hwma(0.5, -0.1, 0.1));
Assert.Equal("beta", ex3.ParamName);
var ex4 = Assert.Throws<ArgumentException>(() => new Hwma(0.5, 0.1, 1.5));
Assert.Equal("gamma", ex4.ParamName);
var hwma = new Hwma(0.2, 0.1, 0.1);
Assert.NotNull(hwma);
}
[Fact]
public void Hwma_Calc_ReturnsValue()
{
var hwma = new Hwma(10);
TValue result = hwma.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(result.Value > 0);
}
[Fact]
public void Hwma_IsHot_BecomesTrueImmediately()
{
// HWMA is recursive - it's hot after first valid value
var hwma = new Hwma(5);
Assert.False(hwma.IsHot);
hwma.Update(new TValue(DateTime.UtcNow, 100));
Assert.True(hwma.IsHot);
}
[Fact]
public void Hwma_StreamingMatchesBatch()
{
var hwmaStreaming = new Hwma(10);
var hwmaBatch = new Hwma(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
var series = new TSeries();
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(new TValue(bar.Time, bar.Close));
}
// Streaming
var streamingResults = new TSeries();
Assert.True(series.Count > 0);
foreach (var item in series)
{
streamingResults.Add(hwmaStreaming.Update(item));
}
// Batch
var batchResults = hwmaBatch.Update(series);
Assert.Equal(streamingResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamingResults[i].Value, batchResults[i].Value, 1e-9);
}
}
[Fact]
public void Hwma_StaticCalculate_MatchesInstance()
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
var instanceResults = new Hwma(10).Update(series);
var staticResults = Hwma.Batch(series, 10);
for (int i = 0; i < instanceResults.Count; i++)
{
Assert.Equal(instanceResults[i].Value, staticResults[i].Value, 1e-9);
}
}
[Fact]
public void Hwma_SpanCalculate_MatchesSeries()
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
var seriesResults = Hwma.Batch(series, 10);
double[] input = series.Values.ToArray();
double[] output = new double[input.Length];
Hwma.Batch(input.AsSpan(), output.AsSpan(), 10);
for (int i = 0; i < input.Length; i++)
{
Assert.Equal(seriesResults[i].Value, output[i], 1e-9);
}
}
[Fact]
public void Hwma_Update_IsNewFalse_CorrectsValue()
{
var hwma = new Hwma(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
// Feed initial data
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next(isNew: true);
hwma.Update(new TValue(bar.Time, bar.Close), isNew: true);
}
// Update with isNew=false (correction)
var newBar = gbm.Next(isNew: true);
hwma.Update(new TValue(newBar.Time, newBar.Close), isNew: true);
double valueAfterCommit = hwma.Last.Value;
// Now update the SAME bar with a different value
hwma.Update(new TValue(newBar.Time, newBar.Close + 10.0), isNew: false);
double valueAfterCorrection = hwma.Last.Value;
Assert.NotEqual(valueAfterCommit, valueAfterCorrection);
// Now restore original value
hwma.Update(new TValue(newBar.Time, newBar.Close), isNew: false);
Assert.Equal(valueAfterCommit, hwma.Last.Value, 1e-9);
}
[Fact]
public void Hwma_NaN_Input_UsesLastValidValue()
{
var hwma = new Hwma(5);
hwma.Update(new TValue(DateTime.UtcNow, 100));
hwma.Update(new TValue(DateTime.UtcNow, 110));
var resultAfterNaN = hwma.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(resultAfterNaN.Value));
Assert.NotEqual(0, resultAfterNaN.Value);
}
[Fact]
public void Hwma_Reset_ClearsState()
{
var hwma = new Hwma(10);
hwma.Update(new TValue(DateTime.UtcNow, 100));
hwma.Update(new TValue(DateTime.UtcNow, 110));
Assert.True(hwma.Last.Value > 0);
Assert.True(hwma.IsHot);
hwma.Reset();
Assert.Equal(0, hwma.Last.Value);
Assert.False(hwma.IsHot);
}
[Fact]
public void Hwma_FirstValue_ReturnsInput()
{
var hwma = new Hwma(10);
TValue result = hwma.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(100.0, result.Value, 1e-9);
}
[Fact]
public void Hwma_Properties_Accessible()
{
var hwma = new Hwma(10);
Assert.False(hwma.IsHot);
Assert.Equal(0, hwma.Last.Value);
}
[Fact]
public void Hwma_Calc_IsNew_AcceptsParameter()
{
var hwma = new Hwma(10);
hwma.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
Assert.Equal(100, hwma.Last.Value);
}
[Fact]
public void Hwma_IterativeCorrections_RestoreToOriginalState()
{
var hwma = new Hwma(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
// Feed 10 new values
TValue tenthInput = default;
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
tenthInput = new TValue(bar.Time, bar.Close);
hwma.Update(tenthInput, isNew: true);
}
// Remember state after 10 values
double valueAfterTen = hwma.Last.Value;
// Generate 9 corrections with isNew=false (different values)
for (int i = 0; i < 9; i++)
{
var bar = gbm.Next(isNew: false);
hwma.Update(new TValue(bar.Time, bar.Close), isNew: false);
}
// Feed the remembered 10th input again with isNew=false
TValue finalValue = hwma.Update(tenthInput, isNew: false);
// Should match the original state after 10 values
Assert.Equal(valueAfterTen, finalValue.Value, 1e-9);
}
[Fact]
public void Hwma_Infinity_Input_UsesLastValidValue()
{
var hwma = new Hwma(10);
hwma.Update(new TValue(DateTime.UtcNow, 100));
hwma.Update(new TValue(DateTime.UtcNow, 110));
var resultPosInf = hwma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(resultPosInf.Value));
var resultNegInf = hwma.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(resultNegInf.Value));
}
[Fact]
public void Hwma_MultipleNaN_ContinuesWithLastValid()
{
var hwma = new Hwma(10);
hwma.Update(new TValue(DateTime.UtcNow, 100));
var r1 = hwma.Update(new TValue(DateTime.UtcNow, double.NaN));
var r2 = hwma.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(r1.Value));
Assert.True(double.IsFinite(r2.Value));
}
[Fact]
public void Hwma_AllModes_ProduceSameResult()
{
// Arrange
const int period = 10;
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 = Hwma.Batch(series, period);
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];
Hwma.Batch(spanInput, spanOutput, period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode
var streamingInd = new Hwma(period);
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 Hwma(pubSource, period);
for (int i = 0; i < series.Count; i++)
{
pubSource.Add(series[i]);
}
double eventingResult = eventingInd.Last.Value;
// Assert
Assert.Equal(expected, spanResult, 1e-9);
Assert.Equal(expected, streamingResult, 1e-9);
Assert.Equal(expected, eventingResult, 1e-9);
}
[Fact]
public void Hwma_SpanCalc_ValidatesInput()
{
double[] source = [1, 2, 3, 4, 5];
double[] output = new double[5];
double[] wrongSizeOutput = new double[3];
Assert.Throws<ArgumentException>(() => Hwma.Batch(source.AsSpan(), output.AsSpan(), 0));
Assert.Throws<ArgumentException>(() => Hwma.Batch(source.AsSpan(), wrongSizeOutput.AsSpan(), 3));
}
[Fact]
public void Hwma_SpanCalc_HandlesNaN()
{
double[] source = [100, 110, double.NaN, 120, 130];
double[] output = new double[5];
Hwma.Batch(source.AsSpan(), output.AsSpan(), 3);
foreach (var val in output)
{
Assert.True(double.IsFinite(val));
}
}
[Fact]
public void Hwma_TripleSmoothing_Components()
{
// Verify the triple smoothing characteristic: tracks level, velocity, acceleration
// When price is trending up consistently, HWMA should lead due to velocity/acceleration
var hwma = new Hwma(10);
// Simulate steady uptrend
double[] prices = new double[30];
for (int i = 0; i < 30; i++)
{
prices[i] = 100 + i * 2; // Linear uptrend
}
double lastResult = 0;
foreach (var price in prices)
{
var result = hwma.Update(new TValue(DateTime.UtcNow, price));
lastResult = result.Value;
}
// HWMA should be close to or slightly ahead of current price in strong trend
// (due to velocity/acceleration extrapolation)
double lastPrice = prices[^1];
Assert.True(Math.Abs(lastResult - lastPrice) < lastPrice * 0.1); // Within 10%
}
[Fact]
public void Hwma_SmoothingFactors_AffectResult()
{
// Different smoothing factors should produce different results
var hwma1 = new Hwma(5); // Higher alpha (more responsive)
var hwma2 = new Hwma(20); // Lower alpha (smoother)
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
hwma1.Update(new TValue(bar.Time, bar.Close));
hwma2.Update(new TValue(bar.Time, bar.Close));
}
// Different periods should produce different results
Assert.NotEqual(hwma1.Last.Value, hwma2.Last.Value);
}
[Fact]
public void Hwma_AlphaConstructor_ProducesResults()
{
// Test the alpha/beta/gamma constructor
var hwma = new Hwma(0.2, 0.1, 0.1);
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next(isNew: true);
hwma.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(double.IsFinite(hwma.Last.Value));
Assert.True(hwma.Last.Value > 0);
}
[Fact]
public void Hwma_ConstantInput_ReturnsConstant()
{
var hwma = new Hwma(10);
const double constantValue = 100.0;
for (int i = 0; i < 20; i++)
{
var result = hwma.Update(new TValue(DateTime.UtcNow, constantValue));
Assert.Equal(constantValue, result.Value, 1e-9);
}
}
[Fact]
public void Hwma_PeriodOne_ReturnsInputValue()
{
var hwma = new Hwma(1);
for (int i = 1; i <= 10; i++)
{
var input = new TValue(DateTime.UtcNow, i * 10.0);
var result = hwma.Update(input);
// Period 1 means alpha=1 (full weighting to current), but beta=gamma=1 as well
// After warmup, should track closely
Assert.True(double.IsFinite(result.Value));
}
}
}
@@ -0,0 +1,218 @@
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for HWMA (Holt-Winters Moving Average).
/// Note: HWMA is not available in most external libraries (TA-Lib, Skender, etc.),
/// so we validate against our own PineScript reference implementation and mathematical properties.
/// </summary>
public class HwmaValidationTests
{
private const double Tolerance = 1e-9;
[Fact]
public void Hwma_MatchesPineScriptReference()
{
// Test that our implementation matches the PineScript reference
// hwma.pine formulas:
// α = 2/(period+1), β = 1/period, γ = 1/period
// F = α × source + (1-α) × (prevF + prevV + 0.5 × prevA)
// V = β × (F - prevF) + (1-β) × (prevV + prevA)
// A = γ × (V - prevV) + (1-γ) × prevA
// output = F + V + 0.5 × A
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
int period = 10;
var hwma = new Hwma(period);
var results = hwma.Update(series);
// Manual calculation
double alpha = 2.0 / (period + 1.0);
double beta = 1.0 / period;
double gamma = 1.0 / period;
double F = series[0].Value;
double V = 0;
double A = 0;
for (int i = 1; i < series.Count; i++)
{
double prevF = F;
double prevV = V;
double prevA = A;
F = alpha * series[i].Value + (1 - alpha) * (prevF + prevV + 0.5 * prevA);
V = beta * (F - prevF) + (1 - beta) * (prevV + prevA);
A = gamma * (V - prevV) + (1 - gamma) * prevA;
}
double expected = F + V + 0.5 * A;
Assert.Equal(expected, results.Last.Value, Tolerance);
}
[Fact]
public void Hwma_SmoothingFactorFormulas()
{
// Verify smoothing factors are calculated correctly from period
// α = 2/(period+1), β = γ = 1/period
int period = 10;
double expectedAlpha = 2.0 / (period + 1.0); // 2/11 ≈ 0.1818
double expectedBeta = 1.0 / period; // 0.1
double expectedGamma = 1.0 / period; // 0.1
Assert.Equal(2.0 / 11.0, expectedAlpha, Tolerance);
Assert.Equal(0.1, expectedBeta, Tolerance);
Assert.Equal(0.1, expectedGamma, Tolerance);
}
[Fact]
public void Hwma_ConsistentAcrossModes()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
var series = new TSeries();
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
int period = 10;
// Batch
var batchResults = Hwma.Batch(series, period);
// Streaming
var streaming = new Hwma(period);
var streamingResults = new TSeries();
foreach (var item in series)
{
streamingResults.Add(streaming.Update(item));
}
// Span
double[] input = series.Values.ToArray();
double[] spanOutput = new double[input.Length];
Hwma.Batch(input.AsSpan(), spanOutput.AsSpan(), period);
// All should match
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(batchResults[i].Value, streamingResults[i].Value, Tolerance);
Assert.Equal(batchResults[i].Value, spanOutput[i], Tolerance);
}
}
[Fact]
public void Hwma_DifferentPeriodsProduceDifferentResults()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
var series = new TSeries();
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
var hwma5 = new Hwma(5);
var hwma10 = new Hwma(10);
var hwma20 = new Hwma(20);
var results5 = hwma5.Update(series);
var results10 = hwma10.Update(series);
var results20 = hwma20.Update(series);
// Different periods should produce different results
Assert.NotEqual(results5.Last.Value, results10.Last.Value);
Assert.NotEqual(results10.Last.Value, results20.Last.Value);
}
[Fact]
public void Hwma_ConstantInput_ReturnsConstant()
{
var hwma = new Hwma(10);
const double constantValue = 100.0;
for (int i = 0; i < 20; i++)
{
var result = hwma.Update(new TValue(DateTime.UtcNow, constantValue));
Assert.Equal(constantValue, result.Value, Tolerance);
}
}
[Fact]
public void Hwma_TripleExponentialSmoothing_Property()
{
// HWMA should exhibit the triple exponential smoothing behavior:
// - Level (F) tracks the current value
// - Velocity (V) tracks the trend/slope
// - Acceleration (A) tracks the change in trend
// For a linear trend, HWMA should converge to track it closely
var hwma = new Hwma(10);
// Linear uptrend: 100, 101, 102, ..., 119
for (int i = 0; i < 20; i++)
{
double price = 100 + i;
hwma.Update(new TValue(DateTime.UtcNow, price));
}
// After 20 points of linear trend, HWMA should be close to current value
double lastPrice = 119;
double hwmaValue = hwma.Last.Value;
// Should be within 5% for a well-adapted filter
Assert.True(Math.Abs(hwmaValue - lastPrice) / lastPrice < 0.05);
}
[Fact]
public void Hwma_VelocityTracking_Uptrend()
{
// In a consistent uptrend, HWMA should be ahead of simple EMA
// because it accounts for velocity
var hwma = new Hwma(10);
var ema = new Ema(10);
// Generate uptrend
for (int i = 0; i < 30; i++)
{
double price = 100 + i * 2; // Strong uptrend
hwma.Update(new TValue(DateTime.UtcNow, price));
ema.Update(new TValue(DateTime.UtcNow, price));
}
// HWMA should be closer to current price than EMA in uptrend
// (or even ahead due to velocity/acceleration extrapolation)
double currentPrice = 100 + 29 * 2; // 158
double hwmaDiff = Math.Abs(hwma.Last.Value - currentPrice);
double emaDiff = Math.Abs(ema.Last.Value - currentPrice);
// HWMA should track better than or equal to EMA in trends
Assert.True(hwmaDiff <= emaDiff * 1.5); // Allow some margin
}
[Fact]
public void Hwma_AlphaBetaGamma_CustomValues()
{
// Test explicit alpha/beta/gamma constructor produces valid results
var hwma = new Hwma(0.3, 0.2, 0.1);
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 30; i++)
{
var bar = gbm.Next(isNew: true);
hwma.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(double.IsFinite(hwma.Last.Value));
Assert.True(hwma.Last.Value > 0);
}
}
+363
View File
@@ -0,0 +1,363 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// HWMA: Holt-Winters Moving Average
/// </summary>
/// <remarks>
/// Triple exponential smoothing tracking level (F), velocity (V), and acceleration (A).
/// O(1) adaptive trend follower responding quickly via higher-order derivatives.
///
/// Calculation: <c>Output = F + V + 0.5×A</c> with recursive updates.
/// </remarks>
/// <seealso href="Hwma.md">Detailed documentation</seealso>
[SkipLocalsInit]
public sealed class Hwma : AbstractBase
{
private readonly double _alpha;
private readonly double _beta;
private readonly double _gamma;
private readonly double _decayAlpha;
private readonly double _decayBeta;
private readonly double _decayGamma;
private readonly ITValuePublisher? _source;
private readonly TValuePublishedHandler? _pubHandler;
private bool _isNew = true;
private bool _disposed;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double F, double V, double A,
double LastValidValue,
bool IsInitialized
);
private State _state;
private State _p_state;
public bool IsNew => _isNew;
public override bool IsHot => _state.IsInitialized;
/// <summary>
/// Creates HWMA with specified period. Calculates α, β, γ automatically.
/// </summary>
/// <param name="period">Period for smoothing factor calculation (must be > 0)</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Hwma(int period = 10)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
_alpha = 2.0 / (period + 1.0);
_beta = 1.0 / period;
_gamma = 1.0 / period;
_decayAlpha = 1.0 - _alpha;
_decayBeta = 1.0 - _beta;
_decayGamma = 1.0 - _gamma;
Name = $"Hwma({period})";
WarmupPeriod = period;
_state = new State(double.NaN, 0, 0, double.NaN, IsInitialized: false);
}
/// <summary>
/// Creates HWMA with explicit smoothing factors.
/// </summary>
/// <param name="alpha">Level smoothing factor (0 to 1)</param>
/// <param name="beta">Velocity smoothing factor (0 to 1)</param>
/// <param name="gamma">Acceleration smoothing factor (0 to 1)</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Hwma(double alpha, double beta, double gamma)
{
if (alpha <= 0 || alpha > 1)
{
throw new ArgumentException("Alpha must be between 0 (exclusive) and 1 (inclusive)", nameof(alpha));
}
if (beta < 0 || beta > 1)
{
throw new ArgumentException("Beta must be between 0 and 1", nameof(beta));
}
if (gamma < 0 || gamma > 1)
{
throw new ArgumentException("Gamma must be between 0 and 1", nameof(gamma));
}
int effectivePeriod = (int)(2.0 / alpha - 1.0); // Reverse calculate for display
_alpha = alpha;
_beta = beta;
_gamma = gamma;
_decayAlpha = 1.0 - alpha;
_decayBeta = 1.0 - beta;
_decayGamma = 1.0 - gamma;
Name = $"Hwma({alpha:F3},{beta:F3},{gamma:F3})";
WarmupPeriod = effectivePeriod > 0 ? effectivePeriod : 10;
_state = new State(double.NaN, 0, 0, double.NaN, IsInitialized: false);
}
/// <summary>
/// Creates HWMA with source for event-based chaining.
/// </summary>
/// <param name="source">Data source for event-based updates</param>
/// <param name="period">Period for smoothing factor calculation (default: 10)</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Hwma(ITValuePublisher source, int period = 10) : this(period)
{
_source = source;
_pubHandler = Handle;
_source.Pub += _pubHandler;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing && _source != null && _pubHandler != null)
{
_source.Pub -= _pubHandler;
}
_disposed = true;
}
base.Dispose(disposing);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input)
{
if (double.IsFinite(input))
{
return input;
}
return _state.IsInitialized ? _state.LastValidValue : double.NaN;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
_isNew = isNew;
return Update(input, isNew, publish: true);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private TValue Update(TValue input, bool isNew, bool publish)
{
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
double val = GetValidValue(input.Value);
if (!double.IsFinite(val))
{
// First value is NaN - return NaN
Last = new TValue(input.Time, double.NaN);
if (publish)
{
PubEvent(Last);
}
return Last;
}
_state = _state with { LastValidValue = val };
double result;
if (!_state.IsInitialized)
{
// First valid value: initialize F to source, V and A to 0
_state = _state with { F = val, V = 0, A = 0, IsInitialized = true };
result = val;
}
else
{
double prevF = _state.F;
double prevV = _state.V;
double prevA = _state.A;
// F = α × source + (1-α) × (prevF + prevV + 0.5 × prevA)
double forecast = prevF + prevV + 0.5 * prevA;
double newF = Math.FusedMultiplyAdd(forecast, _decayAlpha, _alpha * val);
// V = β × (F - prevF) + (1-β) × (prevV + prevA)
double newV = Math.FusedMultiplyAdd(prevV + prevA, _decayBeta, _beta * (newF - prevF));
// A = γ × (V - prevV) + (1-γ) × prevA
double newA = Math.FusedMultiplyAdd(prevA, _decayGamma, _gamma * (newV - prevV));
_state = _state with { F = newF, V = newV, A = newA };
// output = F + V + 0.5 × A
result = newF + newV + 0.5 * newA;
}
Last = new TValue(input.Time, result);
if (publish)
{
PubEvent(Last);
}
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);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
source.Times.CopyTo(tSpan);
// HWMA has IIR filter state (F, V, A) that accumulates from the beginning.
// Must process entire series through streaming to maintain correct state.
Reset();
for (int i = 0; i < len; i++)
{
var result = Update(source[i], isNew: true, publish: false);
vSpan[i] = result.Value;
}
return new TSeries(t, v);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (var value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
/// <summary>
/// Calculates HWMA from a TSeries using streaming updates.
/// </summary>
public static TSeries Batch(TSeries source, int period = 10)
{
var hwma = new Hwma(period);
return hwma.Update(source);
}
/// <summary>
/// Calculates HWMA over a span of values.
/// </summary>
/// <param name="source">Input values</param>
/// <param name="output">Output buffer (must be same length as source)</param>
/// <param name="period">Period for smoothing factors (default: 10)</param>
/// <exception cref="ArgumentException">Thrown when output length doesn't match source length.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 10)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (source.Length == 0)
{
return;
}
double alpha = 2.0 / (period + 1.0);
double beta = 1.0 / period;
double gamma = 1.0 / period;
double decayAlpha = 1.0 - alpha;
double decayBeta = 1.0 - beta;
double decayGamma = 1.0 - gamma;
double lastValid = double.NaN;
double F = double.NaN;
double V = 0;
double A = 0;
bool initialized = false;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
// Handle NaN - use last valid
if (!double.IsFinite(val))
{
if (double.IsFinite(lastValid))
{
val = lastValid;
}
else
{
output[i] = double.NaN; // No valid value yet
continue;
}
}
lastValid = val;
if (!initialized)
{
F = val;
V = 0;
A = 0;
initialized = true;
output[i] = val;
}
else
{
double prevF = F;
double prevV = V;
double prevA = A;
// F = α × source + (1-α) × (prevF + prevV + 0.5 × prevA)
F = Math.FusedMultiplyAdd(prevF + prevV + 0.5 * prevA, decayAlpha, alpha * val);
// V = β × (F - prevF) + (1-β) × (prevV + prevA)
V = Math.FusedMultiplyAdd(prevV + prevA, decayBeta, beta * (F - prevF));
// A = γ × (V - prevV) + (1-γ) × prevA
A = Math.FusedMultiplyAdd(prevA, decayGamma, gamma * (V - prevV));
// output = F + V + 0.5 × A
output[i] = F + V + 0.5 * A;
}
}
}
public static (TSeries Results, Hwma Indicator) Calculate(TSeries source, int period = 10)
{
var indicator = new Hwma(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_state = new State(double.NaN, 0, 0, double.NaN, IsInitialized: false);
_p_state = _state;
Last = default;
}
}
+239
View File
@@ -0,0 +1,239 @@
# HWMA: Holt-Winters Moving Average
> "Triple exponential smoothing: because sometimes tracking level, velocity, and acceleration is exactly what a price series needs—and sometimes it's overkill. Holt and Winters figured this out for inventory forecasting in the 1950s. Traders rediscovered it decades later."
HWMA is an Infinite Impulse Response (IIR) filter that applies triple exponential smoothing with level (F), velocity (V), and acceleration (A) components. Unlike simple exponential smoothing which only tracks the current level, HWMA anticipates future values by extrapolating trend and trend changes.
## Historical Context
Charles C. Holt developed double exponential smoothing in 1957 at the Carnegie Institute of Technology to address the limitations of single exponential smoothing when dealing with trending data. Peter R. Winters extended Holt's method in 1960 to include seasonality components.
The "Holt-Winters" name typically refers to the full seasonal model, but the triple exponential smoothing variant used here focuses on the non-seasonal components: level, trend (velocity), and trend acceleration. This makes it suitable for financial time series where seasonal patterns are less relevant than trend dynamics.
In trading applications, HWMA's ability to track acceleration makes it particularly responsive to trend changes. When a price series begins accelerating in a direction, HWMA detects this faster than single or double exponential smoothing.
## Architecture & Physics
HWMA maintains three state components updated recursively:
* **Level (F)**: The smoothed estimate of the current value
* **Velocity (V)**: The smoothed estimate of the trend/slope
* **Acceleration (A)**: The smoothed estimate of the change in trend
Each component uses its own smoothing factor:
* **α (alpha)**: Level smoothing factor, derived as $\frac{2}{\text{period}+1}$
* **β (beta)**: Velocity smoothing factor, derived as $\frac{1}{\text{period}}$
* **γ (gamma)**: Acceleration smoothing factor, derived as $\frac{1}{\text{period}}$
The physics of HWMA reveal several key properties:
* **O(1) complexity**: Only three state variables, no buffer required
* **Infinite memory**: Past values influence output indefinitely (IIR characteristic)
* **Adaptive response**: Tracks not just where price is, but where it's going
* **Forecast capability**: Output includes extrapolation of velocity and half the acceleration
### The Compute Challenge
HWMA is computationally lightweight. Each update requires only a handful of multiplications and additions—no buffer management, no weight precomputation. The recursive nature means constant time regardless of the conceptual "period."
$$ \text{Runtime Cost} = O(1) \text{ per bar} $$
FMA (Fused Multiply-Add) instructions optimize the core calculations, combining multiplication and addition into single operations without intermediate rounding.
## Mathematical Foundation
The HWMA calculation proceeds in three stages per bar:
### 1. Level Update (F)
$$ F_t = \alpha \cdot P_t + (1-\alpha) \cdot (F_{t-1} + V_{t-1} + 0.5 \cdot A_{t-1}) $$
The level blends the current price with a forecast derived from the previous level, velocity, and half the acceleration.
### 2. Velocity Update (V)
$$ V_t = \beta \cdot (F_t - F_{t-1}) + (1-\beta) \cdot (V_{t-1} + A_{t-1}) $$
The velocity blends the observed change in level with the previous velocity extrapolated by acceleration.
### 3. Acceleration Update (A)
$$ A_t = \gamma \cdot (V_t - V_{t-1}) + (1-\gamma) \cdot A_{t-1} $$
The acceleration blends the observed change in velocity with the previous acceleration.
### 4. Output Calculation
$$ \text{HWMA}_t = F_t + V_t + 0.5 \cdot A_t $$
The output extrapolates the level by adding velocity and half the acceleration—a one-step forecast.
### Example Calculation
For period=10 (α≈0.182, β=0.1, γ=0.1):
Given $F_{t-1}=100$, $V_{t-1}=2$, $A_{t-1}=0.5$, and new price $P_t=105$:
1. **Forecast**: $F_{t-1} + V_{t-1} + 0.5 \cdot A_{t-1} = 100 + 2 + 0.25 = 102.25$
2. **New F**: $0.182 \times 105 + 0.818 \times 102.25 = 19.11 + 83.64 = 102.75$
3. **New V**: $0.1 \times (102.75 - 100) + 0.9 \times (2 + 0.5) = 0.275 + 2.25 = 2.525$
4. **New A**: $0.1 \times (2.525 - 2) + 0.9 \times 0.5 = 0.0525 + 0.45 = 0.5025$
5. **Output**: $102.75 + 2.525 + 0.5 \times 0.5025 = 105.53$
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
HWMA is extremely lightweight—O(1) with minimal operations:
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| FMA | 3 | 4 | 12 |
| MUL | 3 | 3 | 9 |
| ADD/SUB | 4 | 1 | 4 |
| **Total** | **10** | — | **~25 cycles** |
**Hot path breakdown:**
- Level update: `FMA(prevF + prevV + 0.5×prevA, decayAlpha, alpha×val)` → 1 FMA + 2 MUL + 2 ADD
- Velocity update: `FMA(prevV + prevA, decayBeta, beta×(newF - prevF))` → 1 FMA + 1 MUL + 1 SUB
- Acceleration update: `FMA(prevA, decayGamma, gamma×(newV - prevV))` → 1 FMA + 1 MUL + 1 SUB
- Output: `newF + newV + 0.5×newA` → 2 ADD + 1 MUL
### Batch Mode (SIMD)
HWMA is recursive (IIR)—SIMD parallelization across bars is not possible. Each output depends on the previous state.
| Mode | Cycles/bar | Notes |
| :--- | :---: | :--- |
| Streaming (scalar) | ~25 | FMA-optimized |
| Batch (scalar) | ~25 | No SIMD benefit for IIR |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 10/10 | Matches definition to `double` precision |
| **Timeliness** | 9/10 | Acceleration tracking reduces effective lag |
| **Overshoot** | 6/10 | May overshoot during trend reversals |
| **Smoothness** | 7/10 | Good for trends; noisier during consolidation |
### Implementation Details
```csharp
// Initialization
double alpha = 2.0 / (period + 1.0);
double beta = 1.0 / period;
double gamma = 1.0 / period;
double decayAlpha = 1.0 - alpha;
double decayBeta = 1.0 - beta;
double decayGamma = 1.0 - gamma;
// Runtime (Update) with FMA
double newF = Math.FusedMultiplyAdd(prevF + prevV + 0.5 * prevA, decayAlpha, alpha * val);
double newV = Math.FusedMultiplyAdd(prevV + prevA, decayBeta, beta * (newF - prevF));
double newA = Math.FusedMultiplyAdd(prevA, decayGamma, gamma * (newV - prevV));
return newF + newV + 0.5 * newA;
```
## Comparison: Exponential Smoothing Variants
| Method | Components | Trend Handling | Overshoot Risk | Use Case |
| :--- | :--- | :--- | :--- | :--- |
| EMA | Level only | None | Low | Noise filtering |
| DEMA | Level + acceleration term | Implicit | Medium | Trend following |
| **HWMA** | **Level + Velocity + Acceleration** | **Explicit triple** | **Higher** | **Trend anticipation** |
| TEMA | Triple smoothing | Implicit | High | Responsive filtering |
Choose HWMA when you need explicit tracking of trend dynamics. The three-component model provides interpretable state (where it is, where it's going, how that's changing) but introduces overshoot risk during sharp reversals.
## Validation
QuanTAlib validates HWMA against its PineScript reference implementation.
| Library | Status | Notes |
| :--- | :--- | :--- |
| **QuanTAlib** | ✅ | Matches PineScript reference exactly. |
| **PineScript** | ✅ | Reference implementation. |
| **TA-Lib** | ❌ | Not included in standard distribution. |
| **Skender** | ❌ | Not included. |
| **Tulip** | ❌ | Not included. |
| **Ooples** | ❌ | Not included. |
### C# Implementation Considerations
The QuanTAlib HWMA implementation optimizes triple exponential smoothing through FMA operations and precomputed decay constants:
**Precomputed Decay Constants**
```csharp
_alpha = 2.0 / (period + 1.0);
_beta = 1.0 / period;
_gamma = 1.0 / period;
_decayAlpha = 1.0 - _alpha;
_decayBeta = 1.0 - _beta;
_decayGamma = 1.0 - _gamma;
```
All six constants computed once at construction. The decay values (1-α, 1-β, 1-γ) avoid repeated subtraction per tick.
**State Record Struct with Auto Layout**
```csharp
[StructLayout(LayoutKind.Auto)]
private record struct State(
double F, double V, double A,
double LastValidValue,
bool IsInitialized
);
private State _state;
private State _p_state;
```
Compiler optimizes field ordering. Three smoothing components plus validation tracking fit in ~41 bytes.
**FusedMultiplyAdd Throughout**
```csharp
double forecast = prevF + prevV + 0.5 * prevA;
double newF = Math.FusedMultiplyAdd(forecast, _decayAlpha, _alpha * val);
double newV = Math.FusedMultiplyAdd(prevV + prevA, _decayBeta, _beta * (newF - prevF));
double newA = Math.FusedMultiplyAdd(prevA, _decayGamma, _gamma * (newV - prevV));
```
All three component updates use FMA for `a*b+c` patterns, reducing rounding error and leveraging hardware acceleration.
**O(1) Memory Footprint**
Unlike FIR filters, HWMA requires no buffer—only state variables. No `ArrayPool`, no `stackalloc`, no circular buffer management.
**Dual Constructor API**
```csharp
public Hwma(int period = 10) // Derives α, β, γ from period
public Hwma(double alpha, double beta, double gamma) // Explicit smoothing factors
```
Period-based constructor for common use; explicit factors for fine-tuned control with validation.
**Memory Layout**
| Field | Type | Size | Notes |
|:------|:-----|-----:|:------|
| `_period` | int | 4B | Conceptual period (display) |
| `_alpha` | double | 8B | Level smoothing |
| `_beta` | double | 8B | Velocity smoothing |
| `_gamma` | double | 8B | Acceleration smoothing |
| `_decayAlpha` | double | 8B | 1 - α |
| `_decayBeta` | double | 8B | 1 - β |
| `_decayGamma` | double | 8B | 1 - γ |
| `_state` | State | ~41B | F, V, A, lastValid, init flag |
| `_p_state` | State | ~41B | Previous state for rollback |
| **Total** | | ~142B | Fixed size, no period scaling |
HWMA has constant memory regardless of period—approximately **142 bytes** per instance.
## Common Pitfalls
1. **Overshoot During Reversals**: HWMA's acceleration component can cause overshoot when trends reverse sharply. The filter "expects" the trend to continue and takes time to adapt. Consider lower β/γ values for less aggressive acceleration tracking.
2. **Cold Start**: The first value initializes F to the input with V and A at zero. The filter needs several bars to establish meaningful velocity and acceleration estimates.
3. **Period vs. Smoothing Factors**: The period parameter sets all three smoothing factors. For fine-tuned control, use the explicit (α, β, γ) constructor. Higher values mean more responsiveness but also more noise.
4. **Interpretation**: HWMA output is a one-step forecast, not a smoothed current value. The extrapolation can lead the actual price in trending markets but lag during consolidation.
5. **Seasonal Confusion**: "Holt-Winters" often implies seasonal decomposition. This implementation is the non-seasonal variant focusing on level-trend-acceleration only.
6. **Parameter Sensitivity**: Small changes in β and γ significantly affect behavior. Start with the default period-based derivation before experimenting with custom values.
+55
View File
@@ -0,0 +1,55 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Holt-Winters Moving Average (HWMA)", "HWMA", overlay=true)
//@function Calculates HWMA using triple exponential smoothing with level, velocity, and acceleration components
//@param source Series to calculate HWMA from
//@param alpha Level smoothing factor
//@param beta Velocity smoothing factor
//@param gamma Acceleration smoothing factor
//@param period When used, calculate alpha/beta/gamma from period
//@returns HWMA value from first bar with proper compensation
//@optimized Uses triple exponential smoothing with O(1) complexity per bar
hwma(series float source, float alpha=0.0, float beta=0.0, float gamma=0.0, simple int period=0) =>
float a = period > 0 ? 2.0 / (float(period) + 1.0) : alpha
float b = period > 0 ? 1.0 / float(period) : beta
float g = period > 0 ? 1.0 / float(period) : gamma
var float F = na
var float V = 0.0
var float A = 0.0
if na(source)
if na(F)
na
else
float prevF = F
float prevV = V
float prevA = A
F := prevF + prevV + 0.5 * prevA
V := prevV + prevA
A := 0.9 * prevA
F
else
if na(F)
F := source
F
else
float prevF = F
float prevV = V
float prevA = A
F := a * source + (1.0 - a) * (prevF + prevV + 0.5 * prevA)
V := b * (F - prevF) + (1.0 - b) * (prevV + prevA)
A := g * (V - prevV) + (1.0 - g) * prevA
F + V + 0.5 * A
// ---------- Main loop ----------
// Inputs
i_period = input.int(10, "Period", minval=1)
i_source = input.source(close, "Source")
// Calculation
hwma_value = hwma(i_source, period=i_period)
// Plot
plot(hwma_value, "HWMA", color=color.yellow, linewidth=2)