feat: Implement ADX Indicator with Quantower integration

This commit is contained in:
Miha Kralj
2025-12-14 20:32:01 -08:00
parent 78775c1da0
commit 016c10b68a
9 changed files with 830 additions and 202 deletions
+1
View File
@@ -4,6 +4,7 @@ Trend indicators help identify the direction and strength of a market trend. Mov
| Indicator | Full Name | Description |
| :--- | :--- | :--- |
| [ADX](adx/Adx.md) | Average Directional Index | Measures the strength of a trend, regardless of its direction. |
| ALLIGATOR | Williams Alligator | |
| [ALMA](alma/Alma.md) | Arnaud Legoux MA | Uses Gaussian distribution weights to balance smoothness and responsiveness. |
| AMAT | Archer Moving Averages Trends | |
+85
View File
@@ -0,0 +1,85 @@
using Xunit;
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class AdxIndicatorTests
{
[Fact]
public void AdxIndicator_Constructor_SetsDefaults()
{
var indicator = new AdxIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("ADX - Average Directional Index", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void AdxIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new AdxIndicator { Period = 20 };
Assert.Equal(20, indicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(20, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void AdxIndicator_ShortName_IncludesParameters()
{
var indicator = new AdxIndicator { Period = 20 };
indicator.Initialize();
Assert.Contains("ADX", indicator.ShortName);
Assert.Contains("20", indicator.ShortName);
}
[Fact]
public void AdxIndicator_SourceCodeLink_IsValid()
{
var indicator = new AdxIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink);
Assert.Contains("Adx.Quantower.cs", indicator.SourceCodeLink);
}
[Fact]
public void AdxIndicator_Initialize_CreatesInternalAdx()
{
var indicator = new AdxIndicator { Period = 14 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (ADX, +DI, -DI)
Assert.Equal(3, indicator.LinesSeries.Length);
}
[Fact]
public void AdxIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new AdxIndicator { Period = 5 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
// Need enough bars for Period
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double adx = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(adx));
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class AdxIndicator : 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 Adx? _adx;
protected LineSeries? AdxSeries;
protected LineSeries? DiPlusSeries;
protected LineSeries? DiMinusSeries;
public int MinHistoryDepths => Period;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"ADX {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends/adx/Adx.Quantower.cs";
public AdxIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "ADX - Average Directional Index";
Description = "Measures the strength of a trend";
AdxSeries = new(name: "ADX", color: Color.Blue, width: 2, style: LineStyle.Solid);
DiPlusSeries = new(name: "+DI", color: Color.Green, width: 1, style: LineStyle.Solid);
DiMinusSeries = new(name: "-DI", color: Color.Red, width: 1, style: LineStyle.Solid);
AddLineSeries(AdxSeries);
AddLineSeries(DiPlusSeries);
AddLineSeries(DiMinusSeries);
}
protected override void OnInit()
{
_adx = new Adx(Period);
base.OnInit();
}
protected override void OnUpdate(UpdateArgs args)
{
bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar;
TBar bar = this.GetInputBar(args);
TValue result = _adx!.Update(bar, isNew);
if (!_adx.IsHot && !ShowColdValues)
{
return;
}
AdxSeries!.SetValue(result.Value);
DiPlusSeries!.SetValue(_adx.DiPlus.Value);
DiMinusSeries!.SetValue(_adx.DiMinus.Value);
}
}
+111
View File
@@ -0,0 +1,111 @@
using Xunit;
namespace QuanTAlib.Tests;
public class AdxTests
{
private readonly GBM _gbm = new();
[Fact]
public void Constructor_ThrowsArgumentException_WhenPeriodIsInvalid()
{
Assert.Throws<ArgumentException>(() => new Adx(0));
Assert.Throws<ArgumentException>(() => new Adx(-1));
}
[Fact]
public void Update_ReturnsValidValues_WhenInputIsValid()
{
var adx = new Adx(14);
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
var result = adx.Update(bar);
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void Update_HandlesIsNewCorrectly()
{
var adx = new Adx(14);
// We need enough bars to warm up ADX (2 * Period)
int count = 2 * 14 + 5;
var bars = _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Feed all but last bar
for (int i = 0; i < count - 1; i++)
{
adx.Update(bars[i]);
}
// Update with last bar (isNew=true)
var result1 = adx.Update(bars[count - 1], true);
// Update with modified last bar (isNew=false)
var modifiedBar = new TBar(bars[count - 1].Time, bars[count - 1].Open, bars[count - 1].High + 1, bars[count - 1].Low - 1, bars[count - 1].Close, bars[count - 1].Volume);
var result2 = adx.Update(modifiedBar, false);
// The result should change because High/Low changed, affecting TR and DM
Assert.NotEqual(result1.Value, result2.Value);
}
[Fact]
public void Reset_ResetsState()
{
var adx = new Adx(14);
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); // Increased to 100
foreach (var bar in bars)
{
adx.Update(bar);
}
Assert.True(adx.IsHot);
adx.Reset();
Assert.False(adx.IsHot);
Assert.Equal(0, adx.Last.Value);
}
[Fact]
public void IsHot_BecomesTrue_AfterWarmup()
{
var adx = new Adx(14);
var bars = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
int i = 0;
for (; i < bars.Count; i++)
{
adx.Update(bars[i]);
if (adx.IsHot) break;
}
Assert.True(i < bars.Count);
Assert.True(adx.IsHot);
}
[Fact]
public void Update_HandlesNaN_Gracefully()
{
var adx = new Adx(14);
var bar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0);
var result = adx.Update(bar);
// Should not throw and return finite value (likely 0 or last valid)
// Since it's the first value, it might be 0.
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_TValue_ReturnsValidResult()
{
var adx = new Adx(14);
var val = new TValue(DateTime.UtcNow, 100);
var result = adx.Update(val);
Assert.True(double.IsFinite(result.Value));
}
}
+75
View File
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Skender.Stock.Indicators;
using TALib;
using Xunit;
using QuanTAlib.Tests;
namespace QuanTAlib;
public class AdxValidationTests : IDisposable
{
private readonly ValidationTestData _data;
public AdxValidationTests()
{
_data = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_data.Dispose();
}
}
[Fact]
public void MatchesSkender()
{
var adx = new Adx(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
var res = adx.Update(_data.Bars[i]);
results.Add(res.Value);
}
var skenderResults = _data.SkenderQuotes.GetAdx(14).ToList();
ValidationHelper.VerifyData(results, skenderResults, x => x.Adx);
}
[Fact]
public void MatchesTalib()
{
var adx = new Adx(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
var res = adx.Update(_data.Bars[i]);
results.Add(res.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = TALib.Functions.Adx(hData, lData, cData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = TALib.Functions.AdxLookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
}
+292
View File
@@ -0,0 +1,292 @@
using System.Runtime.CompilerServices;
namespace QuanTAlib;
/// <summary>
/// ADX: Average Directional Index
/// </summary>
/// <remarks>
/// ADX measures the strength of a trend, regardless of its direction.
/// It is derived from the Smoothed Directional Movement Index (DX).
///
/// Calculation:
/// 1. Calculate True Range (TR), +DM, and -DM
/// 2. Smooth TR, +DM, -DM using RMA (Wilder's Moving Average)
/// - First value is SMA of first Period values
/// - Subsequent values: Previous + (Input - Previous) / Period
/// 3. Calculate +DI = (+DM_smooth / TR_smooth) * 100
/// 4. Calculate -DI = (-DM_smooth / TR_smooth) * 100
/// 5. Calculate DX = |(+DI - -DI) / (+DI + -DI)| * 100
/// 6. ADX = RMA(DX)
/// - First value is SMA of first Period DX values
/// - Subsequent values: Previous + (Input - Previous) / Period
///
/// Sources:
/// https://www.investopedia.com/terms/a/adx.asp
/// "New Concepts in Technical Trading Systems" by J. Welles Wilder
/// </remarks>
[SkipLocalsInit]
public sealed class Adx : ITValuePublisher
{
private readonly int _period;
private TBar _prevBar;
private TBar _p_prevBar;
private bool _isInitialized;
// State for TR, +DM, -DM smoothing
private double _trSum, _dmPlusSum, _dmMinusSum;
private double _p_trSum, _p_dmPlusSum, _p_dmMinusSum;
private int _samples;
private int _p_samples;
private double _trSmooth, _dmPlusSmooth, _dmMinusSmooth;
private double _p_trSmooth, _p_dmPlusSmooth, _p_dmMinusSmooth;
// State for ADX smoothing
private double _dxSum;
private double _p_dxSum;
private int _dxSamples;
private int _p_dxSamples;
private double _adx;
private double _p_adx;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
public event Action<TValue>? Pub;
/// <summary>
/// Current ADX value.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// Current +DI value.
/// </summary>
public TValue DiPlus { get; private set; }
/// <summary>
/// Current -DI value.
/// </summary>
public TValue DiMinus { get; private set; }
/// <summary>
/// True if the ADX has warmed up and is providing valid results.
/// </summary>
public bool IsHot => _dxSamples >= _period;
/// <summary>
/// Creates ADX with specified period.
/// </summary>
/// <param name="period">Period for ADX calculation (must be > 0)</param>
public Adx(int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_period = period;
Name = $"Adx({period})";
_isInitialized = false;
}
/// <summary>
/// Resets the ADX state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_prevBar = default;
_p_prevBar = default;
_isInitialized = false;
_trSum = _dmPlusSum = _dmMinusSum = 0;
_p_trSum = _p_dmPlusSum = _p_dmMinusSum = 0;
_samples = _p_samples = 0;
_trSmooth = _dmPlusSmooth = _dmMinusSmooth = 0;
_p_trSmooth = _p_dmPlusSmooth = _p_dmMinusSmooth = 0;
_dxSum = _p_dxSum = 0;
_dxSamples = _p_dxSamples = 0;
_adx = _p_adx = 0;
Last = default;
DiPlus = default;
DiMinus = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_p_prevBar = _prevBar;
_p_trSum = _trSum;
_p_dmPlusSum = _dmPlusSum;
_p_dmMinusSum = _dmMinusSum;
_p_samples = _samples;
_p_trSmooth = _trSmooth;
_p_dmPlusSmooth = _dmPlusSmooth;
_p_dmMinusSmooth = _dmMinusSmooth;
_p_dxSum = _dxSum;
_p_dxSamples = _dxSamples;
_p_adx = _adx;
}
else
{
_prevBar = _p_prevBar;
_trSum = _p_trSum;
_dmPlusSum = _p_dmPlusSum;
_dmMinusSum = _p_dmMinusSum;
_samples = _p_samples;
_trSmooth = _p_trSmooth;
_dmPlusSmooth = _p_dmPlusSmooth;
_dmMinusSmooth = _p_dmMinusSmooth;
_dxSum = _p_dxSum;
_dxSamples = _p_dxSamples;
_adx = _p_adx;
}
if (!_isInitialized)
{
if (isNew)
{
_prevBar = input;
_isInitialized = true;
}
return new TValue(input.Time, 0);
}
// Calculate TR
double hl = input.High - input.Low;
double hpc = Math.Abs(input.High - _prevBar.Close);
double lpc = Math.Abs(input.Low - _prevBar.Close);
double tr = Math.Max(hl, Math.Max(hpc, lpc));
// Calculate DM
double dmPlus = 0;
double dmMinus = 0;
double upMove = input.High - _prevBar.High;
double downMove = _prevBar.Low - input.Low;
if (upMove > downMove && upMove > 0)
dmPlus = upMove;
if (downMove > upMove && downMove > 0)
dmMinus = downMove;
if (isNew)
{
_prevBar = input;
}
// Smooth TR, +DM, -DM
if (_samples < _period)
{
_trSum += tr;
_dmPlusSum += dmPlus;
_dmMinusSum += dmMinus;
_samples++;
if (_samples == _period)
{
// Wilder's initialization for TR, +DM, and -DM uses the un-averaged sum (scaled sum).
// Since +DI and -DI are ratios (+DM/TR and -DM/TR), the scaling factor (1/Period)
// cancels out mathematically. This differs from the ADX smoothing later, which
// explicitly uses a true SMA (sum / Period) for its initialization.
_trSmooth = _trSum;
_dmPlusSmooth = _dmPlusSum;
_dmMinusSmooth = _dmMinusSum;
}
}
else
{
// RMA: Previous + (Input - Previous) / Period
// Or: Previous * (1 - 1/Period) + Input * (1/Period)
// Or: (Previous * (Period - 1) + Input) / Period
// Wilder uses sums, but effectively it's RMA.
// Standard formula:
// Smooth = Smooth - (Smooth / Period) + Input
_trSmooth = _trSmooth - (_trSmooth / _period) + tr;
_dmPlusSmooth = _dmPlusSmooth - (_dmPlusSmooth / _period) + dmPlus;
_dmMinusSmooth = _dmMinusSmooth - (_dmMinusSmooth / _period) + dmMinus;
}
// Calculate DI and DX
double diPlus = 0;
double diMinus = 0;
double dx = 0;
if (_samples >= _period)
{
if (_trSmooth > 1e-10)
{
diPlus = (_dmPlusSmooth / _trSmooth) * 100.0;
diMinus = (_dmMinusSmooth / _trSmooth) * 100.0;
}
double diSum = diPlus + diMinus;
if (diSum > 1e-10)
{
dx = (Math.Abs(diPlus - diMinus) / diSum) * 100.0;
}
// Smooth DX to get ADX
if (_dxSamples < _period)
{
_dxSum += dx;
_dxSamples++;
if (_dxSamples == _period)
{
_adx = _dxSum / _period; // First ADX is SMA of DX
}
}
else
{
// ADX = (Prior ADX * (Period - 1) + Current DX) / Period
_adx = ((_adx * (_period - 1)) + dx) / _period;
}
}
DiPlus = new TValue(input.Time, diPlus);
DiMinus = new TValue(input.Time, diMinus);
Last = new TValue(input.Time, _adx);
Pub?.Invoke(Last);
return Last;
}
public TValue Update(TValue input, bool isNew = true)
{
return Update(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew);
}
public TSeries Update(TBarSeries source)
{
var t = new List<long>(source.Count);
var v = new List<double>(source.Count);
Reset();
for (int i = 0; i < source.Count; i++)
{
var val = Update(source[i], true);
t.Add(val.Time);
v.Add(val.Value);
}
return new TSeries(t, v);
}
public static TSeries Calculate(TBarSeries source, int period)
{
var adx = new Adx(period);
return adx.Update(source);
}
}
+80
View File
@@ -0,0 +1,80 @@
# ADX - Average Directional Index
The Average Directional Index (ADX) is a technical analysis indicator used to determine the strength of a trend. The trend can be either up or down, and this is shown by two accompanying indicators, the Negative Directional Indicator (-DI) and the Positive Directional Indicator (+DI). Therefore, ADX consists of three separate lines.
## Core Concepts
- **Trend Strength:** ADX measures the strength of the trend, not the direction.
- **Directional Movement:** +DI and -DI show the direction of the trend.
- **Range:** ADX values range from 0 to 100. Values above 25 usually indicate a strong trend.
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| Period | int | 14 | The number of periods used for the calculation. |
## Formula
1. **Calculate True Range (TR), +DM, and -DM:**
$$TR = \max(High - Low, |High - PreviousClose|, |Low - PreviousClose|)$$
$$+DM = \text{if } (High - PreviousHigh) > (PreviousLow - Low) \text{ and } (High - PreviousHigh) > 0 \text{ then } (High - PreviousHigh) \text{ else } 0$$
$$-DM = \text{if } (PreviousLow - Low) > (High - PreviousHigh) \text{ and } (PreviousLow - Low) > 0 \text{ then } (PreviousLow - Low) \text{ else } 0$$
2. **Smooth TR, +DM, -DM:**
Using Wilder's Moving Average (RMA) over `Period`.
$$TR_{smooth} = RMA(TR, Period)$$
$$+DM_{smooth} = RMA(+DM, Period)$$
$$-DM_{smooth} = RMA(-DM, Period)$$
3. **Calculate +DI and -DI:**
$$+DI = \frac{+DM_{smooth}}{TR_{smooth}} \times 100$$
$$-DI = \frac{-DM_{smooth}}{TR_{smooth}} \times 100$$
4. **Calculate DX:**
$$DX = \frac{|+DI - -DI|}{+DI + -DI} \times 100$$
5. **Calculate ADX:**
$$ADX = RMA(DX, Period)$$
## C# Implementation
### Standard Usage
```csharp
// Create ADX with period 14
var adx = new Adx(14);
// Update with TBar
var result = adx.Update(new TBar(time, open, high, low, close, volume));
Console.WriteLine($"ADX: {result.Value}");
```
### Streaming with TBarSeries
```csharp
var adx = new Adx(14);
var series = new TBarSeries();
// ... populate series ...
var results = adx.Update(series);
```
### Static Calculation
```csharp
var results = Adx.Calculate(series, 14);
```
## Interpretation
- **ADX < 20:** Weak trend or non-trending market.
- **ADX > 25:** Strong trend.
- **ADX > 40:** Very strong trend.
- **ADX > 50:** Extremely strong trend.
Traders typically use ADX to determine whether to use a trend-following system or a range-trading system. When ADX is high, trend-following strategies are preferred. When ADX is low, range-trading strategies are preferred.
## References
- [Investopedia - Average Directional Index (ADX)](https://www.investopedia.com/terms/a/adx.asp)
- Wilder, J. Welles. *New Concepts in Technical Trading Systems*. Trend Research, 1978.