diff --git a/_sidebar.md b/_sidebar.md index ccf4893a..5943395e 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -142,6 +142,7 @@ * [DPO - Detrended Price Oscillator](/lib/oscillators/dpo/Dpo.md) * [DSTOCH - Double Stochastic (Bressert)](/lib/oscillators/dstoch/Dstoch.md) * [DYMI - Dynamic Momentum Index](/lib/oscillators/dymi/dymi.md) + * [EEO - Ehlers Elegant Oscillator](/lib/oscillators/eeo/Eeo.md) * [ER - Efficiency Ratio](/lib/oscillators/er/Er.md) * [ERI - Elder Ray Index](/lib/oscillators/eri/Eri.md) * [FI - Force Index](/lib/oscillators/fi/Fi.md) diff --git a/docs/indicators.md b/docs/indicators.md index b47cf8d7..fae6d8fa 100644 --- a/docs/indicators.md +++ b/docs/indicators.md @@ -183,6 +183,7 @@ Bounded indicators that oscillate around a centerline or between fixed extremes. | [**DSO**](../lib/oscillators/dso/Dso.md) | Ehlers Deviation-Scaled Oscillator | SSF + RMS + Fisher Transform oscillator | | [**DPO**](../lib/oscillators/dpo/Dpo.md) | Detrended Price Oscillator | Displaced SMA trend removal | | [**DYMI**](../lib/oscillators/dymi/Dymi.md) | Dynamic Momentum Index | Volatility-adaptive RSI period | +| [**EEO**](../lib/oscillators/eeo/Eeo.md) | Ehlers Elegant Oscillator | IFT of RMS-normalized momentum with Super Smoother | | [**ER**](../lib/oscillators/er/Er.md) | Efficiency Ratio | Net movement / total path length | | [**ERI**](../lib/oscillators/eri/Eri.md) | Elder Ray Index | Bull/bear power relative to EMA | | [**FI**](../lib/oscillators/fi/Fi.md) | Force Index | Price change × volume buying/selling power | diff --git a/docs/pinescript.md b/docs/pinescript.md index 3d337e2d..f04b58ae 100644 --- a/docs/pinescript.md +++ b/docs/pinescript.md @@ -213,6 +213,7 @@ Numbers that bounce between limits. Overbought, oversold, divergence. You know t | DSO | Ehlers Deviation-Scaled Oscillator | [dso.pine](../lib/oscillators/dso/dso.pine) | | DPO | Detrended Price Oscillator | [dpo.pine](../lib/oscillators/dpo/dpo.pine) | | DYMI | Dynamic Momentum Index | [dymi.pine](../lib/oscillators/dymi/dymi.pine) | +| EEO | Ehlers Elegant Oscillator | [eeo.pine](../lib/oscillators/eeo/eeo.pine) | | ER | Efficiency Ratio | [er.pine](../lib/oscillators/er/er.pine) | | ERI | Elder Ray Index | [eri.pine](../lib/oscillators/eri/eri.pine) | | FI | Force Index | [fi.pine](../lib/oscillators/fi/fi.pine) | diff --git a/lib/_index.md b/lib/_index.md index 4d323694..0567abdd 100644 --- a/lib/_index.md +++ b/lib/_index.md @@ -108,6 +108,7 @@ | [ACP](cycles/acp/Acp.md) | Ehlers Autocorrelation Periodogram | Cycles | | [EBSW](cycles/ebsw/Ebsw.md) | Ehlers Even Better Sinewave | Cycles | | [EDCF](filters/edcf/Edcf.md) | Ehlers Distance Coefficient Filter | Filters | +| [EEO](oscillators/eeo/Eeo.md) | Ehlers Elegant Oscillator | Oscillators | | [EDECAY](numerics/edecay/Edecay.md) | Exponential Decay | Numerics | | [EFI](volume/efi/Efi.md) | Elder's Force Index | Volume | | [ELLIPTIC](filters/elliptic/Elliptic.md) | Elliptic Filter | Filters | diff --git a/lib/oscillators/_index.md b/lib/oscillators/_index.md index 0c7203e8..ce701b40 100644 --- a/lib/oscillators/_index.md +++ b/lib/oscillators/_index.md @@ -23,6 +23,7 @@ Oscillators fluctuate above and below a centerline or within bounded ranges. Use | [DPO](dpo/Dpo.md) | Detrended Price Oscillator | Removes trend via displaced SMA. Reveals cycles. | | [DSTOCH](dstoch/Dstoch.md) | Double Stochastic (Bressert) | Stochastic applied to Stochastic with EMA smoothing. Bounded 0-100. | | [DYMI](dymi/Dymi.md) | Dynamic Momentum Index | RSI with volatility-adaptive period. Shorter in volatile markets. | +| [EEO](eeo/Eeo.md) | Ehlers Elegant Oscillator | IFT of RMS-normalized momentum with Super Smoother. TASC Feb 2022. | | [ER](er/Er.md) | Efficiency Ratio | Measures directional efficiency. Net movement / total path length. | | [ERI](eri/Eri.md) | Elder Ray Index | Separates bull and bear power relative to EMA. | | [FI](fi/Fi.md) | Force Index | Combines price change, direction, and volume to measure buying/selling power. | diff --git a/lib/oscillators/eeo/Eeo.Quantower.cs b/lib/oscillators/eeo/Eeo.Quantower.cs new file mode 100644 index 00000000..4e487dc9 --- /dev/null +++ b/lib/oscillators/eeo/Eeo.Quantower.cs @@ -0,0 +1,56 @@ +using System.Drawing; +using System.Runtime.CompilerServices; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +[SkipLocalsInit] +public sealed class EeoIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("BandEdge", sortIndex: 1, 2, 1000, 1, 0)] + public int BandEdge { get; set; } = 20; + + [IndicatorExtensions.DataSourceInput] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Eeo _ma = null!; + private readonly LineSeries _series; + private string _sourceName = null!; + private Func _priceSelector = null!; + + public static int MinHistoryDepths => 0; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"EEO {BandEdge}:{_sourceName}"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/eeo/Eeo.Quantower.cs"; + + public EeoIndicator() + { + OnBackGround = true; + SeparateWindow = true; + _sourceName = Source.ToString(); + Name = "EEO - Ehlers Elegant Oscillator"; + Description = "Inverse Fisher Transform of RMS-normalized momentum with Super Smoother"; + _series = new LineSeries(name: $"EEO {BandEdge}", color: Color.Yellow, width: 2, style: LineStyle.Solid); + AddLineSeries(_series); + } + + protected override void OnInit() + { + _ma = new Eeo(BandEdge); + _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 = _ma.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar()); + _series.SetValue(result.Value, _ma.IsHot, ShowColdValues); + } +} diff --git a/lib/oscillators/eeo/Eeo.cs b/lib/oscillators/eeo/Eeo.cs new file mode 100644 index 00000000..e34703ba --- /dev/null +++ b/lib/oscillators/eeo/Eeo.cs @@ -0,0 +1,335 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// EEO: Ehlers Elegant Oscillator +/// +/// +/// An Inverse Fisher Transform (IFT) applied to RMS-normalized 2-bar momentum, +/// smoothed by a 2-pole Super Smoother filter. Output bounded approximately [-1, +1]. +/// +/// Calculation: +/// Deriv = Close - Close[2] +/// RMS = √(Σ(Deriv²) / 50) (fixed 50-bar window) +/// NDeriv = Deriv / RMS +/// IFish = tanh(NDeriv) = (e^(2·NDeriv) - 1) / (e^(2·NDeriv) + 1) +/// SS = c1/2 * (IFish + IFish[1]) + c2*SS[1] + c3*SS[2] +/// +/// Detailed documentation +/// Reference Pine Script implementation +[SkipLocalsInit] +public sealed class Eeo : AbstractBase +{ + [StructLayout(LayoutKind.Auto)] + private record struct State( + double Deriv, double Src1, double Src2, + double SumSquared, + double IFish1, + double SS, double SS1, + int Count, double LastValid) + { + public static State New() => new() + { + Deriv = 0, Src1 = 0, Src2 = 0, + SumSquared = 0, + IFish1 = 0, + SS = 0, SS1 = 0, + Count = 0, LastValid = 0 + }; + } + + private const int RmsWindow = 50; + private const double MinRms = 1e-10; + + private readonly int _bandEdge; + private readonly double _c1Half; + private readonly double _c2; + private readonly double _c3; + private readonly double _rmsRecip; + + private State _s = State.New(); + private State _ps = State.New(); + + // RingBuffer for deriv² values — enables O(1) rolling RMS + private readonly RingBuffer _derivSqBuf; + + /// + /// Creates EEO with specified band edge period. + /// + /// Super Smoother cutoff period (must be ≥ 2) + public Eeo(int bandEdge = 20) + { + if (bandEdge < 2) + { + throw new ArgumentOutOfRangeException(nameof(bandEdge), bandEdge, "BandEdge must be at least 2."); + } + + _bandEdge = bandEdge; + _rmsRecip = 1.0 / RmsWindow; + + // Super Smoother (2-pole Butterworth) at BandEdge cutoff + double a1 = Math.Exp(-1.414 * Math.PI / bandEdge); + double b1 = 2.0 * a1 * Math.Cos(1.414 * Math.PI / bandEdge); + _c2 = b1; + _c3 = -(a1 * a1); + double c1 = 1.0 - _c2 - _c3; + _c1Half = c1 * 0.5; + + _derivSqBuf = new RingBuffer(RmsWindow); + + Name = $"Eeo({bandEdge})"; + WarmupPeriod = RmsWindow + bandEdge; + } + + /// + /// Creates EEO with specified source and band edge. + /// Subscribes to source.Pub event. + /// + public Eeo(ITValuePublisher source, int bandEdge = 20) : this(bandEdge) + { + source.Pub += Handle; + } + + /// + /// Creates EEO with a TSeries source, primes from history, then subscribes. + /// + public Eeo(TSeries source, int bandEdge = 20) : this(bandEdge) + { + Prime(source.Values); + if (source.Count > 0) + { + Last = new TValue(source.LastTime, Last.Value); + } + source.Pub += Handle; + } + + public override bool IsHot => _s.Count >= RmsWindow + _bandEdge; + + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) + { + if (source.Length == 0) + { + return; + } + + _s = State.New(); + _ps = State.New(); + _derivSqBuf.Clear(); + + int len = source.Length; + for (int i = 0; i < len; i++) + { + double val = source[i]; + if (double.IsFinite(val)) + { + _s.LastValid = val; + } + else + { + val = _s.LastValid; + } + + Step(val); + } + + Last = new TValue(DateTime.MinValue, ComputeResult()); + _ps = _s; + _derivSqBuf.Snapshot(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double GetValidValue(double input, ref State s) + { + if (double.IsFinite(input)) + { + s.LastValid = input; + return input; + } + return s.LastValid; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public override TValue Update(TValue input, bool isNew = true) + { + if (isNew) + { + _ps = _s; + _derivSqBuf.Snapshot(); + } + else + { + _s = _ps; + _derivSqBuf.Restore(); + } + + double val = GetValidValue(input.Value, ref _s); + Step(val); + double result = ComputeResult(); + + Last = new TValue(input.Time, result); + PubEvent(Last, isNew); + return Last; + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public override TSeries Update(TSeries source) + { + if (source.Count == 0) + { + return []; + } + + int len = source.Count; + var t = new List(len); + var v = new List(len); + CollectionsMarshal.SetCount(t, len); + CollectionsMarshal.SetCount(v, len); + + var tSpan = CollectionsMarshal.AsSpan(t); + var vSpan = CollectionsMarshal.AsSpan(v); + + source.Times.CopyTo(tSpan); + + Reset(); + for (int i = 0; i < len; i++) + { + double val = source.Values[i]; + if (double.IsFinite(val)) + { + _s.LastValid = val; + } + else + { + val = _s.LastValid; + } + + Step(val); + vSpan[i] = ComputeResult(); + } + + _ps = _s; + _derivSqBuf.Snapshot(); + Last = new TValue(tSpan[len - 1], vSpan[len - 1]); + + return new TSeries(t, v); + } + + /// + /// Core streaming step: derivative → RMS buffer update → IFT → SSF. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Step(double input) + { + _s.Count++; + + // 2-bar momentum (derivative) + double deriv = input - _s.Src2; + + // Update RMS buffer with deriv² + double derivSq = deriv * deriv; + double removed = _derivSqBuf.Add(derivSq); + _s.SumSquared = Math.FusedMultiplyAdd(-1.0, removed, _s.SumSquared + derivSq); + + // RMS normalization + double rms = Math.Sqrt(Math.Max(_s.SumSquared * _rmsRecip, MinRms)); + double nDeriv = rms > MinRms ? deriv / rms : 0.0; + + // Inverse Fisher Transform: tanh(nDeriv) + double iFish = Math.Tanh(nDeriv); + + // Super Smoother filter + double ss; + if (_s.Count <= 2) + { + ss = 0.0; + } + else + { + ss = Math.FusedMultiplyAdd(_c1Half, iFish + _s.IFish1, + Math.FusedMultiplyAdd(_c2, _s.SS, _c3 * _s.SS1)); + } + + // Update state + _s.IFish1 = iFish; + _s.SS1 = _s.SS; + _s.SS = ss; + _s.Deriv = deriv; + _s.Src2 = _s.Src1; + _s.Src1 = input; + } + + /// + /// Returns the current Super Smoother output. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double ComputeResult() => _s.SS; + + /// + /// Batch calculation returning a TSeries. + /// + public static TSeries Batch(TSeries source, int bandEdge = 20) + { + var indicator = new Eeo(bandEdge); + return indicator.Update(source); + } + + /// + /// Batch calculation writing to a pre-allocated output span. Zero-allocation hot path. + /// + public static void Batch(ReadOnlySpan source, Span output, int bandEdge = 20) + { + if (source.Length != output.Length) + { + throw new ArgumentException("Source and output must have the same length", nameof(output)); + } + if (bandEdge < 2) + { + throw new ArgumentOutOfRangeException(nameof(bandEdge), bandEdge, "BandEdge must be at least 2."); + } + + if (source.Length == 0) + { + return; + } + + var indicator = new Eeo(bandEdge); + for (int i = 0; i < source.Length; i++) + { + double val = source[i]; + if (double.IsFinite(val)) + { + indicator._s.LastValid = val; + } + else + { + val = indicator._s.LastValid; + } + + indicator.Step(val); + output[i] = indicator.ComputeResult(); + } + } + + /// + /// Creates a hot indicator from historical data, ready for streaming. + /// + public static (TSeries Results, Eeo Indicator) Calculate(TSeries source, int bandEdge = 20) + { + var indicator = new Eeo(bandEdge); + TSeries results = indicator.Update(source); + return (results, indicator); + } + + public override void Reset() + { + _s = State.New(); + _ps = _s; + _derivSqBuf.Clear(); + Last = default; + } +} diff --git a/lib/oscillators/eeo/Eeo.md b/lib/oscillators/eeo/Eeo.md new file mode 100644 index 00000000..f5afd72d --- /dev/null +++ b/lib/oscillators/eeo/Eeo.md @@ -0,0 +1,107 @@ +# EEO: Ehlers Elegant Oscillator + +A bounded zero-crossing oscillator that applies the Inverse Fisher Transform to RMS-normalized 2-bar momentum, then smooths the result with a 2-pole Super Smoother filter. Output is approximately bounded to [-1, +1]. + +| Property | Value | +|:-------------- |:--------------------------------------------- | +| **Category** | Oscillators | +| **Author** | John F. Ehlers | +| **Source** | TASC, February 2022 | +| **Article** | "An Elegant Oscillator: Inverse Fisher Transform Redux" | +| **Input** | Single series (Close) | +| **Parameters** | BandEdge (default 20) | +| **Output** | Bounded ≈ [-1, +1] | +| **Hot after** | 50 + BandEdge bars | + +## Historical Context + +Ehlers' 2022 "Elegant Oscillator" is a refinement of his earlier DSO (2018). Where DSO applies the Fisher Transform (arctanh) to expand a normalized signal, EEO applies the **Inverse Fisher Transform** (tanh) to compress it. The IFT naturally bounds the output to [-1, +1] without needing the ±0.99 clamping that DSO requires. A Super Smoother post-filter then removes residual noise. + +## Architecture & Physics + +### Stage 1: 2-Bar Momentum (Derivative) + +$$\text{Deriv} = \text{Close} - \text{Close}[2]$$ + +This is the same "zeros" whitening used in DSO — it removes DC and Nyquist components, creating a band-limited derivative. + +### Stage 2: RMS Normalization (Fixed 50-Bar Window) + +$$\text{RMS} = \sqrt{\frac{1}{50}\sum_{k=0}^{49}\text{Deriv}[k]^2}$$ + +$$\text{NDeriv} = \frac{\text{Deriv}}{\text{RMS}}$$ + +The fixed 50-bar window (not parameterized) provides a stable normalization base. The RMS measures the "typical" derivative magnitude, so NDeriv represents "how many standard deviations" the current derivative is from zero. + +### Stage 3: Inverse Fisher Transform (tanh) + +$$\text{IFish} = \tanh(\text{NDeriv}) = \frac{e^{2 \cdot \text{NDeriv}} - 1}{e^{2 \cdot \text{NDeriv}} + 1}$$ + +The IFT compresses the normalized derivative into [-1, +1]. Values near ±1 indicate extreme momentum relative to recent history. + +### Stage 4: Super Smoother Filter (2-Pole Butterworth) + +$$a_1 = e^{-1.414\pi / \text{BandEdge}}$$ + +$$b_1 = 2 \cdot a_1 \cdot \cos\!\left(\frac{1.414 \cdot 180°}{\text{BandEdge}}\right)$$ + +$$c_2 = b_1, \quad c_3 = -a_1^2, \quad c_1 = 1 - c_2 - c_3$$ + +$$\text{SS} = \frac{c_1}{2}(\text{IFish} + \text{IFish}[1]) + c_2 \cdot \text{SS}[1] + c_3 \cdot \text{SS}[2]$$ + +The Super Smoother removes high-frequency chatter from the IFT output while preserving the phase relationship. + +## Performance Profile + +### Operation Count (Streaming Mode, Scalar) + +| Operation | Count | Notes | +|:----------------------- |:----- |:------------------------------ | +| Subtraction (Deriv) | 1 | Close - Close[2] | +| Multiply (deriv²) | 1 | For RMS buffer | +| RingBuffer add/remove | 1 | O(1) circular buffer | +| FMA (running sum) | 1 | SumSq update | +| Sqrt (RMS) | 1 | √(sum/50) | +| Division (normalize) | 1 | Deriv / RMS | +| Exp + tanh (IFT) | 1 | Math.Tanh() | +| FMA × 2 (SSF) | 2 | 2-pole recursive filter | +| **Total per bar** | **~9** | Constant O(1) | + +### Batch Mode (SIMD Analysis) + +The algorithm's IIR Super Smoother stage prevents full vectorization. Batch mode processes sequentially but avoids per-bar allocation overhead. + +### Quality Metrics + +| Metric | Score | Notes | +|:------------------- |:----- |:---------------------------------------- | +| Lag | Low | SSF has minimal phase distortion | +| Noise rejection | High | IFT compression + SSF smoothing | +| Sensitivity | High | 2-bar derivative is very responsive | +| Bounded output | Yes | ≈ [-1, +1] from tanh | +| Parameter count | 1 | Only BandEdge | + +## Validation + +EEO is validated through self-consistency tests (streaming ≡ batch ≡ span ≡ eventing) and behavioral tests (constant input → 0, trending → non-zero, symmetry). + +### Behavioral Test Summary + +| Test | Expected Result | +|:----------------------- |:------------------------- | +| Constant input | Output → 0 | +| Strong uptrend | Output > 0 | +| Strong downtrend | Output < 0 | +| Ascending vs descending | Opposite signs | +| NaN/Inf input | Finite output (fallback) | +| Bar correction (isNew) | State restored correctly | + +## Common Pitfalls + +1. **Fixed RMS window**: The 50-bar window is hardcoded per Ehlers' specification. Do not parameterize it — it provides a stable normalization base independent of BandEdge. + +2. **BandEdge vs Period**: BandEdge is the Super Smoother cutoff, not an RMS lookback. Higher BandEdge = more smoothing but more lag. + +3. **Bounded output**: Unlike DSO (which uses Fisher Transform producing unbounded output), EEO output is bounded to ≈ [-1, +1]. Signal levels of ±0.5 are typical thresholds, not ±2 as with DSO. + +4. **Warmup**: Requires 50 + BandEdge bars. The first 50 bars fill the RMS window; then BandEdge more bars are needed for SSF convergence. diff --git a/lib/oscillators/eeo/eeo.pine b/lib/oscillators/eeo/eeo.pine new file mode 100644 index 00000000..4424e74e --- /dev/null +++ b/lib/oscillators/eeo/eeo.pine @@ -0,0 +1,75 @@ +// Licensed under the Apache License, Version 2.0 +// © mihakralj +//@version=6 +indicator("Ehlers Elegant Oscillator (EEO)", "EEO", overlay = false) + +//@function Ehlers Elegant Oscillator — an Inverse Fisher Transform (IFT) applied to +// RMS-normalized 2-bar momentum, smoothed by a Super Smoother filter. +// Pipeline: Deriv = Close - Close[2] → RMS(50) → NDeriv = Deriv/RMS → +// IFish = tanh(NDeriv) → SuperSmoother(BandEdge). +// Output is bounded approximately [-1, +1]. +//@param source Series to analyze +//@param bandEdge Super Smoother cutoff period (>= 2) +//@returns EEO oscillator value (bounded ±1) +//@reference Ehlers, J.F. (2022). "An Elegant Oscillator: Inverse Fisher Transform Redux." +// Technical Analysis of Stocks & Commodities, Feb 2022. +//@optimized O(1) per bar via running sum circular buffer for RMS +eeo(series float source, simple int bandEdge) => + if bandEdge < 2 + runtime.error("BandEdge must be at least 2") + + float price = nz(source) + + // --- 2-bar momentum (derivative) --- + float deriv = price - nz(source[2]) + + // --- Rolling RMS of derivative over 50 bars --- + var array buf = array.new_float(50, 0.0) + var int head = 0 + var float sum_sq = 0.0 + + float deriv_sq = deriv * deriv + float old_sq = array.get(buf, head) + array.set(buf, head, deriv_sq) + sum_sq := sum_sq - old_sq + deriv_sq + head := (head + 1) % 50 + + float rms = math.sqrt(math.max(sum_sq / 50.0, 1e-10)) + + // --- Normalize derivative by RMS --- + float nDeriv = rms > 1e-10 ? deriv / rms : 0.0 + + // --- Inverse Fisher Transform (tanh) --- + float e2n = math.exp(2.0 * nDeriv) + float iFish = (e2n - 1.0) / (e2n + 1.0) + + // --- Super Smoother coefficients (2-pole Butterworth at BandEdge) --- + float a1 = math.exp(-1.414 * math.pi / bandEdge) + float b1 = 2.0 * a1 * math.cos(1.414 * 180.0 / bandEdge) + float c2 = b1 + float c3 = -(a1 * a1) + float c1 = 1.0 - c2 - c3 + + // --- 2-pole Super Smoother filter --- + var float ss = 0.0 + var float ss1 = 0.0 + var float ss2 = 0.0 + float iFish1 = nz(iFish[1]) + ss2 := ss1 + ss1 := ss + ss := c1 * 0.5 * (iFish + iFish1) + c2 * ss1 + c3 * ss2 + + ss + +// ── Inputs ── +int p_bandEdge = input.int(20, "BandEdge", minval = 2) +float p_src = input.source(close, "Source") + +// ── Calculation ── +float out = eeo(p_src, p_bandEdge) + +// ── Plot ── +plot(out, "EEO", color.yellow, 2) +hline(0, "Zero", color.gray) +hline(0.5, "+0.5", color.new(color.red, 60)) +hline(-0.5, "-0.5", color.new(color.green, 60)) diff --git a/lib/oscillators/eeo/tests/Eeo.Quantower.Tests.cs b/lib/oscillators/eeo/tests/Eeo.Quantower.Tests.cs new file mode 100644 index 00000000..9324b3a7 --- /dev/null +++ b/lib/oscillators/eeo/tests/Eeo.Quantower.Tests.cs @@ -0,0 +1,157 @@ +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +public class EeoIndicatorTests +{ + [Fact] + public void EeoIndicator_Constructor_SetsDefaults() + { + var indicator = new EeoIndicator(); + + Assert.Equal(20, indicator.BandEdge); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + Assert.Equal("EEO - Ehlers Elegant Oscillator", indicator.Name); + Assert.True(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void EeoIndicator_MinHistoryDepths_EqualsZero() + { + var indicator = new EeoIndicator(); + + Assert.Equal(0, EeoIndicator.MinHistoryDepths); + Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths); + } + + [Fact] + public void EeoIndicator_ShortName_IncludesBandEdgeAndSource() + { + var indicator = new EeoIndicator { BandEdge = 30 }; + + Assert.Contains("EEO", indicator.ShortName, StringComparison.Ordinal); + Assert.Contains("30", indicator.ShortName, StringComparison.Ordinal); + } + + [Fact] + public void EeoIndicator_SourceCodeLink_IsValid() + { + var indicator = new EeoIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal); + Assert.Contains("Eeo.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal); + } + + [Fact] + public void EeoIndicator_Initialize_CreatesInternalIndicator() + { + var indicator = new EeoIndicator { BandEdge = 20 }; + + indicator.Initialize(); + + // After init, one line series should exist (EEO is single output) + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void EeoIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new EeoIndicator { BandEdge = 3 }; + 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 EeoIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new EeoIndicator { BandEdge = 3 }; + 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 EeoIndicator_InternalIndicator_HandlesBarCorrection() + { + // Test the underlying Eeo with isNew=false (bar correction) + // Use zigzag data to avoid saturation + var ma = new Eeo(3); + double[] prices = [100, 102, 99, 103, 97, 104, 98, 105, 97, 106]; + + var now = DateTime.UtcNow; + for (int i = 0; i < prices.Length; i++) + { + ma.Update(new TValue(now.AddMinutes(i).Ticks, prices[i]), isNew: true); + } + + double beforeCorrection = ma.Last.Value; + + // Correct last bar with a moderately different value + ma.Update(new TValue(now.AddMinutes(9).Ticks, 100), isNew: false); + double afterCorrection = ma.Last.Value; + + Assert.NotEqual(beforeCorrection, afterCorrection); + Assert.True(double.IsFinite(afterCorrection)); + } + + [Fact] + public void EeoIndicator_DifferentSourceTypes() + { + foreach (SourceType sourceType in new[] { SourceType.Close, SourceType.Open, SourceType.High, SourceType.Low }) + { + var indicator = new EeoIndicator(); + indicator.Source = sourceType; + Assert.Equal(sourceType, indicator.Source); + } + } + + [Fact] + public void EeoIndicator_MultipleHistoricalBars() + { + var indicator = new EeoIndicator { BandEdge = 5 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + for (int i = 0; i < 20; i++) + { + indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i); + indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar)); + } + + Assert.Equal(20, indicator.LinesSeries[0].Count); + + // All values should be finite + for (int i = 0; i < 20; i++) + { + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i))); + } + } + + [Fact] + public void EeoIndicator_BandEdgeChange_UpdatesConfig() + { + var indicator = new EeoIndicator(); + indicator.BandEdge = 25; + Assert.Equal(25, indicator.BandEdge); + + indicator.BandEdge = 50; + Assert.Equal(50, indicator.BandEdge); + } +} diff --git a/lib/oscillators/eeo/tests/Eeo.Tests.cs b/lib/oscillators/eeo/tests/Eeo.Tests.cs new file mode 100644 index 00000000..30a598e2 --- /dev/null +++ b/lib/oscillators/eeo/tests/Eeo.Tests.cs @@ -0,0 +1,496 @@ +namespace QuanTAlib; + +public class EeoTests +{ + private const int DefaultBandEdge = 20; + private const double Tolerance = 1e-12; + + private static TSeries MakeSeries(int count = 500) + { + var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.5, seed: 42); + var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + return bars.Close; + } + + // ========== A) Constructor Validation ========== + + [Fact] + public void Constructor_ZeroBandEdge_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new Eeo(0)); + Assert.Equal("bandEdge", ex.ParamName); + } + + [Fact] + public void Constructor_OneBandEdge_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new Eeo(1)); + Assert.Equal("bandEdge", ex.ParamName); + } + + [Fact] + public void Constructor_NegativeBandEdge_ThrowsArgumentOutOfRangeException() + { + var ex = Assert.Throws(() => new Eeo(-5)); + Assert.Equal("bandEdge", ex.ParamName); + } + + [Fact] + public void Constructor_ValidBandEdge_SetsNameAndWarmup() + { + var indicator = new Eeo(20); + Assert.Equal("Eeo(20)", indicator.Name); + Assert.Equal(70, indicator.WarmupPeriod); // 50 + 20 + } + + [Fact] + public void Constructor_BandEdgeTwo_IsValid() + { + var indicator = new Eeo(2); + Assert.Equal("Eeo(2)", indicator.Name); + Assert.Equal(52, indicator.WarmupPeriod); // 50 + 2 + } + + // ========== B) Basic Calculation ========== + + [Fact] + public void Update_ReturnsTValue_WithValidProperties() + { + var indicator = new Eeo(DefaultBandEdge); + var input = new TValue(DateTime.UtcNow, 100.0); + TValue result = indicator.Update(input); + + Assert.Equal(input.Time, result.Time); + Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Update_AfterWarmup_IsHotBecomesTrue() + { + var indicator = new Eeo(DefaultBandEdge); + Assert.False(indicator.IsHot); + + for (int i = 0; i < 500; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.1)); + } + + Assert.True(indicator.IsHot); + } + + [Fact] + public void Update_LastProperty_MatchesReturnValue() + { + var indicator = new Eeo(DefaultBandEdge); + var input = new TValue(DateTime.UtcNow, 42.0); + TValue result = indicator.Update(input); + + Assert.Equal(result.Value, indicator.Last.Value, Tolerance); + } + + // ========== C) State + Bar Correction ========== + + [Fact] + public void IsNew_True_AdvancesState() + { + var indicator = new Eeo(10); + + // Warm up past the threshold first + for (int i = 0; i < 65; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.5), isNew: true); + } + + TValue r1 = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(80), 120.0), isNew: true); + TValue r2 = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(81), 80.0), isNew: true); + + // Two very different bars after warmup must produce different results + Assert.NotEqual(r1.Value, r2.Value); + } + + [Fact] + public void IsNew_False_RewritesCurrentBar() + { + var indicator = new Eeo(10); + + // Use mixed zigzag data to avoid saturation + double[] prices = [100, 102, 99, 103, 97, 104, 98, 105, 97, 106, + 101, 103, 98, 104, 96, 105, 99, 107, 98, 108, + 100, 102, 99, 103, 97, 104, 98, 105, 97, 106, + 101, 103, 98, 104, 96, 105, 99, 107, 98, 108, + 100, 102, 99, 103, 97, 104, 98, 105, 97, 106, + 101, 103, 98, 104, 96, 105, 99, 107, 98, 108, + 100, 102, 99, 103, 97, 104, 98, 105, 97, 106]; + + for (int i = 0; i < prices.Length; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), prices[i])); + } + + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(prices.Length), 110.0), isNew: true); + double afterNew = indicator.Last.Value; + + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(prices.Length), 90.0), isNew: false); + double afterCorrection = indicator.Last.Value; + + Assert.NotEqual(afterNew, afterCorrection); + } + + [Fact] + public void IterativeCorrections_RestoreState() + { + var indicator = new Eeo(10); + TSeries data = MakeSeries(); + + for (int i = 0; i < 80; i++) + { + indicator.Update(data[i], isNew: true); + } + + indicator.Update(data[80], isNew: true); + + for (int j = 0; j < 5; j++) + { + indicator.Update(data[80], isNew: false); + } + + double afterCorrections = indicator.Last.Value; + + var fresh = new Eeo(10); + for (int i = 0; i <= 80; i++) + { + fresh.Update(data[i], isNew: true); + } + + Assert.Equal(fresh.Last.Value, afterCorrections, Tolerance); + } + + [Fact] + public void Reset_ClearsState() + { + var indicator = new Eeo(DefaultBandEdge); + + for (int i = 0; i < 100; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i)); + } + + Assert.True(indicator.IsHot); + + indicator.Reset(); + + Assert.False(indicator.IsHot); + Assert.Equal(default, indicator.Last); + } + + // ========== D) Warmup/Convergence ========== + + [Fact] + public void IsHot_FlipsAtCorrectTime() + { + var indicator = new Eeo(10); + int hotAt = -1; + + for (int i = 0; i < 200; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.1)); + if (indicator.IsHot && hotAt < 0) + { + hotAt = i; + break; + } + } + + Assert.InRange(hotAt, 1, 200); + } + + // ========== E) Robustness ========== + + [Fact] + public void NaN_Input_UsesLastValidValue() + { + var indicator = new Eeo(10); + + for (int i = 0; i < 70; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.1)); + } + + TValue nanResult = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(70), double.NaN)); + + Assert.True(double.IsFinite(nanResult.Value)); + } + + [Fact] + public void Infinity_Input_UsesLastValidValue() + { + var indicator = new Eeo(10); + + for (int i = 0; i < 70; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.1)); + } + + TValue infResult = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(70), double.PositiveInfinity)); + Assert.True(double.IsFinite(infResult.Value)); + } + + [Fact] + public void BatchNaN_DoesNotPropagate() + { + int bandEdge = 10; + double[] source = new double[100]; + double[] output = new double[100]; + + for (int i = 0; i < 100; i++) + { + source[i] = 100.0 + i * 0.5; + } + + source[50] = double.NaN; + source[51] = double.NaN; + + Eeo.Batch(source, output, bandEdge); + + for (int i = 0; i < 100; i++) + { + Assert.True(double.IsFinite(output[i]), $"Output[{i}] is not finite"); + } + } + + // ========== F) Consistency (4 API modes) ========== + + [Fact] + public void AllModes_ProduceSameResult() + { + int bandEdge = 10; + TSeries data = MakeSeries(); + + // 1. Batch (TSeries) + TSeries batchResults = Eeo.Batch(data, bandEdge); + double expected = batchResults.Last.Value; + + // 2. Span batch + var tValues = data.Values.ToArray(); + var spanOutput = new double[tValues.Length]; + Eeo.Batch(new ReadOnlySpan(tValues), spanOutput, bandEdge); + double spanResult = spanOutput[^1]; + + // 3. Streaming + var streaming = new Eeo(bandEdge); + for (int i = 0; i < data.Count; i++) + { + streaming.Update(data[i]); + } + double streamingResult = streaming.Last.Value; + + // 4. Eventing + var pubSource = new TSeries(); + var eventBased = new Eeo(pubSource, bandEdge); + for (int i = 0; i < data.Count; i++) + { + pubSource.Add(data[i]); + } + double eventingResult = eventBased.Last.Value; + + Assert.Equal(expected, spanResult, precision: 9); + Assert.Equal(expected, streamingResult, precision: 9); + Assert.Equal(expected, eventingResult, precision: 9); + } + + // ========== G) Span API Tests ========== + + [Fact] + public void SpanBatch_MismatchedLengths_ThrowsArgumentException() + { + double[] source = new double[10]; + double[] output = new double[5]; + + var ex = Assert.Throws(() => Eeo.Batch(source, output, 5)); + Assert.Equal("output", ex.ParamName); + } + + [Fact] + public void SpanBatch_BandEdgeOne_ThrowsArgumentOutOfRangeException() + { + double[] source = new double[10]; + double[] output = new double[10]; + + Assert.Throws(() => Eeo.Batch(source, output, 1)); + } + + [Fact] + public void SpanBatch_EmptyInput_ProducesEmptyOutput() + { + double[] source = Array.Empty(); + double[] output = Array.Empty(); + var ex = Record.Exception(() => Eeo.Batch(source, output, 10)); + Assert.Null(ex); + } + + [Fact] + public void SpanBatch_LargeData_DoesNotStackOverflow() + { + int size = 5000; + double[] source = new double[size]; + double[] output = new double[size]; + + for (int i = 0; i < size; i++) + { + source[i] = 100.0 + i * 0.1; + } + + Eeo.Batch(source, output, 20); + + Assert.True(double.IsFinite(output[size - 1])); + } + + // ========== H) Chainability ========== + + [Fact] + public void Pub_EventFires_OnUpdate() + { + var indicator = new Eeo(DefaultBandEdge); + int eventCount = 0; + + indicator.Pub += (object? sender, in TValueEventArgs args) => eventCount++; + + for (int i = 0; i < 10; i++) + { + indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i)); + } + + Assert.Equal(10, eventCount); + } + + [Fact] + public void EventBased_Chaining_Works() + { + var source = new TSeries(); + var indicator = new Eeo(source, 5); + + source.Add(new TValue(DateTime.UtcNow, 100)); + source.Add(new TValue(DateTime.UtcNow, 110)); + source.Add(new TValue(DateTime.UtcNow, 120)); + + Assert.True(double.IsFinite(indicator.Last.Value)); + } + + [Fact] + public void Calculate_ReturnsHotIndicator() + { + TSeries data = MakeSeries(); + (TSeries results, Eeo indicator) = Eeo.Calculate(data, DefaultBandEdge); + + Assert.Equal(data.Count, results.Count); + Assert.True(indicator.IsHot); + } + + [Fact] + public void StaticCalculate_MatchesInstance() + { + const int bandEdge = 10; + int count = 100; + var source = new TSeries(); + var indicator = new Eeo(bandEdge); + + for (int i = 0; i < count; i++) + { + source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), i + 10)); + indicator.Update(source.Last); + } + + var staticResult = Eeo.Batch(source, bandEdge); + + Assert.Equal(source.Count, staticResult.Count); + Assert.Equal(indicator.Last.Value, staticResult.Last.Value, 8); + } + + // ========== EEO-specific: Oscillator behavior ========== + + [Fact] + public void ConstantInput_OutputConvergesToZero() + { + var indicator = new Eeo(10); + double lastResult = double.NaN; + + for (int i = 0; i < 300; i++) + { + TValue r = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0)); + lastResult = r.Value; + } + + // Constant input → deriv=0 → IFish(0)=0 → SS=0 + Assert.Equal(0.0, lastResult, 1e-10); + } + + [Fact] + public void TrendingInput_ProducesNonZero() + { + var indicator = new Eeo(10); + double lastResult = 0.0; + + for (int i = 0; i < 100; i++) + { + TValue r = indicator.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 2.0)); + lastResult = r.Value; + } + + // Strong uptrend should produce non-zero EEO + Assert.NotEqual(0.0, lastResult); + Assert.True(double.IsFinite(lastResult)); + } + + [Fact] + public void Output_IsBounded() + { + var indicator = new Eeo(10); + TSeries data = MakeSeries(500); + + for (int i = 0; i < data.Count; i++) + { + TValue r = indicator.Update(data[i]); + // EEO output should be approximately bounded [-1, +1] + // With SSF it might slightly exceed due to filter transients, but should be within [-1.5, +1.5] + Assert.InRange(r.Value, -1.5, 1.5); + } + } + + [Fact] + public void IftOutput_IsSymmetric() + { + // EEO of ascending sequence should be opposite sign to EEO of descending sequence + var up = new Eeo(10); + var down = new Eeo(10); + + for (int i = 0; i < 80; i++) + { + up.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i)); + down.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 200.0 - i)); + } + + // Both should be finite and opposite in sign + Assert.True(double.IsFinite(up.Last.Value)); + Assert.True(double.IsFinite(down.Last.Value)); + Assert.True(up.Last.Value * down.Last.Value < 0, "Ascending and descending should produce opposite signs"); + } + + [Fact] + public void EeoProducesFiniteValues_OnGBMData() + { + var indicator = new Eeo(10); + TSeries data = MakeSeries(200); + + int nonFiniteCount = 0; + for (int i = 0; i < data.Count; i++) + { + TValue r = indicator.Update(data[i]); + if (!double.IsFinite(r.Value)) + { + nonFiniteCount++; + } + } + + Assert.Equal(0, nonFiniteCount); + } +} diff --git a/python/quantalib/_bridge.py b/python/quantalib/_bridge.py index 6f97a657..849f2789 100644 --- a/python/quantalib/_bridge.py +++ b/python/quantalib/_bridge.py @@ -451,6 +451,7 @@ HAS_DOSC = _bind("qtl_dosc", [_dp, _ci, _dp, _ci, _ci, _ci, _ci]) HAS_DSO = _bind("qtl_dso", [_dp, _ci, _dp, _ci]) HAS_RSIH = _bind("qtl_rsih", [_dp, _ci, _dp, _ci]) HAS_MADH = _bind("qtl_madh", [_dp, _ci, _dp, _ci, _ci]) +HAS_EEO = _bind("qtl_eeo", [_dp, _ci, _dp, _ci]) HAS_DYMI = _bind("qtl_dymi", [_dp, _ci, _dp, _ci, _ci, _ci, _ci, _ci]) HAS_CRSI = _bind("qtl_crsi", [_dp, _ci, _dp, _ci, _ci, _ci]) HAS_BBB = _bind("qtl_bbb", [_dp, _ci, _dp, _ci, _cd]) diff --git a/python/quantalib/oscillators.py b/python/quantalib/oscillators.py index 510cf8ce..02dc59d4 100644 --- a/python/quantalib/oscillators.py +++ b/python/quantalib/oscillators.py @@ -573,6 +573,14 @@ def madh(close: object, shortLength: int = 8, dominantCycle: int = 27, offset: i return _wrap(dst, idx, f"MADH_{shortLength}_{dominantCycle}", "oscillators", int(offset)) +def eeo(close: object, bandEdge: int = 20, offset: int = 0, **kwargs) -> object: + """Ehlers Elegant Oscillator.""" + bandEdge = int(kwargs.get("length", bandEdge)); offset = int(offset) + src, idx = _arr(close); n = len(src); dst = _out(n) + _check(_lib.qtl_eeo(_ptr(src), n, _ptr(dst), bandEdge)) + return _wrap(dst, idx, f"EEO_{bandEdge}", "oscillators", offset) + + def dymi(close: object, base_period: int = 14, short_period: int = 5, long_period: int = 10, min_period: int = 3, max_period: int = 30, offset: int = 0, **kwargs) -> object: diff --git a/python/src/Exports.cs b/python/src/Exports.cs index 2d1b376d..e40a3fbe 100644 --- a/python/src/Exports.cs +++ b/python/src/Exports.cs @@ -448,6 +448,16 @@ public static unsafe partial class Exports catch { return StatusCodes.QTL_ERR_INTERNAL; } } + // Eeo: Pattern A (src, out, int bandEdge) + [UnmanagedCallersOnly(EntryPoint = "qtl_eeo")] + public static int QtlEeo(double* src, int n, double* dst, int bandEdge) + { + int v = Chk1(src, dst, n); if (v != 0) return v; + if (bandEdge < 2) return StatusCodes.QTL_ERR_INVALID_PARAM; + try { Eeo.Batch(Src(src, n), Dst(dst, n), bandEdge); return StatusCodes.QTL_OK; } + catch { return StatusCodes.QTL_ERR_INTERNAL; } + } + // Dymi: Pattern A (src, out, int p1..p5) [UnmanagedCallersOnly(EntryPoint = "qtl_dymi")] public static int QtlDymi(double* src, int n, double* dst, int p1, int p2, int p3, int p4, int p5)