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
+2
View File
@@ -10,8 +10,10 @@ Basic mathematical transforms and utility functions for time series. These build
| [BETADIST](betadist/Betadist.md) | Beta Distribution | Beta probability distribution transform. |
| [BINOMDIST](binomdist/Binomdist.md) | Binomial Distribution | Binomial probability distribution transform. |
| [CHANGE](change/Change.md) | Percentage Change | Relative price movement over lookback period. |
| [DECAY](decay/Decay.md) | Linear Decay | Peak envelope with linear degradation; max(input, prev 1/period). |
| [CWT](cwt/Cwt.md) | Continuous Wavelet Transform | Time-frequency decomposition with continuous wavelets. |
| [DWT](dwt/Dwt.md) | Discrete Wavelet Transform | À trous Haar stationary DWT; multi-resolution approximation + detail decomposition. |
| [EDECAY](edecay/Edecay.md) | Exponential Decay | Peak envelope with exponential degradation; max(input, prev × (period1)/period). |
| [EXPDIST](expdist/Expdist.md) | Exponential Distribution | Exponential probability distribution transform. |
| [EXPTRANS](exptrans/Exptrans.md) | Exponential Transform | e^x transform for log-space conversion reversal. |
| [FDIST](fdist/Fdist.md) | F-Distribution | Fisher-Snedecor probability distribution transform. |
+210
View File
@@ -0,0 +1,210 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class DecayIndicatorTests
{
[Fact]
public void DecayIndicator_Constructor_SetsDefaults()
{
var indicator = new DecayIndicator();
Assert.Equal(5, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("DECAY - Linear Decay", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void DecayIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new DecayIndicator { Period = 20 };
Assert.Equal(0, DecayIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void DecayIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new DecayIndicator { Period = 15 };
Assert.Contains("DECAY", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("15", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void DecayIndicator_Initialize_CreatesLineSeries()
{
var indicator = new DecayIndicator { Period = 5 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void DecayIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new DecayIndicator { 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 DecayIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new DecayIndicator { 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 DecayIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new DecayIndicator { 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 DecayIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new DecayIndicator { 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 DecayIndicator_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 DecayIndicator { 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 DecayIndicator_Period_CanBeChanged()
{
var indicator = new DecayIndicator { Period = 10 };
Assert.Equal(10, indicator.Period);
indicator.Period = 20;
Assert.Equal(20, indicator.Period);
Assert.Equal(0, DecayIndicator.MinHistoryDepths);
}
[Fact]
public void DecayIndicator_Uptrend_OutputFollowsPrice()
{
var indicator = new DecayIndicator { 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, decay 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 DecayIndicator_FlatPrices_OutputEqualsInput()
{
var indicator = new DecayIndicator { 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 DecayIndicator_DifferentPeriods_Work()
{
var periods = new[] { 1, 5, 10, 20 };
foreach (var period in periods)
{
var indicator = new DecayIndicator { 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>
/// DECAY (Linear Decay) Quantower indicator.
/// Tracks peaks and decays linearly at a rate of 1/period per bar.
/// Formula: output = max(input, prev_output - 1/period)
/// </summary>
[SkipLocalsInit]
public class DecayIndicator : 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 Decay _decay = 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 => $"DECAY {Period}:{SourceName}";
public DecayIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "DECAY - Linear Decay";
Description = "Linear Decay: output = max(input, prev_output - 1/period)";
Series = new LineSeries(name: $"DECAY {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
_decay = new Decay(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 = _decay.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
Series.SetValue(result.Value, _decay.IsHot, ShowColdValues);
}
}
+427
View File
@@ -0,0 +1,427 @@
using Xunit;
namespace QuanTAlib.Tests;
public class DecayTests
{
private readonly TSeries _gbm;
private const int TestPeriod = 5;
private const int DataPoints = 100;
public DecayTests()
{
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 decay = new Decay(TestPeriod);
Assert.Equal($"Decay({TestPeriod})", decay.Name);
Assert.Equal(1, decay.WarmupPeriod);
}
[Fact]
public void Constructor_WithZeroPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Decay(0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_WithNegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Decay(-1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_WithSource_SubscribesToEvents()
{
var source = new TSeries(DataPoints);
var decay = new Decay(source, TestPeriod);
Assert.NotNull(decay);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_FirstBar_ReturnsInputValue()
{
var decay = new Decay(TestPeriod);
var tv = decay.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(100.0, tv.Value);
}
[Fact]
public void Update_DecayingValues_OutputDecaysLinearly()
{
var decay = new Decay(5); // scale = 0.2
var time = DateTime.UtcNow;
// First bar at 1.0
decay.Update(new TValue(time, 1.0), true);
// Next bars at 0.0 — output should decay by 0.2 per bar
var tv1 = decay.Update(new TValue(time.AddSeconds(1), 0.0), true);
Assert.Equal(0.8, tv1.Value, 10); // 1.0 - 0.2
var tv2 = decay.Update(new TValue(time.AddSeconds(2), 0.0), true);
Assert.Equal(0.6, tv2.Value, 10); // 0.8 - 0.2
var tv3 = decay.Update(new TValue(time.AddSeconds(3), 0.0), true);
Assert.Equal(0.4, tv3.Value, 10); // 0.6 - 0.2
}
[Fact]
public void Update_RisingInput_FollowsInput()
{
var decay = new Decay(5);
var time = DateTime.UtcNow;
decay.Update(new TValue(time, 100.0), true);
var tv = decay.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 decay = new Decay(TestPeriod);
decay.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(100.0, decay.Last.Value, 10);
}
[Fact]
public void IsHot_ReturnsFalseBeforeFirstBar()
{
var decay = new Decay(TestPeriod);
Assert.False(decay.IsHot);
}
[Fact]
public void IsHot_ReturnsTrueAfterFirstBar()
{
var decay = new Decay(TestPeriod);
decay.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(decay.IsHot);
}
[Fact]
public void Name_IsAccessible()
{
var decay = new Decay(TestPeriod);
Assert.Equal($"Decay({TestPeriod})", decay.Name);
}
#endregion
#region State Management Tests
[Fact]
public void Update_WithIsNewTrue_AdvancesState()
{
var decay = new Decay(TestPeriod);
var time = DateTime.UtcNow;
decay.Update(new TValue(time, 100.0), true);
decay.Update(new TValue(time.AddSeconds(1), 105.0), true);
decay.Update(new TValue(time.AddSeconds(2), 110.0), true);
Assert.NotEqual(default, decay.Last);
}
[Fact]
public void Update_WithIsNewFalse_UpdatesCurrentState()
{
var decay = new Decay(5);
var time = DateTime.UtcNow;
decay.Update(new TValue(time, 1.0), true);
var first = decay.Update(new TValue(time.AddSeconds(1), 0.0), true);
// Correct same bar with different value
var corrected = decay.Update(new TValue(time.AddSeconds(1), 0.5), false);
// first: max(0.0, 1.0-0.2)=0.8
Assert.Equal(0.8, first.Value, 10);
// corrected: max(0.5, 1.0-0.2)=0.8
Assert.Equal(0.8, corrected.Value, 10);
}
[Fact]
public void Update_IterativeCorrections_RestoresPreviousState()
{
var decay = new Decay(5);
var time = DateTime.UtcNow;
decay.Update(new TValue(time, 1.0), true);
var baseline = decay.Update(new TValue(time.AddSeconds(1), 0.5), true);
// Make several corrections
decay.Update(new TValue(time.AddSeconds(1), 0.9), false);
decay.Update(new TValue(time.AddSeconds(1), 0.1), false);
var restored = decay.Update(new TValue(time.AddSeconds(1), 0.5), false);
Assert.Equal(baseline.Value, restored.Value, 10);
}
[Fact]
public void Reset_ClearsStateAndLastValidTracking()
{
var decay = new Decay(TestPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
decay.Update(new TValue(time.AddSeconds(i), 100.0 + i));
}
decay.Reset();
Assert.Equal(default, decay.Last);
Assert.False(decay.IsHot);
}
#endregion
#region Robustness Tests
[Fact]
public void Update_WithNaN_UsesLastValidValue()
{
var decay = new Decay(5);
var time = DateTime.UtcNow;
decay.Update(new TValue(time, 1.0), true);
var afterNaN = decay.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.2)=1.0
Assert.Equal(1.0, afterNaN.Value, 10);
}
[Fact]
public void Update_WithInfinity_UsesLastValidValue()
{
var decay = new Decay(5);
var time = DateTime.UtcNow;
decay.Update(new TValue(time, 1.0), true);
var afterInf = decay.Update(new TValue(time.AddSeconds(1), double.PositiveInfinity), true);
Assert.True(double.IsFinite(afterInf.Value));
}
[Fact]
public void Update_BatchNaN_HandlesSafely()
{
var decay = new Decay(TestPeriod);
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
var value = i % 3 == 0 ? double.NaN : 100.0 + i;
var tv = decay.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 = Decay.Batch(_gbm, TestPeriod);
// Mode 2: Streaming
var streamingDecay = new Decay(TestPeriod);
var streamingResult = new TSeries(DataPoints);
for (int i = 0; i < _gbm.Count; i++)
{
var tv = streamingDecay.Update(new TValue(_gbm[i].Time, _gbm[i].Value), true);
streamingResult.Add(tv, true);
}
// Mode 3: Span-based
Span<double> spanOutput = stackalloc double[DataPoints];
Decay.Batch(_gbm.Values, spanOutput, TestPeriod);
// Mode 4: Event-driven
var eventDecay = new Decay(TestPeriod);
var eventResult = new TSeries(DataPoints);
eventDecay.Pub += (object? _, in TValueEventArgs e) => eventResult.Add(e.Value, e.IsNew);
for (int i = 0; i < _gbm.Count; i++)
{
eventDecay.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];
Decay.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
Decay.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];
Decay.Batch(source, output, 0);
});
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Calculate_Span_MatchesTSeries()
{
var batchResult = Decay.Batch(_gbm, TestPeriod);
Span<double> spanOutput = stackalloc double[DataPoints];
Decay.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;
}
Decay.Batch(source, output, TestPeriod);
Assert.Equal(largeSize, output.Length);
}
#endregion
#region Chainability Tests
[Fact]
public void Pub_FiresOnUpdate()
{
var decay = new Decay(TestPeriod);
bool eventFired = false;
decay.Pub += (object? _, in TValueEventArgs e) => eventFired = true;
decay.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(eventFired);
}
[Fact]
public void EventBasedChaining_Works()
{
var source = new TSeries(10);
var decay = new Decay(source, 2);
var results = new List<double>();
decay.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 Decay-Specific Tests
[Fact]
public void Decay_Period1_DecaysByOneEachBar()
{
var decay = new Decay(1); // scale = 1.0
var time = DateTime.UtcNow;
decay.Update(new TValue(time, 5.0), true);
var tv = decay.Update(new TValue(time.AddSeconds(1), 0.0), true);
// max(0.0, 5.0-1.0) = 4.0
Assert.Equal(4.0, tv.Value, 10);
}
[Fact]
public void Decay_ConstantInput_OutputEqualsInput()
{
var decay = new Decay(5);
var time = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
var tv = decay.Update(new TValue(time.AddSeconds(i), 100.0), true);
Assert.Equal(100.0, tv.Value, 10);
}
}
[Fact]
public void Decay_OutputNeverBelowInput()
{
var decay = new Decay(10);
var time = DateTime.UtcNow;
var rng = new Random(42);
for (int i = 0; i < 100; i++)
{
double input = rng.NextDouble() * 200;
var tv = decay.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}");
}
}
#endregion
}
@@ -0,0 +1,230 @@
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for DECAY (Linear 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_decay algorithm:
/// output[0] = input[0]
/// output[i] = max(input[i], output[i-1] - 1.0/period)
/// </summary>
public sealed class DecayValidationTests(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_decay for validation.
/// </summary>
private static double[] TulipDecay(double[] input, int period)
{
double[] output = new double[input.Length];
double scale = 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 Decay_MatchesTulipDecay_Batch()
{
double[] input = _testData.RawData.ToArray();
var quantResult = Decay.Batch(_testData.Data, TestPeriod);
double[] tulipResult = TulipDecay(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("Decay Batch validated successfully against Tulip decay algorithm");
}
[Fact]
public void Decay_MatchesTulipDecay_Streaming()
{
double[] input = _testData.RawData.ToArray();
var decay = new Decay(TestPeriod);
var streamingResults = new List<double>();
foreach (var item in _testData.Data)
{
streamingResults.Add(decay.Update(item).Value);
}
double[] tulipResult = TulipDecay(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("Decay Streaming validated successfully against Tulip decay algorithm");
}
[Fact]
public void Decay_MatchesTulipDecay_Span()
{
double[] input = _testData.RawData.ToArray();
var quantOutput = new double[input.Length];
Decay.Batch(new ReadOnlySpan<double>(input), quantOutput, TestPeriod);
double[] tulipResult = TulipDecay(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("Decay Span validated successfully against Tulip decay algorithm");
}
#endregion
#region Different Periods
[Theory]
[InlineData(1)]
[InlineData(5)]
[InlineData(10)]
[InlineData(20)]
[InlineData(50)]
public void Decay_MatchesTulipDecay_DifferentPeriods(int period)
{
double[] input = _testData.RawData.ToArray();
var quantResult = Decay.Batch(_testData.Data, period);
double[] tulipResult = TulipDecay(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 Decay_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 = Decay.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 Decay_HandlesLinearlyDecreasing()
{
double[] input = new double[20];
for (int i = 0; i < 20; i++)
{
input[i] = 100.0 - i;
}
var quantOutput = new double[20];
Decay.Batch(input, quantOutput, TestPeriod);
double[] tulipResult = TulipDecay(input, TestPeriod);
for (int i = 0; i < 20; i++)
{
Assert.Equal(tulipResult[i], quantOutput[i], TulipTolerance);
}
}
[Fact]
public void Batch_MatchesStreaming_IdenticalResults()
{
var batchResult = Decay.Batch(_testData.Data, TestPeriod);
var decay = new Decay(TestPeriod);
var streamingResults = new List<double>();
foreach (var item in _testData.Data)
{
streamingResults.Add(decay.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("Decay Batch vs Streaming consistency validated");
}
[Fact]
public void Decay_OutputAlwaysGreaterOrEqualInput()
{
double[] input = _testData.RawData.ToArray();
var quantOutput = new double[input.Length];
Decay.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}");
}
}
#endregion
}
+196
View File
@@ -0,0 +1,196 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// DECAY: Linear Decay
/// </summary>
/// <remarks>
/// Tracks the maximum of the current input and the previous output minus a fixed
/// step of 1/period per bar. When price is rising or flat the output follows price;
/// when price drops the output decays linearly toward it.
///
/// Calculation: <c>output = max(input, prev_output - 1/period)</c>.
/// Origin: Tulip Indicators (ti_decay).
/// </remarks>
/// <seealso href="Decay.md">Detailed documentation</seealso>
[SkipLocalsInit]
public sealed class Decay : 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 Linear Decay indicator with specified period.
/// </summary>
/// <param name="period">Decay period (must be >= 1)</param>
public Decay(int period = 5)
{
if (period < 1)
{
throw new ArgumentException("Period must be >= 1", nameof(period));
}
_scale = 1.0 / period;
Name = $"Decay({period})";
WarmupPeriod = 1;
}
/// <summary>
/// Initializes a new Linear Decay indicator with source for event-based chaining.
/// </summary>
/// <param name="source">Source indicator for chaining</param>
/// <param name="period">Decay period</param>
public Decay(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 Decay(period);
return indicator.Update(source);
}
/// <summary>
/// Calculates linear 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 = 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, Decay Indicator) Calculate(TSeries source, int period = 5)
{
var indicator = new Decay(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);
}
}
+153
View File
@@ -0,0 +1,153 @@
# DECAY: Linear Decay
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Numerics |
| **Inputs** | Source (close) |
| **Parameters** | `period` (default 5) |
| **Outputs** | Single series (Decay) |
| **Output range** | Same as input (overlay) |
| **Warmup** | `1` bar |
### TL;DR
- DECAY (Linear Decay) tracks the maximum of the current input and the previous output minus a fixed absolute step of `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_decay` reference algorithm.
> "A ratchet that only moves down slowly: price can push it up instantly, but gravity pulls it back at a steady, linear pace."
DECAY implements the Tulip Indicators `ti_decay` function. When price is above the decayed level, output snaps to price. When price falls below, the output decays linearly at a rate of `1/period` per bar, creating a ceiling that gradually descends. This produces a one-sided envelope that hugs price from above.
## Historical Context
The linear decay indicator originates from the Tulip Indicators library, a high-performance C library of technical indicators. It provides a simple peak-tracking mechanism where the tracked level decays at a constant absolute rate. The indicator is useful for:
- **Trailing stops**: The decaying level acts as a simple trailing stop that descends at a fixed rate.
- **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. Linear Decay Calculation
$$
y_t = \max(x_t, \; y_{t-1} - \frac{1}{p})
$$
where:
- $x_t$ = current input value
- $y_{t-1}$ = previous output value
- $p$ = period parameter
- $\frac{1}{p}$ = fixed decay step 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} - s)
$$
where $s = \frac{1}{p}$ is the fixed linear decay rate.
### Decay Behavior
After a peak at value $v$, with no new inputs exceeding the decayed level, the output follows:
$$
y_{t+k} = v - k \cdot s
$$
reaching zero after $k = v \cdot p$ bars (assuming $v > 0$).
### Properties
| Property | Value |
|----------|-------|
| Lookback | 0 |
| Output ≥ Input | Always (by construction) |
| Decay rate | Constant absolute $\frac{1}{p}$ |
| Monotonic when decaying | Yes (strictly decreasing) |
## Performance Profile
### Operation Count (Streaming Mode)
| Operation | Count | Notes |
| :--- | :---: | :--- |
| SUB | 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) | Sub + 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** | 2/10 | No smoothing — linear staircase |
| **Simplicity** | 10/10 | Single subtraction + compare |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **Tulip** | ✅ | Manual ti_decay algorithm matches exactly |
## Common Pitfalls
1. **Not a moving average**: Decay is a peak-tracking/envelope indicator, not a smoothing filter. It only descends when price is below the decayed level.
2. **Absolute decay rate**: The decay step is `1/period` in absolute terms, regardless of price level. For a stock at $100 with period=5, the decay is $0.20/bar; for a stock at $10, it's the same $0.20/bar. Consider normalizing if comparing across instruments.
3. **Period interpretation**: Period=5 means the output decays by 1.0 over 5 bars (0.2 per bar), not that it looks back 5 bars.
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 `1/period` per bar.
## References
- Tulip Indicators Library: https://tulipindicators.org/decay
- Kegel, L. "Tulip Indicators" — Open-source C library of technical indicators.
+32
View File
@@ -0,0 +1,32 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Linear Decay (DECAY)", "DECAY", overlay=true)
//@function Calculates linear decay: output = max(input, prev_output - 1/period)
//@param source Source price series
//@param length Decay period
//@returns Decayed value that tracks peaks and descends linearly
decay(series float source, simple int length) =>
var float prev = na
float scale = 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 Decay
float decay_val = decay(i_source, i_length)
// Plot
plot(decay_val, "Decay", color=color.yellow, linewidth=2)
@@ -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)