Refactor T3 Moving Average Implementation and Remove Unused Tests

- Deleted DebugTulip.Tests.cs as it was no longer needed.
- Refactored T3.cs to encapsulate parameters in a struct for better organization and readability.
- Updated methods in T3.cs to use the new Parameters struct, improving clarity and reducing redundancy.
- Enhanced T3.md documentation to provide clearer explanations of the T3 moving average and its parameters.
- Removed Wma.Coverage.Tests.cs as it was obsolete.
- Added new tests in IndicatorExtensions.Tests.cs to validate logic methods and ensure correct calculations.
- Updated IndicatorExtensions.cs to improve method organization and add new functionality for handling chart coordinates.
- Refactored mocks in TradingPlatformMocks.cs to align with new chart interface definitions.
This commit is contained in:
Miha Kralj
2025-12-07 17:32:01 -08:00
parent 94d06b0749
commit 3975ff2d7f
10 changed files with 330 additions and 850 deletions
-87
View File
@@ -1,87 +0,0 @@
using System;
using Xunit;
namespace QuanTAlib.Tests;
public class DemaCoverageTests
{
[Fact]
public void Dema_CompensationLogic_IsTriggered()
{
// Compensation happens when E <= 1e-10
// E starts at 1.0 and decays by (1-alpha) each step.
// We need enough steps to reach 1e-10.
// If period=10, alpha ~ 0.18, decay ~ 0.81
// 0.81^n <= 1e-10 => n * log(0.81) <= -10
// n * -0.09 <= -10 => n >= 111
int count = 200;
int period = 10;
var dema = new Dema(period);
for (int i = 0; i < count; i++)
{
dema.Update(new TValue(DateTime.UtcNow, 100.0));
}
// Just ensuring no exception and value is correct
Assert.Equal(100.0, dema.Last.Value, 1e-9);
}
[Fact]
public void Dema_IsHot_Logic()
{
// IsHot happens when E <= 0.05
// 0.81^n <= 0.05 => n >= 14
int period = 10;
var dema = new Dema(period);
Assert.False(dema.IsHot);
for (int i = 0; i < 50; i++)
{
dema.Update(new TValue(DateTime.UtcNow, 100.0));
if (i > 30) // Should be hot by now
{
Assert.True(dema.IsHot);
}
}
}
[Fact]
public void Dema_StaticCalculate_Alpha_Coverage()
{
double[] source = new double[100];
double[] output = new double[100];
for(int i=0; i<100; i++) source[i] = 100.0;
// Use alpha overload
Dema.Calculate(source.AsSpan(), output.AsSpan(), 0.1);
Assert.Equal(100.0, output[^1], 1e-9);
}
[Fact]
public void Dema_Reset_ClearsState()
{
var dema = new Dema(10);
dema.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(0, dema.Last.Value);
dema.Reset();
Assert.Equal(0, dema.Last.Value);
Assert.False(dema.IsHot);
}
[Fact]
public void Dema_Constructor_Alpha_Validation()
{
Assert.Throws<ArgumentException>(() => new Dema(0.0));
Assert.Throws<ArgumentException>(() => new Dema(1.1));
var dema = new Dema(0.5);
Assert.NotNull(dema);
}
}
-77
View File
@@ -1,77 +0,0 @@
using System;
using Xunit;
namespace QuanTAlib.Tests;
public class HmaCoverageTests
{
[Fact]
public void Hma_CalculateIntermediate_Simd_Coverage()
{
// CalculateIntermediate uses SIMD if length >= Vector256<double>.Count (4)
int count = 100;
int period = 10;
double[] source = new double[count];
double[] output = new double[count];
for(int i=0; i<count; i++) source[i] = 100.0;
Hma.Calculate(source.AsSpan(), output.AsSpan(), period);
Assert.Equal(100.0, output[^1], 1e-9);
}
[Fact]
public void Hma_CalculateIntermediate_Scalar_Coverage()
{
// Force scalar path by using small length
int count = 3;
int period = 2; // Min period is 2
double[] source = new double[count];
double[] output = new double[count];
for(int i=0; i<count; i++) source[i] = 100.0;
Hma.Calculate(source.AsSpan(), output.AsSpan(), period);
Assert.Equal(100.0, output[^1], 1e-9);
}
[Fact]
public void Hma_Reset_ClearsState()
{
var hma = new Hma(10);
hma.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.NotEqual(0, hma.Last.Value);
hma.Reset();
Assert.Equal(0, hma.Last.Value);
Assert.False(hma.IsHot);
}
[Fact]
public void Hma_UpdateSeries_RestoresState()
{
var hma = new Hma(10);
var series = new TSeries();
for(int i=0; i<20; i++) series.Add(DateTime.UtcNow.AddMinutes(i), 100.0);
hma.Update(series);
// After batch update, the instance state should be consistent with the end of the series
// So next update should continue correctly
var nextVal = hma.Update(new TValue(DateTime.UtcNow.AddMinutes(20), 100.0));
Assert.Equal(100.0, nextVal.Value, 1e-9);
}
[Fact]
public void Hma_Constructor_Validation()
{
Assert.Throws<ArgumentException>(() => new Hma(1));
Assert.Throws<ArgumentException>(() => new Hma(0));
var hma = new Hma(2);
Assert.NotNull(hma);
}
}
-229
View File
@@ -1,229 +0,0 @@
using System;
using Xunit;
namespace QuanTAlib.Tests;
public class SmaCoverageTests
{
[Fact]
public void Sma_ResyncLogic_IsTriggeredAndCorrect()
{
// ResyncInterval is 1000.
int count = 2500;
int period = 10;
var sma = new Sma(period);
double constantValue = 100.0;
for (int i = 0; i < count; i++)
{
sma.Update(new TValue(DateTime.UtcNow, constantValue));
if (i >= period)
{
Assert.Equal(constantValue, sma.Last.Value, 1e-9);
}
}
}
[Fact]
public void Sma_SpanCalc_LargeDataset_TriggersResync()
{
int count = 5000;
int period = 10;
double[] source = new double[count];
double[] output = new double[count];
for (int i = 0; i < count; i++)
{
source[i] = 100.0;
}
Sma.Calculate(source.AsSpan(), output.AsSpan(), period);
for (int i = period; i < count; i++)
{
Assert.Equal(100.0, output[i], 1e-9);
}
}
[Fact]
public void Sma_SpanCalc_SimdThreshold_Boundary()
{
// SimdThreshold is 256.
int[] lengths = { 250, 256, 260 };
int period = 10;
foreach (int len in lengths)
{
double[] source = new double[len];
double[] output = new double[len];
for (int i = 0; i < len; i++) source[i] = 100.0;
Sma.Calculate(source.AsSpan(), output.AsSpan(), period);
Assert.Equal(100.0, output[^1], 1e-9);
}
}
[Fact]
public void Sma_SpanCalc_Simd_WithResync()
{
int count = 3000;
int period = 5;
double[] source = new double[count];
double[] output = new double[count];
// Linear increase: 0, 1, 2, ...
for (int i = 0; i < count; i++) source[i] = i;
Sma.Calculate(source.AsSpan(), output.AsSpan(), period);
// SMA(5) of x-4, x-3, x-2, x-1, x
// = (5x - 10) / 5 = x - 2
for (int i = period; i < count; i++)
{
double expected = i - 2.0;
Assert.Equal(expected, output[i], 1e-9);
}
}
[Fact]
public void Sma_Constructor_ThrowsOnInvalidPeriod()
{
Assert.Throws<ArgumentException>(() => new Sma(0));
Assert.Throws<ArgumentException>(() => new Sma(-1));
}
[Fact]
public void Sma_StaticCalculate_ThrowsOnInvalidArgs()
{
double[] source = new double[10];
double[] output = new double[5]; // Mismatch
Assert.Throws<ArgumentException>(() => Sma.Calculate(source.AsSpan(), output.AsSpan(), 5));
double[] output2 = new double[10];
Assert.Throws<ArgumentException>(() => Sma.Calculate(source.AsSpan(), output2.AsSpan(), 0));
}
[Fact]
public void Sma_Calculate_EmptyInput_DoesNothing()
{
Sma.Calculate(ReadOnlySpan<double>.Empty, Span<double>.Empty, 5);
// Should not throw
}
[Fact]
public void Sma_Update_WithNaN_UsesLastValid()
{
var sma = new Sma(5);
sma.Update(new TValue(DateTime.UtcNow, 1.0));
sma.Update(new TValue(DateTime.UtcNow, 2.0));
sma.Update(new TValue(DateTime.UtcNow, double.NaN)); // Should use 2.0
// Buffer: 1, 2, 2
// SMA(3) = (1 + 2 + 2) / 3 = 5/3 = 1.666...
Assert.Equal(5.0/3.0, sma.Last.Value, 1e-9);
}
[Fact]
public void Sma_Update_IsNewFalse_UpdatesLastValue()
{
var sma = new Sma(3);
sma.Update(new TValue(DateTime.UtcNow, 1.0));
sma.Update(new TValue(DateTime.UtcNow, 2.0));
// Update existing with 3.0 (replaces 2.0)
sma.Update(new TValue(DateTime.UtcNow, 3.0), isNew: false);
// Buffer should be: 1, 3
// SMA = (1 + 3) / 2 = 2
Assert.Equal(2.0, sma.Last.Value, 1e-9);
}
[Fact]
public void Sma_TSeries_Empty_ReturnsEmpty()
{
var sma = new Sma(5);
var result = sma.Update(new TSeries());
Assert.Empty(result);
}
[Fact]
public void Sma_TSeries_WithNaN_RestoresStateCorrectly()
{
var sma = new Sma(3);
var series = new TSeries();
series.Add(new TValue(DateTime.UtcNow, 1.0));
series.Add(new TValue(DateTime.UtcNow, 2.0));
series.Add(new TValue(DateTime.UtcNow, double.NaN));
series.Add(new TValue(DateTime.UtcNow, 4.0));
sma.Update(series);
// Buffer: 2.0, 2.0 (from NaN), 4.0
// SMA(3) = (2 + 2 + 4) / 3 = 8/3 = 2.666...
// Let's add one more value to verify state is correct
sma.Update(new TValue(DateTime.UtcNow, 5.0));
// Buffer: 2.0, 4.0, 5.0
// SMA(3) = (2 + 4 + 5) / 3 = 11/3 = 3.666...
Assert.Equal(11.0/3.0, sma.Last.Value, 1e-9);
}
[Fact]
public void Sma_Reset_ClearsState()
{
var sma = new Sma(3);
sma.Update(new TValue(DateTime.UtcNow, 1.0));
sma.Update(new TValue(DateTime.UtcNow, 2.0));
sma.Update(new TValue(DateTime.UtcNow, 3.0));
sma.Reset();
Assert.Equal(0, sma.Last.Value);
// Start fresh
sma.Update(new TValue(DateTime.UtcNow, 10.0));
// Buffer: 10
// SMA = 10
Assert.Equal(10.0, sma.Last.Value);
}
[Fact]
public void Sma_Calculate_ScalarFallback_WithNaN()
{
// Force scalar path by including NaN, even with large dataset
int count = 1000;
double[] source = new double[count];
double[] output = new double[count];
for (int i = 0; i < count; i++) source[i] = 1.0;
source[500] = double.NaN; // This should trigger HasNonFiniteValues -> true
Sma.Calculate(source.AsSpan(), output.AsSpan(), 10);
// Check around the NaN
// Index 500 is NaN, so it uses previous valid (1.0)
// So effectively the stream is all 1.0s
Assert.Equal(1.0, output[500], 1e-9);
Assert.Equal(1.0, output[501], 1e-9);
}
[Fact]
public void Sma_Constructor_WithSource_Subscribes()
{
var source = new Sma(10); // Just using Sma as a publisher
var sma = new Sma(source, 5);
source.Update(new TValue(DateTime.UtcNow, 10.0));
Assert.Equal(10.0, sma.Last.Value);
}
}
-37
View File
@@ -1,37 +0,0 @@
using System;
using System.Reflection;
using Tulip;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public class DebugTulipTests
{
private readonly ITestOutputHelper _output;
public DebugTulipTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void ListTulipIndicators()
{
var type = typeof(Tulip.Indicators);
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Static);
var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static);
_output.WriteLine("Tulip Indicators (Properties):");
foreach (var p in properties)
{
_output.WriteLine(p.Name);
}
_output.WriteLine("Tulip Indicators (Fields):");
foreach (var f in fields)
{
_output.WriteLine(f.Name);
}
}
}
+41 -25
View File
@@ -34,8 +34,22 @@ public sealed class T3 : ITValuePublisher
public static State New() => new() { IsInitialized = false };
}
private readonly double _alpha;
private readonly double _c1, _c2, _c3, _c4;
private readonly struct Parameters
{
public readonly double Alpha;
public readonly double C1, C2, C3, C4;
public Parameters(double alpha, double c1, double c2, double c3, double c4)
{
Alpha = alpha;
C1 = c1;
C2 = c2;
C3 = c3;
C4 = c4;
}
}
private readonly Parameters _params;
private State _state = State.New();
private State _p_state = State.New();
private double _lastValidValue;
@@ -57,17 +71,19 @@ public sealed class T3 : ITValuePublisher
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_alpha = 2.0 / (period + 1);
double alpha = 2.0 / (period + 1);
// Precompute coefficients
double v = vfactor;
double v2 = v * v;
double v3 = v2 * v;
_c1 = -v3;
_c2 = 3.0 * (v2 + v3);
_c3 = -3.0 * (2.0 * v2 + v + v3);
_c4 = 1.0 + 3.0 * v + 3.0 * v2 + v3;
double c1 = -v3;
double c2 = 3.0 * (v2 + v3);
double c3 = -3.0 * (2.0 * v2 + v + v3);
double c4 = 1.0 + 3.0 * v + 3.0 * v2 + v3;
_params = new Parameters(alpha, c1, c2, c3, c4);
Name = $"T3({period}, {vfactor:F2})";
}
@@ -118,7 +134,7 @@ public sealed class T3 : ITValuePublisher
}
double val = GetValidValue(input.Value);
val = Compute(val, _alpha, _c1, _c2, _c3, _c4, ref _state);
val = Compute(val, _params, ref _state);
Last = new TValue(input.Time, val);
Pub?.Invoke(Last);
return Last;
@@ -142,21 +158,21 @@ public sealed class T3 : ITValuePublisher
State state = _state;
double lastValidValue = _lastValidValue;
CalculateCore(sourceValues, vSpan, _alpha, _c1, _c2, _c3, _c4, ref state, ref lastValidValue);
CalculateCore(sourceValues, vSpan, _params, ref state, ref lastValidValue);
_state = state;
_lastValidValue = lastValidValue;
sourceTimes.CopyTo(tSpan);
_p_state = _state;
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double Compute(double input, double alpha, double c1, double c2, double c3, double c4, ref State state)
private static double Compute(double input, in Parameters p, ref State state)
{
if (!state.IsInitialized)
{
@@ -165,20 +181,19 @@ public sealed class T3 : ITValuePublisher
}
else
{
state.E1 += alpha * (input - state.E1);
state.E2 += alpha * (state.E1 - state.E2);
state.E3 += alpha * (state.E2 - state.E3);
state.E4 += alpha * (state.E3 - state.E4);
state.E5 += alpha * (state.E4 - state.E5);
state.E6 += alpha * (state.E5 - state.E6);
state.E1 += p.Alpha * (input - state.E1);
state.E2 += p.Alpha * (state.E1 - state.E2);
state.E3 += p.Alpha * (state.E2 - state.E3);
state.E4 += p.Alpha * (state.E3 - state.E4);
state.E5 += p.Alpha * (state.E4 - state.E5);
state.E6 += p.Alpha * (state.E5 - state.E6);
}
return c1 * state.E6 + c2 * state.E5 + c3 * state.E4 + c4 * state.E3;
return p.C1 * state.E6 + p.C2 * state.E5 + p.C3 * state.E4 + p.C4 * state.E3;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateCore(ReadOnlySpan<double> source, Span<double> output, double alpha,
double c1, double c2, double c3, double c4, ref State state, ref double lastValidValue)
private static void CalculateCore(ReadOnlySpan<double> source, Span<double> output, in Parameters p, ref State state, ref double lastValidValue)
{
int len = source.Length;
for (int i = 0; i < len; i++)
@@ -189,7 +204,7 @@ public sealed class T3 : ITValuePublisher
else
val = lastValidValue;
output[i] = Compute(val, alpha, c1, c2, c3, c4, ref state);
output[i] = Compute(val, p, ref state);
}
}
@@ -212,7 +227,7 @@ public sealed class T3 : ITValuePublisher
throw new ArgumentException("Period must be greater than 0", nameof(period));
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length");
double alpha = 2.0 / (period + 1);
double v = vfactor;
double v2 = v * v;
@@ -223,10 +238,11 @@ public sealed class T3 : ITValuePublisher
double c3 = -3.0 * (2.0 * v2 + v + v3);
double c4 = 1.0 + 3.0 * v + 3.0 * v2 + v3;
var p = new Parameters(alpha, c1, c2, c3, c4);
State state = State.New();
double lastValidValue = 0;
CalculateCore(source, output, alpha, c1, c2, c3, c4, ref state, ref lastValidValue);
CalculateCore(source, output, p, ref state, ref lastValidValue);
}
/// <summary>
+8
View File
@@ -1,22 +1,27 @@
# T3: Tillson T3 Moving Average
## Overview and Purpose
The Tillson T3 Moving Average is an advanced technical indicator designed to provide superior smoothing with minimal lag. Developed by Tim Tillson and introduced in the January 1998 issue of Technical Analysis of Stocks & Commodities magazine, T3 implements a sophisticated six-stage EMA architecture with optimized coefficient distribution based on a volume factor parameter.
Unlike simpler moving averages or even triple-EMA approaches, T3 uses a unique mathematical framework that strategically combines multiple EMAs with precisely calculated coefficients. This approach creates a moving average that effectively reduces noise while preserving important trend information and minimizing lag.
## Core Concepts
* **Multi-stage smoothing:** Uses a six-stage EMA cascade with optimized coefficient distribution to achieve superior noise reduction while minimizing lag
* **Volume factor customization:** Provides a parameter that allows traders to fine-tune the balance between smoothness and responsiveness
* **Strategic coefficient weighting:** Employs a sophisticated formula that prevents overshooting at turning points while maintaining responsiveness
## Calculation and Mathematical Foundation
T3 works by running price data through a series of six EMAs, then combining the outputs of these EMAs using carefully calculated weights. These weights are determined by a "volume factor" parameter ($v$) that controls how much the indicator prioritizes smoothness versus responsiveness.
### Formula
$$ T3 = c_1 \cdot EMA_6 + c_2 \cdot EMA_5 + c_3 \cdot EMA_4 + c_4 \cdot EMA_3 $$
Where:
* $EMA_1$ through $EMA_6$ are exponential moving averages applied in sequence:
* $EMA_1(x) = EMA(x)$
* $EMA_n(x) = EMA(EMA_{n-1}(x))$
@@ -28,6 +33,7 @@ Where:
* Default volume factor $v = 0.7$
## Parameters
| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| Period | 10 | > 0 | The smoothing period for the internal EMAs |
@@ -36,6 +42,7 @@ Where:
## C# Usage
### Standard TSeries Usage
```csharp
// Calculate T3 with period 10 and default volume factor 0.7
var t3 = T3.Calculate(sourceSeries, 10);
@@ -47,6 +54,7 @@ Console.WriteLine($"T3 Value: {t3.Last.Value}");
```
### Eventing and Reactive Support
The `T3` class implements `ITValuePublisher`, allowing for event-driven updates.
```csharp
-276
View File
@@ -1,276 +0,0 @@
using System;
using Xunit;
namespace QuanTAlib.Tests;
public class WmaCoverageTests
{
[Fact]
public void Wma_ResyncLogic_IsTriggeredAndCorrect()
{
// ResyncInterval is 1000. We need more than that to trigger it.
int count = 2500;
int period = 10;
var wma = new Wma(period);
// Use a constant value to make verification easy
// WMA of constant X is X
double constantValue = 100.0;
for (int i = 0; i < count; i++)
{
wma.Update(new TValue(DateTime.UtcNow, constantValue));
// After warmup, value should always be constantValue
if (i >= period)
{
Assert.Equal(constantValue, wma.Last.Value, 1e-9);
}
}
}
[Fact]
public void Wma_SpanCalc_LargeDataset_TriggersResync()
{
// ResyncInterval is 1000.
int count = 5000;
int period = 10;
double[] source = new double[count];
double[] output = new double[count];
// Fill with constant value
for (int i = 0; i < count; i++)
{
source[i] = 100.0;
}
Wma.Calculate(source.AsSpan(), output.AsSpan(), period);
// Verify all outputs after warmup are correct
for (int i = period; i < count; i++)
{
Assert.Equal(100.0, output[i], 1e-9);
}
}
[Fact]
public void Wma_SpanCalc_SimdThreshold_Boundary()
{
// SimdThreshold is 256.
// Test just below and just above to ensure both paths work
int[] lengths = { 250, 256, 260 };
int period = 10;
foreach (int len in lengths)
{
double[] source = new double[len];
double[] output = new double[len];
for (int i = 0; i < len; i++) source[i] = 100.0;
Wma.Calculate(source.AsSpan(), output.AsSpan(), period);
Assert.Equal(100.0, output[^1], 1e-9);
}
}
[Fact]
public void Wma_SpanCalc_Simd_WithResync()
{
// This targets the SIMD loop with resync
// Need length > SimdThreshold (256) and enough data to hit ResyncInterval (1000)
// But wait, the SIMD loop in CalculateSimdCore handles resync internally.
// The loop structure is:
// while (idx < simdEnd)
// nextSync = Math.Min(simdEnd, idx + ResyncInterval)
// ... process blocks ...
int count = 3000;
int period = 5;
double[] source = new double[count];
double[] output = new double[count];
// Use a pattern that isn't constant to verify calculation accuracy
// Linear increase: 0, 1, 2, ...
for (int i = 0; i < count; i++) source[i] = i;
Wma.Calculate(source.AsSpan(), output.AsSpan(), period);
// Verify a few points
// WMA(5) of x-4, x-3, x-2, x-1, x
// = (1*(x-4) + 2*(x-3) + 3*(x-2) + 4*(x-1) + 5*x) / 15
// = (x-4 + 2x-6 + 3x-6 + 4x-4 + 5x) / 15
// = (15x - 20) / 15
// = x - 20/15 = x - 1.333...
for (int i = period; i < count; i++)
{
double expected = i - (20.0 / 15.0);
Assert.Equal(expected, output[i], 1e-9);
}
}
[Fact]
public void Wma_Update_Resync_WithFloatingPointDrift()
{
// This test tries to accumulate error and see if resync fixes it (or at least doesn't break it)
// It's hard to deterministically cause drift, but we can ensure the code path is executed.
int period = 10;
var wma = new Wma(period);
// 1200 updates to trigger resync (at 1000)
for (int i = 0; i < 1200; i++)
{
wma.Update(new TValue(DateTime.UtcNow, 1.0));
}
Assert.Equal(1.0, wma.Last.Value, 1e-9);
}
[Fact]
public void Wma_Constructor_ThrowsOnInvalidPeriod()
{
Assert.Throws<ArgumentException>(() => new Wma(0));
Assert.Throws<ArgumentException>(() => new Wma(-1));
}
[Fact]
public void Wma_StaticCalculate_ThrowsOnInvalidArgs()
{
double[] source = new double[10];
double[] output = new double[5]; // Mismatch
Assert.Throws<ArgumentException>(() => Wma.Calculate(source.AsSpan(), output.AsSpan(), 5));
double[] output2 = new double[10];
Assert.Throws<ArgumentException>(() => Wma.Calculate(source.AsSpan(), output2.AsSpan(), 0));
}
[Fact]
public void Wma_Calculate_EmptyInput_DoesNothing()
{
Wma.Calculate(ReadOnlySpan<double>.Empty, Span<double>.Empty, 5);
// Should not throw
}
[Fact]
public void Wma_Update_WithNaN_UsesLastValid()
{
var wma = new Wma(5);
wma.Update(new TValue(DateTime.UtcNow, 1.0));
wma.Update(new TValue(DateTime.UtcNow, 2.0));
wma.Update(new TValue(DateTime.UtcNow, double.NaN)); // Should use 2.0
// Buffer: 1, 2, 2
// WMA(3) = (1*1 + 2*2 + 3*2) / 6 = (1 + 4 + 6) / 6 = 11/6 = 1.8333...
// Wait, period is 5.
// Buffer: 1, 2, 2
// Sum = 5, WSum = 1*1 + 2*2 + 3*2 = 11
// Divisor = 3*4/2 = 6
// Result = 11/6
Assert.Equal(11.0/6.0, wma.Last.Value, 1e-9);
}
[Fact]
public void Wma_Update_IsNewFalse_UpdatesLastValue()
{
var wma = new Wma(3);
wma.Update(new TValue(DateTime.UtcNow, 1.0));
wma.Update(new TValue(DateTime.UtcNow, 2.0));
// Update existing with 3.0 (replaces 2.0)
wma.Update(new TValue(DateTime.UtcNow, 3.0), isNew: false);
// Buffer should be: 1, 3
// Sum = 4, WSum = 1*1 + 2*3 = 7
// Divisor = 2*3/2 = 3
// Result = 7/3 = 2.333...
Assert.Equal(7.0/3.0, wma.Last.Value, 1e-9);
}
[Fact]
public void Wma_TSeries_Empty_ReturnsEmpty()
{
var wma = new Wma(5);
var result = wma.Update(new TSeries());
Assert.Empty(result);
}
[Fact]
public void Wma_TSeries_WithNaN_RestoresStateCorrectly()
{
// This tests the state restoration logic in Update(TSeries)
// specifically the loop that looks for _lastValidValue
var wma = new Wma(3);
var series = new TSeries();
series.Add(new TValue(DateTime.UtcNow, 1.0));
series.Add(new TValue(DateTime.UtcNow, 2.0));
series.Add(new TValue(DateTime.UtcNow, double.NaN));
series.Add(new TValue(DateTime.UtcNow, 4.0));
wma.Update(series);
// After processing series, internal state should match having processed these sequentially
// Last value was 4.0. Previous valid was 2.0 (since NaN used 2.0).
// Buffer: 2.0, 2.0 (from NaN), 4.0
// Let's add one more value to verify state is correct
wma.Update(new TValue(DateTime.UtcNow, 5.0));
// Buffer: 2.0, 4.0, 5.0
// WMA(3) = (1*2 + 2*4 + 3*5) / 6 = (2 + 8 + 15) / 6 = 25/6 = 4.1666...
Assert.Equal(25.0/6.0, wma.Last.Value, 1e-9);
}
[Fact]
public void Wma_Reset_ClearsState()
{
var wma = new Wma(3);
wma.Update(new TValue(DateTime.UtcNow, 1.0));
wma.Update(new TValue(DateTime.UtcNow, 2.0));
wma.Update(new TValue(DateTime.UtcNow, 3.0));
wma.Reset();
Assert.Equal(0, wma.Last.Value);
// Start fresh
wma.Update(new TValue(DateTime.UtcNow, 10.0));
// Buffer: 10
// WMA = 10
Assert.Equal(10.0, wma.Last.Value);
}
[Fact]
public void Wma_Calculate_ScalarFallback_WithNaN()
{
// Force scalar path by including NaN, even with large dataset
int count = 1000;
double[] source = new double[count];
double[] output = new double[count];
for (int i = 0; i < count; i++) source[i] = 1.0;
source[500] = double.NaN; // This should trigger HasNonFiniteValues -> true
Wma.Calculate(source.AsSpan(), output.AsSpan(), 10);
// Check around the NaN
// Index 500 is NaN, so it uses previous valid (1.0)
// So effectively the stream is all 1.0s
Assert.Equal(1.0, output[500], 1e-9);
Assert.Equal(1.0, output[501], 1e-9);
}
[Fact]
public void Wma_Constructor_WithSource_Subscribes()
{
var source = new Wma(10); // Just using Wma as a publisher
var wma = new Wma(source, 5);
source.Update(new TValue(DateTime.UtcNow, 10.0));
Assert.Equal(10.0, wma.Last.Value);
}
}