2026-01-18 19:02:03 -08:00
|
|
|
|
using System;
|
|
|
|
|
|
|
|
|
|
|
|
namespace QuanTAlib.Tests;
|
|
|
|
|
|
|
|
|
|
|
|
public class HemaValidationTests
|
|
|
|
|
|
{
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Hema_Streaming_MatchesReference()
|
|
|
|
|
|
{
|
|
|
|
|
|
int period = 20;
|
|
|
|
|
|
TSeries series = BuildSeries(300, seed: 5);
|
|
|
|
|
|
double[] reference = new double[series.Count];
|
|
|
|
|
|
|
|
|
|
|
|
ReferenceHema(series.Values, reference, period);
|
|
|
|
|
|
|
|
|
|
|
|
var hema = new Hema(period);
|
|
|
|
|
|
for (int i = 0; i < series.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
double actual = hema.Update(series[i]).Value;
|
|
|
|
|
|
Assert.Equal(reference[i], actual, precision: 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Hema_Batch_MatchesReference()
|
|
|
|
|
|
{
|
|
|
|
|
|
int period = 14;
|
|
|
|
|
|
TSeries series = BuildSeries(250, seed: 9);
|
|
|
|
|
|
double[] reference = new double[series.Count];
|
|
|
|
|
|
|
|
|
|
|
|
ReferenceHema(series.Values, reference, period);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
TSeries batch = Hema.Batch(series, period);
|
2026-01-18 19:02:03 -08:00
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < series.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
Assert.Equal(reference[i], batch[i].Value, precision: 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Hema_Span_MatchesReference()
|
|
|
|
|
|
{
|
|
|
|
|
|
int period = 30;
|
|
|
|
|
|
TSeries series = BuildSeries(200, seed: 12);
|
|
|
|
|
|
double[] values = series.Values.ToArray();
|
|
|
|
|
|
var output = new double[values.Length];
|
|
|
|
|
|
var reference = new double[values.Length];
|
|
|
|
|
|
|
|
|
|
|
|
ReferenceHema(values, reference, period);
|
2026-02-10 21:33:16 -08:00
|
|
|
|
Hema.Batch(values, output, period);
|
2026-01-18 19:02:03 -08:00
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < values.Length; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
Assert.Equal(reference[i], output[i], precision: 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static void ReferenceHema(ReadOnlySpan<double> source, Span<double> output, int period)
|
|
|
|
|
|
{
|
2026-02-23 17:27:35 -08:00
|
|
|
|
int n = Math.Max(period, 2);
|
|
|
|
|
|
int halfN = n / 2; // integer floor, same as HMA
|
|
|
|
|
|
int sqrtN = Math.Max((int)Math.Sqrt(n), 1); // integer floor, same as HMA
|
2026-01-18 19:02:03 -08:00
|
|
|
|
|
2026-02-23 17:27:35 -08:00
|
|
|
|
double aS = AlphaFromWmaLag(n);
|
|
|
|
|
|
double aF = AlphaFromWmaLag(Math.Max(halfN, 1));
|
|
|
|
|
|
double aM = AlphaFromWmaLag(Math.Max(sqrtN, 1));
|
2026-01-18 19:02:03 -08:00
|
|
|
|
|
|
|
|
|
|
double bS = 1.0 - aS;
|
|
|
|
|
|
double bF = 1.0 - aF;
|
|
|
|
|
|
double bM = 1.0 - aM;
|
|
|
|
|
|
|
|
|
|
|
|
double lagS = bS / aS;
|
|
|
|
|
|
double lagF = bF / aF;
|
|
|
|
|
|
double ratio = Math.Clamp(lagF / lagS, 0.0, 0.999999);
|
|
|
|
|
|
double invOneMinusRatio = 1.0 / Math.Max(1.0 - ratio, 1e-12);
|
|
|
|
|
|
|
|
|
|
|
|
bool warmup = true;
|
|
|
|
|
|
double decayS = 1.0;
|
|
|
|
|
|
double decayF = 1.0;
|
|
|
|
|
|
double decayM = 1.0;
|
|
|
|
|
|
|
|
|
|
|
|
double eSraw = 0.0;
|
|
|
|
|
|
double eFraw = 0.0;
|
|
|
|
|
|
double eMraw = 0.0;
|
|
|
|
|
|
double lastValid = double.NaN;
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < source.Length; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
double val = source[i];
|
|
|
|
|
|
if (double.IsFinite(val))
|
2026-01-25 16:01:45 -08:00
|
|
|
|
{
|
2026-01-18 19:02:03 -08:00
|
|
|
|
lastValid = val;
|
2026-01-25 16:01:45 -08:00
|
|
|
|
}
|
2026-01-18 19:02:03 -08:00
|
|
|
|
else
|
2026-01-25 16:01:45 -08:00
|
|
|
|
{
|
2026-01-18 19:02:03 -08:00
|
|
|
|
val = lastValid;
|
2026-01-25 16:01:45 -08:00
|
|
|
|
}
|
2026-01-18 19:02:03 -08:00
|
|
|
|
|
|
|
|
|
|
if (double.IsNaN(val))
|
|
|
|
|
|
{
|
|
|
|
|
|
output[i] = double.NaN;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 17:27:35 -08:00
|
|
|
|
eSraw = Math.FusedMultiplyAdd(eSraw, bS, aS * val);
|
|
|
|
|
|
eFraw = Math.FusedMultiplyAdd(eFraw, bF, aF * val);
|
2026-01-18 19:02:03 -08:00
|
|
|
|
|
|
|
|
|
|
if (warmup)
|
|
|
|
|
|
{
|
|
|
|
|
|
decayS *= bS;
|
|
|
|
|
|
decayF *= bF;
|
|
|
|
|
|
decayM *= bM;
|
|
|
|
|
|
|
|
|
|
|
|
double invS = 1.0 / Math.Max(1.0 - decayS, 1e-12);
|
|
|
|
|
|
double invF = 1.0 / Math.Max(1.0 - decayF, 1e-12);
|
|
|
|
|
|
double invM = 1.0 / Math.Max(1.0 - decayM, 1e-12);
|
|
|
|
|
|
|
|
|
|
|
|
double eS = eSraw * invS;
|
|
|
|
|
|
double eF = eFraw * invF;
|
2026-02-23 17:27:35 -08:00
|
|
|
|
double deLag = Math.FusedMultiplyAdd(-ratio, eS, eF) * invOneMinusRatio;
|
2026-01-18 19:02:03 -08:00
|
|
|
|
|
2026-02-23 17:27:35 -08:00
|
|
|
|
eMraw = Math.FusedMultiplyAdd(eMraw, bM, aM * deLag);
|
2026-01-18 19:02:03 -08:00
|
|
|
|
output[i] = eMraw * invM;
|
|
|
|
|
|
|
|
|
|
|
|
double maxDecay = Math.Max(decayS, Math.Max(decayF, decayM));
|
|
|
|
|
|
warmup = maxDecay > 1e-10;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
2026-02-23 17:27:35 -08:00
|
|
|
|
double deLag = Math.FusedMultiplyAdd(-ratio, eSraw, eFraw) * invOneMinusRatio;
|
|
|
|
|
|
eMraw = Math.FusedMultiplyAdd(eMraw, bM, aM * deLag);
|
2026-01-18 19:02:03 -08:00
|
|
|
|
output[i] = eMraw;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 17:27:35 -08:00
|
|
|
|
private static double AlphaFromWmaLag(int p)
|
2026-01-18 19:02:03 -08:00
|
|
|
|
{
|
2026-02-23 17:27:35 -08:00
|
|
|
|
// WMA-lag-matched alpha: EMA lag = (1-α)/α = (P-1)/3
|
|
|
|
|
|
// Solving: α = 3/(P+2)
|
|
|
|
|
|
return 3.0 / (Math.Max(p, 1) + 2.0);
|
2026-01-18 19:02:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static TSeries BuildSeries(int count, int seed)
|
|
|
|
|
|
{
|
|
|
|
|
|
var series = new TSeries();
|
|
|
|
|
|
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: seed);
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
var bar = gbm.Next(isNew: true);
|
|
|
|
|
|
series.Add(bar.Time, bar.Close);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return series;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|