mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-15 17:18:05 +00:00
- 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
274 lines
9.5 KiB
C#
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 < 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 < 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);
|
|
}
|
|
}
|
|
}
|