Add Yang-Zhang Volatility (YZV) Indicator Implementation

- Introduced YZV class for calculating Yang-Zhang Volatility, a comprehensive volatility measure that incorporates overnight, open-to-close, and high-low components.
- Implemented calculation methods, including batch processing for TBarSeries and spans.
- Added documentation for YZV, detailing its mathematical foundation, performance profile, and trading applications.
- Updated volume index documentation to reflect changes in file paths.
- Refactored VWMA calculation method to use a more generic source parameter instead of price.
This commit is contained in:
Miha Kralj
2026-02-02 19:47:21 -08:00
parent a03d7aa0ce
commit c034cbd5e5
78 changed files with 16662 additions and 366 deletions
+2
View File
@@ -26,4 +26,6 @@ Trend indicators based on Infinite Impulse Response (IIR) filters. Recursive arc
| [VAMA](/lib/trends_IIR/vama/Vama.md) | Volatility Adjusted MA | Dynamically adjusts moving average length based on ATR volatility ratio, shortening during high volatility and lengthening during low volatility. |
| [VIDYA](/lib/trends_IIR/vidya/Vidya.md) | Variable Index Dynamic Average | Adjusts smoothing factor based on market volatility using Volatility Index (ratio of short-term to long-term standard deviation). |
| [YZVAMA](/lib/trends_IIR/yzvama/Yzvama.md) | Yang-Zhang Volatility Adjusted MA | Adjusts MA length based on percentile rank of short-term YZV, providing context-aware volatility adaptation for gap-prone markets. |
| [ZLDEMA](/lib/trends_IIR/zldema/Zldema.md) | Zero-Lag Double Exponential MA | Combines zero-lag preprocessing with dual EMA cascade (DEMA) for faster response than DEMA with moderate smoothing. |
| [ZLEMA](/lib/trends_IIR/zlema/Zlema.md) | Zero-Lag Exponential MA | Reduces lag by estimating future price based on current momentum, using dynamically calculated lag period. |
| [ZLTEMA](/lib/trends_IIR/zltema/Zltema.md) | Zero-Lag Triple Exponential MA | Combines zero-lag preprocessing with triple EMA cascade (TEMA) for maximum smoothness with minimal lag. |
@@ -0,0 +1,127 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class ZldemaIndicatorTests
{
[Fact]
public void ZldemaIndicator_Constructor_SetsDefaults()
{
var indicator = new ZldemaIndicator();
Assert.Equal(10, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("ZLDEMA - Zero-Lag Double Exponential Moving Average", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void ZldemaIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new ZldemaIndicator { Period = 20 };
Assert.Equal(0, ZldemaIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void ZldemaIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new ZldemaIndicator { Period = 15 };
Assert.Contains("ZLDEMA", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void ZldemaIndicator_Initialize_CreatesLineSeries()
{
var indicator = new ZldemaIndicator { Period = 10 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void ZldemaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new ZldemaIndicator { Period = 4 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void ZldemaIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new ZldemaIndicator { Period = 4 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void ZldemaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new ZldemaIndicator { Period = 4 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void ZldemaIndicator_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 ZldemaIndicator { 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 ZldemaIndicator_Period_CanBeChanged()
{
var indicator = new ZldemaIndicator { Period = 5 };
Assert.Equal(5, indicator.Period);
indicator.Period = 20;
Assert.Equal(20, indicator.Period);
Assert.Equal(0, ZldemaIndicator.MinHistoryDepths);
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public class ZldemaIndicator : 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 Zldema 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 => $"ZLDEMA {Period}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_IIR/zldema/Zldema.Quantower.cs";
public ZldemaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "ZLDEMA - Zero-Lag Double Exponential Moving Average";
Description = "Zero-lag DEMA combining lagged price compensation with dual EMA smoothing.";
Series = new LineSeries(name: $"ZLDEMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
ma = new Zldema(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);
}
}
+202
View File
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
namespace QuanTAlib.Tests;
public class ZldemaTests
{
[Fact]
public void Zldema_Constructor_ValidatesInput()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Zldema(0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Zldema(-1));
Assert.Throws<ArgumentException>(() => new Zldema(0.0));
var zldema = new Zldema(1);
Assert.Equal("Zldema(1)", zldema.Name);
}
[Fact]
public void Zldema_BasicCalculation_ReturnsFinite()
{
var zldema = new Zldema(12);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
int iterations = zldema.WarmupPeriod + 2;
TValue result = default;
for (int i = 0; i < iterations; i++)
{
var bar = gbm.Next(isNew: true);
result = zldema.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(double.IsFinite(result.Value));
Assert.True(zldema.IsHot);
}
[Fact]
public void Zldema_IsNewFalse_RestoresState()
{
var zldema = new Zldema(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 7);
TValue lastInput = default;
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
lastInput = new TValue(bar.Time, bar.Close);
zldema.Update(lastInput, isNew: true);
}
double original = zldema.Last.Value;
var corrected = new TValue(lastInput.Time, lastInput.Value * 1.1);
zldema.Update(corrected, isNew: false);
zldema.Update(lastInput, isNew: false);
Assert.Equal(original, zldema.Last.Value, precision: 10);
}
[Fact]
public void Zldema_Reset_ClearsState()
{
var zldema = new Zldema(10);
zldema.Update(new TValue(DateTime.UtcNow, 100.0));
zldema.Reset();
Assert.Equal(default, zldema.Last);
Assert.False(zldema.IsHot);
}
[Fact]
public void Zldema_Robustness_NaNAndInfinity_UsesLastValid()
{
var zldema = new Zldema(10);
zldema.Update(new TValue(DateTime.UtcNow, 100.0));
zldema.Update(new TValue(DateTime.UtcNow, 110.0));
TValue nanResult = zldema.Update(new TValue(DateTime.UtcNow, double.NaN));
TValue posInfResult = zldema.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
TValue negInfResult = zldema.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(nanResult.Value));
Assert.True(double.IsFinite(posInfResult.Value));
Assert.True(double.IsFinite(negInfResult.Value));
}
[Fact]
public void Zldema_BatchMatchesStreaming()
{
int period = 12;
TSeries series = BuildSeries(120, seed: 11);
TSeries batch = Zldema.Calculate(series, period);
var zldema = new Zldema(period);
var streamValues = new List<double>(series.Count);
for (int i = 0; i < series.Count; i++)
{
streamValues.Add(zldema.Update(series[i]).Value);
}
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(batch[i].Value, streamValues[i], precision: 10);
}
}
[Fact]
public void Zldema_SpanMatchesBatch()
{
int period = 16;
TSeries series = BuildSeries(200, seed: 21);
double[] values = series.Values.ToArray();
var output = new double[values.Length];
Zldema.Calculate(values, output, period);
TSeries batch = Zldema.Calculate(series, period);
for (int i = 0; i < values.Length; i++)
{
Assert.Equal(batch[i].Value, output[i], precision: 10);
}
}
[Fact]
public void Zldema_EventingMatchesStreaming()
{
int period = 8;
var source = new TSeries();
var zldema = new Zldema(source, period);
var eventValues = new List<double>();
zldema.Pub += (object? sender, in TValueEventArgs args) => eventValues.Add(args.Value.Value);
TSeries series = BuildSeries(60, seed: 32);
for (int i = 0; i < series.Count; i++)
{
source.Add(series[i]);
}
var stream = new Zldema(period);
for (int i = 0; i < series.Count; i++)
{
double expected = stream.Update(series[i]).Value;
Assert.Equal(expected, eventValues[i], precision: 10);
}
}
[Fact]
public void Zldema_SpanValidatesOutputLength()
{
double[] source = [1, 2, 3, 4, 5];
double[] output = new double[3];
var ex = Assert.Throws<ArgumentException>(() => Zldema.Calculate(source, output, 10));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Zldema_WarmupPeriod_TransitionsIsHot()
{
var zldema = new Zldema(20);
int warmup = zldema.WarmupPeriod;
for (int i = 0; i < warmup - 1; i++)
{
zldema.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.False(zldema.IsHot);
}
zldema.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(zldema.IsHot);
}
[Fact]
public void Zldema_Prime_PopulatesState()
{
var zldema = new Zldema(10);
TSeries series = BuildSeries(50, seed: 100);
double[] values = series.Values.ToArray();
zldema.Prime(values);
Assert.True(double.IsFinite(zldema.Last.Value));
Assert.True(zldema.IsHot);
}
private static TSeries BuildSeries(int count, int seed)
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: seed);
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
return series;
}
}
@@ -0,0 +1,154 @@
using System;
namespace QuanTAlib.Tests;
public class ZldemaValidationTests
{
[Fact]
public void Zldema_Streaming_MatchesReference()
{
const int period = 20;
TSeries series = BuildSeries(300, seed: 5);
double[] reference = new double[series.Count];
ReferenceZldema(series.Values, reference, period);
var zldema = new Zldema(period);
for (int i = 0; i < series.Count; i++)
{
double actual = zldema.Update(series[i]).Value;
Assert.Equal(reference[i], actual, precision: 10);
}
}
[Fact]
public void Zldema_Batch_MatchesReference()
{
const int period = 14;
TSeries series = BuildSeries(250, seed: 9);
double[] reference = new double[series.Count];
ReferenceZldema(series.Values, reference, period);
TSeries batch = Zldema.Calculate(series, period);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(reference[i], batch[i].Value, precision: 10);
}
}
[Fact]
public void Zldema_Span_MatchesReference()
{
const int period = 30;
TSeries series = BuildSeries(200, seed: 12);
double[] values = series.Values.ToArray();
var output = new double[values.Length];
var reference = new double[values.Length];
ReferenceZldema(values, reference, period);
Zldema.Calculate(values, output, period);
for (int i = 0; i < values.Length; i++)
{
Assert.Equal(reference[i], output[i], precision: 10);
}
}
private static void ReferenceZldema(ReadOnlySpan<double> source, Span<double> output, int period)
{
double alpha = 2.0 / (period + 1);
double beta = 1.0 - alpha;
int lag = ComputeLag(period);
int bufferSize = lag + 1;
double ema1Raw = 0.0;
double ema2Raw = 0.0;
double e = 1.0;
bool warmup = true;
double lastValid = double.NaN;
double[] buffer = new double[bufferSize];
int head = 0;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
if (double.IsFinite(val))
{
lastValid = val;
}
else
{
val = lastValid;
}
if (double.IsNaN(val))
{
output[i] = double.NaN;
continue;
}
buffer[head] = val;
head++;
if (head == bufferSize)
{
head = 0;
}
double lagged = buffer[head];
double signal = Math.FusedMultiplyAdd(2.0, val, -lagged);
// First EMA stage
ema1Raw = Math.FusedMultiplyAdd(ema1Raw, beta, alpha * signal);
double ema1, ema2;
if (warmup)
{
e *= beta;
double compensator = 1.0 / (1.0 - e);
ema1 = ema1Raw * compensator;
// Second EMA stage
ema2Raw = Math.FusedMultiplyAdd(ema2Raw, beta, alpha * ema1);
ema2 = ema2Raw * compensator;
if (e <= 1e-10)
{
warmup = false;
}
}
else
{
ema1 = ema1Raw;
ema2Raw = Math.FusedMultiplyAdd(ema2Raw, beta, alpha * ema1);
ema2 = ema2Raw;
}
// DEMA formula: 2 * EMA1 - EMA2
output[i] = Math.FusedMultiplyAdd(2.0, ema1, -ema2);
}
}
private static int ComputeLag(double period)
{
double lag = (period - 1.0) * 0.5;
int lagInt = (int)Math.Round(lag, MidpointRounding.AwayFromZero);
return Math.Max(1, lagInt);
}
private static TSeries BuildSeries(int count, int seed)
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: seed);
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
return series;
}
}
+423
View File
@@ -0,0 +1,423 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// ZLDEMA: Zero-Lag Double Exponential Moving Average
/// </summary>
/// <remarks>
/// Hybrid dual-stage predictive architecture combining ZLEMA signal preprocessing with DEMA smoothing.
/// Applies lag compensation to the input signal, then cascades through two EMA stages with
/// optimized coefficients (2, -1) for reduced lag and enhanced noise suppression.
///
/// Calculation: <c>Signal = 2×Price - Price[lag]</c>, then <c>ZLDEMA = 2×EMA1(Signal) - EMA2(EMA1)</c>
/// </remarks>
/// <seealso href="Zldema.md">Detailed documentation</seealso>
/// <seealso href="zldema.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Zldema : AbstractBase
{
private const double CoverageThreshold = 0.05;
private const double CompensatorThreshold = 1e-10;
[StructLayout(LayoutKind.Auto)]
private record struct State(double Ema1Raw, double Ema2Raw, double E, bool IsHot, bool IsCompensated, int Bars)
{
public static State New() => new() { Ema1Raw = 0.0, Ema2Raw = 0.0, E = 1.0, IsHot = false, IsCompensated = false, Bars = 0 };
}
private readonly double _alpha;
private readonly double _beta;
private readonly int _lag;
private readonly RingBuffer _lagBuffer;
private State _s = State.New();
private State _ps = State.New();
private double _lastValidValue = double.NaN;
private double _p_lastValidValue = double.NaN;
private readonly ITValuePublisher? _publisher;
private readonly TValuePublishedHandler? _listener;
public override bool IsHot => _s.IsHot;
public Zldema(int period)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(period);
_alpha = 2.0 / (period + 1);
_beta = 1.0 - _alpha;
_lag = ComputeLag(period);
_lagBuffer = new RingBuffer(_lag + 1);
Name = $"Zldema({period})";
WarmupPeriod = Math.Max(_lag + 1, EstimateWarmupPeriod(_beta));
Reset();
}
public Zldema(double alpha)
{
if (alpha <= 0.0 || alpha > 1.0 || !double.IsFinite(alpha))
{
throw new ArgumentException("Alpha must be finite and in (0, 1].", nameof(alpha));
}
_alpha = alpha;
_beta = 1.0 - _alpha;
double period = (2.0 / alpha) - 1.0;
_lag = ComputeLag(period);
_lagBuffer = new RingBuffer(_lag + 1);
Name = $"Zldema(a={alpha:F4})";
WarmupPeriod = Math.Max(_lag + 1, EstimateWarmupPeriod(_beta));
Reset();
}
public Zldema(ITValuePublisher source, int period) : this(period)
{
_publisher = source;
_listener = Handle;
source.Pub += _listener;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
_p_lastValidValue = _lastValidValue;
_lagBuffer.Snapshot();
}
else
{
_s = _ps;
_lastValidValue = _p_lastValidValue;
_lagBuffer.Restore();
}
double val = input.Value;
if (double.IsFinite(val))
{
_lastValidValue = val;
}
else
{
val = _lastValidValue;
}
if (double.IsNaN(val))
{
Last = new TValue(input.Time, double.NaN);
PubEvent(Last, isNew);
return Last;
}
var s = _s;
s.Bars++;
_lagBuffer.Add(val);
double lagged = _lagBuffer.Oldest;
double signal = Math.FusedMultiplyAdd(2.0, val, -lagged);
double result = Compute(signal, ref s);
_s = s;
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
List<long> t = new(len);
List<double> v = new(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
source.Times.CopyTo(tSpan);
State preBatchState = _s;
double preBatchLastValid = _lastValidValue;
_lagBuffer.Snapshot();
State state = _s;
double lastValid = _lastValidValue;
for (int i = 0; i < len; i++)
{
double val = source.Values[i];
if (double.IsFinite(val))
{
lastValid = val;
}
else
{
val = lastValid;
}
if (double.IsNaN(val))
{
vSpan[i] = double.NaN;
continue;
}
state.Bars++;
_lagBuffer.Add(val);
double lagged = _lagBuffer.Oldest;
double signal = Math.FusedMultiplyAdd(2.0, val, -lagged);
vSpan[i] = Compute(signal, ref state);
}
_s = state;
_lastValidValue = lastValid;
_ps = preBatchState;
_p_lastValidValue = preBatchLastValid;
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
public static TSeries Calculate(TSeries source, int period)
{
var zldema = new Zldema(period);
return zldema.Update(source);
}
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length.", nameof(output));
}
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(period);
if (source.Length == 0)
{
return;
}
double alpha = 2.0 / (period + 1);
Calculate(source, output, alpha, period);
}
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, double alpha)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length.", nameof(output));
}
if (alpha <= 0.0 || alpha > 1.0 || !double.IsFinite(alpha))
{
throw new ArgumentException("Alpha must be finite and in (0, 1].", nameof(alpha));
}
if (source.Length == 0)
{
return;
}
double period = (2.0 / alpha) - 1.0;
Calculate(source, output, alpha, period);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int ComputeLag(double period)
{
double lag = (period - 1.0) * 0.5;
int lagInt = (int)Math.Round(lag, MidpointRounding.AwayFromZero);
return Math.Max(1, lagInt);
}
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private double Compute(double signal, ref State state)
{
// First EMA stage
state.Ema1Raw = Math.FusedMultiplyAdd(state.Ema1Raw, _beta, _alpha * signal);
double ema1, ema2;
if (!state.IsCompensated)
{
state.E *= _beta;
if (!state.IsHot && state.Bars >= _lag + 1 && state.E <= CoverageThreshold)
{
state.IsHot = true;
}
double compensator = 1.0 / (1.0 - state.E);
ema1 = state.Ema1Raw * compensator;
// Second EMA stage
state.Ema2Raw = Math.FusedMultiplyAdd(state.Ema2Raw, _beta, _alpha * ema1);
ema2 = state.Ema2Raw * compensator;
if (state.E <= CompensatorThreshold)
{
state.IsCompensated = true;
}
}
else
{
if (!state.IsHot && state.Bars >= _lag + 1)
{
state.IsHot = true;
}
ema1 = state.Ema1Raw;
state.Ema2Raw = Math.FusedMultiplyAdd(state.Ema2Raw, _beta, _alpha * ema1);
ema2 = state.Ema2Raw;
}
// DEMA formula: 2 * EMA1 - EMA2
return Math.FusedMultiplyAdd(2.0, ema1, -ema2);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int EstimateWarmupPeriod(double beta)
{
if (beta <= 0.0)
{
return 1;
}
double steps = Math.Log(CoverageThreshold) / Math.Log(beta);
if (double.IsNaN(steps) || double.IsInfinity(steps) || steps <= 0.0)
{
return 1;
}
return (int)Math.Ceiling(steps);
}
public override void Reset()
{
_s = State.New();
_ps = _s;
_lastValidValue = double.NaN;
_p_lastValidValue = double.NaN;
_lagBuffer.Clear();
for (int i = 0; i < _lagBuffer.Capacity; i++)
{
_lagBuffer.Add(0.0);
}
Last = default;
}
protected override void Dispose(bool disposing)
{
if (disposing && _publisher != null && _listener != null)
{
_publisher.Pub -= _listener;
}
base.Dispose(disposing);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
private static void Calculate(ReadOnlySpan<double> source, Span<double> output, double alpha, double period)
{
int lag = ComputeLag(period);
int bufferSize = lag + 1;
double beta = 1.0 - alpha;
double ema1Raw = 0.0;
double ema2Raw = 0.0;
double e = 1.0;
bool isCompensated = false;
double lastValid = double.NaN;
Span<double> buffer = bufferSize <= 256
? stackalloc double[bufferSize]
: new double[bufferSize];
buffer.Clear();
int head = 0;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
if (double.IsFinite(val))
{
lastValid = val;
}
else
{
val = lastValid;
}
if (double.IsNaN(val))
{
output[i] = double.NaN;
continue;
}
buffer[head] = val;
head++;
if (head == bufferSize)
{
head = 0;
}
double lagged = buffer[head];
double signal = Math.FusedMultiplyAdd(2.0, val, -lagged);
ema1Raw = Math.FusedMultiplyAdd(ema1Raw, beta, alpha * signal);
double ema1, ema2;
if (!isCompensated)
{
e *= beta;
double compensator = 1.0 / (1.0 - e);
ema1 = ema1Raw * compensator;
ema2Raw = Math.FusedMultiplyAdd(ema2Raw, beta, alpha * ema1);
ema2 = ema2Raw * compensator;
if (e <= CompensatorThreshold)
{
isCompensated = true;
}
}
else
{
ema1 = ema1Raw;
ema2Raw = Math.FusedMultiplyAdd(ema2Raw, beta, alpha * ema1);
ema2 = ema2Raw;
}
output[i] = Math.FusedMultiplyAdd(2.0, ema1, -ema2);
}
}
}
+149
View File
@@ -0,0 +1,149 @@
# ZLDEMA: Zero-Lag Double Exponential Moving Average
## DEMA with lag compensation via a zero-lag signal
> "ZLDEMA combines the speed of zero-lag prediction with the smoothness of double exponential averaging. You get faster response than ZLEMA, with better trend-following than DEMA."
ZLDEMA takes a standard DEMA and feeds it a **zero-lag signal**: current price minus a lagged price. This produces a smoother that responds faster than DEMA without going fully raw. The dual EMA cascade provides additional noise rejection while the zero-lag preprocessing maintains responsiveness.
## Historical Context
ZLDEMA extends the zero-lag concept from ZLEMA to double exponential moving averages. Where ZLEMA applies lag compensation to a single EMA, ZLDEMA applies it to a two-stage EMA cascade using the DEMA formula (2*EMA1 - EMA2). This combination targets the middle ground between ZLEMA's speed and TEMA's smoothness.
## Architecture & Physics
### Pipeline
1. **Lag estimate**
$$\text{lag} = \max(1, \text{round}((N-1)/2))$$
2. **Zero-lag signal**
$$s_t = 2 \cdot x_t - x_{t-\text{lag}}$$
3. **First EMA stage**
$$\text{EMA1}_t = \text{EMA}(s_t, \alpha)$$
4. **Second EMA stage**
$$\text{EMA2}_t = \text{EMA}(\text{EMA1}_t, \alpha)$$
5. **DEMA output**
$$\text{ZLDEMA}_t = 2 \cdot \text{EMA1}_t - \text{EMA2}_t$$
### Warmup compensation
ZLDEMA uses EMA bias compensation during warmup on both EMA stages:
$$y_t^{*} = \frac{y_t}{1 - (1 - \alpha)^t}$$
This avoids the early-stage bias toward zero and makes the first values usable.
## Math Foundation
**EMA update:**
$$y_t = y_{t-1} + \alpha (s_t - y_{t-1})$$
**Zero-lag signal:**
$$s_t = 2 \cdot x_t - x_{t-\text{lag}}$$
**DEMA formula:**
$$\text{DEMA}_t = 2 \cdot \text{EMA1}_t - \text{EMA2}_t$$
**Alpha from period:**
$$\alpha = \frac{2}{N + 1}$$
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
**Hot path (after warmup, compensation complete):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| FMA | 4 | 4 | 16 |
| MUL | 2 | 3 | 6 |
| **Total** | **6** | | **~22 cycles** |
The hot path consists of:
1. Zero-lag signal: `FMA(2.0, val, -lagged)` - 1 FMA
2. EMA1 core: `FMA(ema1Raw, beta, alpha * signal)` - 1 FMA + 1 MUL
3. EMA2 core: `FMA(ema2Raw, beta, alpha * ema1)` - 1 FMA + 1 MUL
4. DEMA output: `FMA(2.0, ema1, -ema2)` - 1 FMA
**Warmup path (with bias compensation):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| FMA | 4 | 4 | 16 |
| MUL | 4 | 3 | 12 |
| DIV | 1 | 15 | 15 |
| CMP | 2 | 1 | 2 |
| **Total** | **11** | | **~45 cycles** |
Additional warmup operations:
- Decay tracking: `e *= beta` - 1 MUL
- Compensator calc: `1 / (1 - e)` - 1 DIV
- Bias compensation: `ema1Raw * compensator`, `ema2Raw * compensator` - 2 MUL
- Hot/compensated checks - 2 CMP
### Batch Mode (SIMD Analysis)
ZLDEMA is an IIR filter with lag buffer dependency - not directly vectorizable across bars. However, within-bar operations use FMA intrinsics.
| Optimization | Benefit |
| :--- | :--- |
| FMA instructions | ~22 cycles vs ~28 scalar |
| stackalloc buffer | Zero heap allocation for lag ≤256 |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 8/10 | Matches PineScript reference |
| **Timeliness** | 9/10 | Faster response than DEMA, comparable to ZLEMA |
| **Overshoot** | 5/10 | Predictive signal plus DEMA amplification causes overshoot |
| **Smoothness** | 7/10 | Smoother than ZLEMA due to dual EMA cascade |
## Validation
ZLDEMA is validated against a PineScript reference implementation.
| Library | Status | Tolerance | Notes |
|:---|:---|:---|:---|
| **TA-Lib** | N/A | - | No ZLDEMA in TA-Lib |
| **Skender** | N/A | - | No ZLDEMA in Skender |
| **Tulip** | N/A | - | No ZLDEMA in Tulip |
| **Ooples** | N/A | - | No ZLDEMA in Ooples |
| **PineScript** | ✓ Passed | 1e-10 | Matches `lib/trends_IIR/zldema/zldema.pine` |
## Common Pitfalls
1. **Increased overshoot on turns**
The zero-lag signal is a forward estimate, and the DEMA formula (2*EMA1 - EMA2) further amplifies deviations. Expect more overshoot than ZLEMA when price reverses sharply.
2. **Period semantics**
ZLDEMA uses EMA alpha; the lag term is derived from period but not equivalent to a window length. Do not compare ZLDEMA period directly to SMA window length.
3. **Warmup discipline**
Use `IsHot` / `WarmupPeriod` before acting on signals. Early values are bias-corrected but still unstable. The dual EMA cascade requires longer warmup than single-stage ZLEMA.
4. **Non-finite data**
NaN or Infinity is replaced with the last valid value. Before the first valid sample, output is `NaN`.
5. **DEMA vs ZLDEMA**
ZLDEMA is not simply DEMA with a different alpha. The zero-lag preprocessing fundamentally changes the input signal, making ZLDEMA more responsive but also more prone to overshoot than standard DEMA.
+57
View File
@@ -0,0 +1,57 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Zero-Lag Double EMA (ZLDEMA)", "ZLDEMA", overlay=true)
//@function Calculates ZLDEMA using zero-lag price and double exponential smoothing with compensator
//@doc https://github.com/mihakralj/pinescript/blob/main/indicators/trends_IIR/zldema.md
//@param source Series to calculate ZLDEMA from
//@param period Smoothing period
//@param alpha Optional smoothing factor (overrides period if provided)
//@returns ZLDEMA value with zero-lag effect applied
//@optimized Uses lag compensation buffer and exponential warmup compensator on both EMA stages for O(1) complexity
zldema(series float source, simple int period=0, simple float alpha=0) =>
if alpha <= 0 and period <= 0
runtime.error("Alpha or period must be provided")
float a = alpha > 0 ? alpha : 2.0 / (period + 1)
float beta = 1.0 - a
simple int lag = math.max(1, math.round((period - 1) / 2))
var bool warmup = true
var float e = 1.0
var float ema1_raw = 0.0
var float ema2_raw = 0.0
var float ema1 = source
var float ema2 = source
var priceBuffer = array.new<float>(lag + 1, na)
if not na(source)
array.shift(priceBuffer)
array.push(priceBuffer, source)
float laggedPrice = nz(array.get(priceBuffer, 0), source)
float signal = 2 * source - laggedPrice
ema1_raw := a * (signal - ema1_raw) + ema1_raw
if warmup
e *= beta
float c = 1.0 / (1.0 - e)
ema1 := c * ema1_raw
ema2_raw := a * (ema1 - ema2_raw) + ema2_raw
ema2 := c * ema2_raw
warmup := e > 1e-10
else
ema1 := ema1_raw
ema2_raw := a * (ema1 - ema2_raw) + ema2_raw
ema2 := ema2_raw
2 * ema1 - ema2
else
na
// ---------- Main loop ----------
// Inputs
i_period = input.int(10, "Period", minval=1)
i_source = input.source(close, "Source")
// Calculation
zldema_value = zldema(i_source, i_period)
// Plot
plot(zldema_value, "ZLDEMA", color=color.yellow, linewidth=2)
@@ -0,0 +1,127 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class ZltemaIndicatorTests
{
[Fact]
public void ZltemaIndicator_Constructor_SetsDefaults()
{
var indicator = new ZltemaIndicator();
Assert.Equal(10, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("ZLTEMA - Zero-Lag Triple Exponential Moving Average", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void ZltemaIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new ZltemaIndicator { Period = 20 };
Assert.Equal(0, ZltemaIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void ZltemaIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new ZltemaIndicator { Period = 15 };
Assert.Contains("ZLTEMA", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void ZltemaIndicator_Initialize_CreatesLineSeries()
{
var indicator = new ZltemaIndicator { Period = 10 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void ZltemaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new ZltemaIndicator { Period = 4 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void ZltemaIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new ZltemaIndicator { Period = 4 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void ZltemaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new ZltemaIndicator { Period = 4 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void ZltemaIndicator_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 ZltemaIndicator { 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 ZltemaIndicator_Period_CanBeChanged()
{
var indicator = new ZltemaIndicator { Period = 5 };
Assert.Equal(5, indicator.Period);
indicator.Period = 20;
Assert.Equal(20, indicator.Period);
Assert.Equal(0, ZltemaIndicator.MinHistoryDepths);
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public class ZltemaIndicator : 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 Zltema 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 => $"ZLTEMA {Period}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_IIR/zltema/Zltema.Quantower.cs";
public ZltemaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "ZLTEMA - Zero-Lag Triple Exponential Moving Average";
Description = "Zero-lag TEMA combining lagged price compensation with triple EMA smoothing.";
Series = new LineSeries(name: $"ZLTEMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
ma = new Zltema(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);
}
}
+202
View File
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
namespace QuanTAlib.Tests;
public class ZltemaTests
{
[Fact]
public void Zltema_Constructor_ValidatesInput()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Zltema(0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Zltema(-1));
Assert.Throws<ArgumentException>(() => new Zltema(0.0));
var zltema = new Zltema(1);
Assert.Equal("Zltema(1)", zltema.Name);
}
[Fact]
public void Zltema_BasicCalculation_ReturnsFinite()
{
var zltema = new Zltema(12);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
int iterations = zltema.WarmupPeriod + 2;
TValue result = default;
for (int i = 0; i < iterations; i++)
{
var bar = gbm.Next(isNew: true);
result = zltema.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(double.IsFinite(result.Value));
Assert.True(zltema.IsHot);
}
[Fact]
public void Zltema_IsNewFalse_RestoresState()
{
var zltema = new Zltema(10);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 7);
TValue lastInput = default;
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
lastInput = new TValue(bar.Time, bar.Close);
zltema.Update(lastInput, isNew: true);
}
double original = zltema.Last.Value;
var corrected = new TValue(lastInput.Time, lastInput.Value * 1.1);
zltema.Update(corrected, isNew: false);
zltema.Update(lastInput, isNew: false);
Assert.Equal(original, zltema.Last.Value, precision: 10);
}
[Fact]
public void Zltema_Reset_ClearsState()
{
var zltema = new Zltema(10);
zltema.Update(new TValue(DateTime.UtcNow, 100.0));
zltema.Reset();
Assert.Equal(default, zltema.Last);
Assert.False(zltema.IsHot);
}
[Fact]
public void Zltema_Robustness_NaNAndInfinity_UsesLastValid()
{
var zltema = new Zltema(10);
zltema.Update(new TValue(DateTime.UtcNow, 100.0));
zltema.Update(new TValue(DateTime.UtcNow, 110.0));
TValue nanResult = zltema.Update(new TValue(DateTime.UtcNow, double.NaN));
TValue posInfResult = zltema.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
TValue negInfResult = zltema.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(nanResult.Value));
Assert.True(double.IsFinite(posInfResult.Value));
Assert.True(double.IsFinite(negInfResult.Value));
}
[Fact]
public void Zltema_BatchMatchesStreaming()
{
int period = 12;
TSeries series = BuildSeries(120, seed: 11);
TSeries batch = Zltema.Calculate(series, period);
var zltema = new Zltema(period);
var streamValues = new List<double>(series.Count);
for (int i = 0; i < series.Count; i++)
{
streamValues.Add(zltema.Update(series[i]).Value);
}
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(batch[i].Value, streamValues[i], precision: 10);
}
}
[Fact]
public void Zltema_SpanMatchesBatch()
{
int period = 16;
TSeries series = BuildSeries(200, seed: 21);
double[] values = series.Values.ToArray();
var output = new double[values.Length];
Zltema.Calculate(values, output, period);
TSeries batch = Zltema.Calculate(series, period);
for (int i = 0; i < values.Length; i++)
{
Assert.Equal(batch[i].Value, output[i], precision: 10);
}
}
[Fact]
public void Zltema_EventingMatchesStreaming()
{
int period = 8;
var source = new TSeries();
var zltema = new Zltema(source, period);
var eventValues = new List<double>();
zltema.Pub += (object? sender, in TValueEventArgs args) => eventValues.Add(args.Value.Value);
TSeries series = BuildSeries(60, seed: 32);
for (int i = 0; i < series.Count; i++)
{
source.Add(series[i]);
}
var stream = new Zltema(period);
for (int i = 0; i < series.Count; i++)
{
double expected = stream.Update(series[i]).Value;
Assert.Equal(expected, eventValues[i], precision: 10);
}
}
[Fact]
public void Zltema_SpanValidatesOutputLength()
{
double[] source = [1, 2, 3, 4, 5];
double[] output = new double[3];
var ex = Assert.Throws<ArgumentException>(() => Zltema.Calculate(source, output, 10));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Zltema_WarmupPeriod_TransitionsIsHot()
{
var zltema = new Zltema(20);
int warmup = zltema.WarmupPeriod;
for (int i = 0; i < warmup - 1; i++)
{
zltema.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.False(zltema.IsHot);
}
zltema.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(zltema.IsHot);
}
[Fact]
public void Zltema_Prime_PopulatesState()
{
var zltema = new Zltema(10);
TSeries series = BuildSeries(50, seed: 100);
double[] values = series.Values.ToArray();
zltema.Prime(values);
Assert.True(double.IsFinite(zltema.Last.Value));
Assert.True(zltema.IsHot);
}
private static TSeries BuildSeries(int count, int seed)
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: seed);
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
return series;
}
}
@@ -0,0 +1,161 @@
using System;
namespace QuanTAlib.Tests;
public class ZltemaValidationTests
{
[Fact]
public void Zltema_Streaming_MatchesReference()
{
const int period = 20;
TSeries series = BuildSeries(300, seed: 5);
double[] reference = new double[series.Count];
ReferenceZltema(series.Values, reference, period);
var zltema = new Zltema(period);
for (int i = 0; i < series.Count; i++)
{
double actual = zltema.Update(series[i]).Value;
Assert.Equal(reference[i], actual, precision: 10);
}
}
[Fact]
public void Zltema_Batch_MatchesReference()
{
const int period = 14;
TSeries series = BuildSeries(250, seed: 9);
double[] reference = new double[series.Count];
ReferenceZltema(series.Values, reference, period);
TSeries batch = Zltema.Calculate(series, period);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(reference[i], batch[i].Value, precision: 10);
}
}
[Fact]
public void Zltema_Span_MatchesReference()
{
const int period = 30;
TSeries series = BuildSeries(200, seed: 12);
double[] values = series.Values.ToArray();
var output = new double[values.Length];
var reference = new double[values.Length];
ReferenceZltema(values, reference, period);
Zltema.Calculate(values, output, period);
for (int i = 0; i < values.Length; i++)
{
Assert.Equal(reference[i], output[i], precision: 10);
}
}
private static void ReferenceZltema(ReadOnlySpan<double> source, Span<double> output, int period)
{
double alpha = 2.0 / (period + 1);
double beta = 1.0 - alpha;
int lag = ComputeLag(period);
int bufferSize = lag + 1;
double ema1Raw = 0.0;
double ema2Raw = 0.0;
double ema3Raw = 0.0;
double e = 1.0;
bool warmup = true;
double lastValid = double.NaN;
double[] buffer = new double[bufferSize];
int head = 0;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
if (double.IsFinite(val))
{
lastValid = val;
}
else
{
val = lastValid;
}
if (double.IsNaN(val))
{
output[i] = double.NaN;
continue;
}
buffer[head] = val;
head++;
if (head == bufferSize)
{
head = 0;
}
double lagged = buffer[head];
double signal = Math.FusedMultiplyAdd(2.0, val, -lagged);
// First EMA stage
ema1Raw = Math.FusedMultiplyAdd(ema1Raw, beta, alpha * signal);
double ema1, ema2, ema3;
if (warmup)
{
e *= beta;
double compensator = 1.0 / (1.0 - e);
ema1 = ema1Raw * compensator;
// Second EMA stage
ema2Raw = Math.FusedMultiplyAdd(ema2Raw, beta, alpha * ema1);
ema2 = ema2Raw * compensator;
// Third EMA stage
ema3Raw = Math.FusedMultiplyAdd(ema3Raw, beta, alpha * ema2);
ema3 = ema3Raw * compensator;
if (e <= 1e-10)
{
warmup = false;
}
}
else
{
ema1 = ema1Raw;
ema2Raw = Math.FusedMultiplyAdd(ema2Raw, beta, alpha * ema1);
ema2 = ema2Raw;
ema3Raw = Math.FusedMultiplyAdd(ema3Raw, beta, alpha * ema2);
ema3 = ema3Raw;
}
// TEMA formula: 3 * EMA1 - 3 * EMA2 + EMA3
output[i] = Math.FusedMultiplyAdd(3.0, ema1, Math.FusedMultiplyAdd(-3.0, ema2, ema3));
}
}
private static int ComputeLag(double period)
{
double lag = (period - 1.0) * 0.5;
int lagInt = (int)Math.Round(lag, MidpointRounding.AwayFromZero);
return Math.Max(1, lagInt);
}
private static TSeries BuildSeries(int count, int seed)
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: seed);
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
return series;
}
}
+437
View File
@@ -0,0 +1,437 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// ZLTEMA: Zero-Lag Triple Exponential Moving Average
/// </summary>
/// <remarks>
/// Hybrid triple-stage predictive architecture combining ZLEMA signal preprocessing with TEMA smoothing.
/// Applies lag compensation to the input signal, then cascades through three EMA stages with
/// optimized coefficients (3, -3, 1) for reduced lag and enhanced noise suppression.
///
/// Calculation: <c>Signal = 2×Price - Price[lag]</c>, then <c>ZLTEMA = 3×EMA1(Signal) - 3×EMA2(EMA1) + EMA3(EMA2)</c>
/// </remarks>
/// <seealso href="Zltema.md">Detailed documentation</seealso>
/// <seealso href="zltema.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Zltema : AbstractBase
{
private const double CoverageThreshold = 0.05;
private const double CompensatorThreshold = 1e-10;
[StructLayout(LayoutKind.Auto)]
private record struct State(double Ema1Raw, double Ema2Raw, double Ema3Raw, double E, bool IsHot, bool IsCompensated, int Bars)
{
public static State New() => new() { Ema1Raw = 0.0, Ema2Raw = 0.0, Ema3Raw = 0.0, E = 1.0, IsHot = false, IsCompensated = false, Bars = 0 };
}
private readonly double _alpha;
private readonly double _beta;
private readonly int _lag;
private readonly RingBuffer _lagBuffer;
private State _s = State.New();
private State _ps = State.New();
private double _lastValidValue = double.NaN;
private double _p_lastValidValue = double.NaN;
private readonly ITValuePublisher? _publisher;
private readonly TValuePublishedHandler? _listener;
public override bool IsHot => _s.IsHot;
public Zltema(int period)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(period);
_alpha = 2.0 / (period + 1);
_beta = 1.0 - _alpha;
_lag = ComputeLag(period);
_lagBuffer = new RingBuffer(_lag + 1);
Name = $"Zltema({period})";
WarmupPeriod = Math.Max(_lag + 1, EstimateWarmupPeriod(_beta));
Reset();
}
public Zltema(double alpha)
{
if (alpha <= 0.0 || alpha > 1.0 || !double.IsFinite(alpha))
{
throw new ArgumentException("Alpha must be finite and in (0, 1].", nameof(alpha));
}
_alpha = alpha;
_beta = 1.0 - _alpha;
double period = (2.0 / alpha) - 1.0;
_lag = ComputeLag(period);
_lagBuffer = new RingBuffer(_lag + 1);
Name = $"Zltema(a={alpha:F4})";
WarmupPeriod = Math.Max(_lag + 1, EstimateWarmupPeriod(_beta));
Reset();
}
public Zltema(ITValuePublisher source, int period) : this(period)
{
_publisher = source;
_listener = Handle;
source.Pub += _listener;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
_p_lastValidValue = _lastValidValue;
_lagBuffer.Snapshot();
}
else
{
_s = _ps;
_lastValidValue = _p_lastValidValue;
_lagBuffer.Restore();
}
double val = input.Value;
if (double.IsFinite(val))
{
_lastValidValue = val;
}
else
{
val = _lastValidValue;
}
if (double.IsNaN(val))
{
Last = new TValue(input.Time, double.NaN);
PubEvent(Last, isNew);
return Last;
}
var s = _s;
s.Bars++;
_lagBuffer.Add(val);
double lagged = _lagBuffer.Oldest;
double signal = Math.FusedMultiplyAdd(2.0, val, -lagged);
double result = Compute(signal, ref s);
_s = s;
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
List<long> t = new(len);
List<double> v = new(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
source.Times.CopyTo(tSpan);
State preBatchState = _s;
double preBatchLastValid = _lastValidValue;
_lagBuffer.Snapshot();
State state = _s;
double lastValid = _lastValidValue;
for (int i = 0; i < len; i++)
{
double val = source.Values[i];
if (double.IsFinite(val))
{
lastValid = val;
}
else
{
val = lastValid;
}
if (double.IsNaN(val))
{
vSpan[i] = double.NaN;
continue;
}
state.Bars++;
_lagBuffer.Add(val);
double lagged = _lagBuffer.Oldest;
double signal = Math.FusedMultiplyAdd(2.0, val, -lagged);
vSpan[i] = Compute(signal, ref state);
}
_s = state;
_lastValidValue = lastValid;
_ps = preBatchState;
_p_lastValidValue = preBatchLastValid;
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
public static TSeries Calculate(TSeries source, int period)
{
var zltema = new Zltema(period);
return zltema.Update(source);
}
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length.", nameof(output));
}
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(period);
if (source.Length == 0)
{
return;
}
double alpha = 2.0 / (period + 1);
Calculate(source, output, alpha, period);
}
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, double alpha)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length.", nameof(output));
}
if (alpha <= 0.0 || alpha > 1.0 || !double.IsFinite(alpha))
{
throw new ArgumentException("Alpha must be finite and in (0, 1].", nameof(alpha));
}
if (source.Length == 0)
{
return;
}
double period = (2.0 / alpha) - 1.0;
Calculate(source, output, alpha, period);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int ComputeLag(double period)
{
double lag = (period - 1.0) * 0.5;
int lagInt = (int)Math.Round(lag, MidpointRounding.AwayFromZero);
return Math.Max(1, lagInt);
}
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private double Compute(double signal, ref State state)
{
// First EMA stage
state.Ema1Raw = Math.FusedMultiplyAdd(state.Ema1Raw, _beta, _alpha * signal);
double ema1, ema2, ema3;
if (!state.IsCompensated)
{
state.E *= _beta;
if (!state.IsHot && state.Bars >= _lag + 1 && state.E <= CoverageThreshold)
{
state.IsHot = true;
}
double compensator = 1.0 / (1.0 - state.E);
ema1 = state.Ema1Raw * compensator;
// Second EMA stage
state.Ema2Raw = Math.FusedMultiplyAdd(state.Ema2Raw, _beta, _alpha * ema1);
ema2 = state.Ema2Raw * compensator;
// Third EMA stage
state.Ema3Raw = Math.FusedMultiplyAdd(state.Ema3Raw, _beta, _alpha * ema2);
ema3 = state.Ema3Raw * compensator;
if (state.E <= CompensatorThreshold)
{
state.IsCompensated = true;
}
}
else
{
if (!state.IsHot && state.Bars >= _lag + 1)
{
state.IsHot = true;
}
ema1 = state.Ema1Raw;
state.Ema2Raw = Math.FusedMultiplyAdd(state.Ema2Raw, _beta, _alpha * ema1);
ema2 = state.Ema2Raw;
state.Ema3Raw = Math.FusedMultiplyAdd(state.Ema3Raw, _beta, _alpha * ema2);
ema3 = state.Ema3Raw;
}
// TEMA formula: 3 * EMA1 - 3 * EMA2 + EMA3
return Math.FusedMultiplyAdd(3.0, ema1, Math.FusedMultiplyAdd(-3.0, ema2, ema3));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int EstimateWarmupPeriod(double beta)
{
if (beta <= 0.0)
{
return 1;
}
double steps = Math.Log(CoverageThreshold) / Math.Log(beta);
if (double.IsNaN(steps) || double.IsInfinity(steps) || steps <= 0.0)
{
return 1;
}
return (int)Math.Ceiling(steps);
}
public override void Reset()
{
_s = State.New();
_ps = _s;
_lastValidValue = double.NaN;
_p_lastValidValue = double.NaN;
_lagBuffer.Clear();
for (int i = 0; i < _lagBuffer.Capacity; i++)
{
_lagBuffer.Add(0.0);
}
Last = default;
}
protected override void Dispose(bool disposing)
{
if (disposing && _publisher != null && _listener != null)
{
_publisher.Pub -= _listener;
}
base.Dispose(disposing);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
private static void Calculate(ReadOnlySpan<double> source, Span<double> output, double alpha, double period)
{
int lag = ComputeLag(period);
int bufferSize = lag + 1;
double beta = 1.0 - alpha;
double ema1Raw = 0.0;
double ema2Raw = 0.0;
double ema3Raw = 0.0;
double e = 1.0;
bool isCompensated = false;
double lastValid = double.NaN;
Span<double> buffer = bufferSize <= 256
? stackalloc double[bufferSize]
: new double[bufferSize];
buffer.Clear();
int head = 0;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
if (double.IsFinite(val))
{
lastValid = val;
}
else
{
val = lastValid;
}
if (double.IsNaN(val))
{
output[i] = double.NaN;
continue;
}
buffer[head] = val;
head++;
if (head == bufferSize)
{
head = 0;
}
double lagged = buffer[head];
double signal = Math.FusedMultiplyAdd(2.0, val, -lagged);
ema1Raw = Math.FusedMultiplyAdd(ema1Raw, beta, alpha * signal);
double ema1, ema2, ema3;
if (!isCompensated)
{
e *= beta;
double compensator = 1.0 / (1.0 - e);
ema1 = ema1Raw * compensator;
ema2Raw = Math.FusedMultiplyAdd(ema2Raw, beta, alpha * ema1);
ema2 = ema2Raw * compensator;
ema3Raw = Math.FusedMultiplyAdd(ema3Raw, beta, alpha * ema2);
ema3 = ema3Raw * compensator;
if (e <= CompensatorThreshold)
{
isCompensated = true;
}
}
else
{
ema1 = ema1Raw;
ema2Raw = Math.FusedMultiplyAdd(ema2Raw, beta, alpha * ema1);
ema2 = ema2Raw;
ema3Raw = Math.FusedMultiplyAdd(ema3Raw, beta, alpha * ema2);
ema3 = ema3Raw;
}
output[i] = Math.FusedMultiplyAdd(3.0, ema1, Math.FusedMultiplyAdd(-3.0, ema2, ema3));
}
}
}
+158
View File
@@ -0,0 +1,158 @@
# ZLTEMA: Zero-Lag Triple Exponential Moving Average
## TEMA with lag compensation via a zero-lag signal
> "ZLTEMA combines the speed of zero-lag prediction with the smoothness of triple exponential averaging. You get the fastest response in the zero-lag family, with the best noise rejection from the TEMA cascade."
ZLTEMA takes a standard TEMA and feeds it a **zero-lag signal**: current price minus a lagged price. This produces a smoother that responds faster than TEMA without going fully raw. The triple EMA cascade provides maximum noise rejection in the exponential family while the zero-lag preprocessing maintains responsiveness.
## Historical Context
ZLTEMA extends the zero-lag concept from ZLEMA to triple exponential moving averages. Where ZLEMA applies lag compensation to a single EMA and ZLDEMA to a double cascade, ZLTEMA applies it to a three-stage EMA cascade using the TEMA formula (3*EMA1 - 3*EMA2 + EMA3). This combination targets the extreme end: maximum smoothness with minimal lag.
## Architecture & Physics
### Pipeline
1. **Lag estimate**
$$\text{lag} = \max(1, \text{round}((N-1)/2))$$
2. **Zero-lag signal**
$$s_t = 2 \cdot x_t - x_{t-\text{lag}}$$
3. **First EMA stage**
$$\text{EMA1}_t = \text{EMA}(s_t, \alpha)$$
4. **Second EMA stage**
$$\text{EMA2}_t = \text{EMA}(\text{EMA1}_t, \alpha)$$
5. **Third EMA stage**
$$\text{EMA3}_t = \text{EMA}(\text{EMA2}_t, \alpha)$$
6. **TEMA output**
$$\text{ZLTEMA}_t = 3 \cdot \text{EMA1}_t - 3 \cdot \text{EMA2}_t + \text{EMA3}_t$$
### Warmup compensation
ZLTEMA uses EMA bias compensation during warmup on all three EMA stages:
$$y_t^{*} = \frac{y_t}{1 - (1 - \alpha)^t}$$
This avoids the early-stage bias toward zero and makes the first values usable.
## Math Foundation
**EMA update:**
$$y_t = y_{t-1} + \alpha (s_t - y_{t-1})$$
**Zero-lag signal:**
$$s_t = 2 \cdot x_t - x_{t-\text{lag}}$$
**TEMA formula:**
$$\text{TEMA}_t = 3 \cdot \text{EMA1}_t - 3 \cdot \text{EMA2}_t + \text{EMA3}_t$$
**Alpha from period:**
$$\alpha = \frac{2}{N + 1}$$
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
**Hot path (after warmup, compensation complete):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| FMA | 6 | 4 | 24 |
| MUL | 3 | 3 | 9 |
| **Total** | **9** | | **~33 cycles** |
The hot path consists of:
1. Zero-lag signal: `FMA(2.0, val, -lagged)` - 1 FMA
2. EMA1 core: `FMA(ema1Raw, beta, alpha * signal)` - 1 FMA + 1 MUL
3. EMA2 core: `FMA(ema2Raw, beta, alpha * ema1)` - 1 FMA + 1 MUL
4. EMA3 core: `FMA(ema3Raw, beta, alpha * ema2)` - 1 FMA + 1 MUL
5. TEMA output: `FMA(3.0, ema1, FMA(-3.0, ema2, ema3))` - 2 FMA (nested)
**Warmup path (with bias compensation):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| FMA | 6 | 4 | 24 |
| MUL | 5 | 3 | 15 |
| DIV | 1 | 15 | 15 |
| CMP | 2 | 1 | 2 |
| **Total** | **14** | | **~56 cycles** |
Additional warmup operations:
- Decay tracking: `e *= beta` - 1 MUL
- Compensator calc: `1 / (1 - e)` - 1 DIV
- Bias compensation: `ema1Raw * compensator`, `ema2Raw * compensator`, `ema3Raw * compensator` - 3 MUL
- Hot/compensated checks - 2 CMP
### Batch Mode (SIMD Analysis)
ZLTEMA is an IIR filter with lag buffer dependency - not directly vectorizable across bars. However, within-bar operations use FMA intrinsics.
| Optimization | Benefit |
| :--- | :--- |
| FMA instructions | ~33 cycles vs ~42 scalar |
| stackalloc buffer | Zero heap allocation for lag ≤256 |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 8/10 | Matches PineScript reference |
| **Timeliness** | 10/10 | Fastest response in ZL family |
| **Overshoot** | 4/10 | Predictive signal plus TEMA amplification causes significant overshoot |
| **Smoothness** | 8/10 | Smoothest in ZL family due to triple EMA cascade |
## Validation
ZLTEMA is validated against a PineScript reference implementation.
| Library | Status | Tolerance | Notes |
|:---|:---|:---|:---|
| **TA-Lib** | N/A | - | No ZLTEMA in TA-Lib |
| **Skender** | N/A | - | No ZLTEMA in Skender |
| **Tulip** | N/A | - | No ZLTEMA in Tulip |
| **Ooples** | N/A | - | No ZLTEMA in Ooples |
| **PineScript** | ✓ Passed | 1e-10 | Matches `lib/trends_IIR/zltema/zltema.pine` |
## Common Pitfalls
1. **Maximum overshoot on turns**
The zero-lag signal is a forward estimate, and the TEMA formula (3*EMA1 - 3*EMA2 + EMA3) has the highest amplification in the exponential family. Expect more overshoot than ZLDEMA or ZLEMA when price reverses sharply.
2. **Period semantics**
ZLTEMA uses EMA alpha; the lag term is derived from period but not equivalent to a window length. Do not compare ZLTEMA period directly to SMA window length.
3. **Warmup discipline**
Use `IsHot` / `WarmupPeriod` before acting on signals. Early values are bias-corrected but still unstable. The triple EMA cascade requires longer warmup than ZLDEMA or ZLEMA.
4. **Non-finite data**
NaN or Infinity is replaced with the last valid value. Before the first valid sample, output is `NaN`.
5. **TEMA vs ZLTEMA**
ZLTEMA is not simply TEMA with a different alpha. The zero-lag preprocessing fundamentally changes the input signal, making ZLTEMA more responsive but also more prone to overshoot than standard TEMA.
6. **ZLDEMA vs ZLTEMA**
ZLTEMA adds a third EMA stage over ZLDEMA. This provides additional smoothing at the cost of more overshoot during reversals. Use ZLDEMA when overshoot is more concerning than noise; use ZLTEMA when maximum smoothness is required.
+71
View File
@@ -0,0 +1,71 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Zero-Lag Triple EMA (ZLTEMA)", "ZLTEMA", overlay=true)
//@function Calculates ZLTEMA using zero-lag price and triple exponential smoothing with compensator
//@doc https://github.com/mihakralj/pinescript/blob/main/indicators/trends_IIR/zltema.md
//@param source Series to calculate ZLTEMA from
//@param period Smoothing period
//@param alpha Optional smoothing factor (overrides period if provided)
//@returns ZLTEMA value with zero-lag effect applied
//@optimized Uses lag compensation buffer and exponential warmup compensator on all three EMA stages for O(1) complexity
zltema(series float source, simple int period=0, simple float alpha=0) =>
if alpha <= 0 and period <= 0
runtime.error("Alpha or period must be provided")
float a1 = alpha > 0 ? alpha : 2.0 / (period + 1)
float beta1 = 1.0 - a1
float r = math.pow(1.0 / a1, 1.0 / 3.0)
float a2 = a1 * r
float a3 = a2 * r
simple int lag = math.max(1, math.round((period - 1) / 2))
var bool warmup = true
var float e = 1.0
var float ema1_raw = 0.0
var float ema2_raw = 0.0
var float ema3_raw = 0.0
var float ema1 = na
var float ema2 = na
var float ema3 = na
var priceBuffer = array.new<float>(lag + 1, na)
if not na(source)
if na(ema1)
ema1 := source
ema2 := source
ema3 := source
array.fill(priceBuffer, source)
array.shift(priceBuffer)
array.push(priceBuffer, source)
float laggedPrice = nz(array.get(priceBuffer, 0), source)
float signal = 2 * source - laggedPrice
ema1_raw := a1 * (signal - ema1_raw) + ema1_raw
if warmup
e *= beta1
float c = 1.0 / (1.0 - e)
ema1 := c * ema1_raw
ema2_raw := a2 * (ema1 - ema2_raw) + ema2_raw
ema2 := c * ema2_raw
ema3_raw := a3 * (ema2 - ema3_raw) + ema3_raw
ema3 := c * ema3_raw
warmup := e > 1e-10
else
ema1 := ema1_raw
ema2_raw := a2 * (ema1 - ema2_raw) + ema2_raw
ema2 := ema2_raw
ema3_raw := a3 * (ema2 - ema3_raw) + ema3_raw
ema3 := ema3_raw
3 * ema1 - 3 * ema2 + ema3
else
na
// ---------- Main loop ----------
// Inputs
i_period = input.int(10, "Period", minval=1)
i_source = input.source(close, "Source")
// Calculation
zltema_value = zltema(i_source, i_period)
// Plot
plot(zltema_value, "ZLTEMA", color=color.yellow, linewidth=2)