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
+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}");
}
}
}