feat: add EPA (Ehlers Phasor Analysis) indicator - TASC Nov 2022

This commit is contained in:
Miha Kralj
2026-03-19 09:22:04 -07:00
parent cd150a6b36
commit c98b0e2a57
15 changed files with 1715 additions and 0 deletions
+1
View File
@@ -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)
+1
View File
@@ -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 |
+1
View File
@@ -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) |
+1
View File
@@ -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 |
+1
View File
@@ -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. |
+66
View File
@@ -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);
}
}
+525
View File
@@ -0,0 +1,525 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// From John F. Ehlers, "Recurring Phase Of Cycle Analysis"
/// (Stocks &amp; 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 &lt; 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)
/// </remarks>
[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;
/// <summary>Phasor angle in degrees, with wraparound compensation and monotonic constraint.</summary>
public double Angle { get; private set; }
/// <summary>Cycle period derived from angle rate-of-change. Clamped to [0, 60].</summary>
public double DerivedPeriod { get; private set; }
/// <summary>Trend state: +1 = trending long, -1 = trending short, 0 = cycling.</summary>
public int TrendState { get; private set; }
/// <inheritdoc />
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>
/// Creates a new Epa indicator.
/// </summary>
/// <param name="period">Presumed dominant cycle wavelength. Must be &gt; 1. Default 28.</param>
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;
}
/// <summary>
/// Creates a new Epa indicator chained to a publisher source.
/// </summary>
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;
}
/// <summary>
/// Processes a full TSeries, returning the Angle for each bar.
/// </summary>
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
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);
}
/// <inheritdoc />
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
foreach (double value in source)
{
Update(new TValue(DateTime.MinValue, value));
}
}
/// <summary>
/// Static batch: creates an Epa, processes source, returns output TSeries.
/// </summary>
public static TSeries Batch(TSeries source, int period = DefaultPeriod)
{
var ind = new Epa(period);
return ind.Update(source);
}
/// <summary>
/// Static span-based batch: computes phasor angle into output span.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public static void Batch(ReadOnlySpan<double> source, Span<double> 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<double> cosTab;
scoped Span<double> sinTab;
if (period <= StackallocThreshold)
{
cosTab = stackalloc double[period];
sinTab = stackalloc double[period];
}
else
{
rentedCos = ArrayPool<double>.Shared.Rent(period);
rentedSin = ArrayPool<double>.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<double> priceBuf;
if (period <= StackallocThreshold)
{
priceBuf = stackalloc double[period];
}
else
{
rentedBuf = ArrayPool<double>.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<double>.Shared.Return(rentedBuf);
}
}
}
finally
{
if (rentedCos != null)
{
ArrayPool<double>.Shared.Return(rentedCos);
}
if (rentedSin != null)
{
ArrayPool<double>.Shared.Return(rentedSin);
}
}
}
/// <summary>
/// Static convenience method: returns (TSeries results, Epa indicator) for inspection.
/// </summary>
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);
}
/// <inheritdoc />
public override void Reset()
{
_s = default;
_ps = default;
_buf.Clear();
Last = default;
Angle = 0;
DerivedPeriod = 0;
TrendState = 0;
}
/// <summary>
/// Computes Pearson correlation between the most recent n values in RingBuffer
/// and the first n entries of a reference wave table.
/// </summary>
[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);
}
/// <summary>
/// Inline Pearson correlation for span-based batch (uses manual circular buffer).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double InlineCorrelation(
Span<double> priceBuf, Span<double> 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;
}
}
+92
View File
@@ -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
+85
View File
@@ -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)
+132
View File
@@ -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);
}
}
}
+487
View File
@@ -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<ArgumentException>(() => new Epa(period: 1));
}
[Fact]
public void Ctor_Period0_Throws()
{
Assert.Throws<ArgumentException>(() => new Epa(period: 0));
}
[Fact]
public void Ctor_NegativePeriod_Throws()
{
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => Epa.Batch(src, dst));
}
[Fact]
public void Batch_Span_InvalidPeriod_Throws()
{
double[] src = new double[10];
double[] dst = new double[10];
Assert.Throws<ArgumentException>(() => 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<ArgumentNullException>(() => 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);
}
}
@@ -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<double>();
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<int>();
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}");
}
}
}
+1
View File
@@ -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])
+10
View File
@@ -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)
+10
View File
@@ -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
// ═══════════════════════════════════════════════════════════════════════