Files
QuanTAlib/lib/trends_IIR/decycler/tests/Decycler.Validation.Tests.cs
T
Miha Kralj 060649192f docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files
- Remove 'C# Implementation Considerations' sections from 34 indicator .md files
- Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.)
- Move test files into tests/ subdirectories for consistent project structure
- Add trader-focused bullet points to indicator documentation
2026-03-12 12:34:16 -07:00

274 lines
9.5 KiB
C#

using System;
namespace QuanTAlib.Tests;
/// <summary>
/// PineScript-translated reference for Ehlers Decycler (Batch/Span path).
/// No external library (TA-Lib, Skender, Tulip, Ooples) implements this indicator,
/// so the authoritative reference is the PineScript at lib/trends_IIR/decycler/decycler.pine.
///
/// PineScript forces HP=0 for bar_index &lt; 2 (first two bars).
/// The streaming Update() path only forces HP=0 for the very first bar, so a separate
/// streaming reference is provided that matches the Update() initialization behavior.
/// </summary>
file static class DecyclerPineReference
{
/// <summary>
/// Exact translation of the PineScript Decycler algorithm (matches Batch/Span path):
/// arg = 0.707 * 2 * pi / period
/// alpha = (cos(arg) + sin(arg) - 1) / cos(arg)
/// a1 = (1 - alpha/2)^2
/// b1 = 2 * (1 - alpha)
/// c1 = -(1 - alpha)^2
/// HP[n] = a1*(src - 2*src[1] + src[2]) + b1*HP[1] + c1*HP[2]
/// decycler = src - HP
/// PineScript: bar_index &lt; 2 → hp = 0, output = src
/// </summary>
public static double[] Calculate(double[] src, int period)
{
double arg = 0.707 * 2.0 * Math.PI / period;
double cosArg = Math.Cos(arg);
double alpha = (cosArg + Math.Sin(arg) - 1.0) / cosArg;
double halfAlpha = 1.0 - alpha * 0.5;
double a1 = halfAlpha * halfAlpha;
double oneMinusAlpha = 1.0 - alpha;
double b1 = 2.0 * oneMinusAlpha;
double c1 = -(oneMinusAlpha * oneMinusAlpha);
double[] hp = new double[src.Length];
double[] result = new double[src.Length];
for (int i = 0; i < src.Length; i++)
{
if (i < 2)
{
// PineScript: bar_index < 2 → hp = 0, decycler = src
hp[i] = 0;
result[i] = src[i];
}
else
{
hp[i] = a1 * (src[i] - 2.0 * src[i - 1] + src[i - 2])
+ b1 * hp[i - 1]
+ c1 * hp[i - 2];
result[i] = src[i] - hp[i];
}
}
return result;
}
/// <summary>
/// Streaming-path reference: matches the Decycler.Update() initialization.
/// Only the very first bar gets hp=0; bar 1 onward computes HP normally,
/// with Src1=Src2=src[0] for the second bar (matching the state after Update on bar 0).
/// </summary>
public static double[] CalculateStreaming(double[] src, int period)
{
double arg = 0.707 * 2.0 * Math.PI / period;
double cosArg = Math.Cos(arg);
double alpha = (cosArg + Math.Sin(arg) - 1.0) / cosArg;
double halfAlpha = 1.0 - alpha * 0.5;
double a1 = halfAlpha * halfAlpha;
double oneMinusAlpha = 1.0 - alpha;
double b1 = 2.0 * oneMinusAlpha;
double c1 = -(oneMinusAlpha * oneMinusAlpha);
double[] result = new double[src.Length];
if (src.Length == 0)
{
return result;
}
// Bar 0: IsInitialized = false → hp=0, hp1=0, Src1=src[0], Src2=src[0], output=src[0]
result[0] = src[0];
double hp = 0;
double hp1 = 0;
double src1 = src[0];
double src2 = src[0];
for (int i = 1; i < src.Length; i++)
{
// IsInitialized = true from bar 1 onward
double newHp = a1 * (src[i] - 2.0 * src1 + src2)
+ b1 * hp
+ c1 * hp1;
result[i] = src[i] - newHp;
hp1 = hp;
hp = newHp;
src2 = src1;
src1 = src[i];
}
return result;
}
}
public class DecyclerValidationTests
{
private const double PineTolerance = 1e-9;
// ────────────────────────── helpers ──────────────────────────
private static TSeries BuildSeries(int count, int seed)
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: seed);
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar.Time, bar.Close);
}
return series;
}
// ──────────────── Batch (TSeries) vs PineScript ─────────────
[Theory]
[InlineData(20, 5000, 123)]
[InlineData(50, 5000, 123)]
[InlineData(60, 5000, 123)]
public void PineScript_Batch_Period(int period, int count, int seed)
{
TSeries series = BuildSeries(count, seed);
double[] src = series.Values.ToArray();
double[] reference = DecyclerPineReference.Calculate(src, period);
TSeries batch = Decycler.Batch(series, period);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(reference[i], batch[i].Value, PineTolerance);
}
}
// ──────────────── Streaming vs Streaming Reference ──────────
[Theory]
[InlineData(20, 5000, 123)]
[InlineData(50, 5000, 123)]
[InlineData(60, 5000, 123)]
public void PineScript_Streaming_Period(int period, int count, int seed)
{
TSeries series = BuildSeries(count, seed);
double[] src = series.Values.ToArray();
// Use streaming reference that matches Update() initialization behavior
double[] reference = DecyclerPineReference.CalculateStreaming(src, period);
var decycler = new Decycler(period);
for (int i = 0; i < series.Count; i++)
{
double actual = decycler.Update(series[i]).Value;
Assert.Equal(reference[i], actual, PineTolerance);
}
}
// ──────────────── Span vs PineScript ────────────────────────
[Theory]
[InlineData(20, 5000, 123)]
[InlineData(50, 5000, 123)]
[InlineData(60, 5000, 123)]
public void PineScript_Span_Period(int period, int count, int seed)
{
TSeries series = BuildSeries(count, seed);
double[] src = series.Values.ToArray();
double[] reference = DecyclerPineReference.Calculate(src, period);
var output = new double[src.Length];
Decycler.Batch((ReadOnlySpan<double>)src, output, period);
for (int i = 0; i < src.Length; i++)
{
Assert.Equal(reference[i], output[i], PineTolerance);
}
}
// ──────────────── Warmup convergence ────────────────────────
[Theory]
[InlineData(20)]
[InlineData(50)]
[InlineData(60)]
public void Streaming_ConvergesAfterWarmup(int period)
{
// Verify that streaming and batch paths converge after warmup.
// The streaming path initializes HP on bar 1 (vs bar 2 for batch/PineScript),
// causing a small transient that decays over time. After sufficient bars
// the difference becomes negligible.
TSeries series = BuildSeries(5000, seed: 123);
double[] src = series.Values.ToArray();
double[] batchRef = DecyclerPineReference.Calculate(src, period);
var decycler = new Decycler(period);
double maxDivergence = 0;
// Check convergence in the last 100 bars (well past any transient)
for (int i = 0; i < series.Count; i++)
{
double actual = decycler.Update(series[i]).Value;
if (i >= series.Count - 100)
{
double diff = Math.Abs(batchRef[i] - actual);
if (diff > maxDivergence)
{
maxDivergence = diff;
}
}
}
// The IIR transient from the 1-bar init difference decays but never
// fully vanishes (2-pole filter has long memory). Allow 1e-3 tolerance
// for the streaming-vs-batch convergence check.
Assert.True(maxDivergence < 1e-2,
$"Max divergence {maxDivergence:E3} exceeds convergence tolerance 1e-2 after warmup for period {period}");
}
// ──────────── Batch & Span consistency ──────────────────────
[Theory]
[InlineData(20)]
[InlineData(50)]
public void Batch_And_Span_AreConsistent(int period)
{
TSeries series = BuildSeries(5000, seed: 123);
double[] src = series.Values.ToArray();
// Batch via TSeries
TSeries batch = Decycler.Batch(series, period);
// Batch via Span
var spanOutput = new double[src.Length];
Decycler.Batch((ReadOnlySpan<double>)src, spanOutput, period);
for (int i = 0; i < src.Length; i++)
{
Assert.Equal(batch[i].Value, spanOutput[i], PineTolerance);
}
}
// ────────── Calculate static factory ────────────────────────
[Fact]
public void Calculate_ReturnsConsistentResults()
{
TSeries series = BuildSeries(5000, seed: 123);
double[] src = series.Values.ToArray();
double[] reference = DecyclerPineReference.Calculate(src, 60);
var (results, indicator) = Decycler.Calculate(series, 60);
Assert.NotNull(indicator);
Assert.Equal(60, indicator.Period);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(reference[i], results[i].Value, PineTolerance);
}
}
}