mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-07 21:47:43 +00:00
feat: add EEO (Ehlers Elegant Oscillator) - TASC Feb 2022
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user