From 016c10b68aaef94c151efefcd82007016d108ddf Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Sun, 14 Dec 2025 20:32:01 -0800 Subject: [PATCH] feat: Implement ADX Indicator with Quantower integration --- .clinerules/good-indicator.md | 183 ---------------- AGENTS.md | 141 ++++++++++-- lib/trends/_index.md | 1 + lib/trends/adx/Adx.Quantower.Tests.cs | 85 +++++++ lib/trends/adx/Adx.Quantower.cs | 64 ++++++ lib/trends/adx/Adx.Tests.cs | 111 ++++++++++ lib/trends/adx/Adx.Validation.Tests.cs | 75 +++++++ lib/trends/adx/Adx.cs | 292 +++++++++++++++++++++++++ lib/trends/adx/Adx.md | 80 +++++++ 9 files changed, 830 insertions(+), 202 deletions(-) delete mode 100644 .clinerules/good-indicator.md create mode 100644 lib/trends/adx/Adx.Quantower.Tests.cs create mode 100644 lib/trends/adx/Adx.Quantower.cs create mode 100644 lib/trends/adx/Adx.Tests.cs create mode 100644 lib/trends/adx/Adx.Validation.Tests.cs create mode 100644 lib/trends/adx/Adx.cs create mode 100644 lib/trends/adx/Adx.md diff --git a/.clinerules/good-indicator.md b/.clinerules/good-indicator.md deleted file mode 100644 index 0e0e400e..00000000 --- a/.clinerules/good-indicator.md +++ /dev/null @@ -1,183 +0,0 @@ -# Good Indicator Guidelines - -This document defines the strict standards for creating high-quality technical indicators in the QuanTAlib library. All new indicators MUST adhere to these rules to ensure consistency, performance, and reliability. - -## 1. Architecture & Design Principles - -* **Source Material:** The algorithm and markdown documentation foundation should be sourced from [https://github.com/mihakralj/pinescript/blob/main/indicators/](PineScript). -* **Zero Allocation:** The core calculation loop must not allocate memory on the heap. Use `stackalloc`, `Span`, and pinned memory where possible. -* **O(1) Complexity:** Streaming updates must be O(1) whenever mathematically possible. Use running sums/products or circular buffers to avoid re-iterating over history. -* **Dual API:** Provide both a stateful object-oriented API (`Update`) and a stateless static vector API (`Calculate`). -* **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 - -Each indicator resides in its own directory such as `lib/trends/`, `lib/indicators/`, or `lib/oscillators/`. - -**Directory:** `lib/[category]/[name]/` - -| File | Purpose | Naming Convention | -|------|---------|-------------------| -| **Source** | Main implementation | `[Name].cs` (e.g., `Sma.cs`) | -| **Tests** | Unit tests | `[Name].Tests.cs` | -| **Validation** | Cross-library validation | `[Name].Validation.Tests.cs` | -| **Docs** | User documentation | `[Name].md` | -| **Quantower** | Quantower adapter | `[Name].Quantower.cs` | -| **Quantower Tests** | Quantower adapter tests | `[Name].Quantower.Tests.cs` | - -## 3. Implementation Rules (`[Name].cs`) - -### Class Definition - -* **Namespace:** `QuanTAlib` -* **Attributes:** `[SkipLocalsInit]` for performance. -* **Modifiers:** `public sealed class` -* **Interface:** Implements `ITValuePublisher` - -### State Management - -* **Scalar State:** Use a `private record struct State` to group all scalar state variables. This ensures value semantics, automatic `IEquatable` implementation, and cleaner rollback logic. -* **State Variables:** Maintain `private State _state;` (current) and `private State _p_state;` (previous valid state). -* **Buffers:** Use `RingBuffer` for sliding window data. -* **Resync:** Implement a periodic full recalculation (e.g., every 1000 ticks) to prevent floating-point drift in running sums. - -### Constructor - -* Validate all parameters (throw `ArgumentException` for invalid values). -* Initialize `Name` property (e.g., `$"Sma({period})"`); -* Support chaining: `public [Name](ITValuePublisher source, ...)` - -### Update Method - -* **Signature:** `public TValue Update(TValue input, bool isNew = true)` -* **Attribute:** `[MethodImpl(MethodImplOptions.AggressiveInlining)]` -* **Logic:** - 1. **State Rollback:** - - ```csharp - if (isNew) { - _p_state = _state; - // ... update state (e.g. counters) ... - } else { - _state = _p_state; - // ... update state ... - } - ``` - - 2. **Input Validation:** Check `double.IsFinite`. If not, use `_lastValidValue` (stored in `State`). - 3. **Calculation:** Perform the math. - 4. **Publish:** Update `Last` property, invoke `Pub` event, return `Last`. - -### Update Method (TSeries) - -* **Signature:** `public TSeries Update(TSeries source)` -* **Placement:** Must be adjacent to the `Update(TValue)` method. -* **Logic:** - 1. Create output series. - 2. Call static `Calculate(Span)` for performance. - 3. Restore internal state by replaying the last `Period` bars (or full series if recursive). - -### Static Calculate (TSeries) - -* Create a new instance of the indicator. -* Iterate through the source series. -* Return the resulting `TSeries`. - -### Static Calculate (Span) - **Critical for Performance** - -* **Signature:** `public static void Calculate(ReadOnlySpan source, Span output, ...)` -* **Attribute:** `[MethodImpl(MethodImplOptions.AggressiveInlining)]` -* **Optimization:** - -* Check for SIMD support (`Avx2.IsSupported`). -* Use `stackalloc` for small buffers (threshold ~256) and for internal state buffers in recursive algorithms where SIMD is not applicable. -* Implement a scalar fallback path that handles `NaN` safely. -* Implement a SIMD path for large, clean datasets (optional but recommended for simple averages). - -## 4. Testing Standards - -### 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). -* Basic calculation correctness (compare against manual calc). -* `isNew=true` vs `isNew=false` behavior (bar correction). -* `Reset()` functionality. -* `IsHot` property behavior. -* `NaN` / `Infinity` handling (must not crash, must return finite values). -* Consistency between Object API, Static TSeries API, and Static Span API. -* Edge cases: Period=1, empty input, single input. - -### Validation Tests (`[Name].Validation.Tests.cs`) - -* **Purpose:** Verify accuracy against **ALL** available external libraries (Skender, TA-Lib, Tulip, OoplesFinance, Python libraries, etc.) where the indicator is implemented. You must actively search for existing implementations to validate against. -* **Data:** Use `GBM` (Geometric Brownian Motion) to generate realistic test data. -* **Scenarios:** - -* Batch processing. -* Streaming processing. -* Span/Vector processing. - -* **Tolerance:** Typically `1e-6` or `1e-9` depending on the algorithm. - -## 5. Documentation Standards (`[Name].md`) - -Follow the standard template and ensure strict adherence to Markdownlint rules, specifically: - -* **MD030:** Ensure exactly one space after list markers (e.g., `* Item`, not `*Item` or `* Item`). -* **MD032:** Ensure lists are surrounded by blank lines (one blank line before the first item and one after the last item). -* **No Issues:** Ensure that markdownlint shows no issues for the file. - -Template structure: - -1. **Title & Overview:** What is it? What does it do? -2. **Core Concepts:** Key features (e.g., equal weighting, noise reduction). -3. **Parameters:** Table of constructor parameters. -4. **Formula:** LaTeX formatted math ($$...$$). -5. **C# Implementation:** Code examples for: - * Standard usage. - * Span API (high performance). - * Bar correction (`isNew`). - * Eventing. -6. **Interpretation:** How to use it in trading. -7. **References:** Books or papers. - -## 6. Quantower Adapter - -* **Implementation:** Create a wrapper class in `[Name].Quantower.cs` that adapts the QuanTAlib indicator for the Quantower platform. -* **Tests:** Create unit tests in `[Name].Quantower.Tests.cs` to verify the adapter's functionality using mocks where necessary. - -## 7. Code Review - -* **Tool:** Run CodeRabbit on the changes. -* **Requirement:** Address and fix **ALL** issues identified by the CodeRabbit review before considering the task complete. - -## 8. Performance Guidelines - -* **Inlining:** Use `[MethodImpl(MethodImplOptions.AggressiveInlining)]` on all hot path methods (`Update`, `Calculate`). -* **Locals Init:** Use `[SkipLocalsInit]` on the class to skip zero-initialization of locals. -* **Loops:** Prefer `for` loops over `foreach` for arrays/spans. -* **Math:** Use `System.Math` or `System.Numerics`. Avoid LINQ in hot paths. -* **Memory:** **NEVER** use `new` inside the `Update` method. Pre-allocate everything in the constructor. - -## 9. Checklist for New Indicators - -* [ ] **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)? -* [ ] **Static API:** Implemented `Calculate(Span)`? -* [ ] **Tests:** Unit tests pass? `NaN` tests included? -* [ ] **Validation:** Matches **ALL** available external libraries? -* [ ] **Docs:** Markdown file created with formula and examples? Linted (MD030, MD032)? -* [ ] **Quantower:** Adapter created in `[Name].Quantower.cs`? -* [ ] **Quantower Tests:** Adapter tests created in `[Name].Quantower.Tests.cs`? -* [ ] **Code Review:** Ran CodeRabbit and fixed all issues? -* [ ] **Index:** Added to category `_index.md` with link and description? -* [ ] **Performance:** No allocations in `Update`? `[SkipLocalsInit]` used? diff --git a/AGENTS.md b/AGENTS.md index 8c5fde12..90088863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,17 @@ We do not store objects in lists. We store primitive arrays. * `TSeries`: The primary data structure for time series. * `ITValuePublisher`: The interface for reactive data flow. +### Design Principles + +* **Source Material:** The algorithm and markdown documentation foundation should be sourced from [https://github.com/mihakralj/pinescript/blob/main/indicators/](PineScript). +* **Zero Allocation:** The core calculation loop must not allocate memory on the heap. Use `stackalloc`, `Span`, and pinned memory where possible. +* **O(1) Complexity:** Streaming updates must be O(1) whenever mathematically possible. Use running sums/products or circular buffers to avoid re-iterating over history. +* **Dual API:** Provide both a stateful object-oriented API (`Update`) and a stateless static vector API (`Calculate`). +* **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. + ### Performance Rules 1. **Zero Allocation**: The `Update` method MUST NOT allocate memory on the heap. Use `stackalloc` or pre-allocated buffers. @@ -44,13 +55,33 @@ Directory: `lib/[category]/[name]/` (e.g., `lib/trends/sma/`) | File | Naming | Purpose | |------|--------|---------| -| **Source** | `[Name].cs` | Main logic. `public sealed class`. | +| **Source** | `[Name].cs` | Main implementation. `public sealed class`. | | **Tests** | `[Name].Tests.cs` | xUnit tests (correctness, edge cases). | | **Validation** | `[Name].Validation.Tests.cs` | Compare against TA-Lib, Skender, etc. | | **Docs** | `[Name].md` | User documentation with formulas. | | **Adapter** | `[Name].Quantower.cs` | Quantower platform integration. | | **Adapter Tests** | `[Name].Quantower.Tests.cs` | Tests for the adapter. | +### Class Definition + +* **Namespace:** `QuanTAlib` +* **Attributes:** `[SkipLocalsInit]` for performance. +* **Modifiers:** `public sealed class` +* **Interface:** Implements `ITValuePublisher` + +### State Management + +* **Scalar State:** Use a `private record struct State` to group all scalar state variables. This ensures value semantics, automatic `IEquatable` implementation, and cleaner rollback logic. +* **State Variables:** Maintain `private State _state;` (current) and `private State _p_state;` (previous valid state). +* **Buffers:** Use `RingBuffer` for sliding window data. +* **Resync:** Implement a periodic full recalculation (e.g., every 1000 ticks) to prevent floating-point drift in running sums. + +### Constructor + +* Validate all parameters (throw `ArgumentException` for invalid values). +* Initialize `Name` property (e.g., `$"Sma({period})"`); +* Support chaining: `public [Name](ITValuePublisher source, ...)` + ### The `Update` Method Contract The `Update` method is the heart of the indicator. @@ -59,35 +90,94 @@ The `Update` method is the heart of the indicator. public TValue Update(TValue input, bool isNew = true) ``` -* **`isNew = true`**: A new bar has arrived. Save current state to history (or `_p_` variables), then calculate. -* **`isNew = false`**: The current bar is updating (tick data). Restore state from history (or `_p_` variables), then recalculate. -* **NaN Handling**: If input is `NaN` or `Infinity`, use the last valid value. Never propagate `NaN`. +* **Attribute:** `[MethodImpl(MethodImplOptions.AggressiveInlining)]` +* **Logic:** + 1. **State Rollback:** -### State Management + ```csharp + if (isNew) { + _p_state = _state; + // ... update state (e.g. counters) ... + } else { + _state = _p_state; + // ... update state ... + } + ``` -* **Scalar State:** Use a `private record struct State` to group all scalar state variables. This ensures value semantics, automatic `IEquatable` implementation, and cleaner rollback logic. -* **State Variables:** Maintain `private State _state;` (current) and `private State _p_state;` (previous valid state). -* **Buffers:** Use `RingBuffer` for sliding windows. -* **Resync:** Periodically recalculate running sums to prevent floating-point drift. + 2. **Input Validation:** Check `double.IsFinite`. If not, use `_lastValidValue` (stored in `State`). + 3. **Calculation:** Perform the math. + 4. **Publish:** Update `Last` property, invoke `Pub` event, return `Last`. -### Dual API Requirement +### Update Method (TSeries) -1. **Stateful (Streaming)**: `Update(TValue)` for live data. -2. **Stateless (Vector)**: `static void Calculate(ReadOnlySpan src, Span dst)` for batch history. +* **Signature:** `public TSeries Update(TSeries source)` +* **Placement:** Must be adjacent to the `Update(TValue)` method. +* **Logic:** + 1. Create output series. + 2. Call static `Calculate(Span)` for performance. + 3. Restore internal state by replaying the last `Period` bars (or full series if recursive). + +### Static Calculate (TSeries) + +* Create a new instance of the indicator. +* Iterate through the source series. +* Return the resulting `TSeries`. + +### Static Calculate (Span) - **Critical for Performance** + +* **Signature:** `public static void Calculate(ReadOnlySpan source, Span output, ...)` +* **Attribute:** `[MethodImpl(MethodImplOptions.AggressiveInlining)]` +* **Optimization:** + * Check for SIMD support (`Avx2.IsSupported`). + * Use `stackalloc` for small buffers (threshold ~256) and for internal state buffers in recursive algorithms where SIMD is not applicable. + * Implement a scalar fallback path that handles `NaN` safely. + * Implement a SIMD path for large, clean datasets (optional but recommended for simple averages). ## 4. Testing Protocol ### Unit Tests (`[Name].Tests.cs`) -* Use `GBM` (Geometric Brownian Motion) for data generation. -* Test `isNew=true` vs `isNew=false` consistency. -* Test `Reset()` and `IsHot` (warmup). -* Test edge cases: `NaN` inputs, empty series, period=1. +* **Framework:** xUnit +* **Data Generation:** Use `GBM` (Geometric Brownian Motion) for generating realistic test data. Avoid using `System.Random` directly. +* **Coverage:** + * Constructor validation (invalid params). + * Basic calculation correctness (compare against manual calc). + * `isNew=true` vs `isNew=false` behavior (bar correction). + * `Reset()` functionality. + * `IsHot` property behavior. + * `NaN` / `Infinity` handling (must not crash, must return finite values). + * Consistency between Object API, Static TSeries API, and Static Span API. + * Edge cases: Period=1, empty input, single input. ### Validation Tests (`[Name].Validation.Tests.cs`) * **Mandatory**: You MUST validate against at least one external authority (TA-Lib, Skender, Tulip, OoplesFinance, Python libs). * **Tolerance**: Typically `1e-6` to `1e-9`. +* **Data**: Use `ValidationTestData` class which wraps `GBM` (Geometric Brownian Motion) to generate realistic test data and provides pre-calculated Skender quotes. + +#### External Library Usage Guide + +* **Skender.Stock.Indicators:** + * Use `_data.SkenderQuotes.Get[Indicator](...)`. + * Compare using `ValidationHelper.VerifyData`. + +* **TA-Lib (TALib.NETCore):** + * Namespace: `using TALib;` + * Method: `TALib.Functions.[Indicator](...)`. + * Check `Assert.Equal(Core.RetCode.Success, retCode)`. + * Use `ValidationHelper.VerifyData` with `outRange` and `lookback`. + +* **Tulip (Tulip.NETCore):** + * Namespace: `using Tulip;` + * Method: `Tulip.Indicators.[indicator].Run(...)`. + * Handle lookback/offset manually (Tulip output is shorter than input). + * Use `ValidationHelper.VerifyData` with `lookback`. + +* **OoplesFinance.StockIndicators:** + * Namespace: `using OoplesFinance.StockIndicators;` + * Convert data: `_data.SkenderQuotes.Select(q => new TickerData { ... }).ToList()`. + * Use `new StockData(ooplesData).Calculate[Indicator](...)`. + * Compare using `ValidationHelper.VerifyData`. ## 5. Documentation Standards @@ -95,8 +185,20 @@ public TValue Update(TValue input, bool isNew = true) * **Content**: Title, Description, Parameters, Formula (LaTeX), C# Usage Examples. * **Index**: Add the new indicator to the category index (e.g., `lib/trends/_index.md`). * **Linting**: Ensure that markdownlint shows no issues for the file. + * **MD030:** Ensure exactly one space after list markers. + * **MD032:** Ensure lists are surrounded by blank lines. -## 6. Development Checklist +## 6. Quantower Adapter + +* **Implementation:** Create a wrapper class in `[Name].Quantower.cs` that adapts the QuanTAlib indicator for the Quantower platform. +* **Tests:** Create unit tests in `[Name].Quantower.Tests.cs` to verify the adapter's functionality using mocks where necessary. + +## 7. Code Review + +* **Tool:** Run CodeRabbit on the changes. +* **Requirement:** Address and fix **ALL** issues identified by the CodeRabbit review before considering the task complete. + +## 8. Development Checklist When creating a new indicator, you are **DONE** only when: @@ -108,9 +210,10 @@ When creating a new indicator, you are **DONE** only when: * [ ] Unit tests pass (including edge cases). * [ ] Validation tests pass against external libs. * [ ] Documentation is complete and linked in `_index.md`. +* [ ] Quantower adapter and tests are implemented. * [ ] CodeRabbit review issues are resolved. -## 7. Forbidden Actions +## 9. Forbidden Actions * **DO NOT** use LINQ in hot paths (`Update` or `Calculate`). * **DO NOT** use `new` inside `Update`. @@ -118,7 +221,7 @@ When creating a new indicator, you are **DONE** only when: * **DO NOT** remove `[SkipLocalsInit]` or `[MethodImpl]` attributes. * **DO NOT** ignore `NaN` inputs; handle them safely. -## 8. Context & Resources +## 10. Context & Resources * **Time**: Use `DateTime.UtcNow`. * **Math**: Use `System.Math` or `System.Numerics`. diff --git a/lib/trends/_index.md b/lib/trends/_index.md index fb31d3f8..4c88e2fc 100644 --- a/lib/trends/_index.md +++ b/lib/trends/_index.md @@ -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 | | diff --git a/lib/trends/adx/Adx.Quantower.Tests.cs b/lib/trends/adx/Adx.Quantower.Tests.cs new file mode 100644 index 00000000..3ad45ea3 --- /dev/null +++ b/lib/trends/adx/Adx.Quantower.Tests.cs @@ -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)); + } +} diff --git a/lib/trends/adx/Adx.Quantower.cs b/lib/trends/adx/Adx.Quantower.cs new file mode 100644 index 00000000..3b643cda --- /dev/null +++ b/lib/trends/adx/Adx.Quantower.cs @@ -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); + } +} diff --git a/lib/trends/adx/Adx.Tests.cs b/lib/trends/adx/Adx.Tests.cs new file mode 100644 index 00000000..ce9f1907 --- /dev/null +++ b/lib/trends/adx/Adx.Tests.cs @@ -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(() => new Adx(0)); + Assert.Throws(() => 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)); + } +} diff --git a/lib/trends/adx/Adx.Validation.Tests.cs b/lib/trends/adx/Adx.Validation.Tests.cs new file mode 100644 index 00000000..bf03f7ce --- /dev/null +++ b/lib/trends/adx/Adx.Validation.Tests.cs @@ -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(); + + 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(); + + 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); + } + +} diff --git a/lib/trends/adx/Adx.cs b/lib/trends/adx/Adx.cs new file mode 100644 index 00000000..b9577be7 --- /dev/null +++ b/lib/trends/adx/Adx.cs @@ -0,0 +1,292 @@ +using System.Runtime.CompilerServices; + +namespace QuanTAlib; + +/// +/// ADX: Average Directional Index +/// +/// +/// 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 +/// +[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; + + /// + /// Display name for the indicator. + /// + public string Name { get; } + + public event Action? Pub; + + /// + /// Current ADX value. + /// + public TValue Last { get; private set; } + + /// + /// Current +DI value. + /// + public TValue DiPlus { get; private set; } + + /// + /// Current -DI value. + /// + public TValue DiMinus { get; private set; } + + /// + /// True if the ADX has warmed up and is providing valid results. + /// + public bool IsHot => _dxSamples >= _period; + + /// + /// Creates ADX with specified period. + /// + /// Period for ADX calculation (must be > 0) + 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; + } + + /// + /// Resets the ADX state. + /// + [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(source.Count); + var v = new List(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); + } +} diff --git a/lib/trends/adx/Adx.md b/lib/trends/adx/Adx.md new file mode 100644 index 00000000..ff94968c --- /dev/null +++ b/lib/trends/adx/Adx.md @@ -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.