feat: add EEO (Ehlers Elegant Oscillator) - TASC Feb 2022

This commit is contained in:
Miha Kralj
2026-03-17 14:18:57 -07:00
parent b9e6a70890
commit f7dcc20f7c
14 changed files with 1250 additions and 0 deletions
+1
View File
@@ -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)
+1
View File
@@ -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 |
+1
View File
@@ -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) |
+1
View File
@@ -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 |
+1
View File
@@ -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. |
+56
View File
@@ -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<IHistoryItem, double> _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);
}
}
+335
View File
@@ -0,0 +1,335 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// EEO: Ehlers Elegant Oscillator
/// </summary>
/// <remarks>
/// 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:
/// <c>Deriv = Close - Close[2]</c>
/// <c>RMS = √(Σ(Deriv²) / 50)</c> (fixed 50-bar window)
/// <c>NDeriv = Deriv / RMS</c>
/// <c>IFish = tanh(NDeriv) = (e^(2·NDeriv) - 1) / (e^(2·NDeriv) + 1)</c>
/// <c>SS = c1/2 * (IFish + IFish[1]) + c2*SS[1] + c3*SS[2]</c>
/// </remarks>
/// <seealso href="Eeo.md">Detailed documentation</seealso>
/// <seealso href="eeo.pine">Reference Pine Script implementation</seealso>
[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;
/// <summary>
/// Creates EEO with specified band edge period.
/// </summary>
/// <param name="bandEdge">Super Smoother cutoff period (must be ≥ 2)</param>
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;
}
/// <summary>
/// Creates EEO with specified source and band edge.
/// Subscribes to source.Pub event.
/// </summary>
public Eeo(ITValuePublisher source, int bandEdge = 20) : this(bandEdge)
{
source.Pub += Handle;
}
/// <summary>
/// Creates EEO with a TSeries source, primes from history, then subscribes.
/// </summary>
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<double> 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<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);
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);
}
/// <summary>
/// Core streaming step: derivative → RMS buffer update → IFT → SSF.
/// </summary>
[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;
}
/// <summary>
/// Returns the current Super Smoother output.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double ComputeResult() => _s.SS;
/// <summary>
/// Batch calculation returning a TSeries.
/// </summary>
public static TSeries Batch(TSeries source, int bandEdge = 20)
{
var indicator = new Eeo(bandEdge);
return indicator.Update(source);
}
/// <summary>
/// Batch calculation writing to a pre-allocated output span. Zero-allocation hot path.
/// </summary>
public static void Batch(ReadOnlySpan<double> source, Span<double> 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();
}
}
/// <summary>
/// Creates a hot indicator from historical data, ready for streaming.
/// </summary>
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;
}
}
+107
View File
@@ -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.
+75
View File
@@ -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<float> 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))
@@ -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);
}
}
+496
View File
@@ -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<ArgumentOutOfRangeException>(() => new Eeo(0));
Assert.Equal("bandEdge", ex.ParamName);
}
[Fact]
public void Constructor_OneBandEdge_ThrowsArgumentOutOfRangeException()
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Eeo(1));
Assert.Equal("bandEdge", ex.ParamName);
}
[Fact]
public void Constructor_NegativeBandEdge_ThrowsArgumentOutOfRangeException()
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => 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<double>(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<ArgumentException>(() => 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<ArgumentOutOfRangeException>(() => Eeo.Batch(source, output, 1));
}
[Fact]
public void SpanBatch_EmptyInput_ProducesEmptyOutput()
{
double[] source = Array.Empty<double>();
double[] output = Array.Empty<double>();
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);
}
}
+1
View File
@@ -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])
+8
View File
@@ -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:
+10
View File
@@ -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)