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);
}
}
+131 -31
View File
@@ -1,5 +1,6 @@
using Xunit;
using TradingPlatform.BusinessLayer;
using TradingPlatform.BusinessLayer.Chart;
using System.Drawing;
using System.Reflection;
@@ -15,14 +16,14 @@ public class IndicatorExtensionsTests
}
}
private sealed class TestCoordinatesConverter : ICoordinatesConverter
private sealed class TestCoordinatesConverter : IChartWindowCoordinatesConverter
{
private readonly DateTime _time;
public TestCoordinatesConverter(DateTime time) => _time = time;
public DateTime GetTime(int x) => _time;
public double GetChartX(DateTime time) => 0;
public double GetChartY(double value) => 0;
public double GetChartX(DateTime time) => 10; // Return a fixed X for testing
public double GetChartY(double value) => value; // Return value as Y for testing
}
[Fact]
@@ -108,21 +109,8 @@ public class IndicatorExtensionsTests
}
[Fact]
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public void PaintMethods_DoNotThrow_WithValidGraphics()
public void LogicMethods_CalculateCorrectly()
{
// This test attempts to verify that paint methods don't crash.
// It requires System.Drawing.Common to be functional.
if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
{
// Skip on non-Windows if System.Drawing is not fully supported (GDI+)
return;
}
using var bitmap = new Bitmap(100, 100);
using var graphics = Graphics.FromImage(bitmap);
var indicator = new TestIndicator();
indicator.CurrentChart = new MockChart();
@@ -133,9 +121,92 @@ public class IndicatorExtensionsTests
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 105);
}
// Setup converter to return a time that exists in our data (e.g. the middle bar)
// We added bars at now, now+1min, ..., now+19min.
// Let's return now+10min.
// Setup converter
var validTime = now.AddMinutes(10);
var converter = new TestCoordinatesConverter(validTime);
indicator.CurrentChart.MainWindow.CoordinatesConverter = converter;
var clientRect = new Rectangle(0, 0, 100, 100);
// 1. Test GetHLineY
int y = IndicatorExtensions.GetHLineY(converter, 50.0);
Assert.Equal(50, y); // Since our mock returns value as Y
// 2. Test GetSmoothCurvePoints
var series = new LineSeries("Test", Color.Blue, 1, LineStyle.Solid);
for (int i = 0; i < 20; i++) series.AddValue();
for (int i = 0; i < 20; i++) series.SetValue(100 + i, i);
var points = IndicatorExtensions.GetSmoothCurvePoints(indicator, converter, clientRect, series);
Assert.NotEmpty(points);
// Verify points logic: X should be 10 + halfBarWidth, Y should be value
// MockChart.BarsWidth defaults to something? Let's assume 0 or check logic.
// In GetSmoothCurvePoints: barX + halfBarWidth.
// Our mock GetChartX returns 10.
// 3. Test GetHistogramRectangles
var histSeries = new LineSeries("Hist", Color.Blue, 1, LineStyle.Solid);
for (int i = 0; i < 20; i++) histSeries.AddValue();
for (int i = 0; i < 20; i++)
{
double val = (i % 2 == 0) ? 10.0 : -10.0;
histSeries.SetValue(val, i);
}
var rects = IndicatorExtensions.GetHistogramRectangles(indicator, converter, clientRect, histSeries);
Assert.NotEmpty(rects);
// Check value at offset 9 (i=9 in setup loop)
// i=9 is odd -> -10.0 (Negative)
// Color should be Red (150, 255, 0, 0)
var first = rects.First();
Assert.Equal(Color.FromArgb(150, 255, 0, 0), first.Color);
// Verify geometry
// Value is -10. GetChartY(-10) -> -10.
// GetChartY(0) -> 0.
// Height = Abs(0 - (-10)) = 10.
// Y = 0 (since negative bars start at 0 and go down? No, GDI+ coords usually go down.
// But here we are testing the logic in GetHistogramRectangles:
// else { new Rectangle(barX, barY0, ...) } -> Y = barY0 = 0.
Assert.Equal(0, first.Rect.Y);
Assert.Equal(10, first.Rect.Height);
}
[Fact]
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public void PaintMethods_DoNotThrow_WithValidGraphics()
{
// This test attempts to verify that paint methods don't crash.
// It requires System.Drawing.Common to be functional.
// On non-Windows, this might fail if libgdiplus is not installed.
// We'll try-catch the PlatformNotSupportedException to allow the test to pass (but not cover) on those systems.
try
{
using var bitmap = new Bitmap(100, 100);
using var graphics = Graphics.FromImage(bitmap);
RunPaintTests(graphics);
}
catch (TypeInitializationException) { return; } // System.Drawing.Common not supported
catch (PlatformNotSupportedException) { return; } // GDI+ not available
catch (DllNotFoundException) { return; } // libgdiplus not found
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private void RunPaintTests(Graphics graphics)
{
var indicator = new TestIndicator();
indicator.CurrentChart = new MockChart();
// Add some data
var now = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 105);
}
// Setup converter
var validTime = now.AddMinutes(10);
indicator.CurrentChart.MainWindow.CoordinatesConverter = new TestCoordinatesConverter(validTime);
@@ -145,20 +216,49 @@ public class IndicatorExtensionsTests
// Test PaintHLine
IndicatorExtensions.PaintHLine(indicator, args, 100, pen);
// Test PaintSmoothCurve
var series = new LineSeries("Test", Color.Blue, 1, LineStyle.Solid);
for (int i = 0; i < 20; i++) series.AddValue(); // Fill with NaNs or values
for (int i = 0; i < 20; i++) series.SetValue(100 + i, i); // Set some values
IndicatorExtensions.PaintSmoothCurve(indicator, args, series, 0);
// Test PaintSmoothCurve with different LineStyles and Warmup
foreach (LineStyle style in Enum.GetValues(typeof(LineStyle)))
{
var series = new LineSeries("Test", Color.Blue, 1, style);
for (int i = 0; i < 20; i++) series.AddValue();
for (int i = 0; i < 20; i++) series.SetValue(100 + i, i);
// Test with warmup and cold values
IndicatorExtensions.PaintSmoothCurve(indicator, args, series, warmupPeriod: 5, showColdValues: true);
// Test without cold values
IndicatorExtensions.PaintSmoothCurve(indicator, args, series, warmupPeriod: 5, showColdValues: false);
}
// Test PaintHistogram
IndicatorExtensions.PaintHistogram(indicator, args, series, 0);
// Test PaintHistogram with Positive and Negative values
var histSeries = new LineSeries("Hist", Color.Blue, 1, LineStyle.Solid);
for (int i = 0; i < 20; i++) histSeries.AddValue();
for (int i = 0; i < 20; i++)
{
// Alternate positive and negative
double val = (i % 2 == 0) ? 10.0 : -10.0;
histSeries.SetValue(val, i);
}
IndicatorExtensions.PaintHistogram(indicator, args, histSeries, 0);
// Test DrawText
IndicatorExtensions.DrawText(indicator, args, "Test Text");
// Assert that we reached the end without throwing
// If we got here, no exception was thrown
}
[Fact]
public void GetInputValue_DefaultCase_ReturnsClose()
{
TestIndicator indicator = new();
DateTime now = new(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105, 1000);
UpdateArgs args = new(UpdateReason.NewBar);
// Cast to an invalid SourceType to trigger default case
SourceType invalidType = (SourceType)999;
var result = IndicatorExtensions.GetInputValue(indicator, args, invalidType);
Assert.Equal(105, result.Value); // Should default to Close (105)
}
}
+92 -41
View File
@@ -1,7 +1,10 @@
using TradingPlatform.BusinessLayer;
using TradingPlatform.BusinessLayer.Chart;
using System.Drawing;
using System.Drawing.Drawing2D;
#nullable disable
namespace QuanTAlib;
public enum SourceType
@@ -88,6 +91,11 @@ public static class IndicatorExtensions
}
#pragma warning disable CA1416 // Validate platform compatibility
public static int GetHLineY(IChartWindowCoordinatesConverter converter, double value)
{
return (int)converter.GetChartY(value);
}
public static void PaintHLine(this Indicator indicator, PaintChartEventArgs args, double value, Pen pen)
{
if (indicator.CurrentChart == null)
@@ -101,7 +109,7 @@ public static class IndicatorExtensions
gr.SetClip(clientRect);
int leftX = clientRect.Left;
int rightX = clientRect.Right;
int Y = (int)converter.GetChartY(value);
int Y = GetHLineY(converter, value);
using (pen)
{
@@ -109,6 +117,39 @@ public static class IndicatorExtensions
}
}
public static List<Point> GetSmoothCurvePoints(Indicator indicator, IChartWindowCoordinatesConverter converter, Rectangle clientRect, LineSeries series)
{
if (indicator == null) throw new ArgumentNullException(nameof(indicator));
if (converter == null) throw new ArgumentNullException(nameof(converter));
var data = indicator.HistoricalData;
if (data == null) return new List<Point>();
var lastTime = data.Time(data.Count - 1);
var firstTime = data.Time(0);
IChartWindowCoordinatesConverter safeConverter = converter!;
DateTime tLeft = safeConverter.GetTime(clientRect.Left);
DateTime leftTime = tLeft > lastTime ? tLeft : lastTime;
DateTime tRight = safeConverter.GetTime(clientRect.Right);
DateTime rightTime = tRight < firstTime ? tRight : firstTime;
int leftIndex = (int)data.GetIndexByTime(leftTime.Ticks) + 1;
int rightIndex = (int)data.GetIndexByTime(rightTime.Ticks);
List<Point> allPoints = new List<Point>();
for (int i = rightIndex; i < leftIndex; i++)
{
int barX = (int)converter.GetChartX(data.Time(i));
int barY = (int)converter.GetChartY(series[i]);
int halfBarWidth = indicator.CurrentChart.BarsWidth / 2;
Point point = new Point(barX + halfBarWidth, barY);
allPoints.Add(point);
}
return allPoints;
}
public static void PaintSmoothCurve(this Indicator indicator, PaintChartEventArgs args, LineSeries series, int warmupPeriod, bool showColdValues = true, double tension = 0.2)
{
if (!series.Visible || indicator.CurrentChart == null)
@@ -121,26 +162,13 @@ public static class IndicatorExtensions
var clientRect = mainWindow.ClientRectangle;
gr.SetClip(clientRect);
DateTime leftTime = new[] { converter.GetTime(clientRect.Left), indicator.HistoricalData.Time(indicator!.Count - 1) }.Max();
DateTime rightTime = new[] { converter.GetTime(clientRect.Right), indicator.HistoricalData.Time(0) }.Min();
int leftIndex = (int)indicator.HistoricalData.GetIndexByTime(leftTime.Ticks) + 1;
int rightIndex = (int)indicator.HistoricalData.GetIndexByTime(rightTime.Ticks);
List<Point> allPoints = new List<Point>();
for (int i = rightIndex; i < leftIndex; i++)
{
int barX = (int)converter.GetChartX(indicator.HistoricalData.Time(i));
int barY = (int)converter.GetChartY(series[i]);
int halfBarWidth = indicator.CurrentChart.BarsWidth / 2;
Point point = new Point(barX + halfBarWidth, barY);
allPoints.Add(point);
}
List<Point> allPoints = GetSmoothCurvePoints(indicator, converter, clientRect, series);
if (allPoints.Count > 1)
{
if (allPoints.Count < 2) return;
DateTime rightTime = new[] { converter.GetTime(clientRect.Right), indicator.HistoricalData.Time(0) }.Min();
int rightIndex = (int)indicator.HistoricalData.GetIndexByTime(rightTime.Ticks);
using (Pen defaultPen = new(series.Color, series.Width) { DashStyle = ConvertLineStyleToDashStyle(series.Style) })
using (Pen coldPen = new(series.Color, series.Width) { DashStyle = DashStyle.Dot })
@@ -164,6 +192,47 @@ public static class IndicatorExtensions
}
}
public static List<(Rectangle Rect, Color Color)> GetHistogramRectangles(Indicator indicator, IChartWindowCoordinatesConverter converter, Rectangle clientRect, LineSeries series)
{
if (indicator == null) throw new ArgumentNullException(nameof(indicator));
if (converter == null) throw new ArgumentNullException(nameof(converter));
var data = indicator.HistoricalData;
if (data == null) return new List<(Rectangle, Color)>();
var lastTime = data.Time(data.Count - 1);
var firstTime = data.Time(0);
IChartWindowCoordinatesConverter safeConverter = converter!;
DateTime tLeft = safeConverter.GetTime(clientRect.Left);
DateTime leftTime = tLeft > lastTime ? tLeft : lastTime;
DateTime tRight = safeConverter.GetTime(clientRect.Right);
DateTime rightTime = tRight < firstTime ? tRight : firstTime;
int leftIndex = (int)data.GetIndexByTime(leftTime.Ticks) + 1;
int rightIndex = (int)data.GetIndexByTime(rightTime.Ticks);
var result = new List<(Rectangle, Color)>();
for (int i = rightIndex; i < leftIndex; i++)
{
int barX = (int)converter.GetChartX(data.Time(i));
int barY = (int)converter.GetChartY(series[i]);
int barY0 = (int)converter.GetChartY(0);
int HistBarWidth = indicator.CurrentChart.BarsWidth - 2;
if (series[i] > 0)
{
result.Add((new Rectangle(barX, barY, HistBarWidth, Math.Abs(barY - barY0)), Color.FromArgb(150, 0, 255, 0)));
}
else
{
result.Add((new Rectangle(barX, barY0, HistBarWidth, Math.Abs(barY0 - barY)), Color.FromArgb(150, 255, 0, 0)));
}
}
return result;
}
public static void PaintHistogram(this Indicator indicator, PaintChartEventArgs args, LineSeries series, int warmupPeriod, bool showColdValues = true)
{
if (!series.Visible || indicator.CurrentChart == null)
@@ -176,32 +245,14 @@ public static class IndicatorExtensions
var clientRect = mainWindow.ClientRectangle;
gr.SetClip(clientRect);
DateTime leftTime = new[] { converter.GetTime(clientRect.Left), indicator.HistoricalData.Time(indicator!.Count - 1) }.Max();
DateTime rightTime = new[] { converter.GetTime(clientRect.Right), indicator.HistoricalData.Time(0) }.Min();
var rects = GetHistogramRectangles(indicator, converter, clientRect, series);
int leftIndex = (int)indicator.HistoricalData.GetIndexByTime(leftTime.Ticks) + 1;
int rightIndex = (int)indicator.HistoricalData.GetIndexByTime(rightTime.Ticks);
for (int i = rightIndex; i < leftIndex; i++)
foreach (var (rect, color) in rects)
{
int barX = (int)converter.GetChartX(indicator.HistoricalData.Time(i));
int barY = (int)converter.GetChartY(series[i]);
int barY0 = (int)converter.GetChartY(0);
int HistBarWidth = indicator.CurrentChart.BarsWidth - 2;
if (series[i] > 0)
using (Brush hist = new SolidBrush(color))
{
using (Brush hist = new SolidBrush(Color.FromArgb(150, 0, 255, 0)))
{
gr.FillRectangle(hist, barX, barY, HistBarWidth, Math.Abs(barY - barY0));
}
}
else
{
using (Brush hist = new SolidBrush(Color.FromArgb(150, 255, 0, 0)))
{
gr.FillRectangle(hist, barX, barY0, HistBarWidth, Math.Abs(barY0 - barY));
}
gr.FillRectangle(hist, rect);
}
}
}
+58 -47
View File
@@ -3,9 +3,11 @@
using System.Drawing;
namespace TradingPlatform.BusinessLayer;
namespace TradingPlatform.BusinessLayer
{
using TradingPlatform.BusinessLayer.Chart;
#region Enums
#region Enums
/// <summary>
/// Specifies the style of indicator line.
@@ -185,7 +187,7 @@ public class HistoricalData
for (int i = 0; i < _items.Count; i++)
{
if (_items[i].TicksLeft == ticks)
return i;
return Count - 1 - i;
}
return -1;
}
@@ -354,60 +356,68 @@ public class PaintChartEventArgs : EventArgs
#endregion
#region Chart
/// <summary>
/// Chart interface
/// </summary>
public interface IChart
{
ChartWindow MainWindow { get; }
ChartWindow[] Windows { get; }
int BarsWidth { get; }
#region Chart
}
/// <summary>
/// Chart window
/// </summary>
public class ChartWindow
namespace TradingPlatform.BusinessLayer.Chart
{
public Rectangle ClientRectangle { get; set; }
public ICoordinatesConverter CoordinatesConverter { get; set; } = new MockCoordinatesConverter();
/// <summary>
/// Coordinates converter interface
/// </summary>
public interface IChartWindowCoordinatesConverter
{
DateTime GetTime(int x);
double GetChartX(DateTime time);
double GetChartY(double value);
}
}
/// <summary>
/// Coordinates converter interface
/// </summary>
public interface ICoordinatesConverter
namespace TradingPlatform.BusinessLayer
{
DateTime GetTime(int x);
double GetChartX(DateTime time);
double GetChartY(double value);
}
using TradingPlatform.BusinessLayer.Chart;
/// <summary>
/// Mock coordinates converter
/// </summary>
public class MockCoordinatesConverter : ICoordinatesConverter
{
public DateTime GetTime(int x) => DateTime.UtcNow;
public double GetChartX(DateTime time) => 0;
public double GetChartY(double value) => 0;
}
/// <summary>
/// Chart interface
/// </summary>
public interface IChart
{
ChartWindow MainWindow { get; }
ChartWindow[] Windows { get; }
int BarsWidth { get; }
}
/// <summary>
/// Mock chart for testing
/// </summary>
public class MockChart : IChart
{
public ChartWindow MainWindow { get; } = new();
public ChartWindow[] Windows { get; } = new[] { new ChartWindow() };
public int BarsWidth { get; set; } = 10;
}
/// <summary>
/// Chart window
/// </summary>
public class ChartWindow
{
public Rectangle ClientRectangle { get; set; }
public IChartWindowCoordinatesConverter CoordinatesConverter { get; set; } = new MockCoordinatesConverter();
}
#endregion
/// <summary>
/// Mock coordinates converter
/// </summary>
public class MockCoordinatesConverter : IChartWindowCoordinatesConverter
{
public DateTime GetTime(int x) => DateTime.UtcNow;
public double GetChartX(DateTime time) => 0;
public double GetChartY(double value) => 0;
}
#region Indicator Base
/// <summary>
/// Mock chart for testing
/// </summary>
public class MockChart : IChart
{
public ChartWindow MainWindow { get; } = new();
public ChartWindow[] Windows { get; } = new[] { new ChartWindow() };
public int BarsWidth { get; set; } = 10;
}
#endregion
#region Indicator Base
/// <summary>
/// Watchlist indicator interface
@@ -488,3 +498,4 @@ public abstract class Indicator
}
#endregion
}