Refactor indicators to include "Ehlers" in names and descriptions for clarity

- Updated the name and description of the Hilbert Trendline (HTIT) to "Ehlers Hilbert Transform Instantaneous Trend (HTIT)".
- Changed the name and description of the MESA Adaptive Moving Average (MAMA) to "Ehlers MESA Adaptive Moving Average".
- Modified the Center of Gravity (CG) indicator to "Ehlers Center of Gravity (CG)".
- Renamed the Detrended Synthetic Price (DSP) to "Ehlers Detrended Synthetic Price (DSP)".
- Updated the Autocorrelation Periodogram (EACP) to "Ehlers Autocorrelation Periodogram (EACP)".
- Changed the Homodyne Discriminator (HOMOD) to "Ehlers Homodyne Discriminator (HOMOD)".
- Updated the Hilbert Transform Dominant Cycle Period and Phase indicators to include "Ehlers" in their names.
- Renamed the Hilbert Transform Phasor Components to "Ehlers Hilbert Transform Phasor Components (HT_PHASOR)".
- Updated the SineWave indicator to "Ehlers Hilbert Transform SineWave (HT_SINE)".
- Changed the Phasor Analysis indicator to "Ehlers Hilbert Transform Phasor Components (HT_PHASOR)".
- Updated the SSF-Based Detrended Synthetic Price to "Ehlers SSF Detrended Synthetic Price (SSFDSP)".
- Renamed the Ultimate Channel to "Ehlers Ultimate Channel (UCHANNEL)".
- Added new indicators: Moving Average Variable Period (MAVP), Ehlers Predictive Moving Average (PMA), Ehlers Reverse EMA (REVERSEEMA), and Ehlers Trendflex Indicator (TRENDFLEX).
- Updated various SVG badges to reflect changes in classes, comments, source files, lines of code, methods, and public types.
This commit is contained in:
Miha Kralj
2026-02-18 19:08:15 -08:00
parent 24e86d762a
commit 3dd05f23e4
144 changed files with 3468 additions and 788 deletions
@@ -0,0 +1,142 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public sealed class DecoIndicatorTests
{
[Fact]
public void DecoIndicator_Constructor_SetsDefaults()
{
var indicator = new DecoIndicator();
Assert.Equal(30, indicator.ShortPeriod);
Assert.Equal(60, indicator.LongPeriod);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("DECO - Ehlers Decycler Oscillator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void DecoIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new DecoIndicator { ShortPeriod = 10, LongPeriod = 20 };
Assert.Equal(0, DecoIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void DecoIndicator_ShortName_IncludesParameters()
{
var indicator = new DecoIndicator { ShortPeriod = 10, LongPeriod = 30 };
indicator.Initialize();
Assert.Contains("DECO", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("30", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void DecoIndicator_SourceCodeLink_IsValid()
{
var indicator = new DecoIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Deco.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void DecoIndicator_Initialize_CreatesInternalDeco()
{
var indicator = new DecoIndicator { ShortPeriod = 5, LongPeriod = 10 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void DecoIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new DecoIndicator { ShortPeriod = 5, LongPeriod = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double value = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(value));
}
[Fact]
public void DecoIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new DecoIndicator { ShortPeriod = 5, LongPeriod = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Add a new bar
indicator.HistoricalData.AddBar(now.AddMinutes(20), 120, 130, 110, 125);
var newArgs = new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(newArgs);
double value = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(value));
}
[Fact]
public void DecoIndicator_ProcessUpdate_DifferentSources()
{
foreach (SourceType source in new[] { SourceType.Close, SourceType.Open, SourceType.High, SourceType.Low })
{
var indicator = new DecoIndicator { ShortPeriod = 5, LongPeriod = 10, Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 15; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double value = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(value), $"Source {source} produced non-finite value");
}
}
[Fact]
public void DecoIndicator_Reinitialize_ResetsState()
{
var indicator = new DecoIndicator { ShortPeriod = 5, LongPeriod = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 15; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Re-initialize should reset
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
}
+66
View File
@@ -0,0 +1,66 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class DecoIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Short Period", sortIndex: 1, 1, 1000, 1, 0)]
public int ShortPeriod { get; set; } = 30;
[InputParameter("Long Period", sortIndex: 2, 2, 2000, 1, 0)]
public int LongPeriod { get; set; } = 60;
[IndicatorExtensions.DataSourceInput(sortIndex: 3)]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Deco _deco = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"DECO ({ShortPeriod},{LongPeriod})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/deco/Deco.Quantower.cs";
public DecoIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "DECO - Ehlers Decycler Oscillator";
Description = "Ehlers' Decycler Oscillator isolates intermediate cycles via dual HP filters";
_series = new LineSeries("DECO", Color.Yellow, 2, LineStyle.Solid);
AddLineSeries(_series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_deco = new Deco(ShortPeriod, LongPeriod);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var priceSelector = Source.GetPriceSelector();
var item = HistoricalData[0, SeekOriginHistory.End];
double price = priceSelector(item);
TValue input = new(item.TimeLeft, price);
TValue result = _deco.Update(input, args.IsNewBar());
if (!_deco.IsHot && !ShowColdValues)
{
return;
}
_series.SetValue(result.Value);
}
}
+391
View File
@@ -0,0 +1,391 @@
namespace QuanTAlib;
public class DecoTests
{
private const double Tolerance = 1e-10;
// ── A) Constructor validation ──
[Fact]
public void Constructor_DefaultParameters_SetsCorrectly()
{
var deco = new Deco();
Assert.Equal("Deco(30,60)", deco.Name);
Assert.Equal(30, deco.ShortPeriod);
Assert.Equal(60, deco.LongPeriod);
Assert.Equal(60, deco.WarmupPeriod);
}
[Fact]
public void Constructor_CustomParameters_SetsCorrectly()
{
var deco = new Deco(shortPeriod: 10, longPeriod: 40);
Assert.Equal("Deco(10,40)", deco.Name);
Assert.Equal(10, deco.ShortPeriod);
Assert.Equal(40, deco.LongPeriod);
}
[Fact]
public void Constructor_ZeroShortPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Deco(shortPeriod: 0));
Assert.Equal("shortPeriod", ex.ParamName);
}
[Fact]
public void Constructor_NegativeShortPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Deco(shortPeriod: -1));
Assert.Equal("shortPeriod", ex.ParamName);
}
[Fact]
public void Constructor_LongNotGreaterThanShort_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Deco(shortPeriod: 30, longPeriod: 30));
Assert.Equal("longPeriod", ex.ParamName);
}
[Fact]
public void Constructor_LongLessThanShort_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Deco(shortPeriod: 30, longPeriod: 20));
Assert.Equal("longPeriod", ex.ParamName);
}
// ── B) Basic calculation ──
[Fact]
public void Update_ReturnsFiniteValue()
{
var deco = new Deco(5, 10);
TValue result = default;
for (int i = 0; i < 20; i++)
{
result = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_Last_MatchesReturnValue()
{
var deco = new Deco(5, 10);
var result = deco.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(result.Value, deco.Last.Value);
}
[Fact]
public void Update_Name_AccessibleAfterUpdate()
{
var deco = new Deco(5, 10);
_ = deco.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Contains("Deco", deco.Name, StringComparison.Ordinal);
}
[Fact]
public void Update_FirstTwoBars_ReturnZero()
{
var deco = new Deco(5, 10);
var r0 = deco.Update(new TValue(DateTime.UtcNow, 100.0));
var r1 = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 101.0));
Assert.Equal(0.0, r0.Value);
Assert.Equal(0.0, r1.Value);
}
// ── C) State + bar correction ──
[Fact]
public void Update_IsNew_True_AdvancesState()
{
var deco = new Deco(5, 10);
var r1 = deco.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
var r2 = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 101.0), isNew: true);
Assert.True(double.IsFinite(r1.Value));
Assert.True(double.IsFinite(r2.Value));
}
[Fact]
public void Update_IsNew_False_RewritesLastBar()
{
var deco = new Deco(5, 10);
for (int i = 0; i < 10; i++)
{
deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), isNew: true);
}
var before = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(10), 120.0), isNew: true);
var correction = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(10), 115.0), isNew: false);
Assert.NotEqual(before.Value, correction.Value);
}
[Fact]
public void Update_IterativeCorrections_RestoreState()
{
var deco = new Deco(5, 10);
for (int i = 0; i < 10; i++)
{
deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), isNew: true);
}
_ = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(10), 120.0), isNew: true);
var restored = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(10), 110.0), isNew: false);
var again = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(10), 110.0), isNew: false);
Assert.Equal(restored.Value, again.Value, Tolerance);
}
[Fact]
public void Reset_ClearsState()
{
var deco = new Deco(5, 10);
for (int i = 0; i < 20; i++)
{
deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
deco.Reset();
Assert.False(deco.IsHot);
Assert.Equal(0.0, deco.Last.Value);
}
// ── D) Warmup / convergence ──
[Fact]
public void IsHot_FlipsWhenWarmupReached()
{
var deco = new Deco(5, 10);
for (int i = 0; i < 9; i++)
{
deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
Assert.False(deco.IsHot);
}
deco.Update(new TValue(DateTime.UtcNow.AddSeconds(10), 110.0));
Assert.True(deco.IsHot);
}
[Fact]
public void WarmupPeriod_EqualsLongPeriod()
{
var deco = new Deco(20, 60);
Assert.Equal(60, deco.WarmupPeriod);
}
// ── E) Robustness ──
[Fact]
public void Update_NaN_UsesLastValidValue()
{
var deco = new Deco(5, 10);
for (int i = 0; i < 5; i++)
{
deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
var result = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(5), double.NaN));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_Infinity_UsesLastValidValue()
{
var deco = new Deco(5, 10);
for (int i = 0; i < 5; i++)
{
deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
var result = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(5), double.PositiveInfinity));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Batch_NaN_Safe()
{
double[] src = [100, 101, double.NaN, 103, 104, 105, 106, 107, 108, 109];
double[] output = new double[src.Length];
Deco.Batch(src, output, 3, 6);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]));
}
}
// ── F) Consistency (4 modes match) ──
[Fact]
public void AllModes_ProduceSameResults()
{
int shortP = 10, longP = 20;
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
// 1. Streaming
var streaming = new Deco(shortP, longP);
var streamResults = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
streamResults[i] = streaming.Update(source[i]).Value;
}
// 2. Batch TSeries
TSeries batchSeries = Deco.Batch(source, shortP, longP);
// 3. Batch Span
var spanOutput = new double[source.Count];
Deco.Batch(source.Values, spanOutput, shortP, longP);
// 4. Event-based
var eventSource = new TSeries();
var eventIndicator = new Deco(eventSource, shortP, longP);
var eventResults = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
eventSource.Add(source[i]);
eventResults[i] = eventIndicator.Last.Value;
}
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(streamResults[i], batchSeries.Values[i], Tolerance);
Assert.Equal(streamResults[i], spanOutput[i], Tolerance);
Assert.Equal(streamResults[i], eventResults[i], Tolerance);
}
}
// ── G) Span API tests ──
[Fact]
public void Batch_MismatchedLengths_Throws()
{
double[] src = [1, 2, 3];
double[] output = new double[2];
var ex = Assert.Throws<ArgumentException>(() => Deco.Batch(src, output, 1, 2));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_ZeroShortPeriod_Throws()
{
double[] src = [1, 2, 3];
double[] output = new double[3];
var ex = Assert.Throws<ArgumentException>(() => Deco.Batch(src, output, 0, 2));
Assert.Equal("shortPeriod", ex.ParamName);
}
[Fact]
public void Batch_LongNotGreater_Throws()
{
double[] src = [1, 2, 3];
double[] output = new double[3];
var ex = Assert.Throws<ArgumentException>(() => Deco.Batch(src, output, 5, 5));
Assert.Equal("longPeriod", ex.ParamName);
}
[Fact]
public void Batch_EmptyInput_NoOp()
{
double[] src = [];
double[] output = [];
var ex = Record.Exception(() => Deco.Batch(src, output, 5, 10));
Assert.Null(ex);
}
[Fact]
public void Batch_Span_MatchesTSeries()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 7);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
int shortP = 10, longP = 20;
TSeries batchTs = Deco.Batch(source, shortP, longP);
var spanOutput = new double[source.Count];
Deco.Batch(source.Values, spanOutput, shortP, longP);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batchTs.Values[i], spanOutput[i], Tolerance);
}
}
// ── H) Chainability ──
[Fact]
public void PubEvent_FiresOnUpdate()
{
var deco = new Deco(5, 10);
int firedCount = 0;
deco.Pub += (object? _, in TValueEventArgs _) => firedCount++;
deco.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(1, firedCount);
}
[Fact]
public void Chained_Constructor_ReceivesEvents()
{
var src = new TSeries();
var deco = new Deco(src, 5, 10);
src.Add(new TValue(DateTime.UtcNow, 100.0));
src.Add(new TValue(DateTime.UtcNow.AddSeconds(1), 101.0));
src.Add(new TValue(DateTime.UtcNow.AddSeconds(2), 102.0));
Assert.True(double.IsFinite(deco.Last.Value));
}
// ── Additional: Oscillator behavior ──
[Fact]
public void ConstantInput_ProducesZeroOutput()
{
var deco = new Deco(5, 10);
for (int i = 0; i < 30; i++)
{
var result = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
if (i >= 2)
{
Assert.Equal(0.0, result.Value, Tolerance);
}
}
}
[Fact]
public void NonLinearInput_NonZeroOutput()
{
// Use quadratic input (non-zero second derivative) since HP filter
// removes linear trends (which have zero second derivative)
var deco = new Deco(5, 10);
TValue last = default;
for (int i = 0; i < 30; i++)
{
last = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * i * 0.1));
}
Assert.NotEqual(0.0, last.Value);
}
[Fact]
public void Calculate_ReturnsResultsAndIndicator()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 99);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var (results, indicator) = Deco.Calculate(source, 10, 20);
Assert.Equal(source.Count, results.Count);
Assert.True(indicator.IsHot);
}
[Fact]
public void Prime_InitializesState()
{
var deco = new Deco(5, 10);
double[] primeData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111];
deco.Prime(primeData);
Assert.True(deco.IsHot);
}
}
@@ -0,0 +1,174 @@
namespace QuanTAlib.Tests;
public class DecoValidationTests
{
private const double Tolerance = 1e-10;
[Fact]
public void StreamingMatchesBatch_DefaultParams()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
// Streaming
var deco = new Deco(30, 60);
var streaming = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
streaming[i] = deco.Update(source[i]).Value;
}
// Batch span
var batch = new double[source.Count];
Deco.Batch(source.Values, batch, 30, 60);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batch[i], streaming[i], Tolerance);
}
}
[Fact]
public void StreamingMatchesBatch_ShortPeriods()
{
var gbm = new GBM(startPrice: 50.0, mu: 0.01, sigma: 0.3, seed: 7);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var deco = new Deco(5, 15);
var streaming = new double[source.Count];
for (int i = 0; i < source.Count; i++)
{
streaming[i] = deco.Update(source[i]).Value;
}
var batch = new double[source.Count];
Deco.Batch(source.Values, batch, 5, 15);
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batch[i], streaming[i], Tolerance);
}
}
[Fact]
public void ConstantPrice_OscillatesAtZero()
{
var source = new TSeries();
for (int i = 0; i < 100; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 50.0));
}
var deco = new Deco(10, 20);
for (int i = 0; i < source.Count; i++)
{
var result = deco.Update(source[i]);
if (i >= 2)
{
Assert.Equal(0.0, result.Value, Tolerance);
}
}
}
[Fact]
public void Deterministic_SameInputSameOutput()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 123);
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var deco1 = new Deco(10, 30);
var deco2 = new Deco(10, 30);
for (int i = 0; i < source.Count; i++)
{
var r1 = deco1.Update(source[i]);
var r2 = deco2.Update(source[i]);
Assert.Equal(r1.Value, r2.Value, Tolerance);
}
}
[Fact]
public void DirectionalCorrectness_UpTrend()
{
// Exponential growth produces non-zero HP output (linear ramp has zero second-difference)
var deco = new Deco(5, 10);
double lastVal = 0;
for (int i = 0; i < 50; i++)
{
var result = deco.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 * Math.Exp(0.02 * i)));
lastVal = result.Value;
}
// Exponential uptrend produces non-zero DECO
Assert.NotEqual(0.0, lastVal);
}
[Fact]
public void SymmetryCheck_OppositeInputs()
{
// Sinusoidal inputs with opposite phase should produce opposite-sign DECO values
var decoUp = new Deco(5, 10);
var decoDown = new Deco(5, 10);
double lastUp = 0, lastDown = 0;
for (int i = 0; i < 60; i++)
{
double phase = 2.0 * Math.PI * i / 20.0; // period=20 bars
var rUp = decoUp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + 10.0 * Math.Sin(phase)));
var rDown = decoDown.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 - 10.0 * Math.Sin(phase)));
lastUp = rUp.Value;
lastDown = rDown.Value;
}
// Opposite-phase sinusoidal inputs should produce opposite-sign DECO values
Assert.True(lastUp * lastDown < 0,
$"Expected opposite signs: up={lastUp}, down={lastDown}");
}
[Fact]
public void HpFilter_Components_SumCorrectly()
{
// Verify that the HP_long and HP_short filters produce sensible output:
// For constant input, both HP outputs should be zero, hence DECO = 0
var source = new double[50];
Array.Fill(source, 42.0);
var output = new double[50];
Deco.Batch(source, output, 10, 20);
for (int i = 0; i < 50; i++)
{
Assert.Equal(0.0, output[i], Tolerance);
}
}
[Fact]
public void LargeDataset_NoOverflow()
{
var gbm = new GBM(startPrice: 1000.0, mu: 0.1, sigma: 0.5, seed: 55);
var bars = gbm.Fetch(5000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var output = new double[source.Count];
var ex = Record.Exception(() => Deco.Batch(source.Values, output, 30, 60));
Assert.Null(ex);
for (int i = 0; i < source.Count; i++)
{
Assert.True(double.IsFinite(output[i]), $"Non-finite at index {i}");
}
}
[Fact]
public void CalculateMethod_ReturnsConsistentResults()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var (results, indicator) = Deco.Calculate(source, 15, 30);
Assert.Equal(source.Count, results.Count);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(results.Values[^1]));
}
}
+318
View File
@@ -0,0 +1,318 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// DECO: Decycler Oscillator
/// </summary>
/// <remarks>
/// Ehlers' Decycler Oscillator isolates market cycles by computing the difference
/// between two 2-pole Butterworth high-pass filters with different cutoff periods.
/// The shorter HP filter passes more cycle content; the longer HP filter passes less.
/// Their difference reveals the intermediate-frequency band where tradable cycles live.
///
/// Formula (Ehlers, TASC September 2015, Equation 4-2):
/// <code>
/// α = (cos(0.707 × 360/period) + sin(0.707 × 360/period) - 1) / cos(0.707 × 360/period)
/// HP[n] = (1 - α/2)² × (x[n] - 2×x[n-1] + x[n-2]) + 2×(1-α)×HP[n-1] - (1-α)²×HP[n-2]
/// DECO = HP_long - HP_short
/// </code>
///
/// The 0.707 factor (1/√2) places the filter at the -3 dB point of the Butterworth response.
///
/// References:
/// John F. Ehlers, "Decyclers", Technical Analysis of Stocks &amp; Commodities, September 2015
/// John F. Ehlers, "Cycle Analytics for Traders", Wiley, 2013, Chapter 4
/// </remarks>
[SkipLocalsInit]
public sealed class Deco : AbstractBase
{
private readonly int _shortPeriod;
private readonly int _longPeriod;
// Precomputed HP filter coefficients for short-period filter
private readonly double _a1Short; // (1 - α/2)²
private readonly double _b1Short; // 2 × (1 - α)
private readonly double _c1Short; // -(1 - α
// Precomputed HP filter coefficients for long-period filter
private readonly double _a1Long;
private readonly double _b1Long;
private readonly double _c1Long;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double HpShort1,
double HpShort2,
double HpLong1,
double HpLong2,
double Price1,
double Price2,
int Count,
double LastValidValue);
private State _s;
private State _ps;
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>Short-period HP cutoff.</summary>
public int ShortPeriod => _shortPeriod;
/// <summary>Long-period HP cutoff.</summary>
public int LongPeriod => _longPeriod;
/// <summary>
/// Creates a Decycler Oscillator with specified cutoff periods.
/// </summary>
/// <param name="shortPeriod">Short HP cutoff period (must be &gt; 0).</param>
/// <param name="longPeriod">Long HP cutoff period (must be &gt; shortPeriod).</param>
public Deco(int shortPeriod = 30, int longPeriod = 60)
{
if (shortPeriod <= 0)
{
throw new ArgumentException("Short period must be greater than 0.", nameof(shortPeriod));
}
if (longPeriod <= shortPeriod)
{
throw new ArgumentException("Long period must be greater than short period.", nameof(longPeriod));
}
_shortPeriod = shortPeriod;
_longPeriod = longPeriod;
// Precompute Butterworth HP coefficients: α = (cos(0.707×360/p) + sin(0.707×360/p) - 1) / cos(0.707×360/p)
double rad = 0.707 * 2.0 * Math.PI; // 0.707 × 360° in radians
double argShort = rad / shortPeriod;
double alphaShort = (Math.Cos(argShort) + Math.Sin(argShort) - 1.0) / Math.Cos(argShort);
double oneMinusAlphaHalfShort = 1.0 - alphaShort * 0.5;
double oneMinusAlphaShort = 1.0 - alphaShort;
_a1Short = oneMinusAlphaHalfShort * oneMinusAlphaHalfShort;
_b1Short = 2.0 * oneMinusAlphaShort;
_c1Short = -(oneMinusAlphaShort * oneMinusAlphaShort);
double argLong = rad / longPeriod;
double alphaLong = (Math.Cos(argLong) + Math.Sin(argLong) - 1.0) / Math.Cos(argLong);
double oneMinusAlphaHalfLong = 1.0 - alphaLong * 0.5;
double oneMinusAlphaLong = 1.0 - alphaLong;
_a1Long = oneMinusAlphaHalfLong * oneMinusAlphaHalfLong;
_b1Long = 2.0 * oneMinusAlphaLong;
_c1Long = -(oneMinusAlphaLong * oneMinusAlphaLong);
Name = $"Deco({shortPeriod},{longPeriod})";
WarmupPeriod = longPeriod;
_s = default;
_ps = default;
}
/// <summary>
/// Creates a chained Decycler Oscillator.
/// </summary>
public Deco(ITValuePublisher source, int shortPeriod = 30, int longPeriod = 60) : this(shortPeriod, longPeriod)
{
source.Pub += Handle;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew) { _ps = _s; } else { _s = _ps; }
var s = _s;
double value = input.Value;
if (!double.IsFinite(value))
{
value = double.IsFinite(s.LastValidValue) ? s.LastValidValue : 0.0;
}
else
{
s = s with { LastValidValue = value };
}
double hpShort, hpLong;
if (s.Count < 2)
{
// Not enough history for 2-pole HP — output zero
hpShort = 0.0;
hpLong = 0.0;
s = s with
{
HpShort1 = 0.0,
HpShort2 = 0.0,
HpLong1 = 0.0,
HpLong2 = 0.0,
};
}
else
{
// HP[n] = a1*(x[n] - 2*x[n-1] + x[n-2]) + b1*HP[n-1] + c1*HP[n-2]
double diff = value - 2.0 * s.Price1 + s.Price2;
hpShort = Math.FusedMultiplyAdd(_a1Short, diff, Math.FusedMultiplyAdd(_b1Short, s.HpShort1, _c1Short * s.HpShort2));
hpLong = Math.FusedMultiplyAdd(_a1Long, diff, Math.FusedMultiplyAdd(_b1Long, s.HpLong1, _c1Long * s.HpLong2));
s = s with
{
HpShort2 = s.HpShort1,
HpShort1 = hpShort,
HpLong2 = s.HpLong1,
HpLong1 = hpLong,
};
}
// DECO = HP_long - HP_short (long-period HP passes fewer cycles → more trend-like)
double deco = hpLong - hpShort;
_s = s with { Price2 = s.Price1, Price1 = value, Count = s.Count + 1 };
Last = new TValue(input.Time, deco);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0) { return []; }
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Batch(source.Values, vSpan, _shortPeriod, _longPeriod);
source.Times.CopyTo(tSpan);
// Replay to set internal state
for (int i = 0; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
}
return new TSeries(t, v);
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double value in source)
{
Update(new TValue(DateTime.UtcNow, value));
}
}
public override void Reset()
{
_s = default;
_ps = default;
Last = default;
}
/// <summary>
/// Calculates DECO for an entire series.
/// </summary>
public static TSeries Batch(TSeries source, int shortPeriod = 30, int longPeriod = 60)
{
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Batch(source.Values, vSpan, shortPeriod, longPeriod);
source.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
/// <summary>
/// Span-based batch DECO calculation.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int shortPeriod = 30, int longPeriod = 60)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length.", nameof(output));
}
if (shortPeriod <= 0)
{
throw new ArgumentException("Short period must be greater than 0.", nameof(shortPeriod));
}
if (longPeriod <= shortPeriod)
{
throw new ArgumentException("Long period must be greater than short period.", nameof(longPeriod));
}
int len = source.Length;
if (len == 0) { return; }
double rad = 0.707 * 2.0 * Math.PI;
double argShort = rad / shortPeriod;
double alphaShort = (Math.Cos(argShort) + Math.Sin(argShort) - 1.0) / Math.Cos(argShort);
double omahShort = 1.0 - alphaShort * 0.5;
double omaShort = 1.0 - alphaShort;
double a1S = omahShort * omahShort;
double b1S = 2.0 * omaShort;
double c1S = -(omaShort * omaShort);
double argLong = rad / longPeriod;
double alphaLong = (Math.Cos(argLong) + Math.Sin(argLong) - 1.0) / Math.Cos(argLong);
double omahLong = 1.0 - alphaLong * 0.5;
double omaLong = 1.0 - alphaLong;
double a1L = omahLong * omahLong;
double b1L = 2.0 * omaLong;
double c1L = -(omaLong * omaLong);
double hpS1 = 0, hpS2 = 0, hpL1 = 0, hpL2 = 0;
double price1 = 0, price2 = 0;
double lastValid = 0;
for (int i = 0; i < len; i++)
{
double val = source[i];
if (!double.IsFinite(val)) { val = lastValid; } else { lastValid = val; }
if (i < 2)
{
output[i] = 0.0;
}
else
{
double diff = val - 2.0 * price1 + price2;
double hpS = Math.FusedMultiplyAdd(a1S, diff, Math.FusedMultiplyAdd(b1S, hpS1, c1S * hpS2));
double hpL = Math.FusedMultiplyAdd(a1L, diff, Math.FusedMultiplyAdd(b1L, hpL1, c1L * hpL2));
output[i] = hpL - hpS;
hpS2 = hpS1; hpS1 = hpS;
hpL2 = hpL1; hpL1 = hpL;
}
price2 = price1;
price1 = val;
}
}
/// <summary>
/// Calculates DECO and returns both results and a primed indicator.
/// </summary>
public static (TSeries Results, Deco Indicator) Calculate(TSeries source,
int shortPeriod = 30, int longPeriod = 60)
{
var ind = new Deco(shortPeriod, longPeriod);
var results = ind.Update(source);
return (results, ind);
}
}
+86
View File
@@ -0,0 +1,86 @@
# DECO: Ehlers Decycler Oscillator
## Overview
The Decycler Oscillator (DECO) is a DSP-based oscillator developed by John F. Ehlers that isolates intermediate-frequency market cycles. It computes the difference between two 2-pole Butterworth high-pass filters with different cutoff periods, revealing the spectral band between the two cutoff frequencies.
## Origin
- **Author:** John F. Ehlers
- **Source:** "Decyclers", Technical Analysis of Stocks & Commodities, September 2015
- **Category:** Oscillator / Digital Signal Processing
## Formula
The DECO uses two 2-pole Butterworth high-pass filters:
```
α = (cos(0.707 × 2π/period) + sin(0.707 × 2π/period) - 1) / cos(0.707 × 2π/period)
HP[n] = (1 - α/2)² × (x[n] - 2×x[n-1] + x[n-2]) + 2×(1-α) × HP[n-1] - (1-α× HP[n-2]
DECO = HP_long - HP_short
```
The 0.707 factor (1/√2) places the filter response at the -3 dB Butterworth design point.
### Transfer Function
Each HP filter has the z-domain transfer function:
```
H(z) = (1-α/2)² × (1 - 2z⁻¹ + z⁻²) / (1 - 2(1-α)z⁻¹ + (1-α)²z⁻²)
```
The DECO output is the difference H_long(z) - H_short(z), which forms a bandpass response isolating cycles between the short and long cutoff periods.
## Parameters
| Parameter | Type | Default | Range | Description |
|-----------|------|---------|-------|-------------|
| shortPeriod | int | 30 | > 0 | Short HP cutoff period (bars) |
| longPeriod | int | 60 | > shortPeriod | Long HP cutoff period (bars) |
## Interpretation
The Decycler Oscillator provides several analytical perspectives:
- **Zero-Line Crossovers:**
- Crossing above zero indicates bullish cycle momentum
- Crossing below zero indicates bearish cycle momentum
- The zero-crossing timing is relatively lag-free
- **Band Isolation:**
- The oscillator extracts only cycles within the frequency band defined by the two cutoff periods
- Shorter cycles and longer trends are both rejected
- This makes the oscillator highly selective
- **Divergence Analysis:**
- Bullish divergence: price makes lower lows while DECO makes higher lows
- Bearish divergence: price makes higher highs while DECO makes lower highs
- Indicates potential trend reversal
- **Multiple Instance Analysis:**
- Ehlers recommends using multiple DECO instances with different period pairs
- Crossovers between instances with different coefficients can identify trend reversals
## Warmup Period
The indicator requires `longPeriod` bars before producing reliable output. The first two bars always output zero (insufficient price history for the 2-pole HP filter).
## Properties
- **Range:** Unbounded (oscillates around zero)
- **Complexity:** O(1) per bar (pure IIR filter, no lookback buffer needed)
- **Memory:** O(1) — only stores filter state variables
## Related Indicators
- **Decycler (DECYCLER):** The low-pass complement — removes cycles, keeps trend
- **SSF-DSP:** Similar concept using Super Smooth Filters instead of HP filters
- **Roofing Filter:** HP + SSF combination for cycle isolation
- **BandPass Filter:** Ehlers' direct bandpass approach
## References
1. Ehlers, J. F. (2015). "Decyclers." *Technical Analysis of Stocks & Commodities*, September 2015.
2. Ehlers, J. F. (2013). *Cycle Analytics for Traders*. Wiley. Chapter 4.
+69
View File
@@ -0,0 +1,69 @@
// The MIT License (MIT)
// © mihakralj
//@version=6
indicator("Ehlers Decycler Oscillator (DECO)", "DECO", overlay=false)
//@function Calculates Decycler Oscillator using dual 2-pole Butterworth high-pass filters
//@param source Source series to calculate DECO from
//@param short_period Short cycle cutoff period for high-pass filter
//@param long_period Long cycle cutoff period for high-pass filter
//@returns DECO value (HP(longPeriod) - HP(shortPeriod))
deco(series float source, simple int short_period, simple int long_period) =>
if short_period <= 0 or long_period <= 0
runtime.error("All periods must be positive")
if short_period >= long_period
runtime.error("Short period must be less than long period")
float src = na(source) ? 0.0 : source
// Butterworth 2-pole HP coefficient: alpha = (cos(x) + sin(x) - 1) / cos(x)
// where x = 0.707 * 2pi / period
float rad = 0.707 * 2.0 * math.pi
float arg_short = rad / short_period
float alpha_s = (math.cos(arg_short) + math.sin(arg_short) - 1.0) / math.cos(arg_short)
float omah_s = 1.0 - alpha_s * 0.5
float oma_s = 1.0 - alpha_s
float a1_s = omah_s * omah_s
float b1_s = 2.0 * oma_s
float c1_s = -(oma_s * oma_s)
float arg_long = rad / long_period
float alpha_l = (math.cos(arg_long) + math.sin(arg_long) - 1.0) / math.cos(arg_long)
float omah_l = 1.0 - alpha_l * 0.5
float oma_l = 1.0 - alpha_l
float a1_l = omah_l * omah_l
float b1_l = 2.0 * oma_l
float c1_l = -(oma_l * oma_l)
// 2-pole HP: HP[n] = a1*(x - 2*x[1] + x[2]) + b1*HP[1] + c1*HP[2]
float diff_src = nz(src) - 2.0 * nz(src[1]) + nz(src[2])
var float hp_s = 0.0
var float hp_s1 = 0.0
var float hp_l = 0.0
var float hp_l1 = 0.0
float new_hp_s = bar_index < 2 ? 0.0 : a1_s * diff_src + b1_s * hp_s + c1_s * hp_s1
float new_hp_l = bar_index < 2 ? 0.0 : a1_l * diff_src + b1_l * hp_l + c1_l * hp_l1
hp_s1 := hp_s
hp_s := new_hp_s
hp_l1 := hp_l
hp_l := new_hp_l
na(source) ? na : new_hp_l - new_hp_s
// ---------- Main loop ----------
// Inputs
i_short_period = input.int(30, "Short Period", minval=1, maxval=500, tooltip="Short cycle cutoff period for high-pass filter")
i_long_period = input.int(60, "Long Period", minval=2, maxval=1000, tooltip="Long cycle cutoff period for high-pass filter")
i_source = input.source(close, "Source", tooltip="Price series to analyze")
// Calculation
result = deco(i_source, i_short_period, i_long_period)
// Plot
plot(result, "DECO", color=color.yellow, linewidth=2)
hline(0, "Zero Line", color=color.gray, linestyle=hline.style_dotted)