mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-25 05:48:06 +00:00
Add TRAMA implementation and comprehensive tests
- Implemented the TRAMA (Trend Regularity Adaptive Moving Average) class with adaptive EMA logic. - Added unit tests for TRAMA functionality, including constructor validation, basic calculations, state management, and robustness checks. - Created validation tests to ensure consistency across different modes of operation (streaming, batch, and static calculations). - Enhanced documentation for TRAMA, including performance profiles and quality metrics. - Updated workspace configuration by removing unnecessary folder references.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class GdemaIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsDefaults()
|
||||
{
|
||||
var ind = new GdemaIndicator();
|
||||
Assert.Equal(10, ind.Period);
|
||||
Assert.Equal(1.0, ind.VFactor);
|
||||
Assert.Equal(SourceType.Close, ind.Source);
|
||||
Assert.True(ind.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_CreatesLineSeries()
|
||||
{
|
||||
var ind = new GdemaIndicator();
|
||||
ind.Initialize();
|
||||
Assert.Single(ind.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
Assert.Equal(0, GdemaIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SourceCodeLink_IsValid()
|
||||
{
|
||||
var ind = new GdemaIndicator();
|
||||
Assert.Contains("Gdema.Quantower.cs", ind.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortName_IncludesPeriodAndSource()
|
||||
{
|
||||
var ind = new GdemaIndicator();
|
||||
ind.Initialize();
|
||||
Assert.Contains("GDEMA", ind.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("10", ind.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Period_CanBeChanged()
|
||||
{
|
||||
var ind = new GdemaIndicator { Period = 20, VFactor = 1.5 };
|
||||
ind.Initialize();
|
||||
Assert.Equal(20, ind.Period);
|
||||
Assert.Equal(1.5, ind.VFactor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var ind = new GdemaIndicator { Period = 3 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
ind.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
ind.ProcessUpdate(args);
|
||||
|
||||
Assert.Equal(1, ind.LinesSeries[0].Count);
|
||||
Assert.True(double.IsFinite(ind.LinesSeries[0].GetValue(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var ind = new GdemaIndicator { Period = 3 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
ind.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
ind.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
|
||||
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, ind.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var ind = new GdemaIndicator { Period = 3 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
ind.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
ind.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 98, 106);
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
double value = ind.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentSourceTypes_Work()
|
||||
{
|
||||
foreach (var sourceType in new[] { SourceType.Close, SourceType.Open, SourceType.High, SourceType.Low })
|
||||
{
|
||||
var ind = new GdemaIndicator { Source = sourceType, Period = 3 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
ind.HistoricalData.AddBar(now, 100, 110, 90, 105);
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
double value = ind.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(value), $"Failed for source type {sourceType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public class GdemaIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
|
||||
public int Period { get; set; } = 10;
|
||||
|
||||
[InputParameter("Volume Factor (v)", sortIndex: 2, 0.0, 3.0, 0.1, 1)]
|
||||
public double VFactor { get; set; } = 1.0;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Gdema ma = null!;
|
||||
protected LineSeries Series;
|
||||
protected string SourceName = null!;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"GDEMA {Period},{VFactor:F1}:{SourceName}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends_IIR/gdema/Gdema.Quantower.cs";
|
||||
|
||||
public GdemaIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = false;
|
||||
SourceName = Source.ToString();
|
||||
Name = "GDEMA - Generalized Double Exponential Moving Average";
|
||||
Description = "Generalized Double Exponential Moving Average with tunable volume factor";
|
||||
Series = new LineSeries(name: $"GDEMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(Series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
ma = new Gdema(Period, VFactor);
|
||||
SourceName = Source.ToString();
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
|
||||
|
||||
TValue result = ma.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
|
||||
|
||||
Series.SetValue(result.Value, ma.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class GdemaTests
|
||||
{
|
||||
private static TSeries MakeSeries(int count = 500)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100, seed: 42);
|
||||
var series = new TSeries();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
series.Add(gbm.Next());
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
// ── A) Constructor validation ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultPeriod_Is10()
|
||||
{
|
||||
var gdema = new Gdema();
|
||||
Assert.Equal("Gdema(10,1.0)", gdema.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsPeriodAndVfactorName()
|
||||
{
|
||||
var gdema = new Gdema(period: 20, vfactor: 0.5);
|
||||
Assert.Equal("Gdema(20,0.5)", gdema.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Period0_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Gdema(period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Gdema(period: -5));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Period1_Valid()
|
||||
{
|
||||
var gdema = new Gdema(period: 1);
|
||||
Assert.Equal("Gdema(1,1.0)", gdema.Name);
|
||||
}
|
||||
|
||||
// ── B) Basic calculation ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
TValue result = gdema.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_LastIsAccessible()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.True(double.IsFinite(gdema.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_FirstBar_SeedsCorrectly()
|
||||
{
|
||||
var gdema = new Gdema(10, vfactor: 1.0);
|
||||
TValue result = gdema.Update(new TValue(DateTime.UtcNow, 50.0));
|
||||
// First bar: both EMAs start at source due to warmup compensation
|
||||
// GDEMA = (1+v)*EMA1 - v*EMA2 = 2*50 - 50 = 50
|
||||
Assert.Equal(50.0, result.Value, 1e-9);
|
||||
}
|
||||
|
||||
// ── C) State + bar correction ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IsNew_True_AdvancesState()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
var r1 = gdema.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
var r2 = gdema.Update(new TValue(DateTime.UtcNow, 110.0), isNew: true);
|
||||
Assert.NotEqual(r1.Value, r2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_RewritesSameBar()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
var r1 = gdema.Update(new TValue(DateTime.UtcNow, 110.0), isNew: true);
|
||||
var r2 = gdema.Update(new TValue(DateTime.UtcNow, 120.0), isNew: false);
|
||||
Assert.NotEqual(r1.Value, r2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrection_Restores()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 105.0), isNew: true);
|
||||
var before = gdema.Update(new TValue(DateTime.UtcNow, 110.0), isNew: true);
|
||||
|
||||
// Correct a few times then restore the "true" value
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 888.0), isNew: false);
|
||||
var restored = gdema.Update(new TValue(DateTime.UtcNow, 110.0), isNew: false);
|
||||
|
||||
Assert.Equal(before.Value, restored.Value, 1e-12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BarCorrection_Idempotent()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
var r1 = gdema.Update(new TValue(DateTime.UtcNow, 110.0), isNew: true);
|
||||
var r2 = gdema.Update(new TValue(DateTime.UtcNow, 110.0), isNew: false);
|
||||
Assert.Equal(r1.Value, r2.Value, 1e-12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 200.0));
|
||||
gdema.Reset();
|
||||
Assert.False(gdema.IsHot);
|
||||
Assert.Equal(default, gdema.Last);
|
||||
}
|
||||
|
||||
// ── D) Warmup / convergence ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsAtPeriod()
|
||||
{
|
||||
const int period = 10;
|
||||
var gdema = new Gdema(period);
|
||||
var gbm = new GBM(startPrice: 100, seed: 42);
|
||||
|
||||
int hotBar = -1;
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
_ = gdema.Update(gbm.Next());
|
||||
if (gdema.IsHot && hotBar < 0)
|
||||
{
|
||||
hotBar = i;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(hotBar >= 0 && hotBar < 200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_MatchesPeriod()
|
||||
{
|
||||
var gdema = new Gdema(15);
|
||||
Assert.Equal(15, gdema.WarmupPeriod);
|
||||
}
|
||||
|
||||
// ── E) Robustness ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NaN_UsesLastValidValue()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
var result = gdema.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_UsesLastValidValue()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
var result = gdema.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllNaN_ReturnsNaN()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
var result = gdema.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsNaN(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchNaN_Safe()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
var result = gdema.Update(new TValue(DateTime.UtcNow, 110.0));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
// ── F) Consistency (4 modes) ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AllModes_Match()
|
||||
{
|
||||
const int period = 10;
|
||||
const double vfactor = 1.0;
|
||||
var source = MakeSeries(200);
|
||||
|
||||
// Mode 1: Streaming
|
||||
var streaming = new Gdema(period, vfactor);
|
||||
var streamResults = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamResults[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
// Mode 2: TSeries batch
|
||||
var batchResults = Gdema.Batch(source, period, vfactor);
|
||||
|
||||
// Mode 3: Span batch
|
||||
double[] srcArr = source.Values.ToArray();
|
||||
double[] spanResults = new double[srcArr.Length];
|
||||
Gdema.Batch(srcArr.AsSpan(), spanResults.AsSpan(), period, vfactor);
|
||||
|
||||
// Mode 4: Event-based
|
||||
var eventSource = new TSeries();
|
||||
var eventGdema = new Gdema(eventSource, period, vfactor);
|
||||
var eventResults = new List<double>();
|
||||
eventGdema.Pub += (object? sender, in TValueEventArgs e) => eventResults.Add(e.Value.Value);
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
eventSource.Add(source[i], true);
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], batchResults[i].Value, 1e-9);
|
||||
Assert.Equal(streamResults[i], spanResults[i], 1e-9);
|
||||
Assert.Equal(streamResults[i], eventResults[i], 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
// ── G) Span API tests ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_LengthMismatch_Throws()
|
||||
{
|
||||
double[] src = [1.0, 2.0, 3.0];
|
||||
double[] output = new double[2];
|
||||
var ex = Assert.Throws<ArgumentException>(() => Gdema.Batch(src.AsSpan(), output.AsSpan(), 10));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_InvalidPeriod_Throws()
|
||||
{
|
||||
double[] src = [1.0, 2.0];
|
||||
double[] output = new double[2];
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Gdema.Batch(src.AsSpan(), output.AsSpan(), 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_EmptySource_NoOp()
|
||||
{
|
||||
Span<double> src = [];
|
||||
Span<double> output = [];
|
||||
Gdema.Batch(src, output, 10);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_MatchesTSeries()
|
||||
{
|
||||
const int period = 10;
|
||||
const double vfactor = 1.5;
|
||||
var source = MakeSeries(300);
|
||||
|
||||
var tsResult = Gdema.Batch(source, period, vfactor);
|
||||
double[] srcArr = source.Values.ToArray();
|
||||
double[] spanResult = new double[srcArr.Length];
|
||||
Gdema.Batch(srcArr.AsSpan(), spanResult.AsSpan(), period, vfactor);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(tsResult[i].Value, spanResult[i], 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_LargeData_NoStackOverflow()
|
||||
{
|
||||
const int size = 10_000;
|
||||
double[] src = new double[size];
|
||||
double[] output = new double[size];
|
||||
var gbm = new GBM(startPrice: 100, seed: 42);
|
||||
for (int i = 0; i < size; i++)
|
||||
{
|
||||
src[i] = gbm.Next().Close;
|
||||
}
|
||||
Gdema.Batch(src.AsSpan(), output.AsSpan(), 20);
|
||||
Assert.True(double.IsFinite(output[^1]));
|
||||
}
|
||||
|
||||
// ── H) Chainability ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PubFires()
|
||||
{
|
||||
var gdema = new Gdema(10);
|
||||
int fires = 0;
|
||||
gdema.Pub += (object? sender, in TValueEventArgs e) => fires++;
|
||||
_ = gdema.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.Equal(1, fires);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventChaining_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var gdema = new Gdema(source, 10);
|
||||
source.Add(new TValue(DateTime.UtcNow, 100.0), true);
|
||||
Assert.True(double.IsFinite(gdema.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnsubscribesPublisher()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var gdema = new Gdema(source, 10);
|
||||
gdema.Dispose();
|
||||
source.Add(new TValue(DateTime.UtcNow, 999.0), true);
|
||||
// After dispose, gdema should not update
|
||||
Assert.NotEqual(999.0, gdema.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsBoth()
|
||||
{
|
||||
var source = MakeSeries(100);
|
||||
var (results, indicator) = Gdema.Calculate(source, 10);
|
||||
Assert.Equal(source.Count, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
|
||||
// ── Special: vfactor behavior ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Vfactor0_EqualsEma()
|
||||
{
|
||||
const int period = 10;
|
||||
var source = MakeSeries(200);
|
||||
var gdema = new Gdema(period, vfactor: 0.0);
|
||||
var ema = new Ema(period);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var gVal = gdema.Update(source[i]);
|
||||
var eVal = ema.Update(source[i]);
|
||||
Assert.Equal(eVal.Value, gVal.Value, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vfactor1_EqualsDema()
|
||||
{
|
||||
const int period = 10;
|
||||
var source = MakeSeries(200);
|
||||
var gdema = new Gdema(period, vfactor: 1.0);
|
||||
var dema = new Dema(period);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var gVal = gdema.Update(source[i]);
|
||||
var dVal = dema.Update(source[i]);
|
||||
Assert.Equal(dVal.Value, gVal.Value, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantInput_ConvergesToConstant()
|
||||
{
|
||||
var gdema = new Gdema(10, vfactor: 1.0);
|
||||
double last = 0;
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
last = gdema.Update(new TValue(DateTime.UtcNow, 42.0)).Value;
|
||||
}
|
||||
Assert.Equal(42.0, last, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentVfactors_ProduceDifferentOutputs()
|
||||
{
|
||||
// Different v-factors should produce measurably different outputs
|
||||
const int period = 20;
|
||||
var source = MakeSeries(100);
|
||||
var v05 = new Gdema(period, vfactor: 0.5);
|
||||
var v15 = new Gdema(period, vfactor: 1.5);
|
||||
|
||||
double totalDiff = 0;
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
double val05 = v05.Update(source[i]).Value;
|
||||
double val15 = v15.Update(source[i]).Value;
|
||||
totalDiff += Math.Abs(val05 - val15);
|
||||
}
|
||||
|
||||
// Different vfactors must produce different trajectories
|
||||
Assert.True(totalDiff > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class GdemaValidationTests
|
||||
{
|
||||
private static TSeries MakeSeries(int count = 500)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100, seed: 42);
|
||||
var series = new TSeries();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
series.Add(gbm.Next());
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Span_And_Streaming_Match()
|
||||
{
|
||||
const int period = 14;
|
||||
const double vfactor = 1.5;
|
||||
var source = MakeSeries(500);
|
||||
|
||||
var streaming = new Gdema(period, vfactor);
|
||||
var streamResults = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamResults[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
double[] srcArr = source.Values.ToArray();
|
||||
double[] spanResults = new double[srcArr.Length];
|
||||
Gdema.Batch(srcArr.AsSpan(), spanResults.AsSpan(), period, vfactor);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], spanResults[i], 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_And_Streaming_Match()
|
||||
{
|
||||
const int period = 10;
|
||||
const double vfactor = 1.0;
|
||||
var source = MakeSeries(300);
|
||||
|
||||
var streaming = new Gdema(period, vfactor);
|
||||
var streamResults = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamResults[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
var batchResults = Gdema.Batch(source, period, vfactor);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], batchResults[i].Value, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 0.0)]
|
||||
[InlineData(3, 0.5)]
|
||||
[InlineData(9, 1.0)]
|
||||
[InlineData(20, 1.5)]
|
||||
[InlineData(50, 2.0)]
|
||||
public void DifferentParams_AllFinite(int period, double vfactor)
|
||||
{
|
||||
var source = MakeSeries(200);
|
||||
var gdema = new Gdema(period, vfactor);
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
double val = gdema.Update(source[i]).Value;
|
||||
Assert.True(double.IsFinite(val), $"NaN/Inf at bar {i} with period={period}, vfactor={vfactor}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constant_ConvergesToConstant()
|
||||
{
|
||||
var gdema = new Gdema(20, vfactor: 1.5);
|
||||
double last = 0;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
last = gdema.Update(new TValue(DateTime.UtcNow, 77.0)).Value;
|
||||
}
|
||||
Assert.Equal(77.0, last, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BarCorrection_Consistency()
|
||||
{
|
||||
const int period = 10;
|
||||
var source = MakeSeries(100);
|
||||
var gdema = new Gdema(period);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var first = gdema.Update(source[i], isNew: true);
|
||||
// Correct with different values, then restore
|
||||
_ = gdema.Update(new TValue(source[i].Time, source[i].Value * 1.1), isNew: false);
|
||||
_ = gdema.Update(new TValue(source[i].Time, source[i].Value * 0.9), isNew: false);
|
||||
var restored = gdema.Update(source[i], isNew: false);
|
||||
Assert.Equal(first.Value, restored.Value, 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsHotIndicator()
|
||||
{
|
||||
var source = MakeSeries(200);
|
||||
var (results, indicator) = Gdema.Calculate(source, 10);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.Equal(source.Count, results.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeDataset_NoOverflow()
|
||||
{
|
||||
var source = MakeSeries(5000);
|
||||
var gdema = new Gdema(50, vfactor: 2.0);
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
double val = gdema.Update(source[i]).Value;
|
||||
Assert.True(double.IsFinite(val), $"Overflow at bar {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubsetStability()
|
||||
{
|
||||
var source = MakeSeries(300);
|
||||
|
||||
// Run on first 200
|
||||
var gdema1 = new Gdema(10);
|
||||
double val200 = 0;
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
val200 = gdema1.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
// Run on all 300
|
||||
var gdema2 = new Gdema(10);
|
||||
double val200_full = 0;
|
||||
for (int i = 0; i < 300; i++)
|
||||
{
|
||||
double v = gdema2.Update(source[i]).Value;
|
||||
if (i == 199)
|
||||
{
|
||||
val200_full = v;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(val200, val200_full, 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// GDEMA: Generalized Double Exponential Moving Average
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Extends standard DEMA with a tunable volume factor v that controls
|
||||
/// the aggressiveness of lag compensation. Two cascaded EMAs with shared
|
||||
/// warmup compensator combined via parameterized linear combination.
|
||||
///
|
||||
/// Calculation: <c>GDEMA = (1+v)×EMA₁ - v×EMA₂</c> where EMA₂ = EMA(EMA₁).
|
||||
/// When v=0 → EMA, v=1 → standard DEMA, v>1 → more aggressive lag removal.
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Gdema : AbstractBase
|
||||
{
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct EmaState(double Ema, double E, bool IsHot, bool IsCompensated)
|
||||
{
|
||||
public static EmaState New() => new() { Ema = 0, E = 1.0, IsHot = false, IsCompensated = false };
|
||||
}
|
||||
|
||||
private readonly double _alpha;
|
||||
private readonly double _decay;
|
||||
private readonly double _vfactor;
|
||||
private readonly double _onePlusV; // precomputed (1 + v)
|
||||
|
||||
private EmaState _state1 = EmaState.New();
|
||||
private EmaState _state2 = EmaState.New();
|
||||
private EmaState _p_state1 = EmaState.New();
|
||||
private EmaState _p_state2 = EmaState.New();
|
||||
|
||||
private double _lastValidValue = double.NaN;
|
||||
private double _p_lastValidValue = double.NaN;
|
||||
|
||||
private readonly ITValuePublisher? _publisher;
|
||||
private readonly TValuePublishedHandler? _listener;
|
||||
|
||||
public override bool IsHot => _state2.IsHot;
|
||||
|
||||
public Gdema(int period = 10, double vfactor = 1.0)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 1);
|
||||
|
||||
_alpha = 2.0 / (period + 1);
|
||||
_decay = 1.0 - _alpha;
|
||||
_vfactor = vfactor;
|
||||
_onePlusV = 1.0 + vfactor;
|
||||
|
||||
Name = $"Gdema({period},{vfactor:F1})";
|
||||
WarmupPeriod = period;
|
||||
}
|
||||
|
||||
public Gdema(ITValuePublisher source, int period = 10, double vfactor = 1.0) : this(period, vfactor)
|
||||
{
|
||||
_publisher = source;
|
||||
_listener = Handle;
|
||||
source.Pub += _listener;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state1 = _state1;
|
||||
_p_state2 = _state2;
|
||||
_p_lastValidValue = _lastValidValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state1 = _p_state1;
|
||||
_state2 = _p_state2;
|
||||
_lastValidValue = _p_lastValidValue;
|
||||
}
|
||||
|
||||
double val = input.Value;
|
||||
if (double.IsFinite(val))
|
||||
{
|
||||
_lastValidValue = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
val = _lastValidValue;
|
||||
}
|
||||
|
||||
if (double.IsNaN(val))
|
||||
{
|
||||
Last = new TValue(input.Time, double.NaN);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
double e1 = ComputeEma(val, _alpha, _decay, ref _state1);
|
||||
double e2 = ComputeEma(e1, _alpha, _decay, ref _state2);
|
||||
|
||||
// GDEMA = (1+v)*EMA1 - v*EMA2
|
||||
double result = Math.FusedMultiplyAdd(_onePlusV, e1, -_vfactor * e2);
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
|
||||
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);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
EmaState preBatch_s1 = _state1;
|
||||
EmaState preBatch_s2 = _state2;
|
||||
double preBatch_lastValid = _lastValidValue;
|
||||
|
||||
EmaState s1 = _state1;
|
||||
EmaState s2 = _state2;
|
||||
double lastValid = _lastValidValue;
|
||||
double alpha = _alpha;
|
||||
double decay = _decay;
|
||||
double onePlusV = _onePlusV;
|
||||
double vf = _vfactor;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double val = source.Values[i];
|
||||
if (double.IsFinite(val))
|
||||
{
|
||||
lastValid = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
val = lastValid;
|
||||
}
|
||||
|
||||
if (double.IsNaN(val))
|
||||
{
|
||||
vSpan[i] = double.NaN;
|
||||
continue;
|
||||
}
|
||||
|
||||
double e1 = ComputeEma(val, alpha, decay, ref s1);
|
||||
double e2 = ComputeEma(e1, alpha, decay, ref s2);
|
||||
|
||||
vSpan[i] = Math.FusedMultiplyAdd(onePlusV, e1, -vf * e2);
|
||||
}
|
||||
|
||||
_state1 = s1;
|
||||
_state2 = s2;
|
||||
_lastValidValue = lastValid;
|
||||
|
||||
_p_state1 = preBatch_s1;
|
||||
_p_state2 = preBatch_s2;
|
||||
_p_lastValidValue = preBatch_lastValid;
|
||||
|
||||
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
foreach (double value in source)
|
||||
{
|
||||
Update(new TValue(DateTime.MinValue, value));
|
||||
}
|
||||
}
|
||||
|
||||
public static TSeries Batch(TSeries source, int period = 10, double vfactor = 1.0)
|
||||
{
|
||||
var gdema = new Gdema(period, vfactor);
|
||||
return gdema.Update(source);
|
||||
}
|
||||
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 10, double vfactor = 1.0)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output must have the same length.", nameof(output));
|
||||
}
|
||||
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 1);
|
||||
|
||||
if (source.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double alpha = 2.0 / (period + 1);
|
||||
double decay = 1.0 - alpha;
|
||||
double onePlusV = 1.0 + vfactor;
|
||||
double lastValid = double.NaN;
|
||||
|
||||
double ema1_val = 0;
|
||||
double ema1_e = 1.0;
|
||||
bool ema1_isCompensated = false;
|
||||
|
||||
double ema2_val = 0;
|
||||
double ema2_e = 1.0;
|
||||
bool ema2_isCompensated = false;
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (double.IsFinite(val))
|
||||
{
|
||||
lastValid = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
val = lastValid;
|
||||
}
|
||||
|
||||
if (double.IsNaN(val))
|
||||
{
|
||||
output[i] = double.NaN;
|
||||
continue;
|
||||
}
|
||||
|
||||
// EMA1
|
||||
ema1_val = Math.FusedMultiplyAdd(ema1_val, decay, alpha * val);
|
||||
double e1;
|
||||
if (!ema1_isCompensated)
|
||||
{
|
||||
ema1_e *= decay;
|
||||
if (ema1_e <= 1e-10)
|
||||
{
|
||||
ema1_isCompensated = true;
|
||||
e1 = ema1_val;
|
||||
}
|
||||
else
|
||||
{
|
||||
e1 = ema1_val / (1.0 - ema1_e);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
e1 = ema1_val;
|
||||
}
|
||||
|
||||
// EMA2
|
||||
ema2_val = Math.FusedMultiplyAdd(ema2_val, decay, alpha * e1);
|
||||
double e2;
|
||||
if (!ema2_isCompensated)
|
||||
{
|
||||
ema2_e *= decay;
|
||||
if (ema2_e <= 1e-10)
|
||||
{
|
||||
ema2_isCompensated = true;
|
||||
e2 = ema2_val;
|
||||
}
|
||||
else
|
||||
{
|
||||
e2 = ema2_val / (1.0 - ema2_e);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
e2 = ema2_val;
|
||||
}
|
||||
|
||||
// GDEMA = (1+v)*EMA1 - v*EMA2
|
||||
output[i] = Math.FusedMultiplyAdd(onePlusV, e1, -vfactor * e2);
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries Results, Gdema Indicator) Calculate(TSeries source, int period = 10, double vfactor = 1.0)
|
||||
{
|
||||
var indicator = new Gdema(period, vfactor);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_state1 = EmaState.New();
|
||||
_state2 = EmaState.New();
|
||||
_p_state1 = EmaState.New();
|
||||
_p_state2 = EmaState.New();
|
||||
_lastValidValue = double.NaN;
|
||||
_p_lastValidValue = double.NaN;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && _publisher != null && _listener != null)
|
||||
{
|
||||
_publisher.Pub -= _listener;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
|
||||
private static double ComputeEma(double input, double alpha, double decay, ref EmaState state)
|
||||
{
|
||||
state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * input);
|
||||
|
||||
double result;
|
||||
if (!state.IsCompensated)
|
||||
{
|
||||
state.E *= decay;
|
||||
|
||||
if (!state.IsHot && state.E <= 0.05)
|
||||
{
|
||||
state.IsHot = true;
|
||||
}
|
||||
|
||||
if (state.E <= 1e-10)
|
||||
{
|
||||
state.IsCompensated = true;
|
||||
result = state.Ema;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = state.Ema / (1.0 - state.E);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result = state.Ema;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user