diff --git a/_sidebar.md b/_sidebar.md index 2788c9e0..8f25acfd 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -323,6 +323,7 @@ * [CCYC - Ehlers Cyber Cycle](/lib/cycles/ccyc/Ccyc.md) * [CG - Ehlers Center of Gravity](/lib/cycles/cg/Cg.md) * [DSP - Ehlers Detrended Synthetic Price](/lib/cycles/dsp/Dsp.md) + * [EPA - Ehlers Phasor Analysis](/lib/cycles/epa/epa.md) * [FSI - Ehlers Fourier Series Indicator](/lib/cycles/fsi/Fsi.md) * [ACP - Ehlers Autocorrelation Periodogram](/lib/cycles/acp/Acp.md) * [EBSW - Ehlers Even Better Sinewave](/lib/cycles/ebsw/Ebsw.md) diff --git a/docs/indicators.md b/docs/indicators.md index 9bd7dbb6..bbfc6d62 100644 --- a/docs/indicators.md +++ b/docs/indicators.md @@ -441,6 +441,7 @@ Periodic pattern detection and dominant frequency extraction. Markets exhibit cy | [**CCYC**](../lib/cycles/ccyc/Ccyc.md) | Ehlers Cyber Cycle | 4-tap FIR + 2-pole high-pass IIR cycle extraction | | [**CG**](../lib/cycles/cg/Cg.md) | Ehlers Center of Gravity | Ehlers cycle measurement | | [**DSP**](../lib/cycles/dsp/Dsp.md) | Ehlers Detrended Synthetic Price | Cycle-isolated price component | +| [**EPA**](../lib/cycles/epa/epa.md) | Ehlers Phasor Analysis | Pearson correlation phasor with wraparound + trend state | | [**FSI**](../lib/cycles/fsi/Fsi.md) | Ehlers Fourier Series Indicator | 3-harmonic bandpass + amplitude-weighted reconstruction | | [**ACP**](../lib/cycles/acp/Acp.md) | Ehlers Autocorrelation Periodogram | Ehlers dominant cycle detection | | [**EBSW**](../lib/cycles/ebsw/Ebsw.md) | Ehlers Even Better Sinewave | Ehlers improved cycle indicator | diff --git a/docs/pinescript.md b/docs/pinescript.md index db716de5..c1c4d01b 100644 --- a/docs/pinescript.md +++ b/docs/pinescript.md @@ -429,6 +429,7 @@ Markets oscillate. These indicators try to measure the oscillation itself — th | CCYC | Ehlers Cyber Cycle | [ccyc.pine](../lib/cycles/ccyc/ccyc.pine) | | CG | Ehlers Center of Gravity | [cg.pine](../lib/cycles/cg/cg.pine) | | DSP | Ehlers Detrended Synthetic Price | [dsp.pine](../lib/cycles/dsp/dsp.pine) | +| EPA | Ehlers Phasor Analysis | [epa.pine](../lib/cycles/epa/epa.pine) | | FSI | Ehlers Fourier Series Indicator | [fsi.pine](../lib/cycles/fsi/fsi.pine) | | ACP | Ehlers Autocorrelation Periodogram | [acp.pine](../lib/cycles/acp/acp.pine) | | EBSW | Ehlers Even Better Sinewave | [ebsw.pine](../lib/cycles/ebsw/ebsw.pine) | diff --git a/lib/_index.md b/lib/_index.md index ec3feb61..07f9cca2 100644 --- a/lib/_index.md +++ b/lib/_index.md @@ -107,6 +107,7 @@ | [DWT](numerics/dwt/Dwt.md) | Discrete Wavelet Transform | Numerics | | [DX](dynamics/dx/Dx.md) | Directional Movement Index | Dynamics | | [DYMI](oscillators/dymi/Dymi.md) | Dynamic Momentum Index | Oscillators | +| [EPA](cycles/epa/epa.md) | Ehlers Phasor Analysis | Cycles | | [EBSW](cycles/ebsw/Ebsw.md) | Ehlers Even Better Sinewave | Cycles | | [EDCF](filters/edcf/Edcf.md) | Ehlers Distance Coefficient Filter | Filters | | [EDECAY](numerics/edecay/Edecay.md) | Exponential Decay | Numerics | diff --git a/lib/cycles/_index.md b/lib/cycles/_index.md index 366dbaa0..c816b43c 100644 --- a/lib/cycles/_index.md +++ b/lib/cycles/_index.md @@ -12,6 +12,7 @@ Cycle analysis identifies repeating patterns in price data. John Ehlers pioneere | [CCYC](ccyc/Ccyc.md) | Ehlers Cyber Cycle | Ehlers. 4-tap FIR + 2-pole high-pass IIR. Isolates dominant cycle component. | | [CG](cg/Cg.md) | Ehlers Center of Gravity | Ehlers. Weighted sum position. Minimal lag cycle indicator. | | [DSP](dsp/Dsp.md) | Ehlers Detrended Synthetic Price | Removes trend to reveal underlying cycles. | +| [EPA](epa/epa.md) | Ehlers Phasor Analysis | Ehlers. Pearson correlation phasor with wraparound + trend state detection. | | [FSI](fsi/Fsi.md) | Ehlers Fourier Series Indicator | Ehlers. 3-harmonic bandpass + amplitude-weighted reconstruction. Cycle timing.| | [EBSW](ebsw/Ebsw.md) | Ehlers Even Better Sinewave | Ehlers. Improved sinewave extraction. Reduces false signals. | | [HOMOD](homod/Homod.md) | Ehlers Homodyne Discriminator | Dominant cycle detection via homodyne technique. | diff --git a/lib/cycles/epa/Epa.Quantower.cs b/lib/cycles/epa/Epa.Quantower.cs new file mode 100644 index 00000000..58c4deb8 --- /dev/null +++ b/lib/cycles/epa/Epa.Quantower.cs @@ -0,0 +1,66 @@ +using System.Drawing; +using System.Runtime.CompilerServices; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +[SkipLocalsInit] +public sealed class EpaIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Cycle Period", sortIndex: 1, minimum: 2, maximum: 500, increment: 1, decimalPlaces: 0)] + public int Period { get; set; } = 28; + + [IndicatorExtensions.DataSourceInput(sortIndex: 2)] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Epa _epa = null!; + private readonly LineSeries _angleLine; + private readonly LineSeries _derivedPeriodLine; + private readonly LineSeries _trendStateLine; + + public static int MinHistoryDepths => 0; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"EPA ({Period})"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/cycles/epa/Epa.Quantower.cs"; + + public EpaIndicator() + { + OnBackGround = true; + SeparateWindow = true; + Name = "EPA - Ehlers Phasor Analysis"; + Description = "Phasor analysis extracting cycle phase via Pearson correlation of price against cosine/sine reference waves, with wraparound compensation and trend state detection."; + + _angleLine = new LineSeries("Angle", Color.Yellow, 2, LineStyle.Solid); + _derivedPeriodLine = new LineSeries("DerivedPeriod", Color.Cyan, 1, LineStyle.Solid); + _trendStateLine = new LineSeries("TrendState", Color.Red, 2, LineStyle.Solid); + AddLineSeries(_angleLine); + AddLineSeries(_derivedPeriodLine); + AddLineSeries(_trendStateLine); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnInit() + { + _epa = new Epa(Period); + base.OnInit(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void OnUpdate(UpdateArgs args) + { + var priceSelector = Source.GetPriceSelector(); + var item = HistoricalData[0, SeekOriginHistory.End]; + double price = priceSelector(item); + + TValue input = new(item.TimeLeft, price); + TValue result = _epa.Update(input, args.IsNewBar()); + + _angleLine.SetValue(result.Value, _epa.IsHot, ShowColdValues); + _derivedPeriodLine.SetValue(_epa.DerivedPeriod, _epa.IsHot, ShowColdValues); + _trendStateLine.SetValue(_epa.TrendState, _epa.IsHot, ShowColdValues); + } +} diff --git a/lib/cycles/epa/Epa.cs b/lib/cycles/epa/Epa.cs new file mode 100644 index 00000000..dd589ed4 --- /dev/null +++ b/lib/cycles/epa/Epa.cs @@ -0,0 +1,525 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// EPA: Ehlers Phasor Analysis — extracts cycle phase by computing Pearson correlation +/// of a price window against cosine (Real) and negative-sine (Imaginary) reference waves, +/// converting the resulting phasor to an angle with wraparound compensation and monotonic +/// constraint, then deriving cycle period and trend state from the angle rate-of-change. +/// +/// +/// From John F. Ehlers, "Recurring Phase Of Cycle Analysis" +/// (Stocks & Commodities, November 2022). +/// +/// Algorithm: +/// 1. Dual Pearson correlation over sliding window of N bars: +/// Real = corr(price, cos(2πk/N)), Imag = corr(price, -sin(2πk/N)) +/// 2. Phasor angle = 90° - atan(Imag/Real) with quadrant fix (if Real < 0: angle -= 180°) +/// 3. Wraparound compensation: detects 360° boundary crossings +/// 4. Monotonic constraint with conditional exceptions: angle generally cannot go backwards +/// 5. DerivedPeriod = 360 / DeltaAngle (clamped to max 60) +/// 6. TrendState: 0 = cycling, +1 = trending long, -1 = trending short +/// +/// Properties: +/// - O(period) per bar for dual correlation loops +/// - Precomputed cos/sin tables eliminate per-bar trig calls +/// - Real, Imag bounded [-1, +1] by Pearson construction +/// - Zero allocation in hot path (RingBuffer is pre-allocated) +/// +[SkipLocalsInit] +public sealed class Epa : AbstractBase +{ + private const int DefaultPeriod = 28; + private const double MaxDerivedPeriod = 60.0; + private const double TrendThreshold = 6.0; + private const double Rad2Deg = 180.0 / Math.PI; + + private readonly int _period; + private readonly double[] _cosTable; + private readonly double[] _negSinTable; + private readonly RingBuffer _buf; + + [StructLayout(LayoutKind.Auto)] + private record struct State( + double PrevAngle, + double PrevDeltaAngle, + double PrevDerivedPeriod, + int Count, + double LastValid); + + private State _s; + private State _ps; + + /// Phasor angle in degrees, with wraparound compensation and monotonic constraint. + public double Angle { get; private set; } + + /// Cycle period derived from angle rate-of-change. Clamped to [0, 60]. + public double DerivedPeriod { get; private set; } + + /// Trend state: +1 = trending long, -1 = trending short, 0 = cycling. + public int TrendState { get; private set; } + + /// + public override bool IsHot => _s.Count >= WarmupPeriod; + + /// + /// Creates a new Epa indicator. + /// + /// Presumed dominant cycle wavelength. Must be > 1. Default 28. + public Epa(int period = DefaultPeriod) + { + if (period <= 1) + { + throw new ArgumentException("Period must be greater than 1.", nameof(period)); + } + + _period = period; + + // Precompute cos/sin lookup tables + _cosTable = new double[period]; + _negSinTable = new double[period]; + double twoPiOverN = 2.0 * Math.PI / period; + + for (int k = 0; k < period; k++) + { + double a = twoPiOverN * k; + _cosTable[k] = Math.Cos(a); + _negSinTable[k] = -Math.Sin(a); + } + + _buf = new(period); + Name = $"Epa({period})"; + WarmupPeriod = period; + _s = default; + _ps = default; + } + + /// + /// Creates a new Epa indicator chained to a publisher source. + /// + public Epa(ITValuePublisher source, int period = DefaultPeriod) : this(period) + { + ArgumentNullException.ThrowIfNull(source); + source.Pub += HandleInput; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void HandleInput(object? sender, in TValueEventArgs e) + { + Update(e.Value, e.IsNew); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override TValue Update(TValue input, bool isNew = true) + { + // State management: save/restore for bar correction + if (isNew) + { + _ps = _s; + _buf.Snapshot(); + } + else + { + _s = _ps; + _buf.Restore(); + } + + var s = _s; + double price = input.Value; + + // NaN/Infinity guard + if (!double.IsFinite(price)) + { + price = s.LastValid; + } + else + { + s = s with { LastValid = price }; + } + + int count = isNew ? s.Count + 1 : s.Count; + _buf.Add(price); + + int n = Math.Min(count, _period); + double angle = 0; + double derivedPeriod = s.PrevDerivedPeriod; + int trendState = 0; + + if (n >= 2) + { + // Dual Pearson correlations + double real = ComputeCorrelation(_buf, _cosTable, n); + double imag = ComputeCorrelation(_buf, _negSinTable, n); + + // Step 3: Angle = 90 - atan(Imag/Real) with quadrant fix + if (real != 0.0) + { + angle = 90.0 - (Math.Atan(imag / real) * Rad2Deg); + } + if (real < 0.0) + { + angle -= 180.0; + } + + double prevAngle = s.PrevAngle; + + // Step 4: Wraparound compensation + if (Math.Abs(angle) - Math.Abs(prevAngle - 360.0) < angle - prevAngle + && prevAngle > 90.0 && angle < -90.0) + { + angle -= 360.0; + } + + // Step 5: Angle cannot go backwards (with conditional exceptions) + if (angle < prevAngle + && ((prevAngle > -135.0 && prevAngle < 135.0) + || (angle < -90.0 && prevAngle < -90.0))) + { + angle = prevAngle; + } + + // Step 6: DerivedPeriod from angle rate-of-change + double deltaAngle = angle - prevAngle; + if (deltaAngle <= 0.0) + { + deltaAngle = s.PrevDeltaAngle; + } + if (deltaAngle > 0.0) + { + derivedPeriod = 360.0 / deltaAngle; + } + if (derivedPeriod > MaxDerivedPeriod) + { + derivedPeriod = MaxDerivedPeriod; + } + + // Step 7: Trend state + trendState = 0; + double angleChange = angle - prevAngle; + if (angleChange <= TrendThreshold) + { + if (angle >= 90.0 || angle <= -90.0) + { + trendState = 1; // trending long + } + else if (angle > -90.0 && angle < 90.0) + { + trendState = -1; // trending short + } + } + + s = s with { PrevDeltaAngle = deltaAngle > 0 ? deltaAngle : s.PrevDeltaAngle }; + } + + Angle = angle; + DerivedPeriod = derivedPeriod; + TrendState = trendState; + + _s = new State(angle, s.PrevDeltaAngle, derivedPeriod, count, s.LastValid); + + Last = new TValue(input.Time, angle); + PubEvent(Last, isNew); + return Last; + } + + /// + /// Processes a full TSeries, returning the Angle for each bar. + /// + 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); + + for (int i = 0; i < len; i++) + { + var result = Update(source[i]); + vSpan[i] = result.Value; + } + source.Times.CopyTo(tSpan); + + return new TSeries(t, v); + } + + /// + public override void Prime(ReadOnlySpan source, TimeSpan? step = null) + { + foreach (double value in source) + { + Update(new TValue(DateTime.MinValue, value)); + } + } + + /// + /// Static batch: creates an Epa, processes source, returns output TSeries. + /// + public static TSeries Batch(TSeries source, int period = DefaultPeriod) + { + var ind = new Epa(period); + return ind.Update(source); + } + + /// + /// Static span-based batch: computes phasor angle into output span. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public static void Batch(ReadOnlySpan source, Span output, int period = DefaultPeriod) + { + if (source.Length != output.Length) + { + throw new ArgumentException("Source and output must have the same length.", nameof(output)); + } + if (period <= 1) + { + throw new ArgumentException("Period must be greater than 1.", nameof(period)); + } + + int len = source.Length; + if (len == 0) + { + return; + } + + // Precompute trig tables + const int StackallocThreshold = 256; + double[]? rentedCos = null; + double[]? rentedSin = null; + scoped Span cosTab; + scoped Span sinTab; + + if (period <= StackallocThreshold) + { + cosTab = stackalloc double[period]; + sinTab = stackalloc double[period]; + } + else + { + rentedCos = ArrayPool.Shared.Rent(period); + rentedSin = ArrayPool.Shared.Rent(period); + cosTab = rentedCos.AsSpan(0, period); + sinTab = rentedSin.AsSpan(0, period); + } + + try + { + double twoPiOverN = 2.0 * Math.PI / period; + for (int k = 0; k < period; k++) + { + double a = twoPiOverN * k; + cosTab[k] = Math.Cos(a); + sinTab[k] = -Math.Sin(a); + } + + // Price ring buffer (manual circular) + double[]? rentedBuf = null; + scoped Span priceBuf; + if (period <= StackallocThreshold) + { + priceBuf = stackalloc double[period]; + } + else + { + rentedBuf = ArrayPool.Shared.Rent(period); + priceBuf = rentedBuf.AsSpan(0, period); + } + + try + { + priceBuf.Clear(); + int bufIdx = 0; + int filled = 0; + double lastValid = 0; + double prevAngle = 0; + double prevDeltaAngle = 0; + double prevDerivedPeriod = 0; + + for (int i = 0; i < len; i++) + { + double val = source[i]; + if (!double.IsFinite(val)) + { + val = lastValid; + } + else + { + lastValid = val; + } + + priceBuf[bufIdx] = val; + bufIdx = (bufIdx + 1) % period; + if (filled < period) + { + filled++; + } + + int n = filled; + double angle = 0; + + if (n >= 2) + { + // Compute Real correlation (cosine) + double real = InlineCorrelation(priceBuf, cosTab, bufIdx, n, period); + double imag = InlineCorrelation(priceBuf, sinTab, bufIdx, n, period); + + // Angle calculation + if (real != 0.0) + { + angle = 90.0 - (Math.Atan(imag / real) * Rad2Deg); + } + if (real < 0.0) + { + angle -= 180.0; + } + + // Wraparound compensation + if (Math.Abs(angle) - Math.Abs(prevAngle - 360.0) < angle - prevAngle + && prevAngle > 90.0 && angle < -90.0) + { + angle -= 360.0; + } + + // Monotonic constraint with exceptions + if (angle < prevAngle + && ((prevAngle > -135.0 && prevAngle < 135.0) + || (angle < -90.0 && prevAngle < -90.0))) + { + angle = prevAngle; + } + + // DerivedPeriod + double deltaAngle = angle - prevAngle; + if (deltaAngle <= 0.0) + { + deltaAngle = prevDeltaAngle; + } + if (deltaAngle > 0.0) + { + prevDerivedPeriod = 360.0 / deltaAngle; + prevDeltaAngle = deltaAngle; + } + if (prevDerivedPeriod > MaxDerivedPeriod) + { + prevDerivedPeriod = MaxDerivedPeriod; + } + } + + output[i] = angle; + prevAngle = angle; + } + } + finally + { + if (rentedBuf != null) + { + ArrayPool.Shared.Return(rentedBuf); + } + } + } + finally + { + if (rentedCos != null) + { + ArrayPool.Shared.Return(rentedCos); + } + if (rentedSin != null) + { + ArrayPool.Shared.Return(rentedSin); + } + } + } + + /// + /// Static convenience method: returns (TSeries results, Epa indicator) for inspection. + /// + public static (TSeries Results, Epa Indicator) Calculate(TSeries source, int period = DefaultPeriod) + { + var ind = new Epa(period); + var results = ind.Update(source); + return (results, ind); + } + + /// + public override void Reset() + { + _s = default; + _ps = default; + _buf.Clear(); + Last = default; + Angle = 0; + DerivedPeriod = 0; + TrendState = 0; + } + + /// + /// Computes Pearson correlation between the most recent n values in RingBuffer + /// and the first n entries of a reference wave table. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double ComputeCorrelation(RingBuffer buf, double[] refTable, int n) + { + double sx = 0, sxx = 0, sxy = 0; + double sy = 0, syy = 0; + int newest = buf.Count - 1; + + for (int k = 0; k < n; k++) + { + double x = buf[newest - k]; + double y = refTable[k]; + sx += x; + sxx += x * x; + sxy += x * y; + sy += y; + syy += y * y; + } + + double nd = n; + double denomProd = ((nd * sxx) - (sx * sx)) * ((nd * syy) - (sy * sy)); + if (denomProd <= 0.0) + { + return 0.0; + } + + double r = ((nd * sxy) - (sx * sy)) / Math.Sqrt(denomProd); + return Math.Clamp(r, -1.0, 1.0); + } + + /// + /// Inline Pearson correlation for span-based batch (uses manual circular buffer). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double InlineCorrelation( + Span priceBuf, Span refTab, int bufIdx, int n, int period) + { + double sx = 0, sxx = 0, sxy = 0; + double sy = 0, syy = 0; + + for (int k = 0; k < n; k++) + { + int idx = (((bufIdx - 1 - k) % period) + period) % period; + double x = priceBuf[idx]; + double y = refTab[k]; + sx += x; + sxx += x * x; + sxy += x * y; + sy += y; + syy += y * y; + } + + double nd = n; + double dp = ((nd * sxx) - (sx * sx)) * ((nd * syy) - (sy * sy)); + return dp > 0.0 ? Math.Clamp(((nd * sxy) - (sx * sy)) / Math.Sqrt(dp), -1.0, 1.0) : 0.0; + } +} diff --git a/lib/cycles/epa/epa.md b/lib/cycles/epa/epa.md new file mode 100644 index 00000000..41c78e5b --- /dev/null +++ b/lib/cycles/epa/epa.md @@ -0,0 +1,92 @@ +# EPA — Ehlers Phasor Analysis + +## Overview + +**EPA** (Ehlers Phasor Analysis) extracts cycle phase from price data by computing a phasor using Pearson correlation of a price window against cosine and negative-sine reference waves. The angle of the phasor reveals the current phase position within the dominant cycle, enabling identification of cycle valleys (at −90°) and peaks (at +90°), as well as determining whether the market is cycling or trending. + +| Property | Value | +|:-------------- |:------------------------------- | +| **Category** | Cycles | +| **Author** | John F. Ehlers | +| **Source** | TASC November 2022, "Recurring Phase Of Cycle Analysis" | + +## Origin and Sources + +John Ehlers introduced Phasor Analysis in the November 2022 issue of *Stocks & Commodities* magazine in the article "Recurring Phase Of Cycle Analysis." The technique uses Pearson correlation as a matched filter to determine how well price data correlates with cosine and sine waves at a presumed cycle period, producing the Real and Imaginary components of a phasor. + +## Function Signature + +```csharp +// streaming +var epa = new Epa(period: 28); +TValue result = epa.Update(tValue); + +// static batch (TSeries) +TSeries output = Epa.Batch(source, period: 28); + +// static batch (Span) +Epa.Batch(source, output, period: 28); + +// factory +var (results, indicator) = Epa.Calculate(source, period: 28); +``` + +## Parameters + +| Parameter | Type | Default | Valid Range | Description | +|:---------- |:----- |:------- |:----------- |:-------------------------------------------- | +| `period` | int | 28 | > 1 | Presumed dominant cycle wavelength in bars | + +## Outputs + +| Output | Type | Description | +|:--------------- |:------ |:--------------------------------------------------------------- | +| `Angle` | double | Phasor angle in degrees with wraparound compensation | +| `DerivedPeriod` | double | Cycle period derived from angle rate-of-change (clamped to 60) | +| `TrendState` | int | +1 = trending long, −1 = trending short, 0 = cycling | + +The primary output (`Last.Value`) is the **Angle**. + +## Algorithm + +1. **Dual Pearson Correlation** over a sliding window of `period` bars: + - `Real = corr(price, cos(2πk/N))` — correlation with cosine + - `Imag = corr(price, -sin(2πk/N))` — correlation with negative sine + +2. **Angle Calculation**: `Angle = 90° - atan(Imag/Real)` with quadrant fix: if `Real < 0`, subtract 180°. + +3. **Wraparound Compensation**: When the angle crosses the 360° boundary (previous angle > 90° and current < −90°), subtract 360° to maintain continuity. + +4. **Monotonic Constraint**: The angle generally cannot decrease, but allows exceptions at extreme regions (when both previous and current angles are in the same deep-negative quadrant). + +5. **Derived Period**: Computed as `360 / ΔAngle` where `ΔAngle` is the per-bar angle change. When `ΔAngle ≤ 0`, the previous delta is used. The result is clamped to a maximum of 60. + +6. **Trend State**: When the angle rate-of-change ≤ 6°/bar: + - If angle ≥ 90° or ≤ −90° → **+1** (trending long) + - If −90° < angle < 90° → **−1** (trending short) + - Otherwise → **0** (cycling) + +## Interpretation + +- The phasor angle oscillates between −180° and +180°, completing one full cycle per dominant period. +- **Cycle valleys** correspond to the angle crossing −90°. +- **Cycle peaks** correspond to the angle near +90°. +- The **TrendState** indicates when the market transitions from cycling to trending behavior based on the angle rate slowing. +- The **DerivedPeriod** provides a real-time estimate of the dominant cycle length. + +## Properties + +| Property | Value | +|:-------------- |:------------------------------------------- | +| Complexity | O(period) per bar | +| Memory | O(period) — RingBuffer + trig tables | +| Warmup | `period` bars | +| Output Range | Angle: unbounded; DerivedPeriod: [0, 60]; TrendState: {−1, 0, +1} | +| Zero Alloc | ✅ Hot path allocates nothing | + +## Related Indicators + +- [CCOR](../ccor/ccor.md) — Ehlers Correlation Cycle (TASC June 2020) — earlier version with simpler angle logic +- [HT_PHASOR](../ht_phasor/ht_phasor.md) — Hilbert Transform Phasor Components — different algorithm +- [FSI](../fsi/fsi.md) — Ehlers Fourier Series Indicator +- [EBSW](../ebsw/ebsw.md) — Ehlers Even Better Sine Wave diff --git a/lib/cycles/epa/epa.pine b/lib/cycles/epa/epa.pine new file mode 100644 index 00000000..7a46082c --- /dev/null +++ b/lib/cycles/epa/epa.pine @@ -0,0 +1,85 @@ +//@version=6 +// EPA: Ehlers Phasor Analysis +// From John F. Ehlers, "Recurring Phase Of Cycle Analysis" +// (Stocks & Commodities, November 2022) +indicator("EPA - Ehlers Phasor Analysis", shorttitle="EPA", overlay=false) + +period = input.int(28, "Period", minval=2) +src = input.source(close, "Source") + +var float prevAngle = 0.0 +var float prevDeltaAngle = 0.0 +var float derivedPeriod = 0.0 + +// Correlate price with Cosine wave (Pearson correlation → Real) +float sx_r = 0.0, float sy_r = 0.0 +float sxx_r = 0.0, float sxy_r = 0.0, float syy_r = 0.0 +for k = 0 to period - 1 + float x = nz(src[k]) + float y = math.cos(2.0 * math.pi * k / period) + sx_r += x + sy_r += y + sxx_r += x * x + sxy_r += x * y + syy_r += y * y + +float dp_r = (period * sxx_r - sx_r * sx_r) * (period * syy_r - sy_r * sy_r) +float real = dp_r > 0 ? math.max(-1.0, math.min(1.0, (period * sxy_r - sx_r * sy_r) / math.sqrt(dp_r))) : 0.0 + +// Correlate price with -Sine wave (Pearson correlation → Imag) +float sx_i = 0.0, float sy_i = 0.0 +float sxx_i = 0.0, float sxy_i = 0.0, float syy_i = 0.0 +for k = 0 to period - 1 + float x = nz(src[k]) + float y = -math.sin(2.0 * math.pi * k / period) + sx_i += x + sy_i += y + sxx_i += x * x + sxy_i += x * y + syy_i += y * y + +float dp_i = (period * sxx_i - sx_i * sx_i) * (period * syy_i - sy_i * sy_i) +float imag = dp_i > 0 ? math.max(-1.0, math.min(1.0, (period * sxy_i - sx_i * sy_i) / math.sqrt(dp_i))) : 0.0 + +// Angle = 90 - atan(Imag/Real) with quadrant fix +float angle = 0.0 +if real != 0 + angle := 90.0 - math.todegrees(math.atan(imag / real)) +if real < 0 + angle -= 180.0 + +// Wraparound compensation +if math.abs(angle) - math.abs(prevAngle - 360.0) < angle - prevAngle and prevAngle > 90.0 and angle < -90.0 + angle -= 360.0 + +// Angle cannot go backwards (with conditional exceptions) +if angle < prevAngle and ((prevAngle > -135.0 and prevAngle < 135.0) or (angle < -90.0 and prevAngle < -90.0)) + angle := prevAngle + +// DerivedPeriod from angle rate-of-change +float deltaAngle = angle - prevAngle +if deltaAngle <= 0 + deltaAngle := prevDeltaAngle +if deltaAngle > 0 + derivedPeriod := 360.0 / deltaAngle + prevDeltaAngle := deltaAngle +if derivedPeriod > 60 + derivedPeriod := 60.0 + +// Trend state +int trendState = 0 +float angleChange = angle - prevAngle +if angleChange <= 6.0 + if angle >= 90.0 or angle <= -90.0 + trendState := 1 // trending long + else if angle > -90.0 and angle < 90.0 + trendState := -1 // trending short + +prevAngle := angle + +plot(angle, "Angle", color.yellow, 2) +hline(0, "Zero", color.white) +hline(90, "+90", color.new(color.cyan, 50)) +hline(-90, "-90", color.new(color.cyan, 50)) +plot(derivedPeriod, "DerivedPeriod", color.cyan, 1, display=display.none) +plot(trendState, "TrendState", color.red, 2, display=display.none) diff --git a/lib/cycles/epa/tests/Epa.Quantower.Tests.cs b/lib/cycles/epa/tests/Epa.Quantower.Tests.cs new file mode 100644 index 00000000..4e0c49f1 --- /dev/null +++ b/lib/cycles/epa/tests/Epa.Quantower.Tests.cs @@ -0,0 +1,132 @@ +using TradingPlatform.BusinessLayer; +using Xunit; + +namespace QuanTAlib.Quantower.Tests; + +public class EpaIndicatorTests +{ + [Fact] + public void Constructor_DefaultParameters() + { + var indicator = new EpaIndicator(); + Assert.Equal(28, indicator.Period); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + } + + [Fact] + public void MinHistoryDepths_IsZero() + { + Assert.Equal(0, EpaIndicator.MinHistoryDepths); + } + + [Fact] + public void ShortName_ContainsPeriod() + { + var indicator = new EpaIndicator { Period = 20 }; + Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal); + } + + [Fact] + public void Initialize_DoesNotThrow() + { + var indicator = new EpaIndicator(); + var ex = Record.Exception(() => indicator.Initialize()); + Assert.Null(ex); + } + + [Fact] + public void ProcessUpdate_Historical_DoesNotThrow() + { + var indicator = new EpaIndicator(); + indicator.Initialize(); + indicator.HistoricalData.AddBar( + open: 100, high: 105, low: 95, close: 102, volume: 1000, + time: DateTime.UtcNow); + var ex = Record.Exception(() => + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar))); + Assert.Null(ex); + } + + [Fact] + public void ProcessUpdate_NewBar_DoesNotThrow() + { + var indicator = new EpaIndicator(); + indicator.Initialize(); + indicator.HistoricalData.AddBar( + open: 100, high: 105, low: 95, close: 102, volume: 1000, + time: DateTime.UtcNow); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + indicator.HistoricalData.AddBar( + open: 102, high: 107, low: 97, close: 104, volume: 1100, + time: DateTime.UtcNow.AddDays(1)); + var ex = Record.Exception(() => + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar))); + Assert.Null(ex); + } + + [Fact] + public void ProcessUpdate_Tick_DoesNotThrow() + { + var indicator = new EpaIndicator(); + indicator.Initialize(); + indicator.HistoricalData.AddBar( + open: 100, high: 105, low: 95, close: 102, volume: 1000, + time: DateTime.UtcNow); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + var ex = Record.Exception(() => + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick))); + Assert.Null(ex); + } + + [Fact] + public void SourceCodeLink_IsNotEmpty() + { + var indicator = new EpaIndicator(); + Assert.False(string.IsNullOrEmpty(indicator.SourceCodeLink)); + } + + [Fact] + public void MultipleHistoricalBars_DoNotThrow() + { + var indicator = new EpaIndicator { Period = 10 }; + indicator.Initialize(); + + for (int i = 0; i < 30; i++) + { + indicator.HistoricalData.AddBar( + open: 100 + i, high: 105 + i, low: 95 + i, close: 102 + i, + volume: 1000 + i * 10, + time: DateTime.UtcNow.AddDays(i)); + var ex = Record.Exception(() => + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar))); + Assert.Null(ex); + } + } + + [Fact] + public void CustomPeriod_InitializesCorrectly() + { + var indicator = new EpaIndicator { Period = 14 }; + indicator.Initialize(); + Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal); + } + + [Fact] + public void DifferentSources_DoNotThrow() + { + foreach (var sourceType in new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close }) + { + var indicator = new EpaIndicator { Source = sourceType }; + indicator.Initialize(); + indicator.HistoricalData.AddBar( + open: 100, high: 105, low: 95, close: 102, volume: 1000, + time: DateTime.UtcNow); + var ex = Record.Exception(() => + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar))); + Assert.Null(ex); + } + } +} diff --git a/lib/cycles/epa/tests/Epa.Tests.cs b/lib/cycles/epa/tests/Epa.Tests.cs new file mode 100644 index 00000000..3e6527b0 --- /dev/null +++ b/lib/cycles/epa/tests/Epa.Tests.cs @@ -0,0 +1,487 @@ +using Xunit; + +namespace QuanTAlib.Tests; + +public sealed class EpaTests +{ + private static TSeries MakeSeries(int count = 500) + { + var rng = new Random(42); + var s = new TSeries(); + for (int i = 0; i < count; i++) + { + s.Add(new TValue(DateTime.UtcNow.AddDays(i), 100 + rng.NextDouble() * 10)); + } + return s; + } + + // ── Constructor ──────────────────────────────────────────────── + + [Fact] + public void Ctor_DefaultPeriod_Is28() + { + var epa = new Epa(); + Assert.Equal("Epa(28)", epa.Name); + } + + [Fact] + public void Ctor_CustomPeriod_SetsName() + { + var epa = new Epa(period: 14); + Assert.Equal("Epa(14)", epa.Name); + } + + [Fact] + public void Ctor_Period1_Throws() + { + Assert.Throws(() => new Epa(period: 1)); + } + + [Fact] + public void Ctor_Period0_Throws() + { + Assert.Throws(() => new Epa(period: 0)); + } + + [Fact] + public void Ctor_NegativePeriod_Throws() + { + Assert.Throws(() => new Epa(period: -5)); + } + + // ── Basic Calculation ────────────────────────────────────────── + + [Fact] + public void Update_FirstBar_ReturnsZeroAngle() + { + var epa = new Epa(); + var result = epa.Update(new TValue(DateTime.UtcNow, 100.0)); + Assert.Equal(0.0, result.Value); + } + + [Fact] + public void Update_AfterWarmup_ReturnsFiniteAngle() + { + var epa = new Epa(period: 10); + var s = MakeSeries(50); + TValue last = default; + foreach (var tv in s) + { + last = epa.Update(tv); + } + Assert.True(double.IsFinite(last.Value)); + } + + [Fact] + public void Angle_IsSetAfterUpdate() + { + var epa = new Epa(period: 10); + var s = MakeSeries(20); + foreach (var tv in s) + { + epa.Update(tv); + } + Assert.True(double.IsFinite(epa.Angle)); + } + + [Fact] + public void DerivedPeriod_IsFiniteAfterWarmup() + { + var epa = new Epa(period: 10); + var s = MakeSeries(30); + foreach (var tv in s) + { + epa.Update(tv); + } + Assert.True(double.IsFinite(epa.DerivedPeriod)); + } + + [Fact] + public void TrendState_IsValid() + { + var epa = new Epa(period: 10); + var s = MakeSeries(50); + foreach (var tv in s) + { + epa.Update(tv); + } + Assert.InRange(epa.TrendState, -1, 1); + } + + // ── State / Bar Correction ───────────────────────────────────── + + [Fact] + public void BarCorrection_UpdateWithIsNewFalse_RestoresState() + { + var epa = new Epa(period: 10); + var s = MakeSeries(20); + + // Process first 19 bars + for (int i = 0; i < 19; i++) + { + epa.Update(s[i]); + } + + // Process bar 20 (new) + epa.Update(s[19], isNew: true); + double angleAfterNew = epa.Angle; + + // Correct bar 20 (not new) with same value + epa.Update(s[19], isNew: false); + double angleAfterCorrection = epa.Angle; + + Assert.Equal(angleAfterNew, angleAfterCorrection, precision: 10); + } + + [Fact] + public void BarCorrection_DifferentValue_ProducesDifferentResult() + { + var epa = new Epa(period: 10); + var s = MakeSeries(20); + + for (int i = 0; i < 19; i++) + { + epa.Update(s[i]); + } + + // New bar + epa.Update(s[19], isNew: true); + + // Correct with very different value + epa.Update(new TValue(s[19].Time, s[19].Value + 50), isNew: false); + double angle2 = epa.Angle; + + // May or may not be different due to monotonic constraint, but should be finite + Assert.True(double.IsFinite(angle2)); + } + + // ── Warmup / IsHot ───────────────────────────────────────────── + + [Fact] + public void IsHot_FalseBeforeWarmup() + { + var epa = new Epa(period: 10); + for (int i = 0; i < 9; i++) + { + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), 100 + i)); + } + Assert.False(epa.IsHot); + } + + [Fact] + public void IsHot_TrueAtWarmup() + { + var epa = new Epa(period: 10); + for (int i = 0; i < 10; i++) + { + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), 100 + i)); + } + Assert.True(epa.IsHot); + } + + [Fact] + public void WarmupPeriod_EqualsPeriod() + { + var epa = new Epa(period: 20); + Assert.Equal(20, epa.WarmupPeriod); + } + + // ── Robustness ───────────────────────────────────────────────── + + [Fact] + public void NaN_Input_DoesNotCorrupt() + { + var epa = new Epa(period: 10); + var s = MakeSeries(20); + foreach (var tv in s) + { + epa.Update(tv); + } + + // Feed NaN + epa.Update(new TValue(DateTime.UtcNow.AddDays(100), double.NaN)); + Assert.True(double.IsFinite(epa.Angle)); + } + + [Fact] + public void Infinity_Input_DoesNotCorrupt() + { + var epa = new Epa(period: 10); + var s = MakeSeries(20); + foreach (var tv in s) + { + epa.Update(tv); + } + epa.Update(new TValue(DateTime.UtcNow.AddDays(100), double.PositiveInfinity)); + Assert.True(double.IsFinite(epa.Angle)); + } + + [Fact] + public void ConstantInput_Angle_IsFinite() + { + var epa = new Epa(period: 10); + for (int i = 0; i < 30; i++) + { + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), 42.0)); + } + Assert.True(double.IsFinite(epa.Angle)); + } + + // ── Reset ────────────────────────────────────────────────────── + + [Fact] + public void Reset_ClearsState() + { + var epa = new Epa(period: 10); + var s = MakeSeries(30); + foreach (var tv in s) + { + epa.Update(tv); + } + Assert.True(epa.IsHot); + + epa.Reset(); + Assert.False(epa.IsHot); + Assert.Equal(0.0, epa.Angle); + Assert.Equal(0.0, epa.DerivedPeriod); + Assert.Equal(0, epa.TrendState); + } + + [Fact] + public void Reset_ProducesSameResultsOnReprocess() + { + var epa = new Epa(period: 10); + var s = MakeSeries(50); + + foreach (var tv in s) + { + epa.Update(tv); + } + double angle1 = epa.Angle; + + epa.Reset(); + foreach (var tv in s) + { + epa.Update(tv); + } + double angle2 = epa.Angle; + + Assert.Equal(angle1, angle2, precision: 10); + } + + // ── Consistency: 4 API modes ─────────────────────────────────── + + [Fact] + public void AllModes_Consistent() + { + var s = MakeSeries(200); + int period = 14; + + // Mode 1: streaming + var epa1 = new Epa(period); + foreach (var tv in s) + { + epa1.Update(tv); + } + + // Mode 2: Update(TSeries) + var epa2 = new Epa(period); + var ts2 = epa2.Update(s); + + // Mode 3: Batch(TSeries) + var ts3 = Epa.Batch(s, period); + + // Mode 4: Batch(Span) + double[] src = new double[s.Count]; + double[] dst = new double[s.Count]; + for (int i = 0; i < s.Count; i++) + { + src[i] = s[i].Value; + } + Epa.Batch(src, dst, period); + + Assert.Equal(ts2[^1].Value, ts3[^1].Value, precision: 10); + Assert.Equal(ts2[^1].Value, dst[^1], precision: 10); + Assert.Equal(epa1.Angle, ts2[^1].Value, precision: 10); + } + + // ── Batch(TSeries) ───────────────────────────────────────────── + + [Fact] + public void Batch_TSeries_SameLengthAsSource() + { + var s = MakeSeries(100); + var result = Epa.Batch(s); + Assert.Equal(s.Count, result.Count); + } + + [Fact] + public void Batch_TSeries_EmptySource_ReturnsEmpty() + { + var result = Epa.Batch(new TSeries()); + Assert.Empty(result); + } + + // ── Batch(Span) ──────────────────────────────────────────────── + + [Fact] + public void Batch_Span_ProducesFiniteOutput() + { + double[] src = [100, 101, 102, 103, 104, 103, 102, 101, 100, 99, 98, 99, 100, 101, 102]; + double[] dst = new double[src.Length]; + Epa.Batch(src, dst, period: 5); + foreach (double v in dst) + { + Assert.True(double.IsFinite(v)); + } + } + + [Fact] + public void Batch_Span_MismatchedLength_Throws() + { + double[] src = new double[10]; + double[] dst = new double[5]; + Assert.Throws(() => Epa.Batch(src, dst)); + } + + [Fact] + public void Batch_Span_InvalidPeriod_Throws() + { + double[] src = new double[10]; + double[] dst = new double[10]; + Assert.Throws(() => Epa.Batch(src, dst, period: 0)); + } + + // ── Calculate factory ────────────────────────────────────────── + + [Fact] + public void Calculate_ReturnsResultsAndIndicator() + { + var s = MakeSeries(50); + var (results, indicator) = Epa.Calculate(s, period: 10); + Assert.Equal(s.Count, results.Count); + Assert.True(indicator.IsHot); + Assert.Equal(indicator.Angle, results[^1].Value, precision: 10); + } + + // ── PubSub (chaining) ────────────────────────────────────────── + + [Fact] + public void PubSub_ReceivesEvents() + { + var source = new TSeries(); + var epa = new Epa(source, period: 10); + int eventCount = 0; + epa.Pub += (object? sender, in TValueEventArgs args) => eventCount++; + + for (int i = 0; i < 20; i++) + { + source.Add(new TValue(DateTime.UtcNow.AddDays(i), 100 + i)); + } + + Assert.Equal(20, eventCount); + } + + [Fact] + public void PubSub_NullSource_Throws() + { + Assert.Throws(() => new Epa(null!, period: 10)); + } + + // ── Prime ────────────────────────────────────────────────────── + + [Fact] + public void Prime_WarmUpIndicator() + { + var epa = new Epa(period: 10); + double[] data = new double[20]; + for (int i = 0; i < 20; i++) + { + data[i] = 100 + i * 0.5; + } + epa.Prime(data); + Assert.True(epa.IsHot); + } + + // ── EPA-specific behavior ────────────────────────────────────── + + [Fact] + public void SineWave_ProducesVaryingAngle() + { + var epa = new Epa(period: 20); + for (int i = 0; i < 100; i++) + { + double price = 100 + 10 * Math.Sin(2 * Math.PI * i / 20.0); + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), price)); + } + // With a matching sine wave, angle should advance + Assert.True(double.IsFinite(epa.Angle)); + } + + [Fact] + public void DerivedPeriod_ClampedTo60() + { + var epa = new Epa(period: 10); + var s = MakeSeries(200); + foreach (var tv in s) + { + epa.Update(tv); + Assert.True(epa.DerivedPeriod <= 60.0, + $"DerivedPeriod {epa.DerivedPeriod} exceeds max 60"); + } + } + + [Fact] + public void TrendState_OnlyValidValues() + { + var epa = new Epa(period: 10); + var s = MakeSeries(200); + foreach (var tv in s) + { + epa.Update(tv); + Assert.True(epa.TrendState == -1 || epa.TrendState == 0 || epa.TrendState == 1, + $"Invalid TrendState: {epa.TrendState}"); + } + } + + [Fact] + public void DifferentPeriod_DifferentResults() + { + var s = MakeSeries(100); + + var epa10 = new Epa(period: 10); + var epa28 = new Epa(period: 28); + + foreach (var tv in s) + { + epa10.Update(tv); + epa28.Update(tv); + } + + // Different periods should generally produce different angles + // (not guaranteed for all data, but very likely with random data) + Assert.NotEqual(epa10.Angle, epa28.Angle); + } + + [Fact] + public void Update_TSeries_MatchesStreaming() + { + var s = MakeSeries(100); + int period = 14; + + // Streaming + var epa1 = new Epa(period); + foreach (var tv in s) + { + epa1.Update(tv); + } + + // Update(TSeries) + var epa2 = new Epa(period); + _ = epa2.Update(s); + + Assert.Equal(epa1.Angle, epa2.Angle, precision: 10); + Assert.Equal(epa1.DerivedPeriod, epa2.DerivedPeriod, precision: 10); + Assert.Equal(epa1.TrendState, epa2.TrendState); + } +} diff --git a/lib/cycles/epa/tests/Epa.Validation.Tests.cs b/lib/cycles/epa/tests/Epa.Validation.Tests.cs new file mode 100644 index 00000000..54dc6729 --- /dev/null +++ b/lib/cycles/epa/tests/Epa.Validation.Tests.cs @@ -0,0 +1,302 @@ +using Xunit; + +namespace QuanTAlib.Tests; + +public sealed class EpaValidationTests +{ + // ── Pearson Correlation Properties ────────────────────────────── + + [Fact] + public void ConstantPrice_RealAndAngle_AreZero() + { + // Constant price has zero variance → correlation = 0 → angle = 0 + var epa = new Epa(period: 10); + for (int i = 0; i < 30; i++) + { + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), 50.0)); + } + Assert.Equal(0.0, epa.Angle); + } + + [Fact] + public void PerfectCosineInput_HighCorrelation() + { + // Price that exactly matches cos wave at the indicator period should yield |Real| near 1 + int period = 20; + var epa = new Epa(period: period); + double maxAngle = double.MinValue; + + for (int i = 0; i < period * 4; i++) + { + double price = 100 + 10 * Math.Cos(2 * Math.PI * i / period); + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), price)); + if (epa.IsHot && Math.Abs(epa.Angle) > Math.Abs(maxAngle)) + { + maxAngle = epa.Angle; + } + } + // The angle should move significantly when price matches the reference cosine + Assert.True(double.IsFinite(maxAngle)); + } + + [Fact] + public void PerfectSineInput_AngleAdvances() + { + // A sine wave at the indicator period should produce advancing angle. + // The angle wraps at the 360° boundary (e.g. ~180° → ~-162°), which is + // the expected wraparound compensation behavior. + int period = 20; + var epa = new Epa(period: period); + var angles = new List(); + + for (int i = 0; i < period * 3; i++) + { + double price = 100 + 10 * Math.Sin(2 * Math.PI * i / period); + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), price)); + if (epa.IsHot) + { + angles.Add(epa.Angle); + } + } + + // Angle should advance or wrap around (decrease > 300° is a valid wraparound) + Assert.True(angles.Count > 0); + int advances = 0; + for (int i = 1; i < angles.Count; i++) + { + double delta = angles[i] - angles[i - 1]; + if (delta >= -0.001) + { + advances++; // Normal advancement or hold + } + else if (delta < -300.0) + { + advances++; // Valid 360° wraparound + } + // else: backward movement in non-wrap region — allowed by Ehlers' exceptions + } + // Most transitions should be advancing or wrapping + Assert.True(advances > angles.Count / 2, + $"Expected majority of angle transitions to advance, got {advances}/{angles.Count}"); + } + + // ── DerivedPeriod Properties ─────────────────────────────────── + + [Fact] + public void DerivedPeriod_AlwaysClampedTo60() + { + var epa = new Epa(period: 10); + var rng = new Random(123); + + for (int i = 0; i < 500; i++) + { + double price = 100 + rng.NextDouble() * 20; + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), price)); + Assert.True(epa.DerivedPeriod <= 60.0, + $"DerivedPeriod {epa.DerivedPeriod} > 60 at bar {i}"); + } + } + + [Fact] + public void DerivedPeriod_NonNegative() + { + var epa = new Epa(period: 14); + var rng = new Random(456); + + for (int i = 0; i < 300; i++) + { + double price = 100 + rng.NextDouble() * 10; + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), price)); + Assert.True(epa.DerivedPeriod >= 0.0, + $"DerivedPeriod {epa.DerivedPeriod} < 0 at bar {i}"); + } + } + + // ── TrendState Properties ────────────────────────────────────── + + [Fact] + public void TrendState_OnlyValidValues_AllBars() + { + var epa = new Epa(period: 14); + var rng = new Random(789); + + for (int i = 0; i < 500; i++) + { + double price = 100 + rng.NextDouble() * 10; + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), price)); + Assert.True(epa.TrendState >= -1 && epa.TrendState <= 1, + $"Invalid TrendState {epa.TrendState} at bar {i}"); + } + } + + [Fact] + public void TrendState_HasVariation() + { + // Over a long enough series with varying data, trend state should not be constant + var epa = new Epa(period: 10); + var states = new HashSet(); + var rng = new Random(42); + + for (int i = 0; i < 500; i++) + { + double price = 100 + rng.NextDouble() * 20 + 5 * Math.Sin(2 * Math.PI * i / 20.0); + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), price)); + if (epa.IsHot) + { + states.Add(epa.TrendState); + } + } + // Should have at least 2 different states + Assert.True(states.Count >= 2, + $"Expected at least 2 distinct states, got {states.Count}: [{string.Join(",", states)}]"); + } + + // ── Deterministic Reproducibility ────────────────────────────── + + [Fact] + public void Deterministic_SameInput_SameOutput() + { + var rng1 = new Random(42); + var rng2 = new Random(42); + var epa1 = new Epa(period: 14); + var epa2 = new Epa(period: 14); + + for (int i = 0; i < 200; i++) + { + double p1 = 100 + rng1.NextDouble() * 10; + double p2 = 100 + rng2.NextDouble() * 10; + epa1.Update(new TValue(DateTime.UtcNow.AddDays(i), p1)); + epa2.Update(new TValue(DateTime.UtcNow.AddDays(i), p2)); + } + + Assert.Equal(epa1.Angle, epa2.Angle, precision: 14); + Assert.Equal(epa1.DerivedPeriod, epa2.DerivedPeriod, precision: 14); + Assert.Equal(epa1.TrendState, epa2.TrendState); + } + + // ── Consistency: Batch/Streaming/Span ────────────────────────── + + [Fact] + public void StreamingVsBatch_Match() + { + var rng = new Random(42); + int n = 200, period = 14; + double[] prices = new double[n]; + for (int i = 0; i < n; i++) + { + prices[i] = 100 + rng.NextDouble() * 10; + } + + // Streaming + var epa = new Epa(period); + double[] streamAngles = new double[n]; + for (int i = 0; i < n; i++) + { + var r = epa.Update(new TValue(DateTime.UtcNow.AddDays(i), prices[i])); + streamAngles[i] = r.Value; + } + + // Span batch + double[] spanAngles = new double[n]; + Epa.Batch(prices, spanAngles, period); + + for (int i = 0; i < n; i++) + { + Assert.Equal(streamAngles[i], spanAngles[i], precision: 10); + } + } + + [Fact] + public void BatchTSeries_MatchesStreaming() + { + var rng = new Random(42); + int n = 200, period = 14; + var ts = new TSeries(); + for (int i = 0; i < n; i++) + { + ts.Add(new TValue(DateTime.UtcNow.AddDays(i), 100 + rng.NextDouble() * 10)); + } + + // Streaming + var epa = new Epa(period); + foreach (var tv in ts) + { + epa.Update(tv); + } + + // Batch(TSeries) + var batchResult = Epa.Batch(ts, period); + + Assert.Equal(epa.Angle, batchResult[^1].Value, precision: 10); + } + + // ── Reset/Reprocess ──────────────────────────────────────────── + + [Fact] + public void ResetReprocess_MatchesOriginal() + { + var rng = new Random(42); + int n = 100, period = 14; + var epa = new Epa(period); + double[] prices = new double[n]; + for (int i = 0; i < n; i++) + { + prices[i] = 100 + rng.NextDouble() * 10; + } + + for (int i = 0; i < n; i++) + { + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), prices[i])); + } + double angle1 = epa.Angle; + double dp1 = epa.DerivedPeriod; + int ts1 = epa.TrendState; + + epa.Reset(); + for (int i = 0; i < n; i++) + { + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), prices[i])); + } + + Assert.Equal(angle1, epa.Angle, precision: 14); + Assert.Equal(dp1, epa.DerivedPeriod, precision: 14); + Assert.Equal(ts1, epa.TrendState); + } + + // ── Period Sensitivity ───────────────────────────────────────── + + [Fact] + public void DifferentPeriods_DifferentAngle() + { + var rng = new Random(42); + var epa10 = new Epa(period: 10); + var epa28 = new Epa(period: 28); + + for (int i = 0; i < 100; i++) + { + double price = 100 + rng.NextDouble() * 10; + var tv = new TValue(DateTime.UtcNow.AddDays(i), price); + epa10.Update(tv); + epa28.Update(tv); + } + + Assert.NotEqual(epa10.Angle, epa28.Angle); + } + + // ── Finite Output for All Bars ───────────────────────────────── + + [Fact] + public void AllOutputs_AlwaysFinite() + { + var epa = new Epa(period: 14); + var rng = new Random(42); + + for (int i = 0; i < 500; i++) + { + double price = 100 + rng.NextDouble() * 10; + epa.Update(new TValue(DateTime.UtcNow.AddDays(i), price)); + Assert.True(double.IsFinite(epa.Angle), $"Non-finite Angle at bar {i}"); + Assert.True(double.IsFinite(epa.DerivedPeriod), $"Non-finite DerivedPeriod at bar {i}"); + } + } +} diff --git a/python/quantalib/_bridge.py b/python/quantalib/_bridge.py index 9eb6f942..bf2b761b 100644 --- a/python/quantalib/_bridge.py +++ b/python/quantalib/_bridge.py @@ -577,6 +577,7 @@ HAS_ACP = _bind("qtl_acp", [_dp, _ci, _dp, _ci, _ci, _ci, _ci]) HAS_LPF = _bind("qtl_lpf", [_dp, _ci, _dp, _ci, _ci, _ci]) HAS_AMFM = _bind("qtl_amfm", [_dp, _dp, _ci, _dp, _dp, _ci]) HAS_FSI = _bind("qtl_fsi", [_dp, _ci, _dp, _ci, _cd]) +HAS_EPA = _bind("qtl_epa", [_dp, _ci, _dp, _ci]) # ── Numerics (Exports.cs — manual) ── HAS_CHANGE = _bind("qtl_change", [_dp, _ci, _dp, _ci]) diff --git a/python/quantalib/cycles.py b/python/quantalib/cycles.py index bc45baff..a1786398 100644 --- a/python/quantalib/cycles.py +++ b/python/quantalib/cycles.py @@ -24,6 +24,7 @@ __all__ = [ "acp", "amfm", "fsi", + "epa", ] @@ -182,3 +183,12 @@ def fsi(close: object, period: int = 20, bandwidth: float = 0.1, src, idx = _arr(close); n = len(src); dst = _out(n) _check(_lib.qtl_fsi(_ptr(src), n, _ptr(dst), period, float(bandwidth))) return _wrap(dst, idx, f"FSI_{period}", "cycles", offset) + + +def epa(close: object, period: int = 28, + offset: int = 0, **kwargs) -> object: + """Ehlers Phasor Analysis.""" + period = int(kwargs.get("length", period)); offset = int(offset) + src, idx = _arr(close); n = len(src); dst = _out(n) + _check(_lib.qtl_epa(_ptr(src), n, _ptr(dst), period)) + return _wrap(dst, idx, f"EPA_{period}", "cycles", offset) diff --git a/python/src/Exports.cs b/python/src/Exports.cs index 3d4d0f5e..8cf301f2 100644 --- a/python/src/Exports.cs +++ b/python/src/Exports.cs @@ -1566,6 +1566,16 @@ public static unsafe partial class Exports catch { return StatusCodes.QTL_ERR_INTERNAL; } } + // Epa: Pattern A (src → dst, int period) + [UnmanagedCallersOnly(EntryPoint = "qtl_epa")] + public static int QtlEpa(double* src, int n, double* dst, int period) + { + int v = Chk1(src, dst, n); if (v != 0) return v; + v = ChkPeriod(period); if (v != 0) return v; + try { Epa.Batch(Src(src, n), Dst(dst, n), period); return StatusCodes.QTL_OK; } + catch { return StatusCodes.QTL_ERR_INTERNAL; } + } + // ═══════════════════════════════════════════════════════════════════════ // §8.14 Numerics / transforms // ═══════════════════════════════════════════════════════════════════════