From 875998b28824e80d7b9b2990cded024ad3b0c39a Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Sun, 7 Dec 2025 16:46:38 -0800 Subject: [PATCH] Add eventing support to WMA indicator and implement unit tests for various indicators - Enhanced WMA indicator with event-driven capabilities using ITValuePublisher interface. - Created a new TODO file listing various indicators and their corresponding libraries. - Added unit tests for DEMA, HMA, TEMA, and WMA indicators to ensure proper functionality. - Implemented tests for handling new bars, ticks, and historical data updates across indicators. - Verified that indicators correctly compute values and handle different source types. --- lib/averages/_index.md | 65 +++++ lib/averages/dema/Dema.Coverage.Tests.cs | 87 ++++++ lib/averages/dema/Dema.Notebook.dib | 219 --------------- lib/averages/dema/Dema.md | 28 ++ lib/averages/ema/Ema.Notebook.dib | 219 --------------- lib/averages/ema/Ema.md | 28 ++ lib/averages/hma/Hma.Coverage.Tests.cs | 77 ++++++ lib/averages/hma/Hma.Quantower.cs | 63 +++++ lib/averages/hma/Hma.Tests.cs | 159 +++++++++++ lib/averages/hma/Hma.Validation.Tests.cs | 232 ++++++++++++++++ lib/averages/hma/Hma.cs | 193 ++++++++++++++ lib/averages/hma/Hma.md | 44 +++ lib/averages/sma/Sma.Coverage.Tests.cs | 229 ++++++++++++++++ lib/averages/sma/Sma.Notebook.dib | 285 -------------------- lib/averages/sma/Sma.cs | 65 ++--- lib/averages/sma/Sma.md | 28 ++ lib/averages/tema/Tema.Notebook.dib | 219 --------------- lib/averages/tema/Tema.md | 28 ++ lib/averages/todo.md | 4 - lib/averages/trima/Trima.Notebook.dib | 158 ----------- lib/averages/trima/Trima.md | 30 +++ lib/averages/wma/Wma.Coverage.Tests.cs | 276 +++++++++++++++++++ lib/averages/wma/Wma.Notebook.dib | 323 ----------------------- lib/averages/wma/Wma.md | 28 ++ lib/todo.md | 142 ++++++++++ quantower/DemaIndicator.Tests.cs | 179 +++++++++++++ quantower/HmaIndicator.Tests.cs | 179 +++++++++++++ quantower/SmaIndicator.Tests.cs | 46 ++++ quantower/TemaIndicator.Tests.cs | 177 +++++++++++++ quantower/TrimaIndicator.Tests.cs | 46 ++++ quantower/WmaIndicator.Tests.cs | 46 ++++ 31 files changed, 2445 insertions(+), 1457 deletions(-) create mode 100644 lib/averages/_index.md create mode 100644 lib/averages/dema/Dema.Coverage.Tests.cs delete mode 100644 lib/averages/dema/Dema.Notebook.dib delete mode 100644 lib/averages/ema/Ema.Notebook.dib create mode 100644 lib/averages/hma/Hma.Coverage.Tests.cs create mode 100644 lib/averages/hma/Hma.Quantower.cs create mode 100644 lib/averages/hma/Hma.Tests.cs create mode 100644 lib/averages/hma/Hma.Validation.Tests.cs create mode 100644 lib/averages/hma/Hma.cs create mode 100644 lib/averages/hma/Hma.md create mode 100644 lib/averages/sma/Sma.Coverage.Tests.cs delete mode 100644 lib/averages/sma/Sma.Notebook.dib delete mode 100644 lib/averages/tema/Tema.Notebook.dib delete mode 100644 lib/averages/todo.md delete mode 100644 lib/averages/trima/Trima.Notebook.dib create mode 100644 lib/averages/wma/Wma.Coverage.Tests.cs delete mode 100644 lib/averages/wma/Wma.Notebook.dib create mode 100644 lib/todo.md create mode 100644 quantower/DemaIndicator.Tests.cs create mode 100644 quantower/HmaIndicator.Tests.cs create mode 100644 quantower/TemaIndicator.Tests.cs diff --git a/lib/averages/_index.md b/lib/averages/_index.md new file mode 100644 index 00000000..d10eeae4 --- /dev/null +++ b/lib/averages/_index.md @@ -0,0 +1,65 @@ +# Averages + +| Indicator | Name | +| --------- | ------------------------------ | +| ALMA | Arnaud Legoux MA | +| BESSEL | Bessel Filter | +| BILATERAL | Bilateral Filter | +| BLMA | Blackman Window MA | +| BPF | Ehlers Bandpass Filter | +| BUTTER | Butterworth Filter | +| BWMA | Bessel-Weighted MA | +| CHEBY1 | Chebyshev Type I Filter | +| CHEBY2 | Chebyshev Type II Filter | +| CONV | Convolution MA with any kernel | +| [DEMA](dema/Dema.cs) | Double Exponential MA | +| DSMA | Deviation-Scaled MA | +| DWMA | Double Weighted MA | +| ELLIPTIC | Elliptic (Cauer) Filter | +| [EMA](ema/Ema.cs) | Exponential MA | +| EPMA | Endpoint MA | +| FRAMA | Fractal Adaptive MA | +| GAUSS | Gaussian Filter | +| GWMA | Gaussian-Weighted MA | +| HAMMA | Hamming Window MA | +| HANN | Hann FIR Filter | +| HANMA | Hanning Window MA | +| HEMA | Hull Exponential MA | +| [HMA](hma/Hma.cs) | Hull MA | +| HP | Hodrick-Prescott Filter | +| HPF | Ehlers Highpass Filter | +| HTIT | Hilbert Transform Instantaneous Trend | +| HWMA | Holt Weighted MA | +| JMA | Jurik MA | +| KAMA | Kaufman Adaptive MA | +| KF | Kalman Filter | +| LOESS | LOESS/LOWESS Smoothing | +| LSMA | Least Squares MA | +| LTMA | Linear Trend MA | +| MAMA | MESA Adaptive MA | +| MEDIAN | Median Filter | +| MGDI | McGinley Dynamic Indicator | +| MMA | Modified MA | +| NOTCH | Notch Filter | +| PWMA | Pascal Weighted MA | +| QEMA | Quadruple Exponential MA | +| REMA | Regularized Exponential MA | +| RGMA | Recursive Gaussian MA | +| RMA | wildeR MA (SMMA, MMA) | +| SGF | Savitzky-Golay Filter | +| SGMA | Savitzky-Golay MA | +| SINEMA | Sine-weighted MA | +| [SMA](sma/Sma.cs) | Simple MA | +| SSF | Ehlers Super Smooth Filter | +| T3 | Tillson T3 MA | +| [TEMA](tema/Tema.cs) | Triple Exponential MA | +| [TRIMA](trima/Trima.cs) | Triangular MA | +| USF | Ehlers Ultrasmooth Filter | +| VAMA | Volatility Adjusted MA | +| VIDYA | Variable Index Dynamic Average | +| WIENER | Wiener Filter | +| [WMA](wma/Wma.cs) | Weighted MA | +| YZVAMA | Yang-Zhang Volatility Adjusted MA | +| ZLDEMA | Zero-Lag Double Exponential MA | +| ZLEMA | Zero-Lag Exponential MA | +| ZLTEMA | Zero-Lag Triple Exponential MA | diff --git a/lib/averages/dema/Dema.Coverage.Tests.cs b/lib/averages/dema/Dema.Coverage.Tests.cs new file mode 100644 index 00000000..ac9fbeb5 --- /dev/null +++ b/lib/averages/dema/Dema.Coverage.Tests.cs @@ -0,0 +1,87 @@ +using System; +using Xunit; + +namespace QuanTAlib.Tests; + +public class DemaCoverageTests +{ + [Fact] + public void Dema_CompensationLogic_IsTriggered() + { + // Compensation happens when E <= 1e-10 + // E starts at 1.0 and decays by (1-alpha) each step. + // We need enough steps to reach 1e-10. + // If period=10, alpha ~ 0.18, decay ~ 0.81 + // 0.81^n <= 1e-10 => n * log(0.81) <= -10 + // n * -0.09 <= -10 => n >= 111 + + int count = 200; + int period = 10; + var dema = new Dema(period); + + for (int i = 0; i < count; i++) + { + dema.Update(new TValue(DateTime.UtcNow, 100.0)); + } + + // Just ensuring no exception and value is correct + Assert.Equal(100.0, dema.Last.Value, 1e-9); + } + + [Fact] + public void Dema_IsHot_Logic() + { + // IsHot happens when E <= 0.05 + // 0.81^n <= 0.05 => n >= 14 + + int period = 10; + var dema = new Dema(period); + + Assert.False(dema.IsHot); + + for (int i = 0; i < 50; i++) + { + dema.Update(new TValue(DateTime.UtcNow, 100.0)); + if (i > 30) // Should be hot by now + { + Assert.True(dema.IsHot); + } + } + } + + [Fact] + public void Dema_StaticCalculate_Alpha_Coverage() + { + double[] source = new double[100]; + double[] output = new double[100]; + for(int i=0; i<100; i++) source[i] = 100.0; + + // Use alpha overload + Dema.Calculate(source.AsSpan(), output.AsSpan(), 0.1); + + Assert.Equal(100.0, output[^1], 1e-9); + } + + [Fact] + public void Dema_Reset_ClearsState() + { + var dema = new Dema(10); + dema.Update(new TValue(DateTime.UtcNow, 100.0)); + Assert.NotEqual(0, dema.Last.Value); + + dema.Reset(); + + Assert.Equal(0, dema.Last.Value); + Assert.False(dema.IsHot); + } + + [Fact] + public void Dema_Constructor_Alpha_Validation() + { + Assert.Throws(() => new Dema(0.0)); + Assert.Throws(() => new Dema(1.1)); + + var dema = new Dema(0.5); + Assert.NotNull(dema); + } +} diff --git a/lib/averages/dema/Dema.Notebook.dib b/lib/averages/dema/Dema.Notebook.dib deleted file mode 100644 index 98ccc8be..00000000 --- a/lib/averages/dema/Dema.Notebook.dib +++ /dev/null @@ -1,219 +0,0 @@ -#!meta - -{"kernelInfo":{"defaultKernelName":"csharp","items":[{"name":"csharp"},{"name":"fsharp","languageName":"F#","aliases":["f#","fs"]},{"name":"html","languageName":"HTML"},{"name":"http","languageName":"HTTP"},{"name":"javascript","languageName":"JavaScript","aliases":["js"]},{"name":"mermaid","languageName":"Mermaid"},{"name":"pwsh","languageName":"PowerShell","aliases":["powershell"]},{"name":"value"}]}} - -#!markdown - -# Double Exponential Moving Average (DEMA) Examples - -This is a **.NET Interactive** notebook. To run it, you need the [Polyglot Notebooks](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.dotnet-interactive-vscode) extension installed in VS Code. - -For detailed documentation on the DEMA indicator, including mathematical formulas and interpretation, please refer to [Dema.md](Dema.md). - -The **Double Exponential Moving Average (DEMA)** is a technical indicator designed to reduce the lag associated with traditional moving averages. It combines a single EMA and a double EMA to achieve higher responsiveness. - -This notebook demonstrates: -1. **Manual Data Processing**: Understanding Batch vs. Streaming modes. -2. **Streaming with `isNew`**: Handling intra-bar updates. -3. **Large Dataset Processing**: Using Geometric Brownian Motion (GBM) generated data. -4. **Handling Invalid Values**: Last-value substitution for NaN/Infinity. - -#!csharp - -// Reference the library -#r "..\..\bin\QuanTAlib.dll" - -using System; -using System.Linq; -using QuanTAlib; - -// Helper to print TSeries -void PrintSeries(TSeries series, int count = 5) -{ - Console.WriteLine($"Series Length: {series.Count}"); - foreach (var item in series.Take(count)) - { - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Value: {item.Value:F2}"); - } - if (series.Count > count) Console.WriteLine("..."); -} - -#!markdown - -## 1. Manual Data: Batch vs. Streaming - -We'll start with a small, manually created dataset to clearly see how Batch and Streaming operations work. - -### Batch Processing -Batch processing calculates the DEMA for the entire dataset at once. This is efficient for historical analysis. - -#!csharp - -// Create a small manual dataset -var manualData = new TSeries(); -manualData.Add(DateTime.Now, 100.0); -manualData.Add(DateTime.Now.AddMinutes(1), 102.0); -manualData.Add(DateTime.Now.AddMinutes(2), 101.0); -manualData.Add(DateTime.Now.AddMinutes(3), 103.0); -manualData.Add(DateTime.Now.AddMinutes(4), 105.0); - -Console.WriteLine("--- Input Data ---"); -PrintSeries(manualData, 5); - -// Batch Calculation -Console.WriteLine("\n--- Batch DEMA (Period 3) ---"); -var demaBatch = new Dema(3); -var resultBatch = demaBatch.Update(manualData); - -PrintSeries(resultBatch, 5); - -#!markdown - -### Streaming Processing -Streaming processing updates the DEMA one data point at a time. This is essential for real-time trading systems where data arrives sequentially. - -#!csharp - -Console.WriteLine("\n--- Streaming DEMA (Period 3) ---"); -var demaStream = new Dema(3); - -foreach (var item in manualData) -{ - var result = demaStream.Update(item); - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Input: {item.Value:F2}, DEMA: {result.Value:F2}, IsHot: {demaStream.IsHot}"); -} - -// Verify that the last values match -var batchLast = resultBatch.Last().Value; -var streamLast = demaStream.Value.Value; -Console.WriteLine($"\nMatch: {Math.Abs(batchLast - streamLast) < 1e-10} (Batch: {batchLast:F2}, Stream: {streamLast:F2})"); - -#!markdown - -## 2. Streaming with `isNew` (Intra-bar Updates) - -In real-time feeds, you often receive multiple updates for the *same* bar (e.g., price changes within the current minute) before the bar closes. -* `isNew = true`: The input is a new bar (advances time). -* `isNew = false`: The input is an update to the current bar (recalculates without advancing). - -#!csharp - -Console.WriteLine("\n--- Streaming with Intra-bar Updates ---"); -var demaIntra = new Dema(3); - -// 1. Process the first 4 bars normally -for (int i = 0; i < 4; i++) -{ - demaIntra.Update(manualData[i]); -} -Console.WriteLine($"After 4th bar: {demaIntra.Value.Value:F2}"); - -// 2. Simulate intra-bar updates for the 5th bar (Final value is 105.0) -// Update 1: Price moves to 104.0 -var update1 = new TValue(manualData[4].Time, 104.0); -demaIntra.Update(update1, isNew: true); // First update for this bar is "New" -Console.WriteLine($"Update 1 (104.0): {demaIntra.Value.Value:F2}"); - -// Update 2: Price moves to 106.0 (Same time, same bar) -var update2 = new TValue(manualData[4].Time, 106.0); -demaIntra.Update(update2, isNew: false); // Not new, just an update -Console.WriteLine($"Update 2 (106.0): {demaIntra.Value.Value:F2}"); - -// Update 3: Final Close at 105.0 -var update3 = manualData[4]; -demaIntra.Update(update3, isNew: false); // Final update -Console.WriteLine($"Update 3 (105.0): {demaIntra.Value.Value:F2}"); - -// Verify match with batch result -Console.WriteLine($"Match with Batch: {Math.Abs(demaIntra.Value.Value - batchLast) < 1e-10}"); - -#!markdown - -## 3. Large Dataset: Geometric Brownian Motion (GBM) - -We'll generate a larger dataset (1000 bars) using a Geometric Brownian Motion generator to simulate realistic market data. - -#!csharp - -// Generate 1000 bars of data -var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2); -var gbmData = gbm.Fetch(1000, DateTime.Now.Ticks, TimeSpan.FromMinutes(1)); -var closeSeries = gbmData.Close; - -Console.WriteLine($"Generated {closeSeries.Count} bars of GBM data."); -Console.WriteLine($"First 5 values: {string.Join(", ", closeSeries.Take(5).Select(x => x.Value.ToString("F2")))}"); - -#!markdown - -### Batch vs. Streaming Performance on Large Data - -#!csharp - -// Batch -var demaLargeBatch = new Dema(20); -var batchLargeResult = demaLargeBatch.Update(closeSeries); -Console.WriteLine($"Batch Last Value: {batchLargeResult.Last().Value:F2}"); - -// Streaming -var demaLargeStream = new Dema(20); -TValue lastStreamVal = default; -foreach(var item in closeSeries) -{ - lastStreamVal = demaLargeStream.Update(item); -} -Console.WriteLine($"Streaming Last Value: {lastStreamVal.Value:F2}"); - -#!markdown - -## 4. Handling Invalid Values (NaN/Infinity) - -`Dema` uses **last-value substitution** for invalid inputs. When a non-finite value (NaN, PositiveInfinity, NegativeInfinity) is encountered, it is replaced with the last valid value. This provides output continuity instead of propagating invalid values through the calculation. - -#!csharp - -Console.WriteLine("\n--- Handling Invalid Values ---"); - -// Single DEMA -var demaNaN = new Dema(10); - -// Feed valid values first -demaNaN.Update(new TValue(DateTime.Now, 100.0)); -demaNaN.Update(new TValue(DateTime.Now.AddMinutes(1), 110.0)); -Console.WriteLine($"After valid values: {demaNaN.Value.Value:F2}"); - -// Feed NaN - should use last valid value (110) -var resultAfterNaN = demaNaN.Update(new TValue(DateTime.Now.AddMinutes(2), double.NaN)); -Console.WriteLine($"After NaN input: {resultAfterNaN.Value:F2} (IsFinite: {double.IsFinite(resultAfterNaN.Value)})"); - -// Feed Infinity - should use last valid value (110) -var resultAfterInf = demaNaN.Update(new TValue(DateTime.Now.AddMinutes(3), double.PositiveInfinity)); -Console.WriteLine($"After Infinity input: {resultAfterInf.Value:F2} (IsFinite: {double.IsFinite(resultAfterInf.Value)})"); - -// Continue with valid value -var resultAfterValid = demaNaN.Update(new TValue(DateTime.Now.AddMinutes(4), 120.0)); -Console.WriteLine($"After valid value (120): {resultAfterValid.Value:F2}"); - -#!csharp - -Console.WriteLine("\n--- Batch Processing with Invalid Values ---"); - -// Create series with NaN values interspersed -var seriesWithNaN = new TSeries(); -seriesWithNaN.Add(DateTime.Now.Ticks, 100.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 1, 110.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 2, double.NaN); -seriesWithNaN.Add(DateTime.Now.Ticks + 3, 120.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 4, double.PositiveInfinity); -seriesWithNaN.Add(DateTime.Now.Ticks + 5, 130.0); - -var demaBatchNaN = new Dema(3); -var resultsWithNaN = demaBatchNaN.Update(seriesWithNaN); - -Console.WriteLine("Input → Output:"); -for (int i = 0; i < seriesWithNaN.Count; i++) -{ - var input = seriesWithNaN[i].Value; - var output = resultsWithNaN[i].Value; - var inputStr = double.IsFinite(input) ? input.ToString("F2") : input.ToString(); - Console.WriteLine($" {inputStr,-10} → {output:F2} (IsFinite: {double.IsFinite(output)})"); -} diff --git a/lib/averages/dema/Dema.md b/lib/averages/dema/Dema.md index 906b7145..223379b9 100644 --- a/lib/averages/dema/Dema.md +++ b/lib/averages/dema/Dema.md @@ -83,6 +83,34 @@ double[] demaOutput = new double[200000]; Dema.Calculate(source.AsSpan(), demaOutput.AsSpan(), period: 50); ``` +### Eventing and Reactive Support + +This indicator implements the `ITValuePublisher` interface, enabling event-driven and reactive workflows. + +* **Subscription:** Can be constructed with an `ITValuePublisher` (e.g., `TSeries`) to automatically update when the source emits a new value. +* **Publication:** Emits a `Pub` event with the new `TValue` whenever it is updated. + +```csharp +using QuanTAlib; + +// 1. Setup a source (publisher) +var source = new TSeries(); + +// 2. Create indicator subscribed to source +// It waits for events from 'source' +var dema = new Dema(source, period: 14); + +// 3. Optional: Subscribe to indicator's output +dema.Pub += (item) => Console.WriteLine($"DEMA Updated: {item.Value}"); + +// 4. Ingest data into source +// This triggers the chain: source -> dema -> Console.WriteLine +source.Add(new TValue(DateTime.Now, 100)); +source.Add(new TValue(DateTime.Now, 105)); +``` + +This pattern allows building complex, reactive processing pipelines without manual update loops. + ### Handling Invalid Values `Dema` delegates value handling to the underlying `Ema` instances, which use **last-value substitution** for `NaN` or `Infinity`. This ensures continuity and stability in the output series. diff --git a/lib/averages/ema/Ema.Notebook.dib b/lib/averages/ema/Ema.Notebook.dib deleted file mode 100644 index 4818ac63..00000000 --- a/lib/averages/ema/Ema.Notebook.dib +++ /dev/null @@ -1,219 +0,0 @@ -#!meta - -{"kernelInfo":{"defaultKernelName":"csharp","items":[{"name":"csharp"},{"name":"fsharp","languageName":"F#","aliases":["f#","fs"]},{"name":"html","languageName":"HTML"},{"name":"http","languageName":"HTTP"},{"name":"javascript","languageName":"JavaScript","aliases":["js"]},{"name":"mermaid","languageName":"Mermaid"},{"name":"pwsh","languageName":"PowerShell","aliases":["powershell"]},{"name":"value"}]}} - -#!markdown - -# Exponential Moving Average (EMA) Examples - -This is a **.NET Interactive** notebook. To run it, you need the [Polyglot Notebooks](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.dotnet-interactive-vscode) extension installed in VS Code. - -For detailed documentation on the EMA indicator, including mathematical formulas and interpretation, please refer to [Ema.md](Ema.md). - -The **Exponential Moving Average (EMA)** is a weighted moving average that gives more importance to recent price data. Unlike the Simple Moving Average (SMA), which assigns equal weight to all data points, the EMA reacts more significantly to recent price changes. - -This notebook demonstrates: -1. **Manual Data Processing**: Understanding Batch vs. Streaming modes. -2. **Streaming with `isNew`**: Handling intra-bar updates. -3. **Large Dataset Processing**: Using Geometric Brownian Motion (GBM) generated data. -4. **Handling Invalid Values**: Last-value substitution for NaN/Infinity. - -#!csharp - -// Reference the library -#r "..\..\bin\QuanTAlib.dll" - -using System; -using System.Linq; -using QuanTAlib; - -// Helper to print TSeries -void PrintSeries(TSeries series, int count = 5) -{ - Console.WriteLine($"Series Length: {series.Count}"); - foreach (var item in series.Take(count)) - { - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Value: {item.Value:F2}"); - } - if (series.Count > count) Console.WriteLine("..."); -} - -#!markdown - -## 1. Manual Data: Batch vs. Streaming - -We'll start with a small, manually created dataset to clearly see how Batch and Streaming operations work. - -### Batch Processing -Batch processing calculates the EMA for the entire dataset at once. This is efficient for historical analysis. - -#!csharp - -// Create a small manual dataset -var manualData = new TSeries(); -manualData.Add(DateTime.Now, 100.0); -manualData.Add(DateTime.Now.AddMinutes(1), 102.0); -manualData.Add(DateTime.Now.AddMinutes(2), 101.0); -manualData.Add(DateTime.Now.AddMinutes(3), 103.0); -manualData.Add(DateTime.Now.AddMinutes(4), 105.0); - -Console.WriteLine("--- Input Data ---"); -PrintSeries(manualData, 5); - -// Batch Calculation -Console.WriteLine("\n--- Batch EMA (Period 3) ---"); -var emaBatch = new Ema(3); -var resultBatch = emaBatch.Update(manualData); - -PrintSeries(resultBatch, 5); - -#!markdown - -### Streaming Processing -Streaming processing updates the EMA one data point at a time. This is essential for real-time trading systems where data arrives sequentially. - -#!csharp - -Console.WriteLine("\n--- Streaming EMA (Period 3) ---"); -var emaStream = new Ema(3); - -foreach (var item in manualData) -{ - var result = emaStream.Update(item); - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Input: {item.Value:F2}, EMA: {result.Value:F2}, IsHot: {emaStream.IsHot}"); -} - -// Verify that the last values match -var batchLast = resultBatch.Last().Value; -var streamLast = emaStream.Value.Value; -Console.WriteLine($"\nMatch: {Math.Abs(batchLast - streamLast) < 1e-10} (Batch: {batchLast:F2}, Stream: {streamLast:F2})"); - -#!markdown - -## 2. Streaming with `isNew` (Intra-bar Updates) - -In real-time feeds, you often receive multiple updates for the *same* bar (e.g., price changes within the current minute) before the bar closes. -* `isNew = true`: The input is a new bar (advances time). -* `isNew = false`: The input is an update to the current bar (recalculates without advancing). - -#!csharp - -Console.WriteLine("\n--- Streaming with Intra-bar Updates ---"); -var emaIntra = new Ema(3); - -// 1. Process the first 4 bars normally -for (int i = 0; i < 4; i++) -{ - emaIntra.Update(manualData[i]); -} -Console.WriteLine($"After 4th bar: {emaIntra.Value.Value:F2}"); - -// 2. Simulate intra-bar updates for the 5th bar (Final value is 105.0) -// Update 1: Price moves to 104.0 -var update1 = new TValue(manualData[4].Time, 104.0); -emaIntra.Update(update1, isNew: true); // First update for this bar is "New" -Console.WriteLine($"Update 1 (104.0): {emaIntra.Value.Value:F2}"); - -// Update 2: Price moves to 106.0 (Same time, same bar) -var update2 = new TValue(manualData[4].Time, 106.0); -emaIntra.Update(update2, isNew: false); // Not new, just an update -Console.WriteLine($"Update 2 (106.0): {emaIntra.Value.Value:F2}"); - -// Update 3: Final Close at 105.0 -var update3 = manualData[4]; -emaIntra.Update(update3, isNew: false); // Final update -Console.WriteLine($"Update 3 (105.0): {emaIntra.Value.Value:F2}"); - -// Verify match with batch result -Console.WriteLine($"Match with Batch: {Math.Abs(emaIntra.Value.Value - batchLast) < 1e-10}"); - -#!markdown - -## 3. Large Dataset: Geometric Brownian Motion (GBM) - -We'll generate a larger dataset (1000 bars) using a Geometric Brownian Motion generator to simulate realistic market data. - -#!csharp - -// Generate 1000 bars of data -var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2); -var gbmData = gbm.Fetch(1000, DateTime.Now.Ticks, TimeSpan.FromMinutes(1)); -var closeSeries = gbmData.Close; - -Console.WriteLine($"Generated {closeSeries.Count} bars of GBM data."); -Console.WriteLine($"First 5 values: {string.Join(", ", closeSeries.Take(5).Select(x => x.Value.ToString("F2")))}"); - -#!markdown - -### Batch vs. Streaming Performance on Large Data - -#!csharp - -// Batch -var emaLargeBatch = new Ema(20); -var batchLargeResult = emaLargeBatch.Update(closeSeries); -Console.WriteLine($"Batch Last Value: {batchLargeResult.Last().Value:F2}"); - -// Streaming -var emaLargeStream = new Ema(20); -TValue lastStreamVal = default; -foreach(var item in closeSeries) -{ - lastStreamVal = emaLargeStream.Update(item); -} -Console.WriteLine($"Streaming Last Value: {lastStreamVal.Value:F2}"); - -#!markdown - -## 4. Handling Invalid Values (NaN/Infinity) - -`Ema` uses **last-value substitution** for invalid inputs. When a non-finite value (NaN, PositiveInfinity, NegativeInfinity) is encountered, it is replaced with the last valid value. This provides output continuity instead of propagating invalid values through the calculation. - -#!csharp - -Console.WriteLine("\n--- Handling Invalid Values ---"); - -// Single EMA -var emaNaN = new Ema(10); - -// Feed valid values first -emaNaN.Update(new TValue(DateTime.Now, 100.0)); -emaNaN.Update(new TValue(DateTime.Now.AddMinutes(1), 110.0)); -Console.WriteLine($"After valid values: {emaNaN.Value.Value:F2}"); - -// Feed NaN - should use last valid value (110) -var resultAfterNaN = emaNaN.Update(new TValue(DateTime.Now.AddMinutes(2), double.NaN)); -Console.WriteLine($"After NaN input: {resultAfterNaN.Value:F2} (IsFinite: {double.IsFinite(resultAfterNaN.Value)})"); - -// Feed Infinity - should use last valid value (110) -var resultAfterInf = emaNaN.Update(new TValue(DateTime.Now.AddMinutes(3), double.PositiveInfinity)); -Console.WriteLine($"After Infinity input: {resultAfterInf.Value:F2} (IsFinite: {double.IsFinite(resultAfterInf.Value)})"); - -// Continue with valid value -var resultAfterValid = emaNaN.Update(new TValue(DateTime.Now.AddMinutes(4), 120.0)); -Console.WriteLine($"After valid value (120): {resultAfterValid.Value:F2}"); - -#!csharp - -Console.WriteLine("\n--- Batch Processing with Invalid Values ---"); - -// Create series with NaN values interspersed -var seriesWithNaN = new TSeries(); -seriesWithNaN.Add(DateTime.Now.Ticks, 100.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 1, 110.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 2, double.NaN); -seriesWithNaN.Add(DateTime.Now.Ticks + 3, 120.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 4, double.PositiveInfinity); -seriesWithNaN.Add(DateTime.Now.Ticks + 5, 130.0); - -var emaBatchNaN = new Ema(3); -var resultsWithNaN = emaBatchNaN.Update(seriesWithNaN); - -Console.WriteLine("Input → Output:"); -for (int i = 0; i < seriesWithNaN.Count; i++) -{ - var input = seriesWithNaN[i].Value; - var output = resultsWithNaN[i].Value; - var inputStr = double.IsFinite(input) ? input.ToString("F2") : input.ToString(); - Console.WriteLine($" {inputStr,-10} → {output:F2} (IsFinite: {double.IsFinite(output)})"); -} diff --git a/lib/averages/ema/Ema.md b/lib/averages/ema/Ema.md index c45ad1fe..c8fbaac8 100644 --- a/lib/averages/ema/Ema.md +++ b/lib/averages/ema/Ema.md @@ -115,6 +115,34 @@ Console.WriteLine($"Last EMA: {emaOutput[^1]}"); * **Hunter's bias correction**: Same accuracy as TSeries API * **Compatible** with `ArrayPool` for buffer management +### Eventing and Reactive Support + +This indicator implements the `ITValuePublisher` interface, enabling event-driven and reactive workflows. + +* **Subscription:** Can be constructed with an `ITValuePublisher` (e.g., `TSeries`) to automatically update when the source emits a new value. +* **Publication:** Emits a `Pub` event with the new `TValue` whenever it is updated. + +```csharp +using QuanTAlib; + +// 1. Setup a source (publisher) +var source = new TSeries(); + +// 2. Create indicator subscribed to source +// It waits for events from 'source' +var ema = new Ema(source, period: 10); + +// 3. Optional: Subscribe to indicator's output +ema.Pub += (item) => Console.WriteLine($"EMA Updated: {item.Value}"); + +// 4. Ingest data into source +// This triggers the chain: source -> ema -> Console.WriteLine +source.Add(new TValue(DateTime.Now, 100)); +source.Add(new TValue(DateTime.Now, 105)); +``` + +This pattern allows building complex, reactive processing pipelines without manual update loops. + ### Handling Invalid Values (NaN/Infinity) `Ema` uses **last-value substitution** for handling invalid inputs: diff --git a/lib/averages/hma/Hma.Coverage.Tests.cs b/lib/averages/hma/Hma.Coverage.Tests.cs new file mode 100644 index 00000000..67e9bb0f --- /dev/null +++ b/lib/averages/hma/Hma.Coverage.Tests.cs @@ -0,0 +1,77 @@ +using System; +using Xunit; + +namespace QuanTAlib.Tests; + +public class HmaCoverageTests +{ + [Fact] + public void Hma_CalculateIntermediate_Simd_Coverage() + { + // CalculateIntermediate uses SIMD if length >= Vector256.Count (4) + int count = 100; + int period = 10; + double[] source = new double[count]; + double[] output = new double[count]; + + for(int i=0; i(() => new Hma(1)); + Assert.Throws(() => new Hma(0)); + + var hma = new Hma(2); + Assert.NotNull(hma); + } +} diff --git a/lib/averages/hma/Hma.Quantower.cs b/lib/averages/hma/Hma.Quantower.cs new file mode 100644 index 00000000..2feed6d8 --- /dev/null +++ b/lib/averages/hma/Hma.Quantower.cs @@ -0,0 +1,63 @@ +using System.Drawing; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +public class HmaIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Period", sortIndex: 1, 2, 1000, 1, 0)] + public int Period { get; set; } = 14; + + [IndicatorExtensions.DataSourceInput] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Hma? ma; + private int _warmupBarIndex = -1; + protected LineSeries? Series; + protected string? SourceName; + + public int MinHistoryDepths => Period + (int)Math.Sqrt(Period); // Approximate warmup + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"HMA {Period}:{SourceName}"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/averages/hma/Hma.cs"; + + public HmaIndicator() + { + OnBackGround = true; + SeparateWindow = false; + SourceName = Source.ToString(); + Name = "HMA - Hull Moving Average"; + Description = "Hull Moving Average for reduced lag"; + Series = new(name: $"HMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid); + AddLineSeries(Series); + } + + protected override void OnInit() + { + ma = new Hma(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); + } + + public override void OnPaintChart(PaintChartEventArgs args) + { + base.OnPaintChart(args); + this.PaintSmoothCurve(args, Series!, _warmupBarIndex, showColdValues: ShowColdValues, tension: 0.2); + } +} diff --git a/lib/averages/hma/Hma.Tests.cs b/lib/averages/hma/Hma.Tests.cs new file mode 100644 index 00000000..64d7ea40 --- /dev/null +++ b/lib/averages/hma/Hma.Tests.cs @@ -0,0 +1,159 @@ +using System; +using System.Linq; +using Xunit; + +namespace QuanTAlib.Tests; + +public class HmaTests +{ + [Fact] + public void Hma_Constructor_ValidatesInput() + { + Assert.Throws(() => new Hma(0)); + Assert.Throws(() => new Hma(1)); // HMA requires period > 1 for sqrt(period) >= 1 + + var hma = new Hma(10); + Assert.NotNull(hma); + } + + [Fact] + public void Hma_Calc_ReturnsValue() + { + var hma = new Hma(10); + TValue result = hma.Update(new TValue(DateTime.UtcNow, 100)); + Assert.True(result.Value > 0); + } + + [Fact] + public void Hma_IsHot_BecomesTrue() + { + var hma = new Hma(9); // sqrt(9) = 3 + // Full WMA needs 9 + // Half WMA needs 4 + // Sqrt WMA needs 3 + // Pipeline: + // 1. Full/Half produce valid values immediately (but with warmup ramp) + // 2. Sqrt consumes them. + // IsHot is defined as Full.IsHot && Sqrt.IsHot. + // Full becomes hot after 9 updates. + // Sqrt becomes hot after 3 updates. + // So HMA should be hot after 9 updates. + + for (int i = 0; i < 8; i++) + { + hma.Update(new TValue(DateTime.UtcNow, 100)); + Assert.False(hma.IsHot); + } + + hma.Update(new TValue(DateTime.UtcNow, 100)); + Assert.True(hma.IsHot); + } + + [Fact] + public void Hma_StreamingMatchesBatch() + { + var hmaStreaming = new Hma(14); + var hmaBatch = new Hma(14); + var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42); + var series = new TSeries(); + + for (int i = 0; i < 100; i++) + { + var bar = gbm.Next(isNew: true); + series.Add(bar.Time, bar.Close); + } + Assert.Equal(100, series.Count); + + // Streaming + var streamingResults = new TSeries(); + foreach (var item in series) + { + streamingResults.Add(hmaStreaming.Update(item)); + } + + // Batch + var batchResults = hmaBatch.Update(series); + + Assert.Equal(streamingResults.Count, batchResults.Count); + for (int i = 0; i < streamingResults.Count; i++) + { + Assert.Equal(streamingResults[i].Value, batchResults[i].Value, 1e-9); + } + } + + [Fact] + public void Hma_StaticCalculate_MatchesInstance() + { + var series = new TSeries(); + var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42); + for (int i = 0; i < 100; i++) + { + var bar = gbm.Next(isNew: true); + series.Add(bar.Time, bar.Close); + } + + var instanceResults = new Hma(14).Update(series); + var staticResults = Hma.Calculate(series, 14); + + for (int i = 0; i < instanceResults.Count; i++) + { + Assert.Equal(instanceResults[i].Value, staticResults[i].Value, 1e-9); + } + } + + [Fact] + public void Hma_SpanCalculate_MatchesSeries() + { + var series = new TSeries(); + var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42); + for (int i = 0; i < 100; i++) + { + var bar = gbm.Next(isNew: true); + series.Add(bar.Time, bar.Close); + } + + var seriesResults = Hma.Calculate(series, 14); + + double[] input = series.Values.ToArray(); + double[] output = new double[input.Length]; + + Hma.Calculate(input.AsSpan(), output.AsSpan(), 14); + + for (int i = 0; i < input.Length; i++) + { + Assert.Equal(seriesResults[i].Value, output[i], 1e-9); + } + } + + [Fact] + public void Hma_Update_IsNewFalse_CorrectsValue() + { + var hma = new Hma(10); + var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42); + + // Feed initial data + for (int i = 0; i < 20; i++) + { + var bar = gbm.Next(isNew: true); + hma.Update(new TValue(bar.Time, bar.Close), isNew: true); + } + + // Update with isNew=false (correction) + var newBar = gbm.Next(isNew: true); // Generate a new value + hma.Update(new TValue(newBar.Time, newBar.Close), isNew: true); // Commit it + + double valueAfterCommit = hma.Last.Value; + + // Now update the SAME bar with a different value + hma.Update(new TValue(newBar.Time, newBar.Close + 10.0), isNew: false); + + double valueAfterCorrection = hma.Last.Value; + + Assert.NotEqual(valueAfterCommit, valueAfterCorrection); + + // Now restore original value + hma.Update(new TValue(newBar.Time, newBar.Close), isNew: false); + + Assert.Equal(valueAfterCommit, hma.Last.Value, 1e-9); + } +} diff --git a/lib/averages/hma/Hma.Validation.Tests.cs b/lib/averages/hma/Hma.Validation.Tests.cs new file mode 100644 index 00000000..c84df2e0 --- /dev/null +++ b/lib/averages/hma/Hma.Validation.Tests.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Skender.Stock.Indicators; +using Tulip; +using Xunit; +using Xunit.Abstractions; + +namespace QuanTAlib.Tests; + +public class HmaValidationTests +{ + private readonly TBarSeries _bars; + private readonly TSeries _data; + private readonly List _skenderQuotes; + private readonly ITestOutputHelper _output; + + public HmaValidationTests(ITestOutputHelper output) + { + _output = output; + + // 1. Generate 1000 records using GBM feed + var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42); + _bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 2. Extract Close TSeries + _data = _bars.Close; + + // 3. Prepare data for Skender (List) + _skenderQuotes = new List(); + for (int i = 0; i < _bars.Count; i++) + { + _skenderQuotes.Add(new Quote + { + Date = new DateTime(_bars.Open.Times[i], DateTimeKind.Utc), + Open = (decimal)_bars.Open[i].Value, + High = (decimal)_bars.High[i].Value, + Low = (decimal)_bars.Low[i].Value, + Close = (decimal)_bars.Close[i].Value, + Volume = (decimal)_bars.Volume[i].Value + }); + } + } + + [Fact] + public void Validate_Skender_Batch() + { + int[] periods = { 9, 14, 20, 50 }; + + foreach (var period in periods) + { + // Calculate QuanTAlib HMA (batch TSeries) + var hma = new global::QuanTAlib.Hma(period); + var qResult = hma.Update(_data); + + // Calculate Skender HMA + var sResult = _skenderQuotes.GetHma(period).ToList(); + + // Compare last 100 records + VerifyData_Skender(qResult, sResult); + } + _output.WriteLine("HMA Batch(TSeries) validated successfully against Skender"); + } + + [Fact] + public void Validate_Tulip_Batch() + { + int[] periods = { 9, 14, 20, 50 }; + + // Prepare data for Tulip (double[]) + double[] tData = _data.Select(x => x.Value).ToArray(); + + foreach (var period in periods) + { + // Calculate QuanTAlib HMA (batch TSeries) + var hma = new global::QuanTAlib.Hma(period); + var qResult = hma.Update(_data); + + // Calculate Tulip HMA + var hmaIndicator = Tulip.Indicators.hma; + double[][] inputs = { tData }; + double[] options = { period }; + + // HMA lookback is period + sqrt(period) - 1 roughly + // We'll calculate the output size based on the input size and expected lookback + // Tulip usually returns (input_len - lookback) elements + // But we can just let it fill what it can if we provide a large enough buffer? + // No, Tulip.NET wrapper usually expects exact size or it might crash/misbehave. + // Let's try to be precise. + // WMA(n) lookback = n-1 + // HMA = WMA(sqrt(n), 2*WMA(n/2) - WMA(n)) + // Path 1: WMA(n) -> valid at n-1 + // Path 2: WMA(n/2) -> valid at n/2-1 + // Combined: valid at max(n-1, n/2-1) = n-1 + // Then WMA(sqrt(n)) on that -> adds sqrt(n)-1 lag + // Total lookback = (n-1) + (sqrt(n)-1) = n + sqrt(n) - 2 + + int sqrtPeriod = (int)Math.Sqrt(period); + int lookback = period + sqrtPeriod - 2; + + double[][] outputs = { new double[tData.Length - lookback] }; + + hmaIndicator.Run(inputs, options, outputs); + var tResult = outputs[0]; + + // Compare last 100 records + VerifyData_Tulip(qResult, tResult, lookback); + } + _output.WriteLine("HMA Batch(TSeries) validated successfully against Tulip"); + } + + [Fact] + public void Validate_Skender_Streaming() + { + int[] periods = { 9, 14, 20, 50 }; + + foreach (var period in periods) + { + // Calculate QuanTAlib HMA (streaming) + var hma = new global::QuanTAlib.Hma(period); + var qResults = new List(); + foreach (var item in _data) + { + qResults.Add(hma.Update(item).Value); + } + + // Calculate Skender HMA + var sResult = _skenderQuotes.GetHma(period).ToList(); + + // Compare last 100 records + VerifyData_Skender_Streaming(qResults, sResult); + } + _output.WriteLine("HMA Streaming validated successfully against Skender"); + } + + [Fact] + public void Validate_Skender_Span() + { + int[] periods = { 9, 14, 20, 50 }; + + // Prepare data for Span API + double[] sourceData = _data.Select(x => x.Value).ToArray(); + + foreach (var period in periods) + { + // Calculate QuanTAlib HMA (Span API) + double[] qOutput = new double[sourceData.Length]; + global::QuanTAlib.Hma.Calculate(sourceData.AsSpan(), qOutput.AsSpan(), period); + + // Calculate Skender HMA + var sResult = _skenderQuotes.GetHma(period).ToList(); + + // Compare last 100 records + VerifyData_Skender_Span(qOutput, sResult); + } + _output.WriteLine("HMA Span validated successfully against Skender"); + } + + private static void VerifyData_Skender(TSeries qSeries, List sSeries) + { + Assert.Equal(qSeries.Count, sSeries.Count); + + int count = qSeries.Count; + int skip = count - 100; + + for (int i = skip; i < count; i++) + { + double qValue = qSeries[i].Value; + double? sValue = sSeries[i].Hma; + + if (!sValue.HasValue) continue; + + Assert.Equal(sValue.Value, qValue, 1e-6); + } + } + + private static void VerifyData_Skender_Streaming(List qResults, List sSeries) + { + Assert.Equal(qResults.Count, sSeries.Count); + + int count = qResults.Count; + int skip = count - 100; + + for (int i = skip; i < count; i++) + { + double qValue = qResults[i]; + double? sValue = sSeries[i].Hma; + + if (!sValue.HasValue) continue; + + Assert.Equal(sValue.Value, qValue, 1e-6); + } + } + + private static void VerifyData_Skender_Span(double[] qOutput, List sSeries) + { + Assert.Equal(qOutput.Length, sSeries.Count); + + int count = qOutput.Length; + int skip = count - 100; + + for (int i = skip; i < count; i++) + { + double qValue = qOutput[i]; + double? sValue = sSeries[i].Hma; + + if (!sValue.HasValue) continue; + + Assert.Equal(sValue.Value, qValue, 1e-6); + } + } + + private static void VerifyData_Tulip(TSeries qSeries, double[] tOutput, int lookback) + { + int count = qSeries.Count; + int skip = count - 100; + + for (int i = skip; i < count; i++) + { + double qValue = qSeries[i].Value; + + if (i < lookback) continue; + + int tIndex = i - lookback; + if (tIndex >= tOutput.Length) continue; + + double tValue = tOutput[tIndex]; + + Assert.Equal(tValue, qValue, 1e-6); + } + } +} diff --git a/lib/averages/hma/Hma.cs b/lib/averages/hma/Hma.cs new file mode 100644 index 00000000..d8552d02 --- /dev/null +++ b/lib/averages/hma/Hma.cs @@ -0,0 +1,193 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; + +namespace QuanTAlib; + +/// +/// HMA: Hull Moving Average +/// +/// +/// HMA reduces lag by using a combination of weighted moving averages. +/// +/// Calculation: +/// HMA = WMA(sqrt(n), 2 * WMA(n/2, price) - WMA(n, price)) +/// +/// Sources: +/// https://alan.hull.com.au/hma.html +/// +[SkipLocalsInit] +public sealed class Hma : ITValuePublisher +{ + private readonly int _period; + private readonly Wma _wmaFull; + private readonly Wma _wmaHalf; + private readonly Wma _wmaSqrt; + + public string Name { get; } + public TValue Last { get; private set; } + public bool IsHot => _wmaFull.IsHot && _wmaSqrt.IsHot; + public event Action? Pub; + + public Hma(int period) + { + if (period <= 1) throw new ArgumentException("Period must be greater than 1", nameof(period)); + + _period = period; + int halfPeriod = period / 2; + int sqrtPeriod = (int)Math.Sqrt(period); + + _wmaFull = new Wma(period); + _wmaHalf = new Wma(halfPeriod); + _wmaSqrt = new Wma(sqrtPeriod); + + Name = $"Hma({period})"; + } + + public Hma(ITValuePublisher source, int period) : this(period) + { + source.Pub += (item) => Update(item); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue Update(TValue input, bool isNew = true) + { + // 1. Calculate WMA(n) + TValue full = _wmaFull.Update(input, isNew); + + // 2. Calculate WMA(n/2) + TValue half = _wmaHalf.Update(input, isNew); + + // 3. Calculate intermediate: 2 * WMA(n/2) - WMA(n) + double intermediate = (2.0 * half.Value) - full.Value; + + // 4. Calculate HMA = WMA(sqrt(n), intermediate) + Last = _wmaSqrt.Update(new TValue(input.Time, intermediate), isNew); + + Pub?.Invoke(Last); + return Last; + } + + public TSeries Update(TSeries source) + { + if (source.Count == 0) return new TSeries(new List(), new List()); + + int len = source.Count; + var t = new List(len); + var v = new List(len); + CollectionsMarshal.SetCount(t, len); + CollectionsMarshal.SetCount(v, len); + + var tSpan = CollectionsMarshal.AsSpan(t); + var vSpan = CollectionsMarshal.AsSpan(v); + + Calculate(source.Values, vSpan, _period); + source.Times.CopyTo(tSpan); + + // Restore state for streaming + _wmaFull.Reset(); + _wmaHalf.Reset(); + _wmaSqrt.Reset(); + + int lookback = _period + (int)Math.Sqrt(_period) + 10; // Sufficient lookback + int startIndex = Math.Max(0, len - lookback); + + for (int i = startIndex; i < len; i++) + { + Update(source[i]); + } + + return new TSeries(t, v); + } + + public static TSeries Calculate(TSeries source, int period) + { + int len = source.Count; + var t = new List(len); + var v = new List(len); + CollectionsMarshal.SetCount(t, len); + CollectionsMarshal.SetCount(v, len); + + Calculate(source.Values, CollectionsMarshal.AsSpan(v), period); + source.Times.CopyTo(CollectionsMarshal.AsSpan(t)); + + return new TSeries(t, v); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Calculate(ReadOnlySpan source, Span output, int period) + { + if (source.Length != output.Length) + throw new ArgumentException("Source and output must have the same length"); + if (period <= 1) + throw new ArgumentException("Period must be greater than 1", nameof(period)); + + int len = source.Length; + if (len == 0) return; + + int halfPeriod = period / 2; + int sqrtPeriod = (int)Math.Sqrt(period); + + double[] rentedFull = System.Buffers.ArrayPool.Shared.Rent(len); + Span fullWma = rentedFull.AsSpan(0, len); + + double[] rentedHalf = System.Buffers.ArrayPool.Shared.Rent(len); + Span halfWma = rentedHalf.AsSpan(0, len); + + // Reuse halfWma buffer for intermediate results + Span intermediate = halfWma; + + try + { + Wma.Calculate(source, fullWma, period); + Wma.Calculate(source, halfWma, halfPeriod); + CalculateIntermediate(halfWma, fullWma, intermediate); + Wma.Calculate(intermediate, output, sqrtPeriod); + } + finally + { + System.Buffers.ArrayPool.Shared.Return(rentedFull); + System.Buffers.ArrayPool.Shared.Return(rentedHalf); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void CalculateIntermediate(ReadOnlySpan halfWma, ReadOnlySpan fullWma, Span output) + { + int len = halfWma.Length; + int i = 0; + + if (Vector256.IsHardwareAccelerated && len >= Vector256.Count) + { + var vTwo = Vector256.Create(2.0); + ref double halfRef = ref MemoryMarshal.GetReference(halfWma); + ref double fullRef = ref MemoryMarshal.GetReference(fullWma); + ref double outRef = ref MemoryMarshal.GetReference(output); + + for (; i <= len - Vector256.Count; i += Vector256.Count) + { + var vHalf = Vector256.LoadUnsafe(ref Unsafe.Add(ref halfRef, i)); + var vFull = Vector256.LoadUnsafe(ref Unsafe.Add(ref fullRef, i)); + + // vResult = 2 * half - full + var vResult = (vHalf * vTwo) - vFull; + + Vector256.StoreUnsafe(vResult, ref Unsafe.Add(ref outRef, i)); + } + } + + for (; i < len; i++) + { + output[i] = (2.0 * halfWma[i]) - fullWma[i]; + } + } + + public void Reset() + { + _wmaFull.Reset(); + _wmaHalf.Reset(); + _wmaSqrt.Reset(); + Last = default; + } +} diff --git a/lib/averages/hma/Hma.md b/lib/averages/hma/Hma.md new file mode 100644 index 00000000..b97b4f19 --- /dev/null +++ b/lib/averages/hma/Hma.md @@ -0,0 +1,44 @@ +# HMA: Hull Moving Average + +[Pine Script Implementation of HMA](https://github.com/mihakralj/pinescript/blob/main/indicators/trends_FIR/hma.pine) + +## Overview and Purpose + +The Hull Moving Average (HMA), developed by Alan Hull in 2005, is designed to solve the age-old problem of making a moving average more responsive to current price activity while maintaining curve smoothness. It achieves this by eliminating lag almost entirely and managing to improve smoothing at the same time. + +## Core Concepts + +* **Lag Reduction:** Uses weighted moving averages (WMA) in a specific combination to offset lag. +* **Smoothness:** The final smoothing step ensures the indicator remains readable and not overly jittery. +* **Formula:** $HMA = WMA(\sqrt{n}, 2 \cdot WMA(n/2, price) - WMA(n, price))$ + +## Calculation + +1. Calculate a WMA with period $n/2$ and multiply by 2. +2. Calculate a WMA with period $n$ and subtract from step 1. +3. Calculate a WMA with period $\sqrt{n}$ using the result of step 2. + +## C# Implementation + +```csharp +using QuanTAlib; + +// Initialize +var hma = new Hma(14); + +// Update +var result = hma.Update(new TValue(time, price)); + +// Batch +var series = Hma.Calculate(sourceSeries, 14); +``` + +## Performance + +* **Streaming:** O(1) complexity per update (uses 3 internal O(1) WMAs). +* **Batch:** Uses SIMD-optimized WMA calculations and vector operations for the intermediate step. +* **Zero Allocation:** Span-based API available for high-performance scenarios. + +## References + +* [Alan Hull's HMA Description](https://alan.hull.com.au/hma.html) diff --git a/lib/averages/sma/Sma.Coverage.Tests.cs b/lib/averages/sma/Sma.Coverage.Tests.cs new file mode 100644 index 00000000..87e9a44e --- /dev/null +++ b/lib/averages/sma/Sma.Coverage.Tests.cs @@ -0,0 +1,229 @@ +using System; +using Xunit; + +namespace QuanTAlib.Tests; + +public class SmaCoverageTests +{ + [Fact] + public void Sma_ResyncLogic_IsTriggeredAndCorrect() + { + // ResyncInterval is 1000. + int count = 2500; + int period = 10; + var sma = new Sma(period); + + double constantValue = 100.0; + + for (int i = 0; i < count; i++) + { + sma.Update(new TValue(DateTime.UtcNow, constantValue)); + + if (i >= period) + { + Assert.Equal(constantValue, sma.Last.Value, 1e-9); + } + } + } + + [Fact] + public void Sma_SpanCalc_LargeDataset_TriggersResync() + { + int count = 5000; + int period = 10; + double[] source = new double[count]; + double[] output = new double[count]; + + for (int i = 0; i < count; i++) + { + source[i] = 100.0; + } + + Sma.Calculate(source.AsSpan(), output.AsSpan(), period); + + for (int i = period; i < count; i++) + { + Assert.Equal(100.0, output[i], 1e-9); + } + } + + [Fact] + public void Sma_SpanCalc_SimdThreshold_Boundary() + { + // SimdThreshold is 256. + int[] lengths = { 250, 256, 260 }; + int period = 10; + + foreach (int len in lengths) + { + double[] source = new double[len]; + double[] output = new double[len]; + + for (int i = 0; i < len; i++) source[i] = 100.0; + + Sma.Calculate(source.AsSpan(), output.AsSpan(), period); + + Assert.Equal(100.0, output[^1], 1e-9); + } + } + + [Fact] + public void Sma_SpanCalc_Simd_WithResync() + { + int count = 3000; + int period = 5; + double[] source = new double[count]; + double[] output = new double[count]; + + // Linear increase: 0, 1, 2, ... + for (int i = 0; i < count; i++) source[i] = i; + + Sma.Calculate(source.AsSpan(), output.AsSpan(), period); + + // SMA(5) of x-4, x-3, x-2, x-1, x + // = (5x - 10) / 5 = x - 2 + + for (int i = period; i < count; i++) + { + double expected = i - 2.0; + Assert.Equal(expected, output[i], 1e-9); + } + } + + [Fact] + public void Sma_Constructor_ThrowsOnInvalidPeriod() + { + Assert.Throws(() => new Sma(0)); + Assert.Throws(() => new Sma(-1)); + } + + [Fact] + public void Sma_StaticCalculate_ThrowsOnInvalidArgs() + { + double[] source = new double[10]; + double[] output = new double[5]; // Mismatch + Assert.Throws(() => Sma.Calculate(source.AsSpan(), output.AsSpan(), 5)); + + double[] output2 = new double[10]; + Assert.Throws(() => Sma.Calculate(source.AsSpan(), output2.AsSpan(), 0)); + } + + [Fact] + public void Sma_Calculate_EmptyInput_DoesNothing() + { + Sma.Calculate(ReadOnlySpan.Empty, Span.Empty, 5); + // Should not throw + } + + [Fact] + public void Sma_Update_WithNaN_UsesLastValid() + { + var sma = new Sma(5); + sma.Update(new TValue(DateTime.UtcNow, 1.0)); + sma.Update(new TValue(DateTime.UtcNow, 2.0)); + sma.Update(new TValue(DateTime.UtcNow, double.NaN)); // Should use 2.0 + + // Buffer: 1, 2, 2 + // SMA(3) = (1 + 2 + 2) / 3 = 5/3 = 1.666... + + Assert.Equal(5.0/3.0, sma.Last.Value, 1e-9); + } + + [Fact] + public void Sma_Update_IsNewFalse_UpdatesLastValue() + { + var sma = new Sma(3); + sma.Update(new TValue(DateTime.UtcNow, 1.0)); + sma.Update(new TValue(DateTime.UtcNow, 2.0)); + + // Update existing with 3.0 (replaces 2.0) + sma.Update(new TValue(DateTime.UtcNow, 3.0), isNew: false); + + // Buffer should be: 1, 3 + // SMA = (1 + 3) / 2 = 2 + + Assert.Equal(2.0, sma.Last.Value, 1e-9); + } + + [Fact] + public void Sma_TSeries_Empty_ReturnsEmpty() + { + var sma = new Sma(5); + var result = sma.Update(new TSeries()); + Assert.Empty(result); + } + + [Fact] + public void Sma_TSeries_WithNaN_RestoresStateCorrectly() + { + var sma = new Sma(3); + var series = new TSeries(); + series.Add(new TValue(DateTime.UtcNow, 1.0)); + series.Add(new TValue(DateTime.UtcNow, 2.0)); + series.Add(new TValue(DateTime.UtcNow, double.NaN)); + series.Add(new TValue(DateTime.UtcNow, 4.0)); + + sma.Update(series); + + // Buffer: 2.0, 2.0 (from NaN), 4.0 + // SMA(3) = (2 + 2 + 4) / 3 = 8/3 = 2.666... + + // Let's add one more value to verify state is correct + sma.Update(new TValue(DateTime.UtcNow, 5.0)); + + // Buffer: 2.0, 4.0, 5.0 + // SMA(3) = (2 + 4 + 5) / 3 = 11/3 = 3.666... + + Assert.Equal(11.0/3.0, sma.Last.Value, 1e-9); + } + + [Fact] + public void Sma_Reset_ClearsState() + { + var sma = new Sma(3); + sma.Update(new TValue(DateTime.UtcNow, 1.0)); + sma.Update(new TValue(DateTime.UtcNow, 2.0)); + sma.Update(new TValue(DateTime.UtcNow, 3.0)); + + sma.Reset(); + + Assert.Equal(0, sma.Last.Value); + + // Start fresh + sma.Update(new TValue(DateTime.UtcNow, 10.0)); + // Buffer: 10 + // SMA = 10 + Assert.Equal(10.0, sma.Last.Value); + } + + [Fact] + public void Sma_Calculate_ScalarFallback_WithNaN() + { + // Force scalar path by including NaN, even with large dataset + int count = 1000; + double[] source = new double[count]; + double[] output = new double[count]; + + for (int i = 0; i < count; i++) source[i] = 1.0; + source[500] = double.NaN; // This should trigger HasNonFiniteValues -> true + + Sma.Calculate(source.AsSpan(), output.AsSpan(), 10); + + // Check around the NaN + // Index 500 is NaN, so it uses previous valid (1.0) + // So effectively the stream is all 1.0s + Assert.Equal(1.0, output[500], 1e-9); + Assert.Equal(1.0, output[501], 1e-9); + } + + [Fact] + public void Sma_Constructor_WithSource_Subscribes() + { + var source = new Sma(10); // Just using Sma as a publisher + var sma = new Sma(source, 5); + + source.Update(new TValue(DateTime.UtcNow, 10.0)); + + Assert.Equal(10.0, sma.Last.Value); + } +} diff --git a/lib/averages/sma/Sma.Notebook.dib b/lib/averages/sma/Sma.Notebook.dib deleted file mode 100644 index 1b0529a2..00000000 --- a/lib/averages/sma/Sma.Notebook.dib +++ /dev/null @@ -1,285 +0,0 @@ -#!meta - -{"kernelInfo":{"defaultKernelName":"csharp","items":[{"name":"csharp"},{"name":"fsharp","languageName":"F#","aliases":["f#","fs"]},{"name":"html","languageName":"HTML"},{"name":"http","languageName":"HTTP"},{"name":"javascript","languageName":"JavaScript","aliases":["js"]},{"name":"mermaid","languageName":"Mermaid"},{"name":"pwsh","languageName":"PowerShell","aliases":["powershell"]},{"name":"value"}]}} - -#!markdown - -# Simple Moving Average (SMA) Examples - -This is a **.NET Interactive** notebook. To run it, you need the [Polyglot Notebooks](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.dotnet-interactive-vscode) extension installed in VS Code. - -The **Simple Moving Average (SMA)** is the most basic form of moving average, calculating the arithmetic mean over a specified period. Unlike the EMA, the SMA assigns equal weight to all data points in the window, making it a good baseline for trend analysis. - -**Key characteristics:** -- Equal weighting for all values in the period -- O(1) update complexity using running sum -- O(1) bar correction using scalar state -- Smooth output with good noise reduction -- More lag than EMA due to equal weighting - -This notebook demonstrates: -1. **Manual Data Processing**: Understanding Batch vs. Streaming modes. -2. **Streaming with `isNew`**: Handling intra-bar updates. -3. **Large Dataset Processing**: Using Geometric Brownian Motion (GBM) generated data. -4. **Handling Invalid Values**: Last-value substitution for NaN/Infinity. -5. **SMA vs EMA**: Comparing Simple and Exponential Moving Averages. - -#!csharp - -// Reference the library -#r "..\..\bin\QuanTAlib.dll" - -using System; -using System.Linq; -using QuanTAlib; - -// Helper to print TSeries -void PrintSeries(TSeries series, int count = 5) -{ - Console.WriteLine($"Series Length: {series.Count}"); - foreach (var item in series.Take(count)) - { - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Value: {item.Value:F2}"); - } - if (series.Count > count) Console.WriteLine("..."); -} - -#!markdown - -## 1. Manual Data: Batch vs. Streaming - -We'll start with a small, manually created dataset to clearly see how Batch and Streaming operations work. - -### Batch Processing -Batch processing calculates the SMA for the entire dataset at once. This is efficient for historical analysis. - -#!csharp - -// Create a small manual dataset -var manualData = new TSeries(); -manualData.Add(DateTime.Now, 100.0); -manualData.Add(DateTime.Now.AddMinutes(1), 102.0); -manualData.Add(DateTime.Now.AddMinutes(2), 101.0); -manualData.Add(DateTime.Now.AddMinutes(3), 103.0); -manualData.Add(DateTime.Now.AddMinutes(4), 105.0); - -Console.WriteLine("--- Input Data ---"); -PrintSeries(manualData, 5); - -// Batch Calculation -Console.WriteLine("\n--- Batch SMA (Period 3) ---"); -var smaBatch = new Sma(3); -var resultBatch = smaBatch.Update(manualData); - -PrintSeries(resultBatch, 5); - -// Show the calculation for each step -Console.WriteLine("\nCalculation breakdown:"); -Console.WriteLine(" SMA[0] = 100 / 1 = 100.00"); -Console.WriteLine(" SMA[1] = (100 + 102) / 2 = 101.00"); -Console.WriteLine(" SMA[2] = (100 + 102 + 101) / 3 = 101.00"); -Console.WriteLine(" SMA[3] = (102 + 101 + 103) / 3 = 102.00"); -Console.WriteLine(" SMA[4] = (101 + 103 + 105) / 3 = 103.00"); - -#!markdown - -### Streaming Processing -Streaming processing updates the SMA one data point at a time. This is essential for real-time trading systems where data arrives sequentially. - -#!csharp - -Console.WriteLine("\n--- Streaming SMA (Period 3) ---"); -var smaStream = new Sma(3); - -foreach (var item in manualData) -{ - var result = smaStream.Update(item); - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Input: {item.Value:F2}, SMA: {result.Value:F2}, IsHot: {smaStream.IsHot}"); -} - -// Verify that the last values match -var batchLast = resultBatch.Last().Value; -var streamLast = smaStream.Value.Value; -Console.WriteLine($"\nMatch: {Math.Abs(batchLast - streamLast) < 1e-10} (Batch: {batchLast:F2}, Stream: {streamLast:F2})"); - -// Show SMA properties -Console.WriteLine($"\nSMA Properties:"); -Console.WriteLine($" Name: {smaStream.Name}"); -Console.WriteLine($" WarmupPeriod: {smaStream.WarmupPeriod}"); -Console.WriteLine($" IsHot: {smaStream.IsHot}"); - -#!markdown - -## 2. Streaming with `isNew` (Intra-bar Updates) - -In real-time feeds, you often receive multiple updates for the *same* bar (e.g., price changes within the current minute) before the bar closes. -* `isNew = true`: The input is a new bar (advances time). -* `isNew = false`: The input is an update to the current bar (recalculates without advancing). - -**SMA achieves O(1) bar correction** by saving scalar state after each `isNew=true` update. - -#!csharp - -Console.WriteLine("\n--- Streaming with Intra-bar Updates ---"); -var smaIntra = new Sma(3); - -// 1. Process the first 4 bars normally -for (int i = 0; i < 4; i++) -{ - smaIntra.Update(manualData[i]); -} -Console.WriteLine($"After 4th bar: {smaIntra.Value.Value:F2}"); - -// 2. Simulate intra-bar updates for the 5th bar (Final value is 105.0) -// Update 1: Price moves to 104.0 -var update1 = new TValue(manualData[4].Time, 104.0); -smaIntra.Update(update1, isNew: true); // First update for this bar is "New" -Console.WriteLine($"Update 1 (104.0): {smaIntra.Value.Value:F2}"); - -// Update 2: Price moves to 106.0 (Same time, same bar) -var update2 = new TValue(manualData[4].Time, 106.0); -smaIntra.Update(update2, isNew: false); // Not new, just an update -Console.WriteLine($"Update 2 (106.0): {smaIntra.Value.Value:F2}"); - -// Update 3: Final Close at 105.0 -var update3 = manualData[4]; -smaIntra.Update(update3, isNew: false); // Final update -Console.WriteLine($"Update 3 (105.0): {smaIntra.Value.Value:F2}"); - -// Verify match with batch result -Console.WriteLine($"Match with Batch: {Math.Abs(smaIntra.Value.Value - batchLast) < 1e-10}"); - -#!markdown - -## 3. Large Dataset: Geometric Brownian Motion (GBM) - -We'll generate a larger dataset (1000 bars) using a Geometric Brownian Motion generator to simulate realistic market data. - -#!csharp - -// Generate 1000 bars of data -var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2); -var gbmData = gbm.Fetch(1000, DateTime.Now.Ticks, TimeSpan.FromMinutes(1)); -var closeSeries = gbmData.Close; - -Console.WriteLine($"Generated {closeSeries.Count} bars of GBM data."); -Console.WriteLine($"First 5 values: {string.Join(", ", closeSeries.Take(5).Select(x => x.Value.ToString("F2")))}"); - -#!markdown - -### Batch vs. Streaming Performance on Large Data - -#!csharp - -// Batch -var smaLargeBatch = new Sma(20); -var batchLargeResult = smaLargeBatch.Update(closeSeries); -Console.WriteLine($"Batch Last Value: {batchLargeResult.Last().Value:F2}"); - -// Streaming -var smaLargeStream = new Sma(20); -TValue lastStreamVal = default; -foreach(var item in closeSeries) -{ - lastStreamVal = smaLargeStream.Update(item); -} -Console.WriteLine($"Streaming Last Value: {lastStreamVal.Value:F2}"); - -// Verify match -Console.WriteLine($"Match: {Math.Abs(batchLargeResult.Last().Value - lastStreamVal.Value) < 1e-10}"); - -#!markdown - -## 4. Handling Invalid Values (NaN/Infinity) - -`Sma` uses **last-value substitution** for invalid inputs. When a non-finite value (NaN, PositiveInfinity, NegativeInfinity) is encountered, it is replaced with the last valid value. This provides output continuity instead of propagating invalid values through the calculation. - -#!csharp - -Console.WriteLine("\n--- Handling Invalid Values ---"); - -// Single SMA -var smaNaN = new Sma(10); - -// Feed valid values first -smaNaN.Update(new TValue(DateTime.Now, 100.0)); -smaNaN.Update(new TValue(DateTime.Now.AddMinutes(1), 110.0)); -Console.WriteLine($"After valid values: {smaNaN.Value.Value:F2}"); - -// Feed NaN - should use last valid value (110) -var resultAfterNaN = smaNaN.Update(new TValue(DateTime.Now.AddMinutes(2), double.NaN)); -Console.WriteLine($"After NaN input: {resultAfterNaN.Value:F2} (IsFinite: {double.IsFinite(resultAfterNaN.Value)})"); - -// Feed Infinity - should use last valid value (110) -var resultAfterInf = smaNaN.Update(new TValue(DateTime.Now.AddMinutes(3), double.PositiveInfinity)); -Console.WriteLine($"After Infinity input: {resultAfterInf.Value:F2} (IsFinite: {double.IsFinite(resultAfterInf.Value)})"); - -// Continue with valid value -var resultAfterValid = smaNaN.Update(new TValue(DateTime.Now.AddMinutes(4), 120.0)); -Console.WriteLine($"After valid value (120): {resultAfterValid.Value:F2}"); - -#!csharp - -Console.WriteLine("\n--- Batch Processing with Invalid Values ---"); - -// Create series with NaN values interspersed -var seriesWithNaN = new TSeries(); -seriesWithNaN.Add(DateTime.Now.Ticks, 100.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 1, 110.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 2, double.NaN); -seriesWithNaN.Add(DateTime.Now.Ticks + 3, 120.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 4, double.PositiveInfinity); -seriesWithNaN.Add(DateTime.Now.Ticks + 5, 130.0); - -var smaBatchNaN = new Sma(3); -var resultsWithNaN = smaBatchNaN.Update(seriesWithNaN); - -Console.WriteLine("Input → Output:"); -for (int i = 0; i < seriesWithNaN.Count; i++) -{ - var input = seriesWithNaN[i].Value; - var output = resultsWithNaN[i].Value; - var inputStr = double.IsFinite(input) ? input.ToString("F2") : input.ToString(); - Console.WriteLine($" {inputStr,-10} → {output:F2} (IsFinite: {double.IsFinite(output)})"); -} - -#!markdown - -## 5. SMA vs EMA Comparison - -The SMA and EMA are both trend-following indicators, but they weight data differently: - -- **SMA**: Equal weight to all values in the window -- **EMA**: More weight to recent values (exponentially decreasing) - -#!csharp - -Console.WriteLine("\n--- SMA vs EMA Comparison (Period 10) ---"); - -var compareData = new TSeries(); -var baseTime = DateTime.Now; -for (int i = 0; i < 20; i++) -{ - // Create data with a sudden spike at position 10 - double value = (i == 10) ? 150.0 : 100.0; - compareData.Add(baseTime.AddMinutes(i), value); -} - -var smaCompare = new Sma(10); -var emaCompare = new Ema(10); - -Console.WriteLine("Position | Input | SMA | EMA | Difference"); -Console.WriteLine("---------+--------+---------+---------+-----------"); - -for (int i = 0; i < compareData.Count; i++) -{ - var smaVal = smaCompare.Update(compareData[i]); - var emaVal = emaCompare.Update(compareData[i]); - var input = compareData[i].Value; - var diff = smaVal.Value - emaVal.Value; - - Console.WriteLine($" {i,2} | {input,6:F0} | {smaVal.Value,7:F2} | {emaVal.Value,7:F2} | {diff,+9:F2}"); -} - -Console.WriteLine("\nNote: After the spike (position 10), EMA reacts faster due to higher weight on recent values."); -Console.WriteLine("SMA takes longer to reflect changes as all values have equal weight."); diff --git a/lib/averages/sma/Sma.cs b/lib/averages/sma/Sma.cs index 425276c9..09eeaa27 100644 --- a/lib/averages/sma/Sma.cs +++ b/lib/averages/sma/Sma.cs @@ -108,25 +108,13 @@ public sealed class Sma : ITValuePublisher } - // Removed GetValidValue and UpdateState as they are not used in the new Update logic - [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TValue input, bool isNew = true) { if (isNew) { double val = GetValidValue(input.Value); - - double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0; - _sum = _sum - removedValue + val; - _buffer.Add(val); - - _tickCount++; - if (_buffer.IsFull && _tickCount >= ResyncInterval) - { - _tickCount = 0; - _sum = _buffer.Sum(); - } + UpdateState(val); _p_sum = _sum; _p_lastInput = val; @@ -136,7 +124,7 @@ public sealed class Sma : ITValuePublisher { _lastValidValue = _p_lastValidValue; double val = GetValidValue(input.Value); - + _sum = _p_sum - _p_lastInput + val; _buffer.UpdateNewest(val); } @@ -150,7 +138,7 @@ public sealed class Sma : ITValuePublisher public TSeries Update(TSeries source) { if (source.Count == 0) return new TSeries(new List(), new List()); - + int len = source.Count; var t = new List(len); var v = new List(len); @@ -159,26 +147,43 @@ public sealed class Sma : ITValuePublisher var tSpan = CollectionsMarshal.AsSpan(t); var vSpan = CollectionsMarshal.AsSpan(v); - var sourceValues = source.Values; - var sourceTimes = source.Times; - // Reset state for batch calculation - Reset(); - - // We can optimize this later with specific batch logic, but for now use core loop - for(int i=0; i < len; i++) + Calculate(source.Values, vSpan, _period); + source.Times.CopyTo(tSpan); + + // Restore state + int windowSize = Math.Min(len, _period); + int startIndex = len - windowSize; + + if (startIndex > 0) { - double val = GetValidValue(sourceValues[i]); - double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0; - _sum = _sum - removedValue + val; - _buffer.Add(val); - vSpan[i] = _sum / _buffer.Count; + for (int i = startIndex - 1; i >= 0; i--) + { + if (double.IsFinite(source.Values[i])) + { + _lastValidValue = source.Values[i]; + break; + } + } + } + else + { + _lastValidValue = 0; + } + + _buffer.Clear(); + _sum = 0; + _tickCount = 0; + + for (int i = startIndex; i < len; i++) + { + double val = GetValidValue(source.Values[i]); + UpdateState(val); } - sourceTimes.CopyTo(tSpan); - _p_lastValidValue = _lastValidValue; _p_sum = _sum; - _p_lastInput = sourceValues[len-1]; + _p_lastInput = source.Values[len - 1]; + _p_lastValidValue = _lastValidValue; Last = new TValue(tSpan[len - 1], vSpan[len - 1]); return new TSeries(t, v); diff --git a/lib/averages/sma/Sma.md b/lib/averages/sma/Sma.md index 5991fe6a..67ac20fa 100644 --- a/lib/averages/sma/Sma.md +++ b/lib/averages/sma/Sma.md @@ -125,6 +125,34 @@ sma.Update(new TValue(time + 1, 101.2), isNew: true); **Implementation detail:** Bar correction is O(1) using scalar state save/restore, not buffer copying. +### Eventing and Reactive Support + +This indicator implements the `ITValuePublisher` interface, enabling event-driven and reactive workflows. + +* **Subscription:** Can be constructed with an `ITValuePublisher` (e.g., `TSeries`) to automatically update when the source emits a new value. +* **Publication:** Emits a `Pub` event with the new `TValue` whenever it is updated. + +```csharp +using QuanTAlib; + +// 1. Setup a source (publisher) +var source = new TSeries(); + +// 2. Create indicator subscribed to source +// It waits for events from 'source' +var sma = new Sma(source, period: 10); + +// 3. Optional: Subscribe to indicator's output +sma.Pub += (item) => Console.WriteLine($"SMA Updated: {item.Value}"); + +// 4. Ingest data into source +// This triggers the chain: source -> sma -> Console.WriteLine +source.Add(new TValue(DateTime.Now, 100)); +source.Add(new TValue(DateTime.Now, 105)); +``` + +This pattern allows building complex, reactive processing pipelines without manual update loops. + ### Handling Invalid Values (NaN/Infinity) `Sma` uses **last-value substitution** for handling invalid inputs: diff --git a/lib/averages/tema/Tema.Notebook.dib b/lib/averages/tema/Tema.Notebook.dib deleted file mode 100644 index 6ee7f637..00000000 --- a/lib/averages/tema/Tema.Notebook.dib +++ /dev/null @@ -1,219 +0,0 @@ -#!meta - -{"kernelInfo":{"defaultKernelName":"csharp","items":[{"name":"csharp"},{"name":"fsharp","languageName":"F#","aliases":["f#","fs"]},{"name":"html","languageName":"HTML"},{"name":"http","languageName":"HTTP"},{"name":"javascript","languageName":"JavaScript","aliases":["js"]},{"name":"mermaid","languageName":"Mermaid"},{"name":"pwsh","languageName":"PowerShell","aliases":["powershell"]},{"name":"value"}]}} - -#!markdown - -# Triple Exponential Moving Average (TEMA) Examples - -This is a **.NET Interactive** notebook. To run it, you need the [Polyglot Notebooks](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.dotnet-interactive-vscode) extension installed in VS Code. - -For detailed documentation on the TEMA indicator, including mathematical formulas and interpretation, please refer to [Tema.md](Tema.md). - -The **Triple Exponential Moving Average (TEMA)** is a technical indicator designed to reduce the lag associated with traditional moving averages even further than DEMA. It combines single, double, and triple EMAs to achieve superior responsiveness. - -This notebook demonstrates: -1. **Manual Data Processing**: Understanding Batch vs. Streaming modes. -2. **Streaming with `isNew`**: Handling intra-bar updates. -3. **Large Dataset Processing**: Using Geometric Brownian Motion (GBM) generated data. -4. **Handling Invalid Values**: Last-value substitution for NaN/Infinity. - -#!csharp - -// Reference the library -#r "..\..\bin\QuanTAlib.dll" - -using System; -using System.Linq; -using QuanTAlib; - -// Helper to print TSeries -void PrintSeries(TSeries series, int count = 5) -{ - Console.WriteLine($"Series Length: {series.Count}"); - foreach (var item in series.Take(count)) - { - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Value: {item.Value:F2}"); - } - if (series.Count > count) Console.WriteLine("..."); -} - -#!markdown - -## 1. Manual Data: Batch vs. Streaming - -We'll start with a small, manually created dataset to clearly see how Batch and Streaming operations work. - -### Batch Processing -Batch processing calculates the TEMA for the entire dataset at once. This is efficient for historical analysis. - -#!csharp - -// Create a small manual dataset -var manualData = new TSeries(); -manualData.Add(DateTime.Now, 100.0); -manualData.Add(DateTime.Now.AddMinutes(1), 102.0); -manualData.Add(DateTime.Now.AddMinutes(2), 101.0); -manualData.Add(DateTime.Now.AddMinutes(3), 103.0); -manualData.Add(DateTime.Now.AddMinutes(4), 105.0); - -Console.WriteLine("--- Input Data ---"); -PrintSeries(manualData, 5); - -// Batch Calculation -Console.WriteLine("\n--- Batch TEMA (Period 3) ---"); -var temaBatch = new Tema(3); -var resultBatch = temaBatch.Update(manualData); - -PrintSeries(resultBatch, 5); - -#!markdown - -### Streaming Processing -Streaming processing updates the TEMA one data point at a time. This is essential for real-time trading systems where data arrives sequentially. - -#!csharp - -Console.WriteLine("\n--- Streaming TEMA (Period 3) ---"); -var temaStream = new Tema(3); - -foreach (var item in manualData) -{ - var result = temaStream.Update(item); - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Input: {item.Value:F2}, TEMA: {result.Value:F2}, IsHot: {temaStream.IsHot}"); -} - -// Verify that the last values match -var batchLast = resultBatch.Last().Value; -var streamLast = temaStream.Value.Value; -Console.WriteLine($"\nMatch: {Math.Abs(batchLast - streamLast) < 1e-10} (Batch: {batchLast:F2}, Stream: {streamLast:F2})"); - -#!markdown - -## 2. Streaming with `isNew` (Intra-bar Updates) - -In real-time feeds, you often receive multiple updates for the *same* bar (e.g., price changes within the current minute) before the bar closes. -* `isNew = true`: The input is a new bar (advances time). -* `isNew = false`: The input is an update to the current bar (recalculates without advancing). - -#!csharp - -Console.WriteLine("\n--- Streaming with Intra-bar Updates ---"); -var temaIntra = new Tema(3); - -// 1. Process the first 4 bars normally -for (int i = 0; i < 4; i++) -{ - temaIntra.Update(manualData[i]); -} -Console.WriteLine($"After 4th bar: {temaIntra.Value.Value:F2}"); - -// 2. Simulate intra-bar updates for the 5th bar (Final value is 105.0) -// Update 1: Price moves to 104.0 -var update1 = new TValue(manualData[4].Time, 104.0); -temaIntra.Update(update1, isNew: true); // First update for this bar is "New" -Console.WriteLine($"Update 1 (104.0): {temaIntra.Value.Value:F2}"); - -// Update 2: Price moves to 106.0 (Same time, same bar) -var update2 = new TValue(manualData[4].Time, 106.0); -temaIntra.Update(update2, isNew: false); // Not new, just an update -Console.WriteLine($"Update 2 (106.0): {temaIntra.Value.Value:F2}"); - -// Update 3: Final Close at 105.0 -var update3 = manualData[4]; -temaIntra.Update(update3, isNew: false); // Final update -Console.WriteLine($"Update 3 (105.0): {temaIntra.Value.Value:F2}"); - -// Verify match with batch result -Console.WriteLine($"Match with Batch: {Math.Abs(temaIntra.Value.Value - batchLast) < 1e-10}"); - -#!markdown - -## 3. Large Dataset: Geometric Brownian Motion (GBM) - -We'll generate a larger dataset (1000 bars) using a Geometric Brownian Motion generator to simulate realistic market data. - -#!csharp - -// Generate 1000 bars of data -var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2); -var gbmData = gbm.Fetch(1000, DateTime.Now.Ticks, TimeSpan.FromMinutes(1)); -var closeSeries = gbmData.Close; - -Console.WriteLine($"Generated {closeSeries.Count} bars of GBM data."); -Console.WriteLine($"First 5 values: {string.Join(", ", closeSeries.Take(5).Select(x => x.Value.ToString("F2")))}"); - -#!markdown - -### Batch vs. Streaming Performance on Large Data - -#!csharp - -// Batch -var temaLargeBatch = new Tema(20); -var batchLargeResult = temaLargeBatch.Update(closeSeries); -Console.WriteLine($"Batch Last Value: {batchLargeResult.Last().Value:F2}"); - -// Streaming -var temaLargeStream = new Tema(20); -TValue lastStreamVal = default; -foreach(var item in closeSeries) -{ - lastStreamVal = temaLargeStream.Update(item); -} -Console.WriteLine($"Streaming Last Value: {lastStreamVal.Value:F2}"); - -#!markdown - -## 4. Handling Invalid Values (NaN/Infinity) - -`Tema` uses **last-value substitution** for invalid inputs. When a non-finite value (NaN, PositiveInfinity, NegativeInfinity) is encountered, it is replaced with the last valid value. This provides output continuity instead of propagating invalid values through the calculation. - -#!csharp - -Console.WriteLine("\n--- Handling Invalid Values ---"); - -// Single TEMA -var temaNaN = new Tema(10); - -// Feed valid values first -temaNaN.Update(new TValue(DateTime.Now, 100.0)); -temaNaN.Update(new TValue(DateTime.Now.AddMinutes(1), 110.0)); -Console.WriteLine($"After valid values: {temaNaN.Value.Value:F2}"); - -// Feed NaN - should use last valid value (110) -var resultAfterNaN = temaNaN.Update(new TValue(DateTime.Now.AddMinutes(2), double.NaN)); -Console.WriteLine($"After NaN input: {resultAfterNaN.Value:F2} (IsFinite: {double.IsFinite(resultAfterNaN.Value)})"); - -// Feed Infinity - should use last valid value (110) -var resultAfterInf = temaNaN.Update(new TValue(DateTime.Now.AddMinutes(3), double.PositiveInfinity)); -Console.WriteLine($"After Infinity input: {resultAfterInf.Value:F2} (IsFinite: {double.IsFinite(resultAfterInf.Value)})"); - -// Continue with valid value -var resultAfterValid = temaNaN.Update(new TValue(DateTime.Now.AddMinutes(4), 120.0)); -Console.WriteLine($"After valid value (120): {resultAfterValid.Value:F2}"); - -#!csharp - -Console.WriteLine("\n--- Batch Processing with Invalid Values ---"); - -// Create series with NaN values interspersed -var seriesWithNaN = new TSeries(); -seriesWithNaN.Add(DateTime.Now.Ticks, 100.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 1, 110.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 2, double.NaN); -seriesWithNaN.Add(DateTime.Now.Ticks + 3, 120.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 4, double.PositiveInfinity); -seriesWithNaN.Add(DateTime.Now.Ticks + 5, 130.0); - -var temaBatchNaN = new Tema(3); -var resultsWithNaN = temaBatchNaN.Update(seriesWithNaN); - -Console.WriteLine("Input → Output:"); -for (int i = 0; i < seriesWithNaN.Count; i++) -{ - var input = seriesWithNaN[i].Value; - var output = resultsWithNaN[i].Value; - var inputStr = double.IsFinite(input) ? input.ToString("F2") : input.ToString(); - Console.WriteLine($" {inputStr,-10} → {output:F2} (IsFinite: {double.IsFinite(output)})"); -} diff --git a/lib/averages/tema/Tema.md b/lib/averages/tema/Tema.md index 4ca384e5..2a12e6c5 100644 --- a/lib/averages/tema/Tema.md +++ b/lib/averages/tema/Tema.md @@ -82,6 +82,34 @@ double[] temaOutput = new double[200000]; Tema.Calculate(source.AsSpan(), temaOutput.AsSpan(), period: 50); ``` +### Eventing and Reactive Support + +This indicator implements the `ITValuePublisher` interface, enabling event-driven and reactive workflows. + +* **Subscription:** Can be constructed with an `ITValuePublisher` (e.g., `TSeries`) to automatically update when the source emits a new value. +* **Publication:** Emits a `Pub` event with the new `TValue` whenever it is updated. + +```csharp +using QuanTAlib; + +// 1. Setup a source (publisher) +var source = new TSeries(); + +// 2. Create indicator subscribed to source +// It waits for events from 'source' +var tema = new Tema(source, period: 14); + +// 3. Optional: Subscribe to indicator's output +tema.Pub += (item) => Console.WriteLine($"TEMA Updated: {item.Value}"); + +// 4. Ingest data into source +// This triggers the chain: source -> tema -> Console.WriteLine +source.Add(new TValue(DateTime.Now, 100)); +source.Add(new TValue(DateTime.Now, 105)); +``` + +This pattern allows building complex, reactive processing pipelines without manual update loops. + ### Handling Invalid Values `Tema` delegates value handling to the underlying `Ema` instances, which use **last-value substitution** for `NaN` or `Infinity`. This ensures continuity and stability in the output series. diff --git a/lib/averages/todo.md b/lib/averages/todo.md deleted file mode 100644 index f5f2707b..00000000 --- a/lib/averages/todo.md +++ /dev/null @@ -1,4 +0,0 @@ -# todo - -- __KAMA__ (Kaufman Adaptive Moving Average) -- __T3__ (T3) diff --git a/lib/averages/trima/Trima.Notebook.dib b/lib/averages/trima/Trima.Notebook.dib deleted file mode 100644 index 2d58dc1b..00000000 --- a/lib/averages/trima/Trima.Notebook.dib +++ /dev/null @@ -1,158 +0,0 @@ -#!meta - -{"kernelInfo":{"defaultKernelName":"csharp","items":[{"name":"csharp"},{"name":"fsharp","languageName":"F#","aliases":["f#","fs"]},{"name":"html","languageName":"HTML"},{"name":"http","languageName":"HTTP"},{"name":"javascript","languageName":"JavaScript","aliases":["js"]},{"name":"mermaid","languageName":"Mermaid"},{"name":"pwsh","languageName":"PowerShell","aliases":["powershell"]},{"name":"value"}]}} - -#!markdown - -# Triangular Moving Average (TRIMA) Examples - -This is a **.NET Interactive** notebook. To run it, you need the [Polyglot Notebooks](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.dotnet-interactive-vscode) extension installed in VS Code. - -The **Triangular Moving Average (TRIMA)** is a weighted moving average where the weights increase linearly to the middle of the period and then decrease. It is equivalent to a double-smoothed SMA (SMA of an SMA). - -**Key characteristics:** -- Triangular weighting (emphasis on middle values) -- Smoother than SMA -- Higher lag than SMA -- O(1) update complexity - -#!csharp - -// Reference the library -#r "..\..\bin\Debug\net10.0\QuanTAlib.dll" - -using System; -using System.Linq; -using QuanTAlib; - -// Helper to print TSeries -void PrintSeries(TSeries series, int count = 5) -{ - Console.WriteLine($"Series Length: {series.Count}"); - foreach (var item in series.Take(count)) - { - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Value: {item.Value:F2}"); - } - if (series.Count > count) Console.WriteLine("..."); -} - -#!markdown - -## 1. Manual Data: Batch vs. Streaming - -We'll start with a small, manually created dataset. - -#!csharp - -// Create a small manual dataset -var manualData = new TSeries(); -manualData.Add(DateTime.Now, 100.0); -manualData.Add(DateTime.Now.AddMinutes(1), 102.0); -manualData.Add(DateTime.Now.AddMinutes(2), 101.0); -manualData.Add(DateTime.Now.AddMinutes(3), 103.0); -manualData.Add(DateTime.Now.AddMinutes(4), 105.0); - -Console.WriteLine("--- Input Data ---"); -PrintSeries(manualData, 5); - -// Batch Calculation -Console.WriteLine("\n--- Batch TRIMA (Period 3) ---"); -var trimaBatch = new Trima(3); -var resultBatch = trimaBatch.Update(manualData); - -PrintSeries(resultBatch, 5); - -#!markdown - -### Streaming Processing - -#!csharp - -Console.WriteLine("\n--- Streaming TRIMA (Period 3) ---"); -var trimaStream = new Trima(3); - -foreach (var item in manualData) -{ - var result = trimaStream.Update(item); - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Input: {item.Value:F2}, TRIMA: {result.Value:F2}, IsHot: {trimaStream.IsHot}"); -} - -// Verify that the last values match -var batchLast = resultBatch.Last().Value; -var streamLast = trimaStream.Value.Value; -Console.WriteLine($"\nMatch: {Math.Abs(batchLast - streamLast) < 1e-10} (Batch: {batchLast:F2}, Stream: {streamLast:F2})"); - -#!markdown - -## 2. Large Dataset: Geometric Brownian Motion (GBM) - -We'll generate a larger dataset (1000 bars) using a Geometric Brownian Motion generator to simulate realistic market data. - -#!csharp - -// Generate 1000 bars of data -var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2); -var gbmData = gbm.Fetch(1000, DateTime.Now.Ticks, TimeSpan.FromMinutes(1)); -var closeSeries = gbmData.Close; - -Console.WriteLine($"Generated {closeSeries.Count} bars of GBM data."); - -#!markdown - -### Batch vs. Streaming Performance on Large Data - -#!csharp - -// Batch -var trimaLargeBatch = new Trima(20); -var batchLargeResult = trimaLargeBatch.Update(closeSeries); -Console.WriteLine($"Batch Last Value: {batchLargeResult.Last().Value:F2}"); - -// Streaming -var trimaLargeStream = new Trima(20); -TValue lastStreamVal = default; -foreach(var item in closeSeries) -{ - lastStreamVal = trimaLargeStream.Update(item); -} -Console.WriteLine($"Streaming Last Value: {lastStreamVal.Value:F2}"); - -// Verify match -Console.WriteLine($"Match: {Math.Abs(batchLargeResult.Last().Value - lastStreamVal.Value) < 1e-10}"); - -#!markdown - -## 3. TRIMA vs SMA Comparison - -TRIMA is smoother than SMA but has more lag. Let's compare them on a volatile dataset. - -#!csharp - -Console.WriteLine("\n--- TRIMA vs SMA Comparison (Period 10) ---"); - -var compareData = new TSeries(); -var baseTime = DateTime.Now; -for (int i = 0; i < 20; i++) -{ - // Create data with a sudden spike at position 10 - double value = (i == 10) ? 150.0 : 100.0; - compareData.Add(baseTime.AddMinutes(i), value); -} - -var trimaCompare = new Trima(10); -var smaCompare = new Sma(10); - -Console.WriteLine("Position | Input | TRIMA | SMA | Difference"); -Console.WriteLine("---------+--------+---------+---------+-----------"); - -for (int i = 0; i < compareData.Count; i++) -{ - var trimaVal = trimaCompare.Update(compareData[i]); - var smaVal = smaCompare.Update(compareData[i]); - var input = compareData[i].Value; - var diff = trimaVal.Value - smaVal.Value; - - Console.WriteLine($" {i,2} | {input,6:F0} | {trimaVal.Value,7:F2} | {smaVal.Value,7:F2} | {diff,+9:F2}"); -} - -Console.WriteLine("\nNote how TRIMA reacts more gradually to the spike compared to SMA."); diff --git a/lib/averages/trima/Trima.md b/lib/averages/trima/Trima.md index 9fa35fb8..09b61af2 100644 --- a/lib/averages/trima/Trima.md +++ b/lib/averages/trima/Trima.md @@ -41,6 +41,36 @@ TRIMA(source, p) = SMA(SMA(source, (p+1)/2), (p+1)/2) > 🔍 **Technical Note:** The double application of SMA explains why TRIMA provides better smoothing than a single SMA or WMA. This approach effectively applies smoothing twice with optimal period adjustment, creating a -18dB/octave roll-off in the frequency domain compared to -6dB/octave for a simple moving average. +## C# Implementation + +### Eventing and Reactive Support + +This indicator implements the `ITValuePublisher` interface, enabling event-driven and reactive workflows. + +* **Subscription:** Can be constructed with an `ITValuePublisher` (e.g., `TSeries`) to automatically update when the source emits a new value. +* **Publication:** Emits a `Pub` event with the new `TValue` whenever it is updated. + +```csharp +using QuanTAlib; + +// 1. Setup a source (publisher) +var source = new TSeries(); + +// 2. Create indicator subscribed to source +// It waits for events from 'source' +var trima = new Trima(source, period: 14); + +// 3. Optional: Subscribe to indicator's output +trima.Pub += (item) => Console.WriteLine($"TRIMA Updated: {item.Value}"); + +// 4. Ingest data into source +// This triggers the chain: source -> trima -> Console.WriteLine +source.Add(new TValue(DateTime.Now, 100)); +source.Add(new TValue(DateTime.Now, 105)); +``` + +This pattern allows building complex, reactive processing pipelines without manual update loops. + ## Interpretation Details TRIMA can be used in various trading strategies: diff --git a/lib/averages/wma/Wma.Coverage.Tests.cs b/lib/averages/wma/Wma.Coverage.Tests.cs new file mode 100644 index 00000000..3410cd37 --- /dev/null +++ b/lib/averages/wma/Wma.Coverage.Tests.cs @@ -0,0 +1,276 @@ +using System; +using Xunit; + +namespace QuanTAlib.Tests; + +public class WmaCoverageTests +{ + [Fact] + public void Wma_ResyncLogic_IsTriggeredAndCorrect() + { + // ResyncInterval is 1000. We need more than that to trigger it. + int count = 2500; + int period = 10; + var wma = new Wma(period); + + // Use a constant value to make verification easy + // WMA of constant X is X + double constantValue = 100.0; + + for (int i = 0; i < count; i++) + { + wma.Update(new TValue(DateTime.UtcNow, constantValue)); + + // After warmup, value should always be constantValue + if (i >= period) + { + Assert.Equal(constantValue, wma.Last.Value, 1e-9); + } + } + } + + [Fact] + public void Wma_SpanCalc_LargeDataset_TriggersResync() + { + // ResyncInterval is 1000. + int count = 5000; + int period = 10; + double[] source = new double[count]; + double[] output = new double[count]; + + // Fill with constant value + for (int i = 0; i < count; i++) + { + source[i] = 100.0; + } + + Wma.Calculate(source.AsSpan(), output.AsSpan(), period); + + // Verify all outputs after warmup are correct + for (int i = period; i < count; i++) + { + Assert.Equal(100.0, output[i], 1e-9); + } + } + + [Fact] + public void Wma_SpanCalc_SimdThreshold_Boundary() + { + // SimdThreshold is 256. + // Test just below and just above to ensure both paths work + int[] lengths = { 250, 256, 260 }; + int period = 10; + + foreach (int len in lengths) + { + double[] source = new double[len]; + double[] output = new double[len]; + + for (int i = 0; i < len; i++) source[i] = 100.0; + + Wma.Calculate(source.AsSpan(), output.AsSpan(), period); + + Assert.Equal(100.0, output[^1], 1e-9); + } + } + + [Fact] + public void Wma_SpanCalc_Simd_WithResync() + { + // This targets the SIMD loop with resync + // Need length > SimdThreshold (256) and enough data to hit ResyncInterval (1000) + // But wait, the SIMD loop in CalculateSimdCore handles resync internally. + // The loop structure is: + // while (idx < simdEnd) + // nextSync = Math.Min(simdEnd, idx + ResyncInterval) + // ... process blocks ... + + int count = 3000; + int period = 5; + double[] source = new double[count]; + double[] output = new double[count]; + + // Use a pattern that isn't constant to verify calculation accuracy + // Linear increase: 0, 1, 2, ... + for (int i = 0; i < count; i++) source[i] = i; + + Wma.Calculate(source.AsSpan(), output.AsSpan(), period); + + // Verify a few points + // WMA(5) of x-4, x-3, x-2, x-1, x + // = (1*(x-4) + 2*(x-3) + 3*(x-2) + 4*(x-1) + 5*x) / 15 + // = (x-4 + 2x-6 + 3x-6 + 4x-4 + 5x) / 15 + // = (15x - 20) / 15 + // = x - 20/15 = x - 1.333... + + for (int i = period; i < count; i++) + { + double expected = i - (20.0 / 15.0); + Assert.Equal(expected, output[i], 1e-9); + } + } + + [Fact] + public void Wma_Update_Resync_WithFloatingPointDrift() + { + // This test tries to accumulate error and see if resync fixes it (or at least doesn't break it) + // It's hard to deterministically cause drift, but we can ensure the code path is executed. + int period = 10; + var wma = new Wma(period); + + // 1200 updates to trigger resync (at 1000) + for (int i = 0; i < 1200; i++) + { + wma.Update(new TValue(DateTime.UtcNow, 1.0)); + } + + Assert.Equal(1.0, wma.Last.Value, 1e-9); + } + + [Fact] + public void Wma_Constructor_ThrowsOnInvalidPeriod() + { + Assert.Throws(() => new Wma(0)); + Assert.Throws(() => new Wma(-1)); + } + + [Fact] + public void Wma_StaticCalculate_ThrowsOnInvalidArgs() + { + double[] source = new double[10]; + double[] output = new double[5]; // Mismatch + Assert.Throws(() => Wma.Calculate(source.AsSpan(), output.AsSpan(), 5)); + + double[] output2 = new double[10]; + Assert.Throws(() => Wma.Calculate(source.AsSpan(), output2.AsSpan(), 0)); + } + + [Fact] + public void Wma_Calculate_EmptyInput_DoesNothing() + { + Wma.Calculate(ReadOnlySpan.Empty, Span.Empty, 5); + // Should not throw + } + + [Fact] + public void Wma_Update_WithNaN_UsesLastValid() + { + var wma = new Wma(5); + wma.Update(new TValue(DateTime.UtcNow, 1.0)); + wma.Update(new TValue(DateTime.UtcNow, 2.0)); + wma.Update(new TValue(DateTime.UtcNow, double.NaN)); // Should use 2.0 + + // Buffer: 1, 2, 2 + // WMA(3) = (1*1 + 2*2 + 3*2) / 6 = (1 + 4 + 6) / 6 = 11/6 = 1.8333... + // Wait, period is 5. + // Buffer: 1, 2, 2 + // Sum = 5, WSum = 1*1 + 2*2 + 3*2 = 11 + // Divisor = 3*4/2 = 6 + // Result = 11/6 + + Assert.Equal(11.0/6.0, wma.Last.Value, 1e-9); + } + + [Fact] + public void Wma_Update_IsNewFalse_UpdatesLastValue() + { + var wma = new Wma(3); + wma.Update(new TValue(DateTime.UtcNow, 1.0)); + wma.Update(new TValue(DateTime.UtcNow, 2.0)); + + // Update existing with 3.0 (replaces 2.0) + wma.Update(new TValue(DateTime.UtcNow, 3.0), isNew: false); + + // Buffer should be: 1, 3 + // Sum = 4, WSum = 1*1 + 2*3 = 7 + // Divisor = 2*3/2 = 3 + // Result = 7/3 = 2.333... + + Assert.Equal(7.0/3.0, wma.Last.Value, 1e-9); + } + + [Fact] + public void Wma_TSeries_Empty_ReturnsEmpty() + { + var wma = new Wma(5); + var result = wma.Update(new TSeries()); + Assert.Empty(result); + } + + [Fact] + public void Wma_TSeries_WithNaN_RestoresStateCorrectly() + { + // This tests the state restoration logic in Update(TSeries) + // specifically the loop that looks for _lastValidValue + var wma = new Wma(3); + var series = new TSeries(); + series.Add(new TValue(DateTime.UtcNow, 1.0)); + series.Add(new TValue(DateTime.UtcNow, 2.0)); + series.Add(new TValue(DateTime.UtcNow, double.NaN)); + series.Add(new TValue(DateTime.UtcNow, 4.0)); + + wma.Update(series); + + // After processing series, internal state should match having processed these sequentially + // Last value was 4.0. Previous valid was 2.0 (since NaN used 2.0). + // Buffer: 2.0, 2.0 (from NaN), 4.0 + + // Let's add one more value to verify state is correct + wma.Update(new TValue(DateTime.UtcNow, 5.0)); + + // Buffer: 2.0, 4.0, 5.0 + // WMA(3) = (1*2 + 2*4 + 3*5) / 6 = (2 + 8 + 15) / 6 = 25/6 = 4.1666... + + Assert.Equal(25.0/6.0, wma.Last.Value, 1e-9); + } + + [Fact] + public void Wma_Reset_ClearsState() + { + var wma = new Wma(3); + wma.Update(new TValue(DateTime.UtcNow, 1.0)); + wma.Update(new TValue(DateTime.UtcNow, 2.0)); + wma.Update(new TValue(DateTime.UtcNow, 3.0)); + + wma.Reset(); + + Assert.Equal(0, wma.Last.Value); + + // Start fresh + wma.Update(new TValue(DateTime.UtcNow, 10.0)); + // Buffer: 10 + // WMA = 10 + Assert.Equal(10.0, wma.Last.Value); + } + + [Fact] + public void Wma_Calculate_ScalarFallback_WithNaN() + { + // Force scalar path by including NaN, even with large dataset + int count = 1000; + double[] source = new double[count]; + double[] output = new double[count]; + + for (int i = 0; i < count; i++) source[i] = 1.0; + source[500] = double.NaN; // This should trigger HasNonFiniteValues -> true + + Wma.Calculate(source.AsSpan(), output.AsSpan(), 10); + + // Check around the NaN + // Index 500 is NaN, so it uses previous valid (1.0) + // So effectively the stream is all 1.0s + Assert.Equal(1.0, output[500], 1e-9); + Assert.Equal(1.0, output[501], 1e-9); + } + + [Fact] + public void Wma_Constructor_WithSource_Subscribes() + { + var source = new Wma(10); // Just using Wma as a publisher + var wma = new Wma(source, 5); + + source.Update(new TValue(DateTime.UtcNow, 10.0)); + + Assert.Equal(10.0, wma.Last.Value); + } +} diff --git a/lib/averages/wma/Wma.Notebook.dib b/lib/averages/wma/Wma.Notebook.dib deleted file mode 100644 index c6ad308f..00000000 --- a/lib/averages/wma/Wma.Notebook.dib +++ /dev/null @@ -1,323 +0,0 @@ -#!meta - -{"kernelInfo":{"defaultKernelName":"csharp","items":[{"name":"csharp"},{"name":"fsharp","languageName":"F#","aliases":["f#","fs"]},{"name":"html","languageName":"HTML"},{"name":"http","languageName":"HTTP"},{"name":"javascript","languageName":"JavaScript","aliases":["js"]},{"name":"mermaid","languageName":"Mermaid"},{"name":"pwsh","languageName":"PowerShell","aliases":["powershell"]},{"name":"value"}]}} - -#!markdown - -# Weighted Moving Average (WMA) Examples - -This is a **.NET Interactive** notebook. To run it, you need the [Polyglot Notebooks](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.dotnet-interactive-vscode) extension installed in VS Code. - -The **Weighted Moving Average (WMA)** applies linear weighting to price data, giving more weight to recent values. Unlike SMA which treats all values equally, WMA assigns weight `n` to the newest value, `n-1` to the second newest, and so on down to weight `1` for the oldest. - -**Key characteristics:** -- Linear weighting: newest gets weight n, oldest gets weight 1 -- O(1) update complexity using dual running sums -- O(1) bar correction using scalar state -- More responsive than SMA, smoother transitions than EMA -- Reduced lag compared to SMA - -This notebook demonstrates: -1. **Manual Data Processing**: Understanding Batch vs. Streaming modes. -2. **Streaming with `isNew`**: Handling intra-bar updates. -3. **Large Dataset Processing**: Using Geometric Brownian Motion (GBM) generated data. -4. **Handling Invalid Values**: Last-value substitution for NaN/Infinity. -5. **WMA vs SMA vs EMA**: Comparing different moving averages. - -#!csharp - -// Reference the library -#r "..\..\bin\QuanTAlib.dll" - -using System; -using System.Linq; -using QuanTAlib; - -// Helper to print TSeries -void PrintSeries(TSeries series, int count = 5) -{ - Console.WriteLine($"Series Length: {series.Count}"); - foreach (var item in series.Take(count)) - { - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Value: {item.Value:F4}"); - } - if (series.Count > count) Console.WriteLine("..."); -} - -#!markdown - -## 1. Manual Data: Batch vs. Streaming - -We'll start with a small, manually created dataset to clearly see how Batch and Streaming operations work. - -### Batch Processing -Batch processing calculates the WMA for the entire dataset at once. This is efficient for historical analysis. - -#!csharp - -// Create a small manual dataset -var manualData = new TSeries(); -manualData.Add(DateTime.Now, 10.0); -manualData.Add(DateTime.Now.AddMinutes(1), 20.0); -manualData.Add(DateTime.Now.AddMinutes(2), 30.0); -manualData.Add(DateTime.Now.AddMinutes(3), 40.0); -manualData.Add(DateTime.Now.AddMinutes(4), 50.0); - -Console.WriteLine("--- Input Data ---"); -PrintSeries(manualData, 5); - -// Batch Calculation -Console.WriteLine("\n--- Batch WMA (Period 3) ---"); -var wmaBatch = new Wma(3); -var resultBatch = wmaBatch.Update(manualData); - -PrintSeries(resultBatch, 5); - -// Show the calculation for each step -Console.WriteLine("\nCalculation breakdown (weights = [1, 2, 3], divisor = 6):"); -Console.WriteLine(" WMA[0] = (1×10) / 1 = 10.0000"); -Console.WriteLine(" WMA[1] = (1×10 + 2×20) / 3 = 50/3 = 16.6667"); -Console.WriteLine(" WMA[2] = (1×10 + 2×20 + 3×30) / 6 = 140/6 = 23.3333"); -Console.WriteLine(" WMA[3] = (1×20 + 2×30 + 3×40) / 6 = 200/6 = 33.3333"); -Console.WriteLine(" WMA[4] = (1×30 + 2×40 + 3×50) / 6 = 260/6 = 43.3333"); - -#!markdown - -### Streaming Processing -Streaming processing updates the WMA one data point at a time. This is essential for real-time trading systems where data arrives sequentially. - -#!csharp - -Console.WriteLine("\n--- Streaming WMA (Period 3) ---"); -var wmaStream = new Wma(3); - -foreach (var item in manualData) -{ - var result = wmaStream.Update(item); - Console.WriteLine($"Time: {item.Time:HH:mm:ss}, Input: {item.Value:F2}, WMA: {result.Value:F4}, IsHot: {wmaStream.IsHot}"); -} - -// Verify that the last values match -var batchLast = resultBatch.Last().Value; -var streamLast = wmaStream.Value.Value; -Console.WriteLine($"\nMatch: {Math.Abs(batchLast - streamLast) < 1e-10} (Batch: {batchLast:F4}, Stream: {streamLast:F4})"); - -// Show WMA properties -Console.WriteLine($"\nWMA Properties:"); -Console.WriteLine($" Name: {wmaStream.Name}"); -Console.WriteLine($" WarmupPeriod: {wmaStream.WarmupPeriod}"); -Console.WriteLine($" IsHot: {wmaStream.IsHot}"); - -#!markdown - -## 2. Streaming with `isNew` (Intra-bar Updates) - -In real-time feeds, you often receive multiple updates for the *same* bar (e.g., price changes within the current minute) before the bar closes. -* `isNew = true`: The input is a new bar (advances time). -* `isNew = false`: The input is an update to the current bar (recalculates without advancing). - -**WMA achieves O(1) bar correction** by saving scalar state after each `isNew=true` update. - -#!csharp - -Console.WriteLine("\n--- Streaming with Intra-bar Updates ---"); -var wmaIntra = new Wma(3); - -// 1. Process the first 4 bars normally -for (int i = 0; i < 4; i++) -{ - wmaIntra.Update(manualData[i]); -} -Console.WriteLine($"After 4th bar (40.0): {wmaIntra.Value.Value:F4}"); - -// 2. Simulate intra-bar updates for the 5th bar (Final value is 50.0) -// Update 1: Price moves to 45.0 -var update1 = new TValue(manualData[4].Time, 45.0); -wmaIntra.Update(update1, isNew: true); // First update for this bar is "New" -Console.WriteLine($"Update 1 (45.0): {wmaIntra.Value.Value:F4}"); - -// Update 2: Price moves to 55.0 (Same time, same bar) -var update2 = new TValue(manualData[4].Time, 55.0); -wmaIntra.Update(update2, isNew: false); // Not new, just an update -Console.WriteLine($"Update 2 (55.0): {wmaIntra.Value.Value:F4}"); - -// Update 3: Final Close at 50.0 -var update3 = manualData[4]; -wmaIntra.Update(update3, isNew: false); // Final update -Console.WriteLine($"Update 3 (50.0): {wmaIntra.Value.Value:F4}"); - -// Verify match with batch result -Console.WriteLine($"Match with Batch: {Math.Abs(wmaIntra.Value.Value - batchLast) < 1e-10}"); - -#!markdown - -## 3. Large Dataset: Geometric Brownian Motion (GBM) - -We'll generate a larger dataset (1000 bars) using a Geometric Brownian Motion generator to simulate realistic market data. - -#!csharp - -// Generate 1000 bars of data -var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2); -var gbmData = gbm.Fetch(1000, DateTime.Now.Ticks, TimeSpan.FromMinutes(1)); -var closeSeries = gbmData.Close; - -Console.WriteLine($"Generated {closeSeries.Count} bars of GBM data."); -Console.WriteLine($"First 5 values: {string.Join(", ", closeSeries.Take(5).Select(x => x.Value.ToString("F2")))}"); - -#!markdown - -### Batch vs. Streaming Performance on Large Data - -#!csharp - -// Batch -var wmaLargeBatch = new Wma(20); -var batchLargeResult = wmaLargeBatch.Update(closeSeries); -Console.WriteLine($"Batch Last Value: {batchLargeResult.Last().Value:F4}"); - -// Streaming -var wmaLargeStream = new Wma(20); -TValue lastStreamVal = default; -foreach(var item in closeSeries) -{ - lastStreamVal = wmaLargeStream.Update(item); -} -Console.WriteLine($"Streaming Last Value: {lastStreamVal.Value:F4}"); - -// Verify match -Console.WriteLine($"Match: {Math.Abs(batchLargeResult.Last().Value - lastStreamVal.Value) < 1e-10}"); - -#!markdown - -## 4. Handling Invalid Values (NaN/Infinity) - -`Wma` uses **last-value substitution** for invalid inputs. When a non-finite value (NaN, PositiveInfinity, NegativeInfinity) is encountered, it is replaced with the last valid value. This provides output continuity instead of propagating invalid values through the calculation. - -#!csharp - -Console.WriteLine("\n--- Handling Invalid Values ---"); - -// Single WMA -var wmaNaN = new Wma(10); - -// Feed valid values first -wmaNaN.Update(new TValue(DateTime.Now, 100.0)); -wmaNaN.Update(new TValue(DateTime.Now.AddMinutes(1), 110.0)); -Console.WriteLine($"After valid values: {wmaNaN.Value.Value:F4}"); - -// Feed NaN - should use last valid value (110) -var resultAfterNaN = wmaNaN.Update(new TValue(DateTime.Now.AddMinutes(2), double.NaN)); -Console.WriteLine($"After NaN input: {resultAfterNaN.Value:F4} (IsFinite: {double.IsFinite(resultAfterNaN.Value)})"); - -// Feed Infinity - should use last valid value (110) -var resultAfterInf = wmaNaN.Update(new TValue(DateTime.Now.AddMinutes(3), double.PositiveInfinity)); -Console.WriteLine($"After Infinity input: {resultAfterInf.Value:F4} (IsFinite: {double.IsFinite(resultAfterInf.Value)})"); - -// Continue with valid value -var resultAfterValid = wmaNaN.Update(new TValue(DateTime.Now.AddMinutes(4), 120.0)); -Console.WriteLine($"After valid value (120): {resultAfterValid.Value:F4}"); - -#!csharp - -Console.WriteLine("\n--- Batch Processing with Invalid Values ---"); - -// Create series with NaN values interspersed -var seriesWithNaN = new TSeries(); -seriesWithNaN.Add(DateTime.Now.Ticks, 100.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 1, 110.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 2, double.NaN); -seriesWithNaN.Add(DateTime.Now.Ticks + 3, 120.0); -seriesWithNaN.Add(DateTime.Now.Ticks + 4, double.PositiveInfinity); -seriesWithNaN.Add(DateTime.Now.Ticks + 5, 130.0); - -var wmaBatchNaN = new Wma(3); -var resultsWithNaN = wmaBatchNaN.Update(seriesWithNaN); - -Console.WriteLine("Input → Output:"); -for (int i = 0; i < seriesWithNaN.Count; i++) -{ - var input = seriesWithNaN[i].Value; - var output = resultsWithNaN[i].Value; - var inputStr = double.IsFinite(input) ? input.ToString("F2") : input.ToString(); - Console.WriteLine($" {inputStr,-10} → {output:F4} (IsFinite: {double.IsFinite(output)})"); -} - -#!markdown - -## 5. WMA vs SMA vs EMA Comparison - -The WMA, SMA, and EMA are all trend-following indicators, but they weight data differently: - -- **SMA**: Equal weight to all values in the window -- **WMA**: Linear weights (newest = n, oldest = 1) -- **EMA**: Exponential weights (more weight on recent values) - -#!csharp - -Console.WriteLine("\n--- WMA vs SMA vs EMA Comparison (Period 10) ---"); - -var compareData = new TSeries(); -var baseTime = DateTime.Now; -for (int i = 0; i < 20; i++) -{ - // Create data with a sudden spike at position 10 - double value = (i == 10) ? 150.0 : 100.0; - compareData.Add(baseTime.AddMinutes(i), value); -} - -var wmaCompare = new Wma(10); -var smaCompare = new Sma(10); -var emaCompare = new Ema(10); - -Console.WriteLine("Position | Input | WMA | SMA | EMA | WMA-SMA"); -Console.WriteLine("---------+--------+---------+---------+---------+--------"); - -for (int i = 0; i < compareData.Count; i++) -{ - var wmaVal = wmaCompare.Update(compareData[i]); - var smaVal = smaCompare.Update(compareData[i]); - var emaVal = emaCompare.Update(compareData[i]); - var input = compareData[i].Value; - var diff = wmaVal.Value - smaVal.Value; - - Console.WriteLine($" {i,2} | {input,6:F0} | {wmaVal.Value,7:F2} | {smaVal.Value,7:F2} | {emaVal.Value,7:F2} | {diff,+7:F2}"); -} - -Console.WriteLine("\nNote: After the spike (position 10):"); -Console.WriteLine("- EMA reacts fastest due to exponential weighting on recent values"); -Console.WriteLine("- WMA reacts faster than SMA due to linear weighting"); -Console.WriteLine("- SMA takes longest as all values have equal weight"); -Console.WriteLine("- WMA provides a balance between SMA's stability and EMA's responsiveness"); - -#!markdown - -## 6. WMA Weights More Recent Values - -This example demonstrates how WMA weights more recent values compared to SMA. - -#!csharp - -Console.WriteLine("\n--- WMA vs SMA: Weight Distribution Effect ---"); - -// Create data where the most recent value is significantly different -var weightDemo = new TSeries(); -weightDemo.Add(DateTime.Now, 10.0); -weightDemo.Add(DateTime.Now.AddMinutes(1), 20.0); -weightDemo.Add(DateTime.Now.AddMinutes(2), 100.0); // High recent value - -var wmaWeight = new Wma(3); -var smaWeight = new Sma(3); - -foreach (var item in weightDemo) -{ - wmaWeight.Update(item); - smaWeight.Update(item); -} - -Console.WriteLine("Data: [10, 20, 100] (oldest to newest)"); -Console.WriteLine($"\nSMA(3) = (10 + 20 + 100) / 3 = {smaWeight.Value.Value:F4}"); -Console.WriteLine($"WMA(3) = (1×10 + 2×20 + 3×100) / 6 = {wmaWeight.Value.Value:F4}"); -Console.WriteLine($"\nWMA is {wmaWeight.Value.Value - smaWeight.Value.Value:F2} higher than SMA"); -Console.WriteLine("Because WMA gives 3× weight to the recent high value (100)"); -Console.WriteLine("while SMA treats all values equally."); diff --git a/lib/averages/wma/Wma.md b/lib/averages/wma/Wma.md index 1f2d3a3c..f8ce17ed 100644 --- a/lib/averages/wma/Wma.md +++ b/lib/averages/wma/Wma.md @@ -140,6 +140,34 @@ wma.Update(new TValue(time + 1, 101.2), isNew: true); **Implementation detail:** Bar correction is O(1) using scalar state save/restore, not buffer copying. +### Eventing and Reactive Support + +This indicator implements the `ITValuePublisher` interface, enabling event-driven and reactive workflows. + +* **Subscription:** Can be constructed with an `ITValuePublisher` (e.g., `TSeries`) to automatically update when the source emits a new value. +* **Publication:** Emits a `Pub` event with the new `TValue` whenever it is updated. + +```csharp +using QuanTAlib; + +// 1. Setup a source (publisher) +var source = new TSeries(); + +// 2. Create indicator subscribed to source +// It waits for events from 'source' +var wma = new Wma(source, period: 10); + +// 3. Optional: Subscribe to indicator's output +wma.Pub += (item) => Console.WriteLine($"WMA Updated: {item.Value}"); + +// 4. Ingest data into source +// This triggers the chain: source -> wma -> Console.WriteLine +source.Add(new TValue(DateTime.Now, 100)); +source.Add(new TValue(DateTime.Now, 105)); +``` + +This pattern allows building complex, reactive processing pipelines without manual update loops. + ### Handling Invalid Values (NaN/Infinity) `Wma` uses **last-value substitution** for handling invalid inputs: diff --git a/lib/todo.md b/lib/todo.md new file mode 100644 index 00000000..b5ebcdbe --- /dev/null +++ b/lib/todo.md @@ -0,0 +1,142 @@ +| **Indicator Name** | **Libraries** | +| ---------------------------------------------------------------------------- | --------------------------------------------- | +| **Aroon** – measures trend strength (Aroon Up/Down) | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Average True Range (ATR)** – volatility measure | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Balance of Power (BOP)** – momentum indicator | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Bollinger Bands** – volatility bands around moving average | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Chaikin Oscillator** (Accumulation/Distribution Osc) – volume momentum | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Chande Momentum Oscillator (CMO)** – momentum indicator | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Commodity Channel Index (CCI)** – deviation from mean price | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Double Exponential Moving Average (DEMA)** – a smoother EMA | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Exponential Moving Average (EMA)** – weighted moving average | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Linear Regression** (Line of Best Fit) – trend line value | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Momentum** (Rate of Change) – price change over period | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Money Flow Index (MFI)** – volume-weighted RSI | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Moving Average Convergence Divergence (MACD)** – trend/momentum oscillator | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Normalized ATR (NATR)** – ATR normalized to price | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Parabolic SAR** – stop-and-reverse trend indicator | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Relative Strength Index (RSI)** – momentum oscillator | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Simple Moving Average (SMA)** – arithmetic moving average | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Stochastic Oscillator** (Stoch) – %K and %D oscillators | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Stochastic RSI** – RSI applied to stochastic formula | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Triple Exponential Moving Average (T3)** – Tillson’s T3 MA | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **TRIX** (Triple EMA Oscillator) – triple EMA rate-of-change | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **True Range (TR)** – high/low range measure | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Ultimate Oscillator** – multi-period oscillator | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Weighted Moving Average (WMA)** – volume/point-weighted MA | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | +| **Williams %R** – Larry Williams overbought/oversold oscillator | TA-Lib, Tulip, Skender, PineScript, Pandas-TA | + +| **Indicator Name** | **Libraries** | +| --------------------------------------------------------------------- | ------------------------------------------------------- | +| **Arnaud Legoux Moving Average (ALMA)** – smoothing MA | Skender, PineScript, Pandas-TA (not in TA-Lib or Tulip) | +| **Average Directional Index (ADX)** – smoothed DMI oscillator | TA-Lib, Skender, PineScript (not in Tulip or Pandas-TA) | +| **Average Directional Movement Index Rating (ADXR)** – lagged ADX | TA-Lib, Tulip, PineScript (not in Skender or Pandas-TA) | +| **Beta Coefficient** – Beta (relative volatility vs market) | TA-Lib, Skender, PineScript (not in Tulip or Pandas-TA) | +| **Chaikin Money Flow (CMF)** – volume flow oscillator | Skender, PineScript, Pandas-TA (not in TA-Lib or Tulip) | +| **Choppiness Index** – market choppiness (trend/no-trend) | Skender, PineScript, Pandas-TA (not in TA-Lib or Tulip) | +| **Ease of Movement (EMV)** – price/volume change oscillator | Tulip, PineScript, Pandas-TA (not in TA-Lib or Skender) | +| **Hull Moving Average (HMA)** – fast smoothing MA | Tulip, PineScript, Pandas-TA (not in TA-Lib or Skender) | +| **Kaufman’s Adaptive Moving Average (KAMA)** – volatility-adaptive MA | TA-Lib, PineScript, Pandas-TA (not in Tulip or Skender) | +| **Klinger Volume Oscillator (KVO)** – volume force oscillator | Tulip, Skender, PineScript (not in TA-Lib or Pandas-TA) | +| **Mass Index** – price range compression index | Tulip, PineScript, Pandas-TA (not in TA-Lib or Skender) | +| **Negative Volume Index (NVI)** – volume-based index | Tulip, PineScript, Pandas-TA (not in TA-Lib or Skender) | +| **On-Balance Volume (OBV)** – cumulative volume | TA-Lib, Skender, Pandas-TA (not in Tulip or PineScript) | +| **Positive Volume Index (PVI)** – volume-based index | Tulip, PineScript, Pandas-TA (not in TA-Lib or Skender) | +| **Qstick** – price change oscillator (candlestick average) | Tulip, PineScript, Skender (not in TA-Lib or Pandas-TA) | +| **Schaff Trend Cycle** – cycle oscillator by Schaff | Skender, PineScript, Pandas-TA (not in TA-Lib or Tulip) | +| **Smoothed Moving Average (SMMA)** – Wilder’s smoothing (RMA) | Skender, PineScript, Pandas-TA (not in TA-Lib or Tulip) | +| **SuperTrend** – ATR-based trend indicator | Skender, PineScript, Pandas-TA (not in TA-Lib or Tulip) | +| **Volatility (Historical Volatility)** – statistical volatility | Tulip, PineScript, Pandas-TA (not in TA-Lib or Skender) | +| **Volume Oscillator (PVO/VOSC)** – difference in EMAs of volume | Tulip, PineScript, Pandas-TA (not in TA-Lib or Skender) | +| **Williams Alligator** – Bill Williams’ Alligator (3 MAs) | Skender, PineScript, Tulip (not in TA-Lib or Pandas-TA) | +| **Williams Fractal** – Bill Williams’ fractal pattern | Skender, PineScript, Pandas-TA (not in TA-Lib or Tulip) | + +| **Indicator Name** | **Libraries** | +| ------------------------------------------------------------------------------------------------------- | --------------------- | +| **ATR Trailing Stop** – ATR-based stop indicator | Skender, PineScript | +| **Aberration** – trend-following band indicator | PineScript, Pandas-TA | +| **Acceleration Bands** – Bollinger-type bands by Price Headley | PineScript, Pandas-TA | +| **Archer Moving Averages Trends (AMAT)** – Archer’s composite trend MA | PineScript, Pandas-TA | +| **Archer On-Balance Volume (AOBV)** – Archer’s OBV variation | PineScript, Pandas-TA | +| **BRAR** – Bull Ratio & Bear Ratio indicator | PineScript, Pandas-TA | +| **Bias (BIAS)** – price bias from MA (percentage) | PineScript, Pandas-TA | +| **Bull and Bear Power** – Elder’s bull power and bear power | Skender, PineScript | +| **Center of Gravity** – Ehlers’ center-of-gravity oscillator | PineScript, Pandas-TA | +| **Chande Forecast Oscillator (CFO)** – deviation from linear regression forecast | PineScript, Pandas-TA | +| **Chande Kroll Stop (CKSP)** – volatility stop by Chande & Kroll | Skender, Pandas-TA | +| **Chandelier Exit** – ATR-based stop by Chuck LeBeau | Skender, PineScript | +| **ConnorsRSI** – Connors’ 3-component RSI | Skender, PineScript | +| **Dominant Cycle Periods** – dominant cycle period (Ehlers) | Skender, Pandas-TA | +| **Elder-ray Index (ERI)** – measures bull and bear pressure | Skender, Pandas-TA | +| **Endpoint Moving Average (EPMA)** – end-point linear regression MA | Skender, PineScript | +| **Entropy** – Shannon entropy of returns | PineScript, Pandas-TA | +| **Even Better Sinewave (EBSW)** – improved MESA cycle indicator | PineScript, Pandas-TA | +| **Fractal Chaos Bands** – price bands using fractal geometry | Skender, PineScript | +| **Gator Oscillator** – Bill Williams’ Gator (Alligator derivative) | Skender, PineScript | +| **Gann High-Low Activator (HiLo)** – trend indicator by Gann | PineScript, Pandas-TA | +| **Heikin-Ashi** (HA candles) – averaged candlestick values | Skender, Pandas-TA | +| **Historical Volatility (HV)** – statistical volatility (std dev) | Skender, PineScript | +| **Holt-Winter Moving Average (HWMA)** – Holt-Winters double EMA | PineScript, Pandas-TA | +| **Inertia** – RSI-based trend inertia indicator | PineScript, Pandas-TA | +| **Increasing/Decreasing** – price increase/decrease streak | PineScript, Pandas-TA | +| **Ichimoku Cloud** (Ichimoku Kinkō Hyō) – five-line system | Skender, Pandas-TA | +| **Least Squares Moving Average (LSMA)** – linear regression line as MA | Skender, PineScript | +| **Long Run / Short Run** – long-term and short-term trend lines | PineScript, Pandas-TA | +| **Market Facilitation Index (MFI)** – volume-price efficiency (Bill Williams) | Tulip, PineScript | +| **McGinley Dynamic** – adaptive moving average by McGinley | Skender, Pandas-TA | +| **Median Price** – (High+Low)/2 series | TA-Lib, Tulip | +| **Modified Moving Average (MMA)** – arithmetic moving average variant | Skender, PineScript | +| **Momentum Oscillator** (alternate term for Momentum) | *See Momentum above* | +| **Pretty Good Oscillator (PGO)** – distance from EMA in std dev | PineScript, Pandas-TA | +| **Price Channels** – highest high/lowest low channel | Skender, PineScript | +| **Price Distance (PDIST)** – distance of price from MA | PineScript, Pandas-TA | +| **Price Momentum Oscillator (PMO)** – Tushar Chande’s momentum osc | Skender, PineScript | +| **Price Relative Strength (PRS)** – ratio of asset to benchmark | Skender, PineScript | +| **Pivot Points** (Floor pivots) – support/resistance levels | Skender, PineScript | +| **Rolling Pivot Points** – continuously updated pivots | Skender, PineScript | +| **Rescaled Range (R/S) Analysis** – Hurst exponent calculation | Skender, PineScript | +| **Relative Vigor Index (RVI)** – oscillator of confirmation | PineScript, Pandas-TA | +| **Schaff Trend Cycle** – *(see above in 3-library list)* | | +| **Sine Weighted MA (SINWMA)** – sine-weighted moving average | PineScript, Pandas-TA | +| **Slope** (Linear Regression Slope) – slope of trendline | TA-Lib, Pandas-TA | +| **Standard Error** (of price) – std error over period | Tulip, Pandas-TA | +| **Super Smoother Filter (SSF)** – Ehlers’ low-pass filter | PineScript, Pandas-TA | +| **Summation (SUM)** – cumulative sum over period | TA-Lib, Tulip | +| **TTM Trend** – Trend indicator from TradeTheMarkets | PineScript, Pandas-TA | +| **Typical Price** – (H+L+C)/3 series | TA-Lib, Tulip | +| **Ulcer Index (UI)** – drawdown volatility measure | Skender, Pandas-TA | +| **Vertical Horizontal Filter (VHF)** – trend noise filter | Tulip, PineScript | +| **Volatility Stop** – ATR-based stop indicator | Skender, PineScript | +| **Volume Weighted Average Price (VWAP)** – price weighted by volume | Skender, Pandas-TA | +| **Volume Profile (VP)** – volume distribution by price (histogram) | PineScript, Pandas-TA | +| **Williams %R (percent Range)** – *listed above in 5-library list* | | +| **Zig Zag** – filtered price swings (visual aid) | Skender, PineScript | + +| **Indicator Name** | **Library** | +| ----------------------------------------------------------------------------- | -------------- | +| **Hilbert Transform Dominant Cycle Period** – Ehlers’ cycle period | TA-Lib (only) | +| **Hilbert Transform Dominant Cycle Phase** – Ehlers’ cycle phase | TA-Lib (only) | +| **Hilbert Transform Phasor Components** – In-phase/quadrature components | TA-Lib (only) | +| **Hilbert Transform SineWave** – Ehlers’ sine/cosine of cycle | TA-Lib (only) | +| **Hilbert Transform Instantaneous Trendline** – Ehlers’ IMAT | TA-Lib (only) | +| **Hilbert Transform Trend vs Cycle Mode** – cycle/trend discrimination | TA-Lib (only) | +| **Highest/Lowest values over period (MIN/MAX)** – period extrema | TA-Lib (only) | +| **Index of Highest/Lowest value (MININDEX/MAXINDEX)** – extrema index | TA-Lib (only) | +| **Lowest & Highest values (MINMAX)** – both extrema in one output | TA-Lib (only) | +| **Pearson’s Correlation Coefficient (CORREL)** – correlation of two series | TA-Lib (only) | +| **Linear Regression Intercept** – intercept of best-fit line | TA-Lib (only) | +| **Linear Regression Angle** – angle of best-fit line | TA-Lib (only) | +| **Time Series Forecast (TSF)** – forecast of next value via LR | TA-Lib (only) | +| **Vector Trigonometric Functions** (SIN, COS, TAN, etc) – elementwise math | Tulip (only) | +| **Vector Arithmetic Ops** (ADD, SUB, MUL, DIV, etc) – elementwise math | Tulip (only) | +| **Vector Log, Exp, etc** – elementwise transforms (LN, EXP, etc) | Tulip (only) | +| **Crossovers** (Crossany/Crossover) – series cross above/below logic | Tulip (only) | +| **Mean Deviation (MD)** – mean absolute deviation | Tulip (only) | +| **Standard Error (STDERR)** – std error of values | Tulip (only) | +| **Decay** (Linear/Exponential) – value decay over time | Tulip (only) | +| **Moving Average Envelopes** – percentage envelopes around MA | Skender (only) | +| **Donchian Channels** – high/low channel over *n* periods | Skender (only) | +| **Fractal Chaos Bands** – *(also listed in 2-library category: Pine)* | | +| **Schaff Trend Cycle** – *(also listed in 3-library category)* | | +| **ConnorsRSI** – *(if PineScript not included; otherwise 2 libraries)* | | + diff --git a/quantower/DemaIndicator.Tests.cs b/quantower/DemaIndicator.Tests.cs new file mode 100644 index 00000000..b48b7cdf --- /dev/null +++ b/quantower/DemaIndicator.Tests.cs @@ -0,0 +1,179 @@ +using Xunit; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +public class DemaIndicatorTests +{ + [Fact] + public void DemaIndicator_Constructor_SetsDefaults() + { + var indicator = new DemaIndicator(); + + Assert.Equal(10, indicator.Period); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + Assert.Equal("DEMA - Double Exponential Moving Average", indicator.Name); + Assert.False(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void DemaIndicator_MinHistoryDepths_EqualsPeriod() + { + var indicator = new DemaIndicator { Period = 20 }; + + Assert.Equal(20, indicator.MinHistoryDepths); + Assert.Equal(20, ((IWatchlistIndicator)indicator).MinHistoryDepths); + } + + [Fact] + public void DemaIndicator_ShortName_IncludesPeriodAndSource() + { + var indicator = new DemaIndicator { Period = 15 }; + + Assert.Contains("DEMA", indicator.ShortName); + Assert.Contains("15", indicator.ShortName); + } + + [Fact] + public void DemaIndicator_SourceCodeLink_IsValid() + { + var indicator = new DemaIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink); + Assert.Contains("Dema.Quantower.cs", indicator.SourceCodeLink); + } + + [Fact] + public void DemaIndicator_Initialize_CreatesInternalDema() + { + var indicator = new DemaIndicator { Period = 10 }; + + // Initialize should not throw + indicator.Initialize(); + + // After init, line series should exist + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void DemaIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new DemaIndicator { 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))); + } + + [Fact] + public void DemaIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new DemaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void DemaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new DemaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + [Fact] + public void DemaIndicator_OnPaintChart_DoesNotThrow() + { + var indicator = new DemaIndicator(); + indicator.Initialize(); + + // We can't easily mock PaintChartEventArgs fully, but we can verify the method exists and is callable + // if we could mock the args. Since we can't, we skip the actual call but verify the method is overridden. + var method = indicator.GetType().GetMethod("OnPaintChart"); + Assert.NotNull(method); + Assert.Equal(typeof(DemaIndicator), method.DeclaringType); + } + + [Fact] + public void DemaIndicator_MultipleUpdates_ProducesCorrectDemaSequence() + { + var indicator = new DemaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 102, 104, 103, 105 }; + + foreach (var close in closes) + { + indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + now = now.AddMinutes(1); + } + + // All values should be finite + for (int i = 0; i < closes.Length; i++) + { + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i))); + } + } + + [Fact] + public void DemaIndicator_DifferentSourceTypes_Work() + { + var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 }; + + foreach (var source in sources) + { + var indicator = new DemaIndicator { Period = 3, Source = source }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 110, 90, 105); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)), + $"Source {source} should produce finite value"); + } + } + + [Fact] + public void DemaIndicator_Period_CanBeChanged() + { + var indicator = new DemaIndicator { Period = 5 }; + Assert.Equal(5, indicator.Period); + + indicator.Period = 20; + Assert.Equal(20, indicator.Period); + Assert.Equal(20, indicator.MinHistoryDepths); + } +} diff --git a/quantower/HmaIndicator.Tests.cs b/quantower/HmaIndicator.Tests.cs new file mode 100644 index 00000000..20e40e95 --- /dev/null +++ b/quantower/HmaIndicator.Tests.cs @@ -0,0 +1,179 @@ +using Xunit; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +public class HmaIndicatorTests +{ + [Fact] + public void HmaIndicator_Constructor_SetsDefaults() + { + var indicator = new HmaIndicator(); + + Assert.Equal(14, indicator.Period); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + Assert.Equal("HMA - Hull Moving Average", indicator.Name); + Assert.False(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void HmaIndicator_MinHistoryDepths_CalculatedCorrectly() + { + var indicator = new HmaIndicator { Period = 16 }; + // HMA warmup is roughly Period + Sqrt(Period) + // 16 + Sqrt(16) = 16 + 4 = 20 + Assert.Equal(20, indicator.MinHistoryDepths); + Assert.Equal(20, ((IWatchlistIndicator)indicator).MinHistoryDepths); + } + + [Fact] + public void HmaIndicator_ShortName_IncludesPeriodAndSource() + { + var indicator = new HmaIndicator { Period = 21 }; + + Assert.Contains("HMA", indicator.ShortName); + Assert.Contains("21", indicator.ShortName); + } + + [Fact] + public void HmaIndicator_SourceCodeLink_IsValid() + { + var indicator = new HmaIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink); + Assert.Contains("Hma.cs", indicator.SourceCodeLink); + } + + [Fact] + public void HmaIndicator_Initialize_CreatesInternalHma() + { + var indicator = new HmaIndicator { Period = 10 }; + + // Initialize should not throw + indicator.Initialize(); + + // After init, line series should exist + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void HmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new HmaIndicator { Period = 4 }; // Small period for easier testing + 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))); + } + + [Fact] + public void HmaIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new HmaIndicator { Period = 4 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void HmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new HmaIndicator { Period = 4 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + [Fact] + public void HmaIndicator_OnPaintChart_DoesNotThrow() + { + var indicator = new HmaIndicator(); + indicator.Initialize(); + + var method = indicator.GetType().GetMethod("OnPaintChart"); + Assert.NotNull(method); + Assert.Equal(typeof(HmaIndicator), method.DeclaringType); + } + + [Fact] + public void HmaIndicator_MultipleUpdates_ProducesCorrectHmaSequence() + { + var indicator = new HmaIndicator { Period = 4 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 102, 104, 103, 105, 106, 107 }; + + foreach (var close in closes) + { + indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + now = now.AddMinutes(1); + } + + // All values should be finite + for (int i = 0; i < closes.Length; i++) + { + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i))); + } + } + + [Fact] + public void HmaIndicator_DifferentSourceTypes_Work() + { + var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 }; + + foreach (var source in sources) + { + var indicator = new HmaIndicator { Period = 4, Source = source }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 110, 90, 105); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)), + $"Source {source} should produce finite value"); + } + } + + [Fact] + public void HmaIndicator_Period_CanBeChanged() + { + var indicator = new HmaIndicator { Period = 5 }; + Assert.Equal(5, indicator.Period); + + indicator.Period = 20; + Assert.Equal(20, indicator.Period); + // 20 + sqrt(20) = 20 + 4 = 24 + Assert.Equal(24, indicator.MinHistoryDepths); + } +} diff --git a/quantower/SmaIndicator.Tests.cs b/quantower/SmaIndicator.Tests.cs index 79eeb8b2..5a9ffe27 100644 --- a/quantower/SmaIndicator.Tests.cs +++ b/quantower/SmaIndicator.Tests.cs @@ -76,6 +76,52 @@ public class SmaIndicatorTests Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); } + [Fact] + public void SmaIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new SmaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void SmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new SmaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + [Fact] + public void SmaIndicator_OnPaintChart_DoesNotThrow() + { + var indicator = new SmaIndicator(); + indicator.Initialize(); + + var method = indicator.GetType().GetMethod("OnPaintChart"); + Assert.NotNull(method); + Assert.Equal(typeof(SmaIndicator), method.DeclaringType); + } + [Fact] public void SmaIndicator_MultipleUpdates_ProducesCorrectSmaSequence() { diff --git a/quantower/TemaIndicator.Tests.cs b/quantower/TemaIndicator.Tests.cs new file mode 100644 index 00000000..d6fb1580 --- /dev/null +++ b/quantower/TemaIndicator.Tests.cs @@ -0,0 +1,177 @@ +using Xunit; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +public class TemaIndicatorTests +{ + [Fact] + public void TemaIndicator_Constructor_SetsDefaults() + { + var indicator = new TemaIndicator(); + + Assert.Equal(10, indicator.Period); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + Assert.Equal("TEMA - Triple Exponential Moving Average", indicator.Name); + Assert.False(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void TemaIndicator_MinHistoryDepths_EqualsPeriod() + { + var indicator = new TemaIndicator { Period = 20 }; + + Assert.Equal(20, indicator.MinHistoryDepths); + Assert.Equal(20, ((IWatchlistIndicator)indicator).MinHistoryDepths); + } + + [Fact] + public void TemaIndicator_ShortName_IncludesPeriodAndSource() + { + var indicator = new TemaIndicator { Period = 15 }; + + Assert.Contains("TEMA", indicator.ShortName); + Assert.Contains("15", indicator.ShortName); + } + + [Fact] + public void TemaIndicator_SourceCodeLink_IsValid() + { + var indicator = new TemaIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink); + Assert.Contains("Tema.Quantower.cs", indicator.SourceCodeLink); + } + + [Fact] + public void TemaIndicator_Initialize_CreatesInternalTema() + { + var indicator = new TemaIndicator { Period = 10 }; + + // Initialize should not throw + indicator.Initialize(); + + // After init, line series should exist + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void TemaIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new TemaIndicator { 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))); + } + + [Fact] + public void TemaIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new TemaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void TemaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new TemaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + [Fact] + public void TemaIndicator_OnPaintChart_DoesNotThrow() + { + var indicator = new TemaIndicator(); + indicator.Initialize(); + + var method = indicator.GetType().GetMethod("OnPaintChart"); + Assert.NotNull(method); + Assert.Equal(typeof(TemaIndicator), method.DeclaringType); + } + + [Fact] + public void TemaIndicator_MultipleUpdates_ProducesCorrectTemaSequence() + { + var indicator = new TemaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 102, 104, 103, 105 }; + + foreach (var close in closes) + { + indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + now = now.AddMinutes(1); + } + + // All values should be finite + for (int i = 0; i < closes.Length; i++) + { + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i))); + } + } + + [Fact] + public void TemaIndicator_DifferentSourceTypes_Work() + { + var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 }; + + foreach (var source in sources) + { + var indicator = new TemaIndicator { Period = 3, Source = source }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 110, 90, 105); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)), + $"Source {source} should produce finite value"); + } + } + + [Fact] + public void TemaIndicator_Period_CanBeChanged() + { + var indicator = new TemaIndicator { Period = 5 }; + Assert.Equal(5, indicator.Period); + + indicator.Period = 20; + Assert.Equal(20, indicator.Period); + Assert.Equal(20, indicator.MinHistoryDepths); + } +} diff --git a/quantower/TrimaIndicator.Tests.cs b/quantower/TrimaIndicator.Tests.cs index a5763249..9629efb7 100644 --- a/quantower/TrimaIndicator.Tests.cs +++ b/quantower/TrimaIndicator.Tests.cs @@ -76,6 +76,52 @@ public class TrimaIndicatorTests Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); } + [Fact] + public void TrimaIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new TrimaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void TrimaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new TrimaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + [Fact] + public void TrimaIndicator_OnPaintChart_DoesNotThrow() + { + var indicator = new TrimaIndicator(); + indicator.Initialize(); + + var method = indicator.GetType().GetMethod("OnPaintChart"); + Assert.NotNull(method); + Assert.Equal(typeof(TrimaIndicator), method.DeclaringType); + } + [Fact] public void TrimaIndicator_MultipleUpdates_ProducesCorrectTrimaSequence() { diff --git a/quantower/WmaIndicator.Tests.cs b/quantower/WmaIndicator.Tests.cs index c93b8ec3..f82ded07 100644 --- a/quantower/WmaIndicator.Tests.cs +++ b/quantower/WmaIndicator.Tests.cs @@ -76,6 +76,52 @@ public class WmaIndicatorTests Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); } + [Fact] + public void WmaIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new WmaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void WmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new WmaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + [Fact] + public void WmaIndicator_OnPaintChart_DoesNotThrow() + { + var indicator = new WmaIndicator(); + indicator.Initialize(); + + var method = indicator.GetType().GetMethod("OnPaintChart"); + Assert.NotNull(method); + Assert.Equal(typeof(WmaIndicator), method.DeclaringType); + } + [Fact] public void WmaIndicator_MultipleUpdates_ProducesCorrectWmaSequence() {