DWMA Indicator implementation and tests

This commit is contained in:
Miha Kralj
2025-12-10 18:22:10 -05:00
parent 81830c9031
commit 8df3480d1f
11 changed files with 525 additions and 31 deletions
+3 -1
View File
@@ -11,6 +11,7 @@ This document defines the strict standards for creating high-quality technical i
* **Bar Correction:** Support intra-bar updates via the `isNew` parameter. The indicator must be able to rollback the last update and apply a new value for the same timestamp.
* **Robustness:** Handle `NaN` and `Infinity` gracefully using last-valid-value substitution. Never propagate invalid values.
* **Reactive:** Implement `ITValuePublisher` to support event-driven architectures.
* **Time Handling:** Always use `DateTime.UtcNow` instead of `DateTime.Now` to ensure consistent time handling across timezones.
## 2. File Structure
@@ -91,6 +92,7 @@ Each indicator resides in its own directory such as `lib/trends/`, `lib/indicato
### Unit Tests (`[Name].Tests.cs`)
* **Framework:** xUnit
* **Data Generation:** Use `GBM` (Geometric Brownian Motion) for generating realistic test data. Avoid using `System.Random` directly.
* **Coverage:**
* Constructor validation (invalid params).
@@ -155,7 +157,7 @@ Template structure:
## 9. Checklist for New Indicators
* [ ] **Source Material:** Sourced algorithm and docs from `mihakralj/pinescript`?
* [ ] **Source Material:** Sourced algorithm and docs from `mihakralj/pinescript` or `mihakralj/quantalib`?
* [ ] **File Structure:** Created all 6 required files?
* [ ] **Constructor:** Validates inputs? Sets `Name`?
* [ ] **Update:** Handles `isNew` correctly? Handles `NaN`? O(1)?
+1 -1
View File
@@ -16,7 +16,7 @@ Trend indicators help identify the direction and strength of a market trend. Mov
| [CONV](trends/conv/Conv.md) | Convolution Indicator | Applies a custom kernel (weights) to the data window. |
| [DEMA](trends/dema/Dema.md) | Double Exponential Moving Average | Reduces lag by placing more weight on recent data than a standard EMA. |
| DSMA | Deviation-Scaled MA | |
| DWMA | Double Weighted MA | |
| [DWMA](trends/dwma/Dwma.md) | Double Weighted MA | Applies WMA smoothing twice to reduce noise further. |
| ELLIPTIC | Elliptic (Cauer) Filter | |
| [EMA](trends/ema/Ema.md) | Exponential Moving Average | Weighted average giving more importance to recent price data. |
| EPMA | Endpoint MA | |
+9 -9
View File
@@ -17,11 +17,11 @@ public class ConvValidationTests
var sma = new Sma(period);
var conv = new Conv(kernel);
var rnd = new Random(123);
var gbm = new GBM(startPrice: 100, seed: 123);
for (int i = 0; i < 1000; i++)
{
double price = rnd.NextDouble() * 100;
var tValue = new TValue(DateTime.UtcNow, price);
var bar = gbm.Next();
var tValue = bar.C;
var smaVal = sma.Update(tValue);
var convVal = conv.Update(tValue);
@@ -48,11 +48,11 @@ public class ConvValidationTests
var wma = new Wma(period);
var conv = new Conv(kernel);
var rnd = new Random(123);
var gbm = new GBM(startPrice: 100, seed: 123);
for (int i = 0; i < 1000; i++)
{
double price = rnd.NextDouble() * 100;
var tValue = new TValue(DateTime.UtcNow, price);
var bar = gbm.Next();
var tValue = bar.C;
var wmaVal = wma.Update(tValue);
var convVal = conv.Update(tValue);
@@ -105,11 +105,11 @@ public class ConvValidationTests
var trima = new Trima(period);
var conv = new Conv(kernel);
var rnd = new Random(123);
var gbm = new GBM(startPrice: 100, seed: 123);
for (int i = 0; i < 1000; i++)
{
double price = rnd.NextDouble() * 100;
var tValue = new TValue(DateTime.UtcNow, price);
var bar = gbm.Next();
var tValue = bar.C;
var trimaVal = trima.Update(tValue);
var convVal = conv.Update(tValue);
+2 -2
View File
@@ -79,7 +79,7 @@ public class DemaTests
// Assert
for (int i = 0; i < count; i++)
{
var val = demaObj.Update(new TValue(DateTime.Now, source[i]));
var val = demaObj.Update(new TValue(DateTime.UtcNow, source[i]));
Assert.Equal(val.Value, output[i], 1e-9);
}
}
@@ -156,7 +156,7 @@ public class DemaTests
// Assert
for (int i = 0; i < count; i++)
{
var val = demaObj.Update(new TValue(DateTime.Now, source[i]));
var val = demaObj.Update(new TValue(DateTime.UtcNow, source[i]));
Assert.Equal(val.Value, output[i], 1e-9);
}
}
+78
View File
@@ -0,0 +1,78 @@
using Xunit;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class DwmaIndicatorTests
{
[Fact]
public void DwmaIndicator_Constructor_SetsDefaults()
{
var indicator = new DwmaIndicator();
Assert.Equal(10, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("DWMA - Double Weighted Moving Average", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void DwmaIndicator_MinHistoryDepths_EqualsTwoTimesPeriod()
{
var indicator = new DwmaIndicator { Period = 20 };
Assert.Equal(40, indicator.MinHistoryDepths);
Assert.Equal(40, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void DwmaIndicator_ShortName_IncludesPeriodAndSource()
{
var indicator = new DwmaIndicator { Period = 15 };
Assert.Contains("DWMA", indicator.ShortName);
Assert.Contains("15", indicator.ShortName);
}
[Fact]
public void DwmaIndicator_SourceCodeLink_IsValid()
{
var indicator = new DwmaIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink);
Assert.Contains("Dwma.Quantower.cs", indicator.SourceCodeLink);
}
[Fact]
public void DwmaIndicator_Initialize_CreatesInternalDwma()
{
var indicator = new DwmaIndicator { Period = 10 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void DwmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new DwmaIndicator { Period = 3 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
// Process update
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
// Line series should have a value
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class DwmaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 10;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Dwma? ma;
private int _warmupBarIndex = -1;
protected LineSeries? Series;
protected string? SourceName;
public int MinHistoryDepths => Period * 2; // DWMA needs roughly 2x period to warm up
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"DWMA {Period}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends/dwma/Dwma.Quantower.cs";
public DwmaIndicator()
{
OnBackGround = true;
SeparateWindow = false;
SourceName = Source.ToString();
Name = "DWMA - Double Weighted Moving Average";
Description = "Double Weighted Moving Average";
Series = new(name: $"DWMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
ma = new Dwma(Period);
_warmupBarIndex = -1;
SourceName = Source.ToString();
base.OnInit();
}
protected override void OnUpdate(UpdateArgs args)
{
TValue input = this.GetInputValue(args, Source);
bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar;
TValue result = ma!.Update(input, isNew);
if (_warmupBarIndex < 0 && ma!.IsHot)
_warmupBarIndex = Count;
Series!.SetValue(result.Value);
Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here
}
public override void OnPaintChart(PaintChartEventArgs args)
{
base.OnPaintChart(args);
this.PaintSmoothCurve(args, Series!, _warmupBarIndex, showColdValues: ShowColdValues, tension: 0.2);
}
}
+102
View File
@@ -0,0 +1,102 @@
using System;
using Xunit;
namespace QuanTAlib;
public class DwmaTests
{
[Fact]
public void Constructor_InvalidPeriod_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new Dwma(0));
Assert.Throws<ArgumentException>(() => new Dwma(-1));
}
[Fact]
public void Update_ValidInput_CalculatesCorrectly()
{
// DWMA(3) of [1, 2, 3, 4, 5]
// WMA(3) of [1, 2, 3, 4, 5]
// 1: 1
// 2: (1*1 + 2*2) / 3 = 5/3 = 1.666...
// 3: (1*1 + 2*2 + 3*3) / 6 = 14/6 = 2.333...
// 4: (1*2 + 2*3 + 3*4) / 6 = 20/6 = 3.333...
// 5: (1*3 + 2*4 + 3*5) / 6 = 26/6 = 4.333...
// WMA(3) results: [1, 1.666, 2.333, 3.333, 4.333]
// DWMA(3) = WMA(3) of [1, 1.666, 2.333, 3.333, 4.333]
// 1: 1
// 2: (1*1 + 2*1.666) / 3 = 4.333/3 = 1.444...
// 3: (1*1 + 2*1.666 + 3*2.333) / 6 = (1 + 3.333 + 7) / 6 = 11.333/6 = 1.888...
var dwma = new Dwma(3);
var v1 = dwma.Update(new TValue(DateTime.UtcNow, 1)).Value;
var v2 = dwma.Update(new TValue(DateTime.UtcNow, 2)).Value;
var v3 = dwma.Update(new TValue(DateTime.UtcNow, 3)).Value;
Assert.Equal(1.0, v1, 6);
Assert.Equal(1.444444, v2, 5);
Assert.Equal(1.888888, v3, 5);
}
[Fact]
public void Update_IsNewFalse_CorrectsValue()
{
var dwma = new Dwma(3);
dwma.Update(new TValue(DateTime.UtcNow, 1));
dwma.Update(new TValue(DateTime.UtcNow, 2));
// Update with 3, then correct to 4
var v3 = dwma.Update(new TValue(DateTime.UtcNow, 3), isNew: true).Value;
var v3_corrected = dwma.Update(new TValue(DateTime.UtcNow, 4), isNew: false).Value;
// Manual calc for sequence [1, 2, 4]
// WMA(3):
// 1: 1
// 2: 1.666
// 4: (1*1 + 2*2 + 3*4) / 6 = 17/6 = 2.8333
// DWMA(3) of [1, 1.666, 2.8333]
// 3: (1*1 + 2*1.666 + 3*2.8333) / 6 = (1 + 3.333 + 8.5) / 6 = 12.833/6 = 2.1388
Assert.Equal(1.888888, v3, 5); // From previous test
Assert.Equal(2.138888, v3_corrected, 5);
}
[Fact]
public void Reset_ClearsState()
{
var dwma = new Dwma(3);
dwma.Update(new TValue(DateTime.UtcNow, 1));
dwma.Update(new TValue(DateTime.UtcNow, 2));
dwma.Reset();
Assert.False(dwma.IsHot);
var v1 = dwma.Update(new TValue(DateTime.UtcNow, 1)).Value;
Assert.Equal(1.0, v1);
}
[Fact]
public void StaticCalculate_MatchesInstance()
{
int period = 10;
int count = 100;
var source = new TSeries();
var dwma = new Dwma(period);
for (int i = 0; i < count; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), i));
dwma.Update(source.Last);
}
var staticResult = Dwma.Calculate(source, period);
Assert.Equal(source.Count, staticResult.Count);
Assert.Equal(dwma.Last.Value, staticResult.Last.Value, 8);
}
}
+41
View File
@@ -0,0 +1,41 @@
using System;
using Xunit;
namespace QuanTAlib;
public class DwmaValidationTests
{
[Fact]
public void Validate_Against_DoubleWma()
{
// DWMA should be exactly WMA(WMA(source, period), period)
int period = 10;
int count = 1000;
var source = new TSeries();
var rnd = new Random(42);
for (int i = 0; i < count; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), rnd.NextDouble() * 100));
}
var dwma = new Dwma(period);
var wma1 = new Wma(period);
var wma2 = new Wma(period);
for (int i = 0; i < count; i++)
{
var val = source[i];
// Calculate DWMA
var dwmaVal = dwma.Update(val);
// Calculate WMA(WMA) manually
var wma1Val = wma1.Update(val);
var wma2Val = wma2.Update(wma1Val);
Assert.Equal(wma2Val.Value, dwmaVal.Value, 10);
}
}
}
+139
View File
@@ -0,0 +1,139 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// DWMA: Double Weighted Moving Average
/// </summary>
/// <remarks>
/// DWMA applies a Weighted Moving Average (WMA) twice.
/// It provides a smoother curve than a standard WMA but with slightly more lag.
///
/// Formula:
/// DWMA = WMA(WMA(source, period), period)
/// </remarks>
[SkipLocalsInit]
public sealed class Dwma : ITValuePublisher
{
private readonly int _period;
private readonly Wma _wma1;
private readonly Wma _wma2;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
/// <summary>
/// Current DWMA value.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// True if the indicator has enough data to produce valid results.
/// </summary>
public bool IsHot => _wma1.IsHot && _wma2.IsHot;
public event Action<TValue>? Pub;
/// <summary>
/// Creates DWMA with specified period.
/// </summary>
/// <param name="period">Window size (must be > 0)</param>
public Dwma(int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_period = period;
_wma1 = new Wma(period);
_wma2 = new Wma(period);
Name = $"Dwma({period})";
}
public Dwma(ITValuePublisher source, int period) : this(period)
{
source.Pub += (item) => Update(item);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
TValue wma1Result = _wma1.Update(input, isNew);
Last = _wma2.Update(wma1Result, isNew);
Pub?.Invoke(Last);
return Last;
}
public TSeries Update(TSeries source)
{
if (source.Count == 0) return new TSeries();
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);
Calculate(source.Values, vSpan, _period);
// Restore state
// We need to replay the last part to restore the internal WMAs state
// Since DWMA is WMA(WMA), the effective lookback is roughly 2*Period
// But to be safe and simple, we can just reset and replay the last 2*Period bars.
_wma1.Reset();
_wma2.Reset();
int warmup = _period * 2; // Approximate warmup needed
int startIndex = Math.Max(0, len - warmup);
for (int i = startIndex; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]));
}
return new TSeries(t, v);
}
public static TSeries Calculate(TSeries source, int period)
{
var dwma = new Dwma(period);
return dwma.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
throw new ArgumentException("Source and output must have the same length");
// We need a temporary buffer for the first WMA pass
// Use stackalloc for small sizes, heap for large
if (source.Length <= 1024)
{
Span<double> temp = stackalloc double[source.Length];
Wma.Calculate(source, temp, period);
Wma.Calculate(temp, output, period);
}
else
{
double[] temp = new double[source.Length];
Wma.Calculate(source, temp, period);
Wma.Calculate(temp, output, period);
}
}
public void Reset()
{
_wma1.Reset();
_wma2.Reset();
Last = default;
}
}
+67
View File
@@ -0,0 +1,67 @@
# DWMA - Double Weighted Moving Average
DWMA is a moving average that applies the Weighted Moving Average (WMA) twice. It provides a smoother curve than a standard WMA but with slightly more lag.
## Core Concepts
* **Double Smoothing:** Applies WMA smoothing twice to reduce noise further.
* **Weighted:** Gives more weight to recent data points, similar to WMA.
* **Recursive Calculation:** Uses the efficient O(1) WMA implementation.
## Parameters
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `period` | `int` | - | The lookback period for both WMA passes. |
## Formula
$$
DWMA_t = WMA(WMA(Price, n), n)
$$
Where:
* $WMA$ is the Weighted Moving Average.
* $n$ is the period.
## C# Implementation
### Standard Usage
```csharp
// Create DWMA with period 14
var dwma = new Dwma(14);
// Update with new value
var result = dwma.Update(new TValue(DateTime.Now, 123.45));
Console.WriteLine($"DWMA: {result.Value}");
```
### Span API (High Performance)
```csharp
// Calculate on a span of data
ReadOnlySpan<double> input = ...;
Span<double> output = new double[input.Length];
Dwma.Calculate(input, output, 14);
```
### Bar Correction
```csharp
// Update with a value
dwma.Update(new TValue(time, 100), isNew: true);
// Correct the last value
dwma.Update(new TValue(time, 101), isNew: false);
```
## Interpretation
DWMA is used similarly to other moving averages to identify trends. Due to the double smoothing, it is less susceptible to whipsaws than WMA but reacts slower to price changes.
## References
* [Pine Script Implementation](https://github.com/mihakralj/pinescript/blob/main/indicators/trends_FIR/dwma.md)
+20 -18
View File
@@ -26,7 +26,7 @@ public class LsmaTests
public void Update_SingleValue_ReturnsSameValue()
{
var lsma = new Lsma(14);
var result = lsma.Update(new TValue(DateTime.Now, 100));
var result = lsma.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(100, result.Value);
}
@@ -39,7 +39,7 @@ public class LsmaTests
for (int i = 0; i < period * 2; i++)
{
var result = lsma.Update(new TValue(DateTime.Now, i));
var result = lsma.Update(new TValue(DateTime.UtcNow, i));
if (i >= period) // After warmup
{
Assert.Equal(i, result.Value, 1e-9);
@@ -56,7 +56,7 @@ public class LsmaTests
for (int i = 0; i < period * 2; i++)
{
var result = lsma.Update(new TValue(DateTime.Now, value));
var result = lsma.Update(new TValue(DateTime.UtcNow, value));
Assert.Equal(value, result.Value, 1e-9);
}
}
@@ -75,7 +75,7 @@ public class LsmaTests
for (int i = 0; i < 20; i++)
{
double y = 2 * i + 1;
var result = lsma.Update(new TValue(DateTime.Now, y));
var result = lsma.Update(new TValue(DateTime.UtcNow, y));
if (i >= period)
{
@@ -93,20 +93,20 @@ public class LsmaTests
// Fill buffer
for (int i = 0; i < 5; i++)
{
lsma.Update(new TValue(DateTime.Now, i));
lsma.Update(new TValue(DateTime.UtcNow, i));
}
// New bar
var result1 = lsma.Update(new TValue(DateTime.Now, 10));
var result1 = lsma.Update(new TValue(DateTime.UtcNow, 10));
// Update same bar with different value
var result2 = lsma.Update(new TValue(DateTime.Now, 20), isNew: false);
var result2 = lsma.Update(new TValue(DateTime.UtcNow, 20), isNew: false);
Assert.NotEqual(result1.Value, result2.Value);
// Verify internal state by adding next bar
// If state was corrupted, this would fail
var result3 = lsma.Update(new TValue(DateTime.Now, 30));
var result3 = lsma.Update(new TValue(DateTime.UtcNow, 30));
Assert.True(double.IsFinite(result3.Value));
}
@@ -115,9 +115,9 @@ public class LsmaTests
{
var lsma = new Lsma(5);
lsma.Update(new TValue(DateTime.Now, 1));
lsma.Update(new TValue(DateTime.Now, 2));
var result = lsma.Update(new TValue(DateTime.Now, double.NaN));
lsma.Update(new TValue(DateTime.UtcNow, 1));
lsma.Update(new TValue(DateTime.UtcNow, 2));
var result = lsma.Update(new TValue(DateTime.UtcNow, double.NaN));
// Input sequence becomes: 1, 2, 2 (NaN replaced by last valid 2)
// Regression on (2,1), (1,2), (0,2)
@@ -131,11 +131,12 @@ public class LsmaTests
int period = 10;
int count = 100;
var source = new TSeries();
var rnd = new Random(42);
var gbm = new GBM(startPrice: 100, seed: 42);
for (int i = 0; i < count; i++)
{
source.Add(new TValue(DateTime.Now.AddMinutes(i), rnd.NextDouble() * 100));
var bar = gbm.Next();
source.Add(bar.C);
}
var lsma = new Lsma(period);
@@ -156,11 +157,12 @@ public class LsmaTests
int count = 100;
var values = new double[count];
var output = new double[count];
var rnd = new Random(42);
var gbm = new GBM(startPrice: 100, seed: 42);
for (int i = 0; i < count; i++)
{
values[i] = rnd.NextDouble() * 100;
var bar = gbm.Next();
values[i] = bar.Close;
}
Lsma.Calculate(values, output, period);
@@ -168,7 +170,7 @@ public class LsmaTests
var lsma = new Lsma(period);
for (int i = 0; i < count; i++)
{
var result = lsma.Update(new TValue(DateTime.Now, values[i]));
var result = lsma.Update(new TValue(DateTime.UtcNow, values[i]));
Assert.Equal(result.Value, output[i], 1e-9);
}
}
@@ -179,7 +181,7 @@ public class LsmaTests
var lsma = new Lsma(5);
for (int i = 0; i < 10; i++)
{
lsma.Update(new TValue(DateTime.Now, i));
lsma.Update(new TValue(DateTime.UtcNow, i));
}
Assert.True(lsma.IsHot);
@@ -190,7 +192,7 @@ public class LsmaTests
Assert.Equal(0, lsma.Last.Value);
// Should behave like new instance
var result = lsma.Update(new TValue(DateTime.Now, 100));
var result = lsma.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(100, result.Value);
}
}