refactor: move Decay/Edecay from trends_IIR to numerics; update filter signatures

- Move lib/trends_IIR/decay/ → lib/numerics/decay/
- Move lib/trends_IIR/edecay/ → lib/numerics/edecay/
- Update Category in Decay.md/Edecay.md from Trends (IIR) to Numerics
- Add DECAY/EDECAY entries to lib/numerics/_index.md and docs/indicators.md
- Update filter signature .md files and .svg assets
- Update trends_IIR signature docs (htit, mama, holt, etc.)
- All 163 tests passing, 0 warnings, 0 errors
This commit is contained in:
Miha Kralj
2026-03-10 20:33:55 -07:00
parent 35a6702b06
commit 7ec79538aa
64 changed files with 183229 additions and 157318 deletions
@@ -0,0 +1,210 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class EdecayIndicatorTests
{
[Fact]
public void EdecayIndicator_Constructor_SetsDefaults()
{
var indicator = new EdecayIndicator();
Assert.Equal(5, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("EDECAY - Exponential Decay", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void EdecayIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new EdecayIndicator { Period = 20 };
Assert.Equal(0, EdecayIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void EdecayIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new EdecayIndicator { Period = 15 };
Assert.Contains("EDECAY", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void EdecayIndicator_Initialize_CreatesLineSeries()
{
var indicator = new EdecayIndicator { Period = 5 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void EdecayIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new EdecayIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void EdecayIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new EdecayIndicator { Period = 5 };
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 EdecayIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new EdecayIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void EdecayIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new EdecayIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(
now.AddMinutes(i),
100 + i * 2,
105 + i * 2,
95 + i * 2,
102 + i * 2);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.Equal(20, indicator.LinesSeries[0].Count);
for (int i = 0; i < 20; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i)));
}
}
[Fact]
public void EdecayIndicator_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 EdecayIndicator { Period = 5, 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 EdecayIndicator_Period_CanBeChanged()
{
var indicator = new EdecayIndicator { Period = 10 };
Assert.Equal(10, indicator.Period);
indicator.Period = 20;
Assert.Equal(20, indicator.Period);
Assert.Equal(0, EdecayIndicator.MinHistoryDepths);
}
[Fact]
public void EdecayIndicator_Uptrend_OutputFollowsPrice()
{
var indicator = new EdecayIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
double price = 100 + i * 5;
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// In uptrend, edecay output should equal close price (input > decayed)
double lastValue = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(145, lastValue, 1); // last close = 100 + 9*5 = 145
}
[Fact]
public void EdecayIndicator_FlatPrices_OutputEqualsInput()
{
var indicator = new EdecayIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double lastValue = indicator.LinesSeries[0].GetValue(0);
Assert.Equal(100, lastValue, 1);
}
[Fact]
public void EdecayIndicator_DifferentPeriods_Work()
{
var periods = new[] { 1, 5, 10, 20 };
foreach (var period in periods)
{
var indicator = new EdecayIndicator { Period = period };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 102 + i, 98 + i, 101 + i);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.Equal(10, indicator.LinesSeries[0].Count);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
/// <summary>
/// EDECAY (Exponential Decay) Quantower indicator.
/// Tracks peaks and decays exponentially at a rate of (period-1)/period per bar.
/// Formula: output = max(input, prev_output * (period-1)/period)
/// </summary>
[SkipLocalsInit]
public class EdecayIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 5;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Edecay _edecay = 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 => $"EDECAY {Period}:{SourceName}";
public EdecayIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "EDECAY - Exponential Decay";
Description = "Exponential Decay: output = max(input, prev_output * (period-1)/period)";
Series = new LineSeries(name: $"EDECAY {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
_edecay = new Edecay(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 = _edecay.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
Series.SetValue(result.Value, _edecay.IsHot, ShowColdValues);
}
}
+449
View File
@@ -0,0 +1,449 @@
using Xunit;
namespace QuanTAlib.Tests;
public class EdecayTests
{
private readonly TSeries _gbm;
private const int TestPeriod = 5;
private const int DataPoints = 100;
public EdecayTests()
{
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.5, seed: 42);
var bars = gbm.Fetch(DataPoints, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
_gbm = bars.Close;
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidPeriod_SetsProperties()
{
var edecay = new Edecay(TestPeriod);
Assert.Equal($"Edecay({TestPeriod})", edecay.Name);
Assert.Equal(1, edecay.WarmupPeriod);
}
[Fact]
public void Constructor_WithZeroPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Edecay(0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_WithNegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Edecay(-1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries(DataPoints);
var edecay = new Edecay(source, TestPeriod);
Assert.NotNull(edecay);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_FirstBar_ReturnsInputValue()
{
var edecay = new Edecay(TestPeriod);
var tv = edecay.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(100.0, tv.Value);
}
[Fact]
public void Update_DecayingValues_OutputDecaysExponentially()
{
var edecay = new Edecay(5); // scale = 4/5 = 0.8
var time = DateTime.UtcNow;
// First bar at 1.0
edecay.Update(new TValue(time, 1.0), true);
// Next bars at 0.0 — output should decay by ×0.8 per bar
var tv1 = edecay.Update(new TValue(time.AddSeconds(1), 0.0), true);
Assert.Equal(0.8, tv1.Value, 10); // 1.0 * 0.8
var tv2 = edecay.Update(new TValue(time.AddSeconds(2), 0.0), true);
Assert.Equal(0.64, tv2.Value, 10); // 0.8 * 0.8
var tv3 = edecay.Update(new TValue(time.AddSeconds(3), 0.0), true);
Assert.Equal(0.512, tv3.Value, 10); // 0.64 * 0.8
}
[Fact]
public void Update_RisingInput_FollowsInput()
{
var edecay = new Edecay(5);
var time = DateTime.UtcNow;
edecay.Update(new TValue(time, 100.0), true);
var tv = edecay.Update(new TValue(time.AddSeconds(1), 105.0), true);
Assert.Equal(105.0, tv.Value, 10); // input > decayed, so follows input
}
[Fact]
public void Last_IsAccessible()
{
var edecay = new Edecay(TestPeriod);
edecay.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(100.0, edecay.Last.Value, 10);
}
[Fact]
public void IsHot_ReturnsFalseBeforeFirstBar()
{
var edecay = new Edecay(TestPeriod);
Assert.False(edecay.IsHot);
}
[Fact]
public void IsHot_ReturnsTrueAfterFirstBar()
{
var edecay = new Edecay(TestPeriod);
edecay.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(edecay.IsHot);
}
[Fact]
public void Name_IsAccessible()
{
var edecay = new Edecay(TestPeriod);
Assert.Equal($"Edecay({TestPeriod})", edecay.Name);
}
#endregion
#region State Management Tests
[Fact]
public void Update_WithIsNewTrue_AdvancesState()
{
var edecay = new Edecay(TestPeriod);
var time = DateTime.UtcNow;
edecay.Update(new TValue(time, 100.0), true);
edecay.Update(new TValue(time.AddSeconds(1), 105.0), true);
edecay.Update(new TValue(time.AddSeconds(2), 110.0), true);
Assert.NotEqual(default, edecay.Last);
}
[Fact]
public void Update_WithIsNewFalse_UpdatesCurrentState()
{
var edecay = new Edecay(5); // scale = 0.8
var time = DateTime.UtcNow;
edecay.Update(new TValue(time, 1.0), true);
var first = edecay.Update(new TValue(time.AddSeconds(1), 0.0), true);
// Correct same bar with different value
var corrected = edecay.Update(new TValue(time.AddSeconds(1), 0.5), false);
// first: max(0.0, 1.0*0.8)=0.8
Assert.Equal(0.8, first.Value, 10);
// corrected: max(0.5, 1.0*0.8)=0.8
Assert.Equal(0.8, corrected.Value, 10);
}
[Fact]
public void Update_IterativeCorrections_RestoresPreviousState()
{
var edecay = new Edecay(5);
var time = DateTime.UtcNow;
edecay.Update(new TValue(time, 1.0), true);
var baseline = edecay.Update(new TValue(time.AddSeconds(1), 0.5), true);
// Make several corrections
edecay.Update(new TValue(time.AddSeconds(1), 0.9), false);
edecay.Update(new TValue(time.AddSeconds(1), 0.1), false);
var restored = edecay.Update(new TValue(time.AddSeconds(1), 0.5), false);
Assert.Equal(baseline.Value, restored.Value, 10);
}
[Fact]
public void Reset_ClearsStateAndLastValidTracking()
{
var edecay = new Edecay(TestPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
edecay.Update(new TValue(time.AddSeconds(i), 100.0 + i));
}
edecay.Reset();
Assert.Equal(default, edecay.Last);
Assert.False(edecay.IsHot);
}
#endregion
#region Robustness Tests
[Fact]
public void Update_WithNaN_UsesLastValidValue()
{
var edecay = new Edecay(5);
var time = DateTime.UtcNow;
edecay.Update(new TValue(time, 1.0), true);
var afterNaN = edecay.Update(new TValue(time.AddSeconds(1), double.NaN), true);
Assert.True(double.IsFinite(afterNaN.Value));
// NaN uses last valid (1.0), so max(1.0, 1.0*0.8)=1.0
Assert.Equal(1.0, afterNaN.Value, 10);
}
[Fact]
public void Update_WithInfinity_UsesLastValidValue()
{
var edecay = new Edecay(5);
var time = DateTime.UtcNow;
edecay.Update(new TValue(time, 1.0), true);
var afterInf = edecay.Update(new TValue(time.AddSeconds(1), double.PositiveInfinity), true);
Assert.True(double.IsFinite(afterInf.Value));
}
[Fact]
public void Update_BatchNaN_HandlesSafely()
{
var edecay = new Edecay(TestPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
var value = i % 3 == 0 ? double.NaN : 100.0 + i;
var tv = edecay.Update(new TValue(time.AddSeconds(i), value), true);
Assert.True(double.IsFinite(tv.Value));
}
}
#endregion
#region Consistency Tests (All 4 modes must match)
[Fact]
public void AllModes_ProduceSameResults()
{
// Mode 1: Batch via TSeries
var batchResult = Edecay.Batch(_gbm, TestPeriod);
// Mode 2: Streaming
var streamingEdecay = new Edecay(TestPeriod);
var streamingResult = new TSeries(DataPoints);
for (int i = 0; i < _gbm.Count; i++)
{
var tv = streamingEdecay.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
streamingResult.Add(tv, true);
}
// Mode 3: Span-based
Span<double> spanOutput = stackalloc double[DataPoints];
Edecay.Batch(_gbm.Values, spanOutput, TestPeriod);
// Mode 4: Event-driven
var eventEdecay = new Edecay(TestPeriod);
var eventResult = new TSeries(DataPoints);
eventEdecay.Pub += (object? _, in TValueEventArgs e) => eventResult.Add(e.Value, e.IsNew);
for (int i = 0; i < _gbm.Count; i++)
{
eventEdecay.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
}
int compareCount = Math.Min(100, DataPoints);
for (int i = DataPoints - compareCount; i < DataPoints; i++)
{
Assert.Equal(batchResult[i].Value, streamingResult[i].Value, 10);
Assert.Equal(batchResult[i].Value, spanOutput[i], 10);
Assert.Equal(batchResult[i].Value, eventResult[i].Value, 10);
}
}
#endregion
#region Span API Tests
[Fact]
public void Calculate_Span_ValidatesEmptySource()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> empty = [];
Span<double> output = stackalloc double[1];
Edecay.Batch(empty, output, TestPeriod);
});
Assert.Equal("source", ex.ParamName);
}
[Fact]
public void Calculate_Span_ValidatesOutputLength()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[] { 1, 2, 3, 4, 5 };
Span<double> output = stackalloc double[3]; // too short
Edecay.Batch(source, output, TestPeriod);
});
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Calculate_Span_ValidatesPeriod()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
ReadOnlySpan<double> source = stackalloc double[] { 1, 2, 3, 4, 5 };
Span<double> output = stackalloc double[5];
Edecay.Batch(source, output, 0);
});
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Calculate_Span_MatchesTSeries()
{
var batchResult = Edecay.Batch(_gbm, TestPeriod);
Span<double> spanOutput = stackalloc double[DataPoints];
Edecay.Batch(_gbm.Values, spanOutput, TestPeriod);
for (int i = 0; i < DataPoints; i++)
{
Assert.Equal(batchResult[i].Value, spanOutput[i], 10);
}
}
[Fact]
public void Calculate_Span_LargeData_NoStackOverflow()
{
int largeSize = 10000;
double[] source = new double[largeSize];
double[] output = new double[largeSize];
for (int i = 0; i < largeSize; i++)
{
source[i] = 100.0 + i * 0.1;
}
Edecay.Batch(source, output, TestPeriod);
Assert.Equal(largeSize, output.Length);
}
#endregion
#region Chainability Tests
[Fact]
public void Pub_FiresOnUpdate()
{
var edecay = new Edecay(TestPeriod);
bool eventFired = false;
edecay.Pub += (object? _, in TValueEventArgs e) => eventFired = true;
edecay.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(eventFired);
}
[Fact]
public void EventBasedChaining_Works()
{
var source = new TSeries(10);
var edecay = new Edecay(source, 2);
var results = new List<double>();
edecay.Pub += (object? _, in TValueEventArgs e) => results.Add(e.Value.Value);
for (int i = 0; i < 10; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), true);
}
Assert.Equal(10, results.Count);
}
#endregion
#region Edecay-Specific Tests
[Fact]
public void Edecay_Period1_DecaysToZero()
{
var edecay = new Edecay(1); // scale = 0/1 = 0.0
var time = DateTime.UtcNow;
edecay.Update(new TValue(time, 5.0), true);
var tv = edecay.Update(new TValue(time.AddSeconds(1), 0.0), true);
// max(0.0, 5.0*0.0) = 0.0
Assert.Equal(0.0, tv.Value, 10);
}
[Fact]
public void Edecay_ConstantInput_OutputEqualsInput()
{
var edecay = new Edecay(5);
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
var tv = edecay.Update(new TValue(time.AddSeconds(i), 100.0), true);
Assert.Equal(100.0, tv.Value, 10);
}
}
[Fact]
public void Edecay_OutputNeverBelowInput()
{
var edecay = new Edecay(10);
var time = DateTime.UtcNow;
var rng = new Random(42);
for (int i = 0; i < 100; i++)
{
double input = rng.NextDouble() * 200;
var tv = edecay.Update(new TValue(time.AddSeconds(i), input), true);
Assert.True(tv.Value >= input || Math.Abs(tv.Value - input) < 1e-10,
$"Output {tv.Value} should be >= input {input}");
}
}
[Fact]
public void Edecay_DiffersFromLinearDecay()
{
var edecay = new Edecay(5); // scale = 0.8
var decay = new Decay(5); // scale = 0.2
var time = DateTime.UtcNow;
// Start both at 100
edecay.Update(new TValue(time, 100.0), true);
decay.Update(new TValue(time, 100.0), true);
// Feed 0.0 and compare
var e1 = edecay.Update(new TValue(time.AddSeconds(1), 0.0), true);
var d1 = decay.Update(new TValue(time.AddSeconds(1), 0.0), true);
// Edecay: max(0, 100*0.8) = 80
// Decay: max(0, 100-0.2) = 99.8
Assert.Equal(80.0, e1.Value, 10);
Assert.Equal(99.8, d1.Value, 10);
Assert.NotEqual(e1.Value, d1.Value);
}
#endregion
}
@@ -0,0 +1,260 @@
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for EDECAY (Exponential Decay) against the Tulip Indicators algorithm.
/// The Tulip .NET binding does not expose decay/edecay directly, so validation
/// uses manual computation of the Tulip ti_edecay algorithm:
/// output[0] = input[0]
/// output[i] = max(input[i], output[i-1] * (period-1)/period)
/// </summary>
public sealed class EdecayValidationTests(ITestOutputHelper output) : IDisposable
{
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
private const int TestPeriod = 5;
private const double TulipTolerance = 1e-9;
public void Dispose()
{
Dispose(disposing: true);
}
private void Dispose(bool disposing)
{
if (_disposed) { return; }
_disposed = true;
if (disposing) { _testData?.Dispose(); }
}
/// <summary>
/// Reference implementation of Tulip ti_edecay for validation.
/// </summary>
private static double[] TulipEdecay(double[] input, int period)
{
double[] output = new double[input.Length];
double scale = (period - 1.0) / period;
output[0] = input[0];
for (int i = 1; i < input.Length; i++)
{
double d = output[i - 1] * scale;
output[i] = input[i] > d ? input[i] : d;
}
return output;
}
#region Tulip Algorithm Validation
[Fact]
public void Edecay_MatchesTulipEdecay_Batch()
{
double[] input = _testData.RawData.ToArray();
var quantResult = Edecay.Batch(_testData.Data, TestPeriod);
double[] tulipResult = TulipEdecay(input, TestPeriod);
int count = quantResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.True(
Math.Abs(quantResult[i].Value - tulipResult[i]) <= TulipTolerance,
$"Mismatch at index {i}: QuanTAlib={quantResult[i].Value:G17}, Tulip={tulipResult[i]:G17}");
}
_output.WriteLine("Edecay Batch validated successfully against Tulip edecay algorithm");
}
[Fact]
public void Edecay_MatchesTulipEdecay_Streaming()
{
double[] input = _testData.RawData.ToArray();
var edecay = new Edecay(TestPeriod);
var streamingResults = new List<double>();
foreach (var item in _testData.Data)
{
streamingResults.Add(edecay.Update(item).Value);
}
double[] tulipResult = TulipEdecay(input, TestPeriod);
int count = streamingResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.True(
Math.Abs(streamingResults[i] - tulipResult[i]) <= TulipTolerance,
$"Mismatch at index {i}: QuanTAlib={streamingResults[i]:G17}, Tulip={tulipResult[i]:G17}");
}
_output.WriteLine("Edecay Streaming validated successfully against Tulip edecay algorithm");
}
[Fact]
public void Edecay_MatchesTulipEdecay_Span()
{
double[] input = _testData.RawData.ToArray();
var quantOutput = new double[input.Length];
Edecay.Batch(new ReadOnlySpan<double>(input), quantOutput, TestPeriod);
double[] tulipResult = TulipEdecay(input, TestPeriod);
int count = quantOutput.Length;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.True(
Math.Abs(quantOutput[i] - tulipResult[i]) <= TulipTolerance,
$"Mismatch at index {i}: QuanTAlib={quantOutput[i]:G17}, Tulip={tulipResult[i]:G17}");
}
_output.WriteLine("Edecay Span validated successfully against Tulip edecay algorithm");
}
#endregion
#region Different Periods
[Theory]
[InlineData(1)]
[InlineData(5)]
[InlineData(10)]
[InlineData(20)]
[InlineData(50)]
public void Edecay_MatchesTulipEdecay_DifferentPeriods(int period)
{
double[] input = _testData.RawData.ToArray();
var quantResult = Edecay.Batch(_testData.Data, period);
double[] tulipResult = TulipEdecay(input, period);
int count = quantResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.True(
Math.Abs(quantResult[i].Value - tulipResult[i]) <= TulipTolerance,
$"Period={period}, Mismatch at index {i}: QuanTAlib={quantResult[i].Value:G17}, Tulip={tulipResult[i]:G17}");
}
}
#endregion
#region Edge Cases
[Fact]
public void Edecay_HandlesConstantValues()
{
var constantData = new TSeries(100);
for (int i = 0; i < 100; i++)
{
constantData.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0), true);
}
var result = Edecay.Batch(constantData, TestPeriod);
// Constant input: output always equals input since input >= decayed
for (int i = 0; i < 100; i++)
{
Assert.Equal(100.0, result[i].Value, TulipTolerance);
}
}
[Fact]
public void Edecay_HandlesExponentiallyDecreasing()
{
double[] input = new double[20];
for (int i = 0; i < 20; i++)
{
input[i] = 100.0 * Math.Pow(0.9, i);
}
var quantOutput = new double[20];
Edecay.Batch(input, quantOutput, TestPeriod);
double[] tulipResult = TulipEdecay(input, TestPeriod);
for (int i = 0; i < 20; i++)
{
Assert.Equal(tulipResult[i], quantOutput[i], TulipTolerance);
}
}
[Fact]
public void Batch_MatchesStreaming_IdenticalResults()
{
var batchResult = Edecay.Batch(_testData.Data, TestPeriod);
var edecay = new Edecay(TestPeriod);
var streamingResults = new List<double>();
foreach (var item in _testData.Data)
{
streamingResults.Add(edecay.Update(item).Value);
}
int count = _testData.Data.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], ValidationHelper.DefaultTolerance);
}
_output.WriteLine("Edecay Batch vs Streaming consistency validated");
}
[Fact]
public void Edecay_OutputAlwaysGreaterOrEqualInput()
{
double[] input = _testData.RawData.ToArray();
var quantOutput = new double[input.Length];
Edecay.Batch(input, quantOutput, TestPeriod);
for (int i = 0; i < input.Length; i++)
{
Assert.True(quantOutput[i] >= input[i] - 1e-15,
$"Output {quantOutput[i]} must be >= input {input[i]} at index {i}");
}
}
[Fact]
public void Edecay_DecayIsMultiplicative()
{
// With period=5, scale = 4/5 = 0.8
// After a spike, each subsequent bar without new highs should multiply by 0.8
double[] input = [100.0, 0.0, 0.0, 0.0, 0.0, 0.0];
double[] tulipResult = TulipEdecay(input, TestPeriod);
// output[0] = 100.0
// output[1] = max(0, 100 * 0.8) = 80.0
// output[2] = max(0, 80 * 0.8) = 64.0
// output[3] = max(0, 64 * 0.8) = 51.2
// output[4] = max(0, 51.2 * 0.8) = 40.96
// output[5] = max(0, 40.96 * 0.8) = 32.768
Assert.Equal(100.0, tulipResult[0], TulipTolerance);
Assert.Equal(80.0, tulipResult[1], TulipTolerance);
Assert.Equal(64.0, tulipResult[2], TulipTolerance);
Assert.Equal(51.2, tulipResult[3], TulipTolerance);
Assert.Equal(40.96, tulipResult[4], TulipTolerance);
Assert.Equal(32.768, tulipResult[5], TulipTolerance);
var quantOutput = new double[6];
Edecay.Batch(input, quantOutput, TestPeriod);
for (int i = 0; i < 6; i++)
{
Assert.Equal(tulipResult[i], quantOutput[i], TulipTolerance);
}
}
#endregion
}
+196
View File
@@ -0,0 +1,196 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// EDECAY: Exponential Decay
/// </summary>
/// <remarks>
/// Tracks the maximum of the current input and the previous output multiplied by
/// a decay factor of (period-1)/period per bar. When price is rising or flat the
/// output follows price; when price drops the output decays exponentially toward it.
///
/// Calculation: <c>output = max(input, prev_output * (period-1)/period)</c>.
/// Origin: Tulip Indicators (ti_edecay).
/// </remarks>
/// <seealso href="Edecay.md">Detailed documentation</seealso>
[SkipLocalsInit]
public sealed class Edecay : AbstractBase
{
private readonly double _scale;
private int _count;
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValid, double LastOutput);
private State _state, _p_state;
private int _p_count;
private ITValuePublisher? _source;
private bool _disposed;
public override bool IsHot => _count > 0;
/// <summary>
/// Initializes a new Exponential Decay indicator with specified period.
/// </summary>
/// <param name="period">Decay period (must be >= 1)</param>
public Edecay(int period = 5)
{
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
_scale = (period - 1.0) / period;
Name = $"Edecay({period})";
WarmupPeriod = 1;
}
/// <summary>
/// Initializes a new Exponential Decay indicator with source for event-based chaining.
/// </summary>
/// <param name="source">Source indicator for chaining</param>
/// <param name="period">Decay period</param>
public Edecay(ITValuePublisher source, int period = 5) : this(period)
{
_source = source;
_source.Pub += HandleUpdate;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
_p_count = _count;
}
else
{
_state = _p_state;
_count = _p_count;
}
double value = double.IsFinite(input.Value) ? input.Value : _state.LastValid;
double result;
if (_count == 0)
{
result = value;
}
else
{
double decayed = _state.LastOutput * _scale;
result = value > decayed ? value : decayed;
}
_state = new State(value, result);
if (isNew)
{
_count++;
}
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
var result = new TSeries(source.Count);
ReadOnlySpan<double> values = source.Values;
ReadOnlySpan<long> times = source.Times;
for (int i = 0; i < source.Count; i++)
{
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
result.Add(tv, true);
}
return result;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
DateTime time = DateTime.UtcNow - (interval * source.Length);
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(time, source[i]), true);
time += interval;
}
}
public static TSeries Batch(TSeries source, int period = 5)
{
var indicator = new Edecay(period);
return indicator.Update(source);
}
/// <summary>
/// Calculates exponential decay over a span of values. Zero-allocation.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 5)
{
if (source.Length == 0)
{
throw new ArgumentException("Source cannot be empty", nameof(source));
}
if (output.Length < source.Length)
{
throw new ArgumentException("Output length must be >= source length", nameof(output));
}
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
double scale = (period - 1.0) / period;
ref double srcRef = ref MemoryMarshal.GetReference(source);
ref double outRef = ref MemoryMarshal.GetReference(output);
Unsafe.Add(ref outRef, 0) = Unsafe.Add(ref srcRef, 0);
for (int i = 1; i < source.Length; i++)
{
double d = Unsafe.Add(ref outRef, i - 1) * scale;
double s = Unsafe.Add(ref srcRef, i);
Unsafe.Add(ref outRef, i) = s > d ? s : d;
}
}
public static (TSeries Results, Edecay Indicator) Calculate(TSeries source, int period = 5)
{
var indicator = new Edecay(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_count = 0;
_p_count = 0;
_state = default;
_p_state = default;
Last = default;
}
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing && _source != null)
{
_source.Pub -= HandleUpdate;
_source = null;
}
_disposed = true;
}
base.Dispose(disposing);
}
}
+165
View File
@@ -0,0 +1,165 @@
# EDECAY: Exponential Decay
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Numerics |
| **Inputs** | Source (close) |
| **Parameters** | `period` (default 5) |
| **Outputs** | Single series (Edecay) |
| **Output range** | Same as input (overlay) |
| **Warmup** | `1` bar |
### TL;DR
- EDECAY (Exponential Decay) tracks the maximum of the current input and the previous output multiplied by a decay factor of `(period-1)/period`.
- Parameterized by `period` (default 5).
- Output range: Same as input — this is an overlay indicator.
- Requires `1` bar of warmup before first valid output (IsHot = true).
- Validated against Tulip Indicators `ti_edecay` reference algorithm.
> "A ratchet that only moves down gradually: price can push it up instantly, but gravity pulls it back at an exponential pace — faster when far from zero, slower as it approaches."
EDECAY implements the exponential decaying function. When price is above the decayed level, output snaps to price. When price falls below, the output decays exponentially by multiplying by `(period-1)/period` per bar, creating a ceiling that gradually descends. Unlike linear DECAY which subtracts a fixed amount, EDECAY's multiplicative factor produces a proportional decay rate.
## Historical Context
The exponential decay indicator originates from the Tulip Indicators library, a high-performance C library of technical indicators. It provides a peak-tracking mechanism where the tracked level decays at a proportional rate. The indicator is useful for:
- **Trailing stops**: The decaying level acts as a trailing stop that descends proportionally.
- **Peak detection**: Identifies when price last reached a new high relative to the decay rate.
- **Signal filtering**: Removes noise by requiring price to exceed the decayed level to register as significant.
## Architecture & Physics
### 1. Pure IIR (No Buffer)
The indicator requires no history buffer — only the previous output value is needed:
$$
\text{state} = \{y_{t-1}\}
$$
This makes it O(1) in both time and space.
### 2. Exponential Decay Calculation
$$
y_t = \max(x_t, \; y_{t-1} \cdot \frac{p-1}{p})
$$
where:
- $x_t$ = current input value
- $y_{t-1}$ = previous output value
- $p$ = period parameter
- $\frac{p-1}{p}$ = multiplicative decay factor per bar
### 3. First Bar Initialization
$$
y_0 = x_0
$$
The first bar simply passes through the input value.
### 4. State Management
The indicator uses state rollback for bar correction:
```
if isNew:
save current state as previous
else:
restore previous state
```
## Mathematical Foundation
### Core Formula
$$
y_t = \max(x_t, \; y_{t-1} \cdot s)
$$
where $s = \frac{p-1}{p}$ is the multiplicative decay factor.
### Decay Behavior
After a peak at value $v$, with no new inputs exceeding the decayed level, the output follows:
$$
y_{t+k} = v \cdot s^k = v \cdot \left(\frac{p-1}{p}\right)^k
$$
The output asymptotically approaches zero but never reaches it ($v > 0$).
### Comparison with Linear Decay
| Property | DECAY (Linear) | EDECAY (Exponential) |
|----------|----------------|---------------------|
| Formula | $y - \frac{1}{p}$ | $y \cdot \frac{p-1}{p}$ |
| Decay rate | Constant absolute | Proportional to current value |
| Reaches zero | Yes, in finite time | No, asymptotic approach |
| Scale-invariant | No | Yes |
### Properties
| Property | Value |
|----------|-------|
| Lookback | 0 |
| Output ≥ Input | Always (by construction) |
| Decay rate | Proportional $\frac{p-1}{p}$ |
| Monotonic when decaying | Yes (strictly decreasing) |
| Scale-invariant | Yes |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Notes |
| :--- | :---: | :--- |
| MUL | 1 | prev_output × scale |
| MAX/CMP | 1 | max(input, decayed) |
| State copy | 1 | rollback support |
| **Total** | **~3 ops** | Extremely lightweight |
### Batch Mode (Span-based)
| Operation | Complexity | Notes |
| :--- | :---: | :--- |
| Per-element | O(1) | Mul + compare |
| Total | O(n) | Linear scan |
| Memory | O(1) | No additional allocation |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 10/10 | Exact arithmetic, no approximation |
| **Timeliness** | 10/10 | Zero lag on upward moves |
| **Smoothness** | 3/10 | Exponential curve smoother than linear staircase |
| **Simplicity** | 10/10 | Single multiplication + compare |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **Tulip** | ✅ | Manual ti_edecay algorithm matches exactly |
## Common Pitfalls
1. **Not a moving average**: Edecay is a peak-tracking/envelope indicator, not a smoothing filter. It only descends when price is below the decayed level.
2. **Proportional decay rate**: Unlike linear DECAY, EDECAY decays proportionally. For a stock at $100 with period=5, the first bar decays by $20; for a stock at $10, it decays by $2. This makes EDECAY scale-invariant.
3. **Period interpretation**: Period=5 means `scale = 4/5 = 0.8`, so each bar retains 80% of the previous value. After 5 bars, approximately 32.8% of the peak value remains.
4. **First bar**: The first bar always equals the input — there is no warmup period in the traditional sense.
5. **Asymmetric behavior**: Upward moves are instant (output = input), but downward moves are rate-limited to multiplication by `(period-1)/period` per bar.
6. **Never reaches zero**: Unlike linear DECAY, exponential decay asymptotically approaches zero but never reaches it (assuming positive values).
## References
- Tulip Indicators Library: https://tulipindicators.org/edecay
- Kegel, L. "Tulip Indicators" — Open-source C library of technical indicators.
+33
View File
@@ -0,0 +1,33 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Exponential Decay (EDECAY)", "EDECAY", overlay=true)
//@function Calculates exponential decay: output = max(input, prev_output * (period-1)/period)
//@param source Source price series
//@param length Decay period
//@returns Decayed value that tracks peaks and descends exponentially
//@optimized Uses multiplicative decay factor for O(1) complexity per bar
edecay(series float source, simple int length) =>
var float prev = na
float scale = (length - 1.0) / length
float result = na
if na(prev)
result := source
else
float d = prev * scale
result := source > d ? source : d
prev := result
result
// ---------- Main loop ----------
// Inputs
i_source = input.source(close, "Source")
i_length = input.int(5, "Length", minval=1)
// Calculate Edecay
float edecay_val = edecay(i_source, i_length)
// Plot
plot(edecay_val, "Edecay", color=color.yellow, linewidth=2)