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
// ═══════════════════════════════════════════════════════════════════════