feat(dynamics): add PlusDI, MinusDI, PlusDM, MinusDM indicators

Complete thin Dx-composition wrapper indicators with full test coverage:

- PlusDi/MinusDi: Directional Indicator wrappers (DiPlus/DiMinus from Dx)
- PlusDm/MinusDm: Directional Movement wrappers (DmPlus/DmMinus from Dx)
- Individual validation tests per indicator directory (TALib, Skender, bounds)
- Combined unit tests (DiDm.Tests.cs) and validation tests (DiDm.Validation.Tests.cs)
- Quantower wrappers + tests for all 4 indicators
- PineScript v6 implementations with compensated RMA
- Normalized .md documentation for all indicators and categories
- 182 tests passing, 0 failures
This commit is contained in:
Miha Kralj
2026-03-11 20:21:52 -07:00
parent 56b86bebfb
commit 33d20f2a18
437 changed files with 4589 additions and 2792 deletions
@@ -0,0 +1,73 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class PlusDmIndicatorTests
{
[Fact]
public void PlusDmIndicator_Constructor_SetsDefaults()
{
var indicator = new PlusDmIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("+DM - Plus Directional Movement", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void PlusDmIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new PlusDmIndicator { Period = 20 };
Assert.Equal(0, PlusDmIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void PlusDmIndicator_Initialize_CreatesInternal()
{
var indicator = new PlusDmIndicator { Period = 14 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void PlusDmIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new PlusDmIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double value = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(value));
}
[Fact]
public void PlusDmIndicator_ShortName_IsCorrect()
{
var indicator = new PlusDmIndicator { Period = 20 };
Assert.Equal("+DM 20", indicator.ShortName);
}
[Fact]
public void PlusDmIndicator_SourceCodeLink_IsValid()
{
var indicator = new PlusDmIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
Assert.Contains("PlusDm.Quantower.cs", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class PlusDmIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 14;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private PlusDm _plusDm = null!;
private readonly LineSeries _plusDmSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"+DM {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/dynamics/plusdm/PlusDm.Quantower.cs";
public PlusDmIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "+DM - Plus Directional Movement";
Description = "Wilder-smoothed upward directional movement in price units";
_plusDmSeries = new LineSeries(name: "+DM", color: Color.Green, width: 2, style: LineStyle.Solid);
AddLineSeries(_plusDmSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_plusDm = new PlusDm(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TValue result = _plusDm.Update(this.GetInputBar(args), args.IsNewBar());
_plusDmSeries.SetValue(result.Value, _plusDm.IsHot, ShowColdValues);
}
}
@@ -0,0 +1,231 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using OoplesFinance.StockIndicators.Enums;
using Skender.Stock.Indicators;
using TALib;
using QuanTAlib.Tests;
namespace QuanTAlib;
/// <summary>
/// Validation tests for PlusDm (+DM). Cross-validates against TA-Lib,
/// OoplesFinance, and internal Dx equivalence with multiple periods.
/// Note: Skender does not expose DM values directly; only DI values via GetAdx().
/// </summary>
public sealed class PlusDmValidationTests : IDisposable
{
private readonly ValidationTestData _data;
public PlusDmValidationTests()
{
_data = new ValidationTestData();
}
public void Dispose()
{
_data.Dispose();
}
// ═══════════════════════════════════════════════
// TA-Lib Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesTalib()
{
var indicator = new PlusDm(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDMLookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MatchesTalib_VariousPeriods(int period)
{
var indicator = new PlusDm(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDM(hData, lData, 0..^0, outReal, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDMLookback(period);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Dx Equivalence
// ═══════════════════════════════════════════════
[Fact]
public void ExactlyMatchesDx_DmPlus()
{
var indicator = new PlusDm(14);
var dx = new Dx(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
dx.Update(_data.Bars[i]);
Assert.Equal(dx.DmPlus.Value, indicator.Last.Value, 1e-12);
}
}
// ═══════════════════════════════════════════════
// OoplesFinance Structural Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesOoples_Structural()
{
var ooplesData = _data.SkenderQuotes
.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
})
.ToList();
var stockData = new StockData(ooplesData);
var adxResults = stockData.CalculateAverageDirectionalIndex(MovingAvgType.WildersSmoothingMethod, 14);
var allValues = adxResults.OutputValues.Values.SelectMany(v => v).ToList();
int finiteCount = allValues.Count(v => double.IsFinite(v));
Assert.True(finiteCount > 100, $"Expected >100 finite Ooples ADX/DI values, got {finiteCount}");
}
// ═══════════════════════════════════════════════
// Self-Consistency: Batch == Streaming
// ═══════════════════════════════════════════════
[Fact]
public void BatchEqualsStreaming()
{
var batchResults = PlusDm.Batch(_data.Bars, 14);
var streaming = new PlusDm(14);
var streamResults = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
streamResults.Add(streaming.Update(_data.Bars[i]).Value);
}
Assert.Equal(streamResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-9);
}
}
[Fact]
public void BatchMatchesTalib()
{
var batchResults = PlusDm.Batch(_data.Bars, 14);
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDMLookback(14);
ValidationHelper.VerifyData(batchResults.Select(x => x.Value).ToList(), outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Determinism
// ═══════════════════════════════════════════════
[Fact]
public void ConsistentAcrossMultipleRuns()
{
var ind1 = new PlusDm(14);
var ind2 = new PlusDm(14);
var results1 = new List<double>();
var results2 = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
ind1.Update(_data.Bars[i]);
results1.Add(ind1.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
ind2.Update(_data.Bars[i]);
results2.Add(ind2.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(results1[i], results2[i], 1e-10);
}
}
// ═══════════════════════════════════════════════
// Output Range Validation
// ═══════════════════════════════════════════════
[Fact]
public void OutputIsNonNegative()
{
var indicator = new PlusDm(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
Assert.True(indicator.Last.Value >= 0, $"+DM output at bar {i} was {indicator.Last.Value}");
}
}
// ═══════════════════════════════════════════════
// Different Periods Produce Different Results
// ═══════════════════════════════════════════════
[Fact]
public void DifferentPeriods_ProduceDifferentResults()
{
var short7 = new PlusDm(7);
var long28 = new PlusDm(28);
for (int i = 0; i < _data.Bars.Count; i++)
{
short7.Update(_data.Bars[i]);
long28.Update(_data.Bars[i]);
}
Assert.NotEqual(short7.Last.Value, long28.Last.Value);
}
}
+83 -18
View File
@@ -1,27 +1,92 @@
# PLUS_DM: Plus Directional Movement
Wilder-smoothed upward directional movement in price units (≥0).
> *+DM captures the raw upward price extension, smoothed by Wilder's RMA — the building block before normalization to +DI.*
## Introduction
Plus Directional Movement (+DM) measures the magnitude of upward price movement, smoothed using Wilder's method. Unlike +DI which normalizes by true range to produce a percentage, +DM outputs raw smoothed values in price units.
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | `period` (default 14) |
| **Outputs** | Single series |
| **Output range** | ≥ 0 (price units) |
| **Warmup** | `period` bars |
| **PineScript** | [plusdm.pine](plusdm.pine) |
+DM captures when the current bar's high exceeds the previous bar's high by more than the previous bar's low exceeds the current bar's low. It is the raw building block of the Directional Movement System.
- Plus Directional Movement outputs the Wilder-smoothed upward directional movement in price units.
- Parameterized by `period` (default 14).
- Output range: ≥ 0 (price units, scales with instrument).
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib and Dx equivalence.
## Calculation
+DM = max(High - PrevHigh, 0) when High - PrevHigh > PrevLow - Low, else 0
Plus Directional Movement (+DM) measures the magnitude of upward price movement, smoothed using Wilder's method. Unlike +DI which normalizes by true range to produce a percentage, +DM outputs raw smoothed values in price units. +DM captures when the current bar's high exceeds the previous bar's high by more than the previous bar's low exceeds the current bar's low. It is the raw building block of the Directional Movement System — the unnormalized signal before division by True Range converts it to +DI.
Smoothed using Wilder's method: Smooth = Smooth - Smooth/N + Input
## Historical Context
## Parameters
| Parameter | Default | Range | Description |
| :--- | :--- | :--- | :--- |
| Period | 14 | 2-∞ | Wilder smoothing period |
J. Welles Wilder Jr. designed the Directional Movement System as a pipeline in *New Concepts in Technical Trading Systems* (1978). +DM is the first computational stage: a binary event detector that fires when upward range expansion dominates. Wilder's insight was that direction should be measured by range *extension*, not by close-to-close returns. A bar that pushes to a new high by more than it pushes to a new low registers as positive directional movement. The smoothed +DM series shows how much upward thrust is being sustained over the lookback period, in absolute price units.
## Interpretation
- **Rising +DM:** Increasing upward price extension
- **+DM > -DM:** Upward movement exceeds downward movement
- **Zero +DM:** No upward directional movement on the bar
- Values are in price units and scale with the instrument
## Architecture & Physics
## References
- Wilder, J. Welles Jr. "New Concepts in Technical Trading Systems" (1978)
### 1. Plus Directional Movement (Raw)
$$\text{UpMove} = H_t - H_{t-1}, \quad \text{DownMove} = L_{t-1} - L_t$$
$$+DM = \begin{cases} \text{UpMove} & \text{if UpMove} > \text{DownMove and UpMove} > 0 \\ 0 & \text{otherwise} \end{cases}$$
Only one of +DM or -DM can be non-zero per bar — the dominant direction wins.
### 2. Wilder Smoothing (RMA)
$$+DM_{\text{smooth}} = \text{RMA}(+DM, N), \quad \alpha = 1/N$$
### 3. Complexity
- **Time:** $O(1)$ per bar — recursive RMA update
- **Space:** $O(1)$ — scalar state only (delegates to Dx)
- **Warmup:** $N$ bars
## Mathematical Foundation
### Parameters
| Symbol | Parameter | Default | Constraint |
|--------|-----------|---------|------------|
| $N$ | period | 14 | $N \geq 2$ |
### Interpretation
| +DM Behavior | Signal |
|--------------|--------|
| Rising +DM | Increasing upward price extension |
| +DM > -DM | Upward movement exceeds downward movement |
| Zero +DM | No upward directional movement on the bar |
| Values scale with instrument | Compare within same instrument only |
+DM values are in price units and scale with the instrument. They cannot be compared across different instruments without normalization (which is what +DI provides).
## Performance Profile
### Operation Count (Streaming Mode)
+DM is a thin wrapper around Dx. The per-bar cost is identical to Dx (one property extraction after Dx completes its update).
**Post-warmup steady state (per bar):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| Dx.Update (full pipeline) | 1 | 75 | 75 |
| Property extraction | 1 | 1 | 1 |
| **Total** | **2** | — | **~76 cycles** |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Exact Dx delegation; FMA-precise RMA smoothing |
| **Timeliness** | 7/10 | N-bar warmup; responds to bar-level changes |
| **Smoothness** | 7/10 | Single RMA layer; moderate noise suppression |
| **Noise Rejection** | 7/10 | Wilder smoothing filters transient spikes |
## Resources
- Wilder, J.W. — *New Concepts in Technical Trading Systems* (Trend Research, 1978)
- PineScript reference: `plusdm.pine` in indicator directory
+44
View File
@@ -0,0 +1,44 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Plus Directional Movement (+DM)", "+DM", overlay=false)
//@function Calculates Wilder-smoothed +DM using compensated RMA
//@param period Number of bars used in the calculation
//@returns Smoothed +DM value in price units (≥0)
//@optimized Uses Wilder's smoothing (RMA) with warmup compensation for accurate values from bar 1
plusdm(simple int period) =>
if period <= 0
runtime.error("Period must be greater than 0")
float alpha = 1.0 / period
float beta = 1.0 - alpha
float plus_dm = 0.0
if not na(high[1])
float upMove = high - high[1]
float downMove = low[1] - low
if upMove > downMove and upMove > 0
plus_dm := upMove
var bool warmup = true
var float e = 1.0
var float plus_dm_ema = 0.0
var float plus_dm_result = plus_dm
plus_dm_ema := alpha * (plus_dm - plus_dm_ema) + plus_dm_ema
if warmup
e *= beta
float c = 1.0 / (1.0 - e)
plus_dm_result := c * plus_dm_ema
warmup := e > 1e-10
else
plus_dm_result := plus_dm_ema
plus_dm_result
// ---------- Main loop ----------
// Inputs
i_period = input.int(14, "Period", minval=1, tooltip="Number of bars used in the calculation")
// Calculation
plus_dm_value = plusdm(i_period)
// Plot
plot(plus_dm_value, "+DM", color=color.green, linewidth=2)