From 5c1fb18520b496460dedf5c6b8c281201061ac36 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Sat, 29 Nov 2025 19:31:50 -0800 Subject: [PATCH] Implement Weighted Moving Average (WMA) and Multi-Period WMA (WmaVector) classes with O(1) update complexity - Added Wma class for calculating the Weighted Moving Average with detailed documentation and optimized performance using dual running sums. - Introduced WmaVector class to handle multiple WMAs simultaneously, supporting batch calculations and real-time updates. - Implemented last-value substitution for handling invalid inputs (NaN/Infinity) in both classes. - Created comprehensive unit tests for Wma and WmaVector to ensure accuracy and reliability of calculations. - Updated documentation to include usage examples, mathematical foundations, and performance characteristics. --- .vscode/extensions.json | 1 + lib/averages/ema/Ema.md | 18 +- lib/averages/sma/Sma.Quantower.cs | 59 ++++ lib/averages/wma/Wma.Notebook.dib | 377 +++++++++++++++++++++ lib/averages/wma/Wma.Quantower.cs | 59 ++++ lib/averages/wma/Wma.Tests.cs | 403 ++++++++++++++++++++++ lib/averages/wma/Wma.Validation.Tests.cs | 197 +++++++++++ lib/averages/wma/Wma.cs | 265 +++++++++++++++ lib/averages/wma/Wma.md | 220 ++++++++++++ lib/averages/wma/WmaVector.Tests.cs | 410 +++++++++++++++++++++++ lib/averages/wma/WmaVector.cs | 264 +++++++++++++++ 11 files changed, 2267 insertions(+), 6 deletions(-) create mode 100644 lib/averages/sma/Sma.Quantower.cs create mode 100644 lib/averages/wma/Wma.Notebook.dib create mode 100644 lib/averages/wma/Wma.Quantower.cs create mode 100644 lib/averages/wma/Wma.Tests.cs create mode 100644 lib/averages/wma/Wma.Validation.Tests.cs create mode 100644 lib/averages/wma/Wma.cs create mode 100644 lib/averages/wma/Wma.md create mode 100644 lib/averages/wma/WmaVector.Tests.cs create mode 100644 lib/averages/wma/WmaVector.cs diff --git a/.vscode/extensions.json b/.vscode/extensions.json index efeabb26..f5b09f39 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ "recommendations": [ "ms-dotnettools.csdevkit", "ms-dotnettools.csharp", + "ms-dotnettools.dotnet-interactive-vscode", "bierner.markdown-mermaid" ] } diff --git a/lib/averages/ema/Ema.md b/lib/averages/ema/Ema.md index 2fcf4795..bd22a215 100644 --- a/lib/averages/ema/Ema.md +++ b/lib/averages/ema/Ema.md @@ -42,11 +42,16 @@ Where: This form is algebraically equivalent to the traditional EMA formula but offers better computational efficiency and numerical stability. -> 🔍 **Technical Note:** The implementation uses a sophisticated warm-up compensation method that provides accurate EMA values from the first bar. The compensation works by tracking an error term that decays exponentially: -> $$e_t = e_{t-1} \cdot (1 - \alpha)$$ +> 🔍 **Technical Note:** The implementation uses **Hunter's bias compensation** method, which provides mathematically correct EMA values from the very first data point. This technique, introduced by J.S. Hunter in 1986, corrects for the initialization bias that occurs when starting an EMA from zero rather than from an infinite history of data. +> +> The compensation works by tracking an error term $e$ that decays exponentially: +> $$e_t = e_{t-1} \cdot (1 - \alpha), \quad e_0 = 1$$ > $$Compensation = \frac{1}{1 - e_t}$$ > $$EMA_{corrected} = Compensation \cdot EMA_{raw}$$ -> This compensation automatically adjusts during the warm-up phase and becomes negligible ($e \le 1e^{-10}$) once sufficient data has been processed, ensuring mathematically correct values throughout the entire data series without requiring a traditional warm-up period. +> +> **Why it works:** The standard EMA formula implicitly assumes all historical values before the first observation were zero. This creates a downward bias in early values. The compensation factor $\frac{1}{1 - (1-\alpha)^n}$ exactly corrects for this missing history, making the first output equal to the first input and ensuring all subsequent values are consistent with what a properly-seeded infinite EMA would produce. +> +> The compensation automatically diminishes as more data is processed and becomes negligible ($e \le 10^{-10}$) after approximately $\frac{23}{\alpha}$ observations, at which point the implementation switches to the raw EMA for efficiency. ## C# Implementation @@ -159,6 +164,7 @@ EMAs work particularly well in trending markets but may generate false signals d ## References -1. Murphy, J.J. (1999). *Technical Analysis of the Financial Markets*. New York Institute of Finance. -2. Kaufman, P. (2013). *Trading Systems and Methods*, 5th Edition. Wiley Trading. -3. Ehlers, J. (2001). *Rocket Science for Traders*. John Wiley & Sons. +1. Hunter, J.S. (1986). "The Exponentially Weighted Moving Average." *Journal of Quality Technology*, 18(4), 203-210. +2. Murphy, J.J. (1999). *Technical Analysis of the Financial Markets*. New York Institute of Finance. +3. Kaufman, P. (2013). *Trading Systems and Methods*, 5th Edition. Wiley Trading. +4. Ehlers, J. (2001). *Rocket Science for Traders*. John Wiley & Sons. diff --git a/lib/averages/sma/Sma.Quantower.cs b/lib/averages/sma/Sma.Quantower.cs new file mode 100644 index 00000000..8548a1e2 --- /dev/null +++ b/lib/averages/sma/Sma.Quantower.cs @@ -0,0 +1,59 @@ +using System.Drawing; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +public class SmaIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)] + public int Period { get; set; } = 10; + + [IndicatorExtensions.DataSourceInput] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Sma? ma; + protected LineSeries? Series; + protected string? SourceName; + + public int MinHistoryDepths => Period; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"SMA {Period}:{SourceName}"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/averages/sma/Sma.Quantower.cs"; + + public SmaIndicator() + { + OnBackGround = true; + SeparateWindow = false; + SourceName = Source.ToString(); + Name = "SMA - Simple Moving Average"; + Description = "Simple Moving Average"; + Series = new(name: $"SMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid); + AddLineSeries(Series); + } + + protected override void OnInit() + { + ma = new Sma(Period); + 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); + Series!.SetValue(result.Value); + Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here + } + + public override void OnPaintChart(PaintChartEventArgs args) + { + base.OnPaintChart(args); + this.PaintSmoothCurve(args, Series!, 0, showColdValues: ShowColdValues, tension: 0.2); + } +} diff --git a/lib/averages/wma/Wma.Notebook.dib b/lib/averages/wma/Wma.Notebook.dib new file mode 100644 index 00000000..9af05684 --- /dev/null +++ b/lib/averages/wma/Wma.Notebook.dib @@ -0,0 +1,377 @@ +#!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. **Vectorized Operations**: Calculating multiple WMAs simultaneously. +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. Vectorized WMA (Multiple Periods) + +`WmaVector` allows calculating multiple WMAs (e.g., 5, 10, 20) simultaneously. This is useful for comparing different timeframes. + +### Vectorized Batch + +#!csharp + +int[] periods = { 5, 10, 20 }; +Console.WriteLine($"\n--- Vectorized Batch WMA (Periods: {string.Join(", ", periods)}) ---"); + +var wmaVectorBatch = new WmaVector(periods); +var vectorBatchResults = wmaVectorBatch.Calculate(closeSeries); + +for (int i = 0; i < periods.Length; i++) +{ + Console.WriteLine($"WMA({periods[i]}) Last Value: {vectorBatchResults[i].Last().Value:F4}"); +} + +#!markdown + +### Vectorized Streaming + +#!csharp + +Console.WriteLine($"\n--- Vectorized Streaming WMA (Periods: {string.Join(", ", periods)}) ---"); + +var wmaVectorStream = new WmaVector(periods); +TValue[] lastVectorVal = null; + +foreach(var item in closeSeries) +{ + lastVectorVal = wmaVectorStream.Update(item); +} + +for (int i = 0; i < periods.Length; i++) +{ + Console.WriteLine($"WMA({periods[i]}) Last Value: {lastVectorVal[i].Value:F4}"); +} + +// Verification +bool allMatch = true; +for (int i = 0; i < periods.Length; i++) +{ + if (Math.Abs(vectorBatchResults[i].Last().Value - lastVectorVal[i].Value) > 1e-10) + { + allMatch = false; + break; + } +} +Console.WriteLine($"\nAll Vectorized Stream/Batch values match: {allMatch}"); + +#!markdown + +## 5. Handling Invalid Values (NaN/Infinity) + +Both `Wma` and `WmaVector` use **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 + +## 6. 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 + +## 7. 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.Quantower.cs b/lib/averages/wma/Wma.Quantower.cs new file mode 100644 index 00000000..c35cd996 --- /dev/null +++ b/lib/averages/wma/Wma.Quantower.cs @@ -0,0 +1,59 @@ +using System.Drawing; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +public class WmaIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)] + public int Period { get; set; } = 10; + + [IndicatorExtensions.DataSourceInput] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Wma? ma; + protected LineSeries? Series; + protected string? SourceName; + + public int MinHistoryDepths => Period; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"WMA {Period}:{SourceName}"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/averages/wma/Wma.Quantower.cs"; + + public WmaIndicator() + { + OnBackGround = true; + SeparateWindow = false; + SourceName = Source.ToString(); + Name = "WMA - Weighted Moving Average"; + Description = "Weighted Moving Average with linear weighting"; + Series = new(name: $"WMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid); + AddLineSeries(Series); + } + + protected override void OnInit() + { + ma = new Wma(Period); + 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); + Series!.SetValue(result.Value); + Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here + } + + public override void OnPaintChart(PaintChartEventArgs args) + { + base.OnPaintChart(args); + this.PaintSmoothCurve(args, Series!, 0, showColdValues: ShowColdValues, tension: 0.2); + } +} diff --git a/lib/averages/wma/Wma.Tests.cs b/lib/averages/wma/Wma.Tests.cs new file mode 100644 index 00000000..6628ab9a --- /dev/null +++ b/lib/averages/wma/Wma.Tests.cs @@ -0,0 +1,403 @@ +using System; +using Xunit; +using QuanTAlib; + +namespace QuanTAlib.Tests; + +public class WmaTests +{ + [Fact] + public void Wma_Constructor_ValidatesInput() + { + Assert.Throws(() => new Wma(0)); + Assert.Throws(() => new Wma(-1)); + + var wma = new Wma(10); + Assert.NotNull(wma); + } + + [Fact] + public void Wma_Calc_ReturnsValue() + { + var wma = new Wma(10); + + Assert.Equal(0, wma.Value.Value); + + TValue result = wma.Update(new TValue(DateTime.UtcNow, 100)); + + Assert.True(result.Value > 0); + Assert.Equal(result.Value, wma.Value.Value); + } + + [Fact] + public void Wma_FirstValue_ReturnsItself() + { + var wma = new Wma(10); + + TValue result = wma.Update(new TValue(DateTime.UtcNow, 100)); + + Assert.Equal(100.0, result.Value, 1e-10); + } + + [Fact] + public void Wma_Calc_IsNew_AcceptsParameter() + { + var wma = new Wma(10); + + wma.Update(new TValue(DateTime.UtcNow, 100), isNew: true); + double value1 = wma.Value; + + wma.Update(new TValue(DateTime.UtcNow, 200), isNew: true); + double value2 = wma.Value; + + // Values should change with new bars + Assert.NotEqual(value1, value2); + } + + [Fact] + public void Wma_Calc_IsNew_False_UpdatesValue() + { + var wma = new Wma(10); + + wma.Update(new TValue(DateTime.UtcNow, 100)); + wma.Update(new TValue(DateTime.UtcNow, 110), isNew: true); + double beforeUpdate = wma.Value; + + wma.Update(new TValue(DateTime.UtcNow, 120), isNew: false); + double afterUpdate = wma.Value; + + // Update should change the value + Assert.NotEqual(beforeUpdate, afterUpdate); + } + + [Fact] + public void Wma_Reset_ClearsState() + { + var wma = new Wma(10); + + wma.Update(new TValue(DateTime.UtcNow, 100)); + wma.Update(new TValue(DateTime.UtcNow, 105)); + double valueBefore = wma.Value; + + wma.Reset(); + + Assert.Equal(0, wma.Value.Value); + + // After reset, should accept new values + wma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.NotEqual(0, wma.Value.Value); + Assert.NotEqual(valueBefore, wma.Value.Value); + } + + [Fact] + public void Wma_Properties_Accessible() + { + var wma = new Wma(10); + + Assert.Equal(0, wma.Value.Value); + Assert.False(wma.IsHot); + + wma.Update(new TValue(DateTime.UtcNow, 100)); + + Assert.NotEqual(0, wma.Value.Value); + } + + [Fact] + public void Wma_IsHot_BecomesTrueWhenBufferFull() + { + var wma = new Wma(5); + + Assert.False(wma.IsHot); + + for (int i = 1; i <= 4; i++) + { + wma.Update(new TValue(DateTime.UtcNow, i * 10)); + Assert.False(wma.IsHot); + } + + wma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.True(wma.IsHot); + } + + [Fact] + public void Wma_CalculatesCorrectWeightedAverage() + { + var wma = new Wma(5); + + wma.Update(new TValue(DateTime.UtcNow, 10)); + wma.Update(new TValue(DateTime.UtcNow, 20)); + wma.Update(new TValue(DateTime.UtcNow, 30)); + wma.Update(new TValue(DateTime.UtcNow, 40)); + wma.Update(new TValue(DateTime.UtcNow, 50)); + + // WMA(5) of 10,20,30,40,50 = (1*10 + 2*20 + 3*30 + 4*40 + 5*50) / 15 + // = (10 + 40 + 90 + 160 + 250) / 15 = 550 / 15 = 36.666... + Assert.Equal(550.0 / 15.0, wma.Value.Value, 1e-10); + } + + [Fact] + public void Wma_SlidingWindow_Works() + { + var wma = new Wma(3); + + wma.Update(new TValue(DateTime.UtcNow, 10)); + wma.Update(new TValue(DateTime.UtcNow, 20)); + wma.Update(new TValue(DateTime.UtcNow, 30)); + + // WMA(3) of 10,20,30 = (1*10 + 2*20 + 3*30) / 6 = (10 + 40 + 90) / 6 = 140/6 = 23.333... + Assert.Equal(140.0 / 6.0, wma.Value.Value, 1e-10); + + wma.Update(new TValue(DateTime.UtcNow, 40)); + + // WMA(3) of 20,30,40 = (1*20 + 2*30 + 3*40) / 6 = (20 + 60 + 120) / 6 = 200/6 = 33.333... + Assert.Equal(200.0 / 6.0, wma.Value.Value, 1e-10); + + wma.Update(new TValue(DateTime.UtcNow, 50)); + + // WMA(3) of 30,40,50 = (1*30 + 2*40 + 3*50) / 6 = (30 + 80 + 150) / 6 = 260/6 = 43.333... + Assert.Equal(260.0 / 6.0, wma.Value.Value, 1e-10); + } + + [Fact] + public void Wma_IterativeCorrections_RestoreToOriginalState() + { + var wma = new Wma(5); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Feed 10 new values + TValue tenthInput = default; + for (int i = 0; i < 10; i++) + { + var bar = gbm.Next(isNew: true); + tenthInput = new TValue(bar.Time, bar.Close); + wma.Update(tenthInput, isNew: true); + } + + // Remember WMA state after 10 values + double wmaAfterTen = wma.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + wma.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 10th input again with isNew=false + TValue finalWma = wma.Update(tenthInput, isNew: false); + + // WMA should match the original state after 10 values + Assert.Equal(wmaAfterTen, finalWma.Value, 1e-10); + } + + [Fact] + public void Wma_BatchCalc_MatchesIterativeCalc() + { + var wmaIterative = new Wma(10); + var wmaBatch = new Wma(10); + var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1); + + // Generate data + var series = new TSeries(); + for (int i = 0; i < 100; i++) + { + var bar = gbm.Next(isNew: true); + series.Add(bar.Time, bar.Close); + } + + Assert.True(series.Count > 0); + + // Calculate iteratively + var iterativeResults = new TSeries(); + foreach (var item in series) + { + iterativeResults.Add(wmaIterative.Update(item)); + } + + // Calculate batch + var batchResults = wmaBatch.Update(series); + + // Compare + Assert.Equal(iterativeResults.Count, batchResults.Count); + for (int i = 0; i < iterativeResults.Count; i++) + { + Assert.Equal(iterativeResults[i].Value, batchResults[i].Value, 1e-10); + Assert.Equal(iterativeResults[i].Time, batchResults[i].Time); + } + } + + [Fact] + public void Wma_Result_ImplicitConversionToDouble() + { + var wma = new Wma(10); + wma.Update(new TValue(DateTime.UtcNow, 100)); + + // This should compile and work because TValue has implicit conversion to double + double result = wma.Value; + + Assert.Equal(100.0, result, 1e-10); + } + + [Fact] + public void Wma_NaN_Input_UsesLastValidValue() + { + var wma = new Wma(5); + + // Feed some valid values + wma.Update(new TValue(DateTime.UtcNow, 100)); + wma.Update(new TValue(DateTime.UtcNow, 110)); + + // Feed NaN - should use last valid value (110) + var resultAfterNaN = wma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + // Result should be finite (not NaN) + Assert.True(double.IsFinite(resultAfterNaN.Value)); + Assert.NotEqual(0, resultAfterNaN.Value); + } + + [Fact] + public void Wma_Infinity_Input_UsesLastValidValue() + { + var wma = new Wma(5); + + // Feed some valid values + wma.Update(new TValue(DateTime.UtcNow, 100)); + wma.Update(new TValue(DateTime.UtcNow, 110)); + + // Feed positive infinity - should use last valid value + var resultAfterPosInf = wma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(resultAfterPosInf.Value)); + + // Feed negative infinity - should use last valid value + var resultAfterNegInf = wma.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); + Assert.True(double.IsFinite(resultAfterNegInf.Value)); + } + + [Fact] + public void Wma_MultipleNaN_ContinuesWithLastValid() + { + var wma = new Wma(5); + + // Feed valid values + wma.Update(new TValue(DateTime.UtcNow, 100)); + wma.Update(new TValue(DateTime.UtcNow, 110)); + wma.Update(new TValue(DateTime.UtcNow, 120)); + + // Feed multiple NaN values + var r1 = wma.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r2 = wma.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r3 = wma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + // All results should be finite + Assert.True(double.IsFinite(r1.Value)); + Assert.True(double.IsFinite(r2.Value)); + Assert.True(double.IsFinite(r3.Value)); + } + + [Fact] + public void Wma_BatchCalc_HandlesNaN() + { + var wma = new Wma(5); + + // Create series with NaN values interspersed + var series = new TSeries(); + series.Add(DateTime.UtcNow.Ticks, 100); + series.Add(DateTime.UtcNow.Ticks + 1, 110); + series.Add(DateTime.UtcNow.Ticks + 2, double.NaN); + series.Add(DateTime.UtcNow.Ticks + 3, 120); + series.Add(DateTime.UtcNow.Ticks + 4, double.PositiveInfinity); + series.Add(DateTime.UtcNow.Ticks + 5, 130); + + var results = wma.Update(series); + + // All results should be finite + foreach (var result in results) + { + Assert.True(double.IsFinite(result.Value), $"Expected finite value but got {result.Value}"); + } + } + + [Fact] + public void Wma_Reset_ClearsLastValidValue() + { + var wma = new Wma(5); + + // Feed values including NaN + wma.Update(new TValue(DateTime.UtcNow, 100)); + wma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + // Reset + wma.Reset(); + + // After reset, first valid value should establish new baseline + var result = wma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.Equal(50.0, result.Value, 1e-10); + } + + [Fact] + public void Wma_StaticCalculate_Works() + { + var series = new TSeries(); + series.Add(DateTime.UtcNow.Ticks, 10); + series.Add(DateTime.UtcNow.Ticks + 1, 20); + series.Add(DateTime.UtcNow.Ticks + 2, 30); + series.Add(DateTime.UtcNow.Ticks + 3, 40); + series.Add(DateTime.UtcNow.Ticks + 4, 50); + + var results = Wma.Calculate(series, 3); + + Assert.Equal(5, results.Count); + // WMA(3) for last 3 values [30,40,50]: (1*30 + 2*40 + 3*50) / 6 = 260/6 = 43.333... + Assert.Equal(260.0 / 6.0, results.Last.Value, 1e-10); + } + + [Fact] + public void Wma_Period1_ReturnsInputValues() + { + var wma = new Wma(1); + + Assert.Equal(100.0, wma.Update(new TValue(DateTime.UtcNow, 100)).Value, 1e-10); + Assert.Equal(200.0, wma.Update(new TValue(DateTime.UtcNow, 200)).Value, 1e-10); + Assert.Equal(150.0, wma.Update(new TValue(DateTime.UtcNow, 150)).Value, 1e-10); + } + + [Fact] + public void Wma_MoreWeightOnRecentValues() + { + var wma = new Wma(3); + var sma = new Sma(3); + + // Feed same values to both + wma.Update(new TValue(DateTime.UtcNow, 10)); + sma.Update(new TValue(DateTime.UtcNow, 10)); + wma.Update(new TValue(DateTime.UtcNow, 20)); + sma.Update(new TValue(DateTime.UtcNow, 20)); + wma.Update(new TValue(DateTime.UtcNow, 100)); // High recent value + sma.Update(new TValue(DateTime.UtcNow, 100)); + + // WMA should be higher than SMA because it weights the high recent value more + // SMA = (10 + 20 + 100) / 3 = 43.333... + // WMA = (1*10 + 2*20 + 3*100) / 6 = (10 + 40 + 300) / 6 = 58.333... + Assert.True(wma.Value.Value > sma.Value.Value); + Assert.Equal(350.0 / 6.0, wma.Value.Value, 1e-10); + Assert.Equal(130.0 / 3.0, sma.Value.Value, 1e-10); + } + + [Fact] + public void Wma_WarmupDivisor_CalculatedCorrectly() + { + var wma = new Wma(5); + + // First value: divisor = 1*(1+1)/2 = 1 + var r1 = wma.Update(new TValue(DateTime.UtcNow, 100)); + Assert.Equal(100.0, r1.Value, 1e-10); + + // Second value: divisor = 2*(2+1)/2 = 3, wsum = 1*100 + 2*200 = 500 + var r2 = wma.Update(new TValue(DateTime.UtcNow, 200)); + Assert.Equal(500.0 / 3.0, r2.Value, 1e-10); + + // Third value: divisor = 3*(3+1)/2 = 6, wsum = 1*100 + 2*200 + 3*300 = 1400 + var r3 = wma.Update(new TValue(DateTime.UtcNow, 300)); + Assert.Equal(1400.0 / 6.0, r3.Value, 1e-10); + } +} diff --git a/lib/averages/wma/Wma.Validation.Tests.cs b/lib/averages/wma/Wma.Validation.Tests.cs new file mode 100644 index 00000000..2f84f5d3 --- /dev/null +++ b/lib/averages/wma/Wma.Validation.Tests.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Skender.Stock.Indicators; +using TALib; +using Tulip; +using Xunit; +using Xunit.Abstractions; +using QuanTAlib; + +namespace QuanTAlib.Tests; + +public class WmaValidationTests +{ + private readonly TBarSeries _bars; + private readonly TSeries _data; + private readonly List _skenderQuotes; + private readonly ITestOutputHelper _output; + + public WmaValidationTests(ITestOutputHelper output) + { + _output = output; + + // 1. Generate 1000 records using GBM feed + var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2); + _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() + { + int[] periods = { 5, 10, 20, 50, 100 }; + + foreach (var period in periods) + { + // Calculate QuanTAlib WMA + var wma = new global::QuanTAlib.Wma(period); + var qResult = wma.Update(_data); + + // Calculate Skender WMA + var sResult = _skenderQuotes.GetWma(period).ToList(); + + // Compare last 100 records + VerifyData(qResult, sResult); + } + _output.WriteLine("WMA validated successfully against Skender"); + } + + [Fact] + public void Validate_Talib() + { + int[] periods = { 5, 10, 20, 50, 100 }; + + // Prepare data for TA-Lib (double[]) + double[] tData = _data.Select(x => x.Value).ToArray(); + double[] output = new double[tData.Length]; + + foreach (var period in periods) + { + // Calculate QuanTAlib WMA + var wma = new global::QuanTAlib.Wma(period); + var qResult = wma.Update(_data); + + // Calculate TA-Lib WMA + var retCode = TALib.Functions.Wma(tData, 0..^0, output, out var outRange, period); + + // Check success + Assert.Equal(Core.RetCode.Success, retCode); + + // TA-Lib skips the lookback period, so output[0] corresponds to input[lookback] + int lookback = TALib.Functions.WmaLookback(period); + + // Compare last 100 records + VerifyData_Talib(qResult, output, outRange, lookback); + } + _output.WriteLine("WMA validated successfully against TA-Lib"); + } + + [Fact] + public void Validate_Tulip() + { + int[] periods = { 5, 10, 20, 50, 100 }; + + // Prepare data for Tulip (double[]) + double[] tData = _data.Select(x => x.Value).ToArray(); + + foreach (var period in periods) + { + // Calculate QuanTAlib WMA + var wma = new global::QuanTAlib.Wma(period); + var qResult = wma.Update(_data); + + // Calculate Tulip WMA - Tulip returns fewer elements (skips lookback) + var wmaIndicator = Tulip.Indicators.wma; + double[][] inputs = { tData }; + double[] options = { (double)period }; + int lookback = period - 1; + double[][] outputs = { new double[tData.Length - lookback] }; + + wmaIndicator.Run(inputs, options, outputs); + var tResult = outputs[0]; + + // Compare last 100 records (accounting for lookback offset) + VerifyData_Tulip(qResult, tResult, lookback); + } + _output.WriteLine("WMA validated successfully against Tulip"); + } + + private static void VerifyData_Tulip(TSeries qSeries, double[] tOutput, int lookback) + { + int count = qSeries.Count; + int skip = count - 100; // Last 100 records + + for (int i = skip; i < count; i++) + { + double qValue = qSeries[i].Value; + + // Tulip skips lookback, so output[0] = input[lookback] + if (i < lookback) continue; + + int tIndex = i - lookback; + if (tIndex >= tOutput.Length) continue; + + double tValue = tOutput[tIndex]; + + // Assert equality with tolerance + Assert.Equal(tValue, qValue, 1e-6); + } + } + + private static void VerifyData(TSeries qSeries, List sSeries) + { + // Ensure we have enough data + Assert.Equal(qSeries.Count, sSeries.Count); + + int count = qSeries.Count; + int skip = count - 100; // Last 100 records + + for (int i = skip; i < count; i++) + { + double qValue = qSeries[i].Value; + double? sValue = sSeries[i].Wma; + + // Skip if Skender returns null (warmup period) + if (!sValue.HasValue) continue; + + // Assert equality with tolerance + Assert.Equal(sValue.Value, qValue, 1e-6); + } + } + + private static void VerifyData_Talib(TSeries qSeries, double[] tOutput, Range outRange, int lookback) + { + int count = qSeries.Count; + int skip = count - 100; // Last 100 records + + // outRange.End.Value is the number of elements written to tOutput + int validCount = outRange.End.Value - outRange.Start.Value; + + for (int i = skip; i < count; i++) + { + double qValue = qSeries[i].Value; + + // Calculate index in tOutput + // If i < lookback, we don't have a value from TA-Lib + if (i < lookback) continue; + + int tIndex = i - lookback; + + // Check if tIndex is within valid range + if (tIndex >= validCount) continue; + + double tValue = tOutput[tIndex]; + + // Assert equality with tolerance + Assert.Equal(tValue, qValue, 1e-6); + } + } +} diff --git a/lib/averages/wma/Wma.cs b/lib/averages/wma/Wma.cs new file mode 100644 index 00000000..fa455d98 --- /dev/null +++ b/lib/averages/wma/Wma.cs @@ -0,0 +1,265 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// WMA: Weighted Moving Average +/// +/// +/// WMA applies linear weighting to data points, giving more weight to recent values. +/// Uses dual running sums for O(1) complexity per update. +/// +/// Key characteristics: +/// - Linear weighting: newest value has weight n, oldest has weight 1 +/// - More responsive than SMA due to emphasis on recent data +/// - Less lag than SMA, but more than EMA +/// - O(1) time complexity for both update and bar correction +/// - O(1) space complexity for state save/restore (scalars only) +/// +/// Calculation method: +/// WMA = (n*P_n + (n-1)*P_(n-1) + ... + 2*P_2 + 1*P_1) / (n*(n+1)/2) +/// +/// O(1) update formula: +/// S_new = S - oldest + newest +/// W_new = W - S_old + n*newest +/// WMA = W_new / divisor +/// +/// Bar correction (isNew=false): +/// - Restores to state after last isNew=true +/// - Then replaces the last value with new correction value +/// - All O(1) using scalar state +/// +/// Sources: +/// - https://www.investopedia.com/terms/w/weightedaverage.asp +/// - https://school.stockcharts.com/doku.php?id=technical_indicators:weighted_moving_average +/// +[SkipLocalsInit] +public sealed class Wma +{ + private readonly int _period; + private readonly double _divisor; + private readonly RingBuffer _buffer; + + // Dual running sums for O(1) WMA calculation + private double _sum; // Simple sum of values in window + private double _wsum; // Weighted sum of values in window + private double _p_sum; // Sum AFTER last isNew=true (for correction restore) + private double _p_wsum; // Weighted sum AFTER last isNew=true + private double _p_lastInput; // Input that was added on last isNew=true + private double _lastValidValue; + private double _p_lastValidValue; + + /// + /// Display name for the indicator. + /// + public string Name { get; } + + /// + /// Number of data points needed for the indicator to become "hot". + /// + public int WarmupPeriod { get; } + + /// + /// Creates WMA with specified period. + /// + /// Number of values to average (must be > 0) + public Wma(int period) + { + if (period <= 0) + throw new ArgumentException("Period must be greater than 0", nameof(period)); + + _period = period; + _divisor = period * (period + 1) * 0.5; + _buffer = new RingBuffer(period); + Name = $"Wma({period})"; + WarmupPeriod = period; + } + + /// + /// Current WMA value. + /// + public TValue Value { get; private set; } + + /// + /// True if the WMA has enough data to produce valid results. + /// WMA is "hot" when the buffer is full (has received at least 'period' values). + /// + public bool IsHot => _buffer.IsFull; + + /// + /// Gets a valid input value, using last-value substitution for non-finite inputs. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double GetValidValue(double input) + { + if (double.IsFinite(input)) + { + _lastValidValue = input; + return input; + } + return _lastValidValue; + } + + /// + /// Updates WMA with the given value. + /// O(1) for both isNew=true and isNew=false. + /// + /// Input value + /// True for new bar, false for update to current bar (default: true) + /// Current WMA value + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue Update(TValue input, bool isNew = true) + { + if (isNew) + { + // Get valid value (this may update _lastValidValue) + double val = GetValidValue(input.Value); + + if (_buffer.IsFull) + { + // Buffer is full: O(1) update using dual running sums + double oldSum = _sum; // Capture before update + double oldest = _buffer.Oldest; + _sum = _sum - oldest + val; + _wsum = _wsum - oldSum + (_period * val); + } + else + { + // Warmup phase: incrementally build sums + int count = _buffer.Count + 1; + _sum += val; + _wsum += count * val; + } + + // Update buffer + _buffer.Add(val); + + // Save state AFTER this update for potential future corrections + _p_sum = _sum; + _p_wsum = _wsum; + _p_lastInput = val; + _p_lastValidValue = _lastValidValue; + } + else + { + // Bar correction: restore to state AFTER last isNew=true, then swap last value + // Restore _lastValidValue BEFORE calling GetValidValue + _lastValidValue = _p_lastValidValue; + + // Get valid value (this may update _lastValidValue) + double val = GetValidValue(input.Value); + + // Restore sums to state after last isNew=true + _sum = _p_sum; + _wsum = _p_wsum; + + // Correction: replace _p_lastInput with val + // S_corrected = S - lastInput + val + // W_corrected = W + weight*(val - lastInput), where weight = period (if full) or count (if warmup) + int weight = _buffer.IsFull ? _period : _buffer.Count; + _sum = _sum - _p_lastInput + val; + _wsum += weight * (val - _p_lastInput); + + // Update buffer's newest value + _buffer.UpdateNewest(val); + } + + // Calculate WMA using current divisor (handles warmup) + double currentDivisor = _buffer.IsFull ? _divisor : _buffer.Count * (_buffer.Count + 1) * 0.5; + double result = _wsum / currentDivisor; + Value = new TValue(input.Time, result); + return Value; + } + + /// + /// Updates WMA with the entire series. + /// + /// Input series + /// WMA series + public TSeries Update(TSeries source) + { + 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); + var sourceValues = source.Values; + var sourceTimes = source.Times; + + // Use local state for batch processing + var localBuffer = new RingBuffer(_period); + double localSum = 0; + double localWsum = 0; + + for (int i = 0; i < len; i++) + { + // Last-value substitution: replace non-finite inputs with last valid value + double val = GetValidValue(sourceValues[i]); + + if (localBuffer.IsFull) + { + // Buffer is full: O(1) update + double oldSum = localSum; + double oldest = localBuffer.Oldest; + localSum = localSum - oldest + val; + localWsum = localWsum - oldSum + (_period * val); + } + else + { + // Warmup phase + int count = localBuffer.Count + 1; + localSum += val; + localWsum += count * val; + } + + localBuffer.Add(val); + + tSpan[i] = sourceTimes[i]; + double currentDivisor = localBuffer.IsFull ? _divisor : localBuffer.Count * (localBuffer.Count + 1) * 0.5; + vSpan[i] = localWsum / currentDivisor; + } + + // Update instance state to the final state + _buffer.CopyFrom(localBuffer); + _sum = localSum; + _wsum = localWsum; + _p_sum = localSum; + _p_wsum = localWsum; + _p_lastInput = sourceValues[len - 1]; + + Value = new TValue(tSpan[len - 1], vSpan[len - 1]); + return new TSeries(t, v); + } + + /// + /// Calculates WMA for the entire series using a new instance. + /// + /// Input series + /// WMA period + /// WMA series + public static TSeries Calculate(TSeries source, int period) + { + var wma = new Wma(period); + return wma.Update(source); + } + + /// + /// Resets the WMA state. + /// + public void Reset() + { + _buffer.Clear(); + _sum = 0; + _wsum = 0; + _p_sum = 0; + _p_wsum = 0; + _p_lastInput = 0; + _lastValidValue = 0; + _p_lastValidValue = 0; + Value = default; + } +} diff --git a/lib/averages/wma/Wma.md b/lib/averages/wma/Wma.md new file mode 100644 index 00000000..fef235de --- /dev/null +++ b/lib/averages/wma/Wma.md @@ -0,0 +1,220 @@ +# WMA: Weighted Moving Average + +[Pine Script Implementation of WMA](https://github.com/mihakralj/pinescript/blob/main/indicators/trends_FIR/wma.pine) + +## Overview and Purpose + +The Weighted Moving Average (WMA) is a technical indicator that applies progressively increasing weights to more recent price data. Emerging in the early 1950s during the formative years of technical analysis, WMA gained significant adoption among professional traders through the 1970s as computational methods became more accessible. The approach was formalized in Robert Colby's 1988 "Encyclopedia of Technical Market Indicators," establishing it as a staple in technical analysis software. Unlike the Simple Moving Average (SMA) which gives equal weight to all prices, WMA assigns greater importance to recent prices, creating a more responsive indicator that reacts faster to price changes while still providing effective noise filtering. + +## Core Concepts + +* **Linear weighting:** WMA applies progressively increasing weights to more recent price data, creating a recency bias that improves responsiveness +* **Market application:** Particularly effective for identifying trend changes earlier than SMA while maintaining better noise filtering than faster-responding averages like EMA +* **Timeframe flexibility:** Works effectively across all timeframes, with appropriate period adjustments for different trading horizons +* **O(1) complexity:** This implementation uses a dual running sum technique for constant-time updates regardless of period + +The core innovation of WMA is its linear weighting scheme, which strikes a balance between the equal-weight approach of SMA and the exponential decay of EMA. This creates an intuitive and effective compromise that prioritizes recent data while maintaining a finite lookback period, making it particularly valuable for traders seeking to reduce lag without excessive sensitivity to price fluctuations. + +## Common Settings and Parameters + +| Parameter | Default | Function | When to Adjust | +|-----------|---------|----------|---------------| +| Period | 14 | Controls the lookback period | Increase for smoother signals in volatile markets, decrease for responsiveness | +| Source | Close | Price data used for calculation | Consider using HLC3 for a more balanced price representation | + +**Pro Tip:** For most trading applications, using a WMA with period N provides better responsiveness than an SMA with the same period, while generating fewer whipsaws than an EMA with comparable responsiveness. + +## Calculation and Mathematical Foundation + +**Simplified explanation:** +WMA calculates a weighted average of prices where the most recent price receives the highest weight, and each progressively older price receives one unit less weight. For example, in a 5-period WMA, the most recent price gets a weight of 5, the next most recent a weight of 4, and so on, with the oldest price getting a weight of 1. + +**Technical formula:** +$$WMA = \frac{\sum_{i=1}^{n} w_i \cdot P_i}{\sum_{i=1}^{n} w_i} = \frac{n \cdot P_n + (n-1) \cdot P_{n-1} + \ldots + 1 \cdot P_1}{\frac{n(n+1)}{2}}$$ + +Where: + +* $n$ is the period length +* $P_i$ is the price at position $i$ (oldest to newest) +* $w_i = i$ (linear weights from 1 to n) +* Divisor $= \frac{n(n+1)}{2}$ (sum of weights 1 through n) + +**O(1) Optimization - Dual Running Sums:** + +This implementation uses an advanced O(1) algorithm that eliminates the need to loop through all period values on each bar. The key insight is maintaining two running sums: + +1. **Unweighted sum (S)**: Simple sum of all values in the window +2. **Weighted sum (W)**: Sum of all weighted values + +The recurrence relation for a full window is: +$$S_{new} = S - P_{oldest} + P_{new}$$ +$$W_{new} = W - S_{old} + n \cdot P_{new}$$ +$$WMA = \frac{W_{new}}{divisor}$$ + +This works because when all weights decrement by 1 (as the window slides), it's mathematically equivalent to subtracting the entire unweighted sum. The implementation: + +* **During warmup**: Accumulates both sums as the window fills, computing denominator each bar +* **After warmup**: Uses cached denominator (constant at $\frac{n(n+1)}{2}$), updates both sums in constant time +* **Performance**: ~8 operations per bar regardless of period, vs ~100+ for naive O(n) implementation + +> 🔍 **Technical Note:** Unlike EMA which theoretically considers all historical data (with diminishing influence), WMA has a finite memory, completely dropping prices that fall outside its lookback window. This creates a cleaner break from outdated market conditions. The O(1) optimization achieves 12-25x speedup over naive implementations while maintaining exact mathematical equivalence. + +## C# Implementation + +The library provides two implementations: a standard scalar version and a multi-period vector version for calculating multiple WMAs simultaneously. + +### Single WMA (`Wma`) + +The `Wma` class calculates a single weighted moving average with O(1) update complexity. + +```csharp +using QuanTAlib; + +// Initialize with period 10 +var wma = new Wma(10); + +// Streaming update +TValue result = wma.Update(new TValue(time, price)); +Console.WriteLine($"Current WMA: {result.Value}"); + +// Access properties +Console.WriteLine($"Name: {wma.Name}"); // "Wma(10)" +Console.WriteLine($"WarmupPeriod: {wma.WarmupPeriod}"); // 10 +Console.WriteLine($"IsHot: {wma.IsHot}"); // true when buffer is full + +// Batch calculation +TSeries source = ...; +TSeries results = Wma.Calculate(source, 10); +``` + +### Multi-Period WMA (`WmaVector`) + +The `WmaVector` class calculates multiple WMAs with different periods on the same input series simultaneously. + +```csharp +using QuanTAlib; + +// Initialize with multiple periods +int[] periods = { 5, 10, 20 }; +var wmaVector = new WmaVector(periods); + +// Streaming update +TValue[] results = wmaVector.Update(new TValue(time, price)); + +// Access values +Console.WriteLine($"WMA(5): {results[0].Value}"); +Console.WriteLine($"WMA(10): {results[1].Value}"); +Console.WriteLine($"WMA(20): {results[2].Value}"); + +// Batch calculation +TSeries source = ...; +TSeries[] seriesResults = wmaVector.Calculate(source); +``` + +### Bar Correction (isNew Parameter) + +Both `Wma` and `WmaVector` support intra-bar updates for real-time trading systems: + +```csharp +var wma = new Wma(10); + +// Process historical bars +for (int i = 0; i < historicalBars.Count; i++) +{ + wma.Update(historicalBars[i], isNew: true); +} + +// Real-time: receive initial tick for new bar +wma.Update(new TValue(time, 100.5), isNew: true); + +// Real-time: price updates within same bar +wma.Update(new TValue(time, 101.0), isNew: false); // O(1) correction +wma.Update(new TValue(time, 100.8), isNew: false); // O(1) correction + +// Bar closes, next bar starts +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. + +### Handling Invalid Values (NaN/Infinity) + +Both `Wma` and `WmaVector` use **last-value substitution** for handling invalid inputs: + +```csharp +var wma = new Wma(10); + +// Valid values establish baseline +wma.Update(new TValue(time, 100)); +wma.Update(new TValue(time, 110)); + +// NaN or Infinity inputs are replaced with last valid value (110) +var result = wma.Update(new TValue(time, double.NaN)); +Console.WriteLine(double.IsFinite(result.Value)); // true + +// Works identically for batch operations +var series = new TSeries(); +series.Add(time, 100); +series.Add(time + 1, double.NaN); // Will use 100 +series.Add(time + 2, 120); +var results = wma.Update(series); // All values are finite +``` + +**Behavior:** + +* When `NaN`, `PositiveInfinity`, or `NegativeInfinity` is encountered, the last valid value is substituted +* This provides output continuity instead of propagating invalid values +* `Reset()` clears the last valid value, so the next valid input establishes a new baseline + +### Performance Characteristics + +| Operation | Complexity | Notes | +|-----------|------------|-------| +| Update (isNew=true) | O(1) | Dual running sums: `S = S - oldest + new; W = W - S_old + n*new` | +| Update (isNew=false) | O(1) | Scalar state restore + recalculate | +| Batch processing | O(n) | Where n is series length | +| Memory (single) | O(period) | One RingBuffer for values | +| Memory (state) | O(1) | 7 doubles for bar correction | + +The implementation uses: + +* **Dual running sums** for O(1) weighted average calculation +* **Scalar state save/restore** for O(1) bar correction +* **Pinned memory** in RingBuffer for cache-friendly access +* **CollectionsMarshal.SetCount** for zero-allocation batch processing + +## Interpretation Details + +WMA can be used in various trading strategies: + +* **Trend identification:** The direction of WMA indicates the prevailing trend with greater responsiveness than SMA +* **Signal generation:** Crossovers between price and WMA generate trade signals earlier than with SMA +* **Support/resistance levels:** WMA can act as dynamic support during uptrends and resistance during downtrends +* **Moving average crossovers:** When a shorter-period WMA crosses above a longer-period WMA, it signals a potential uptrend (and vice versa) +* **Trend strength assessment:** Distance between price and WMA can indicate trend strength + +### WMA vs SMA vs EMA Comparison + +| Aspect | WMA | SMA | EMA | +|--------|-----|-----|-----| +| Weighting | Linear (n, n-1, ..., 1) | Equal for all values | Exponential decay | +| Lag | Medium | Highest | Lowest | +| Sensitivity | Medium | Low | High | +| Noise filtering | Good | Best | Medium | +| Memory required | O(period) buffer | O(period) buffer | O(1) - no buffer | +| Window behavior | Finite, clean cutoff | Finite, abrupt exit | Infinite, gradual decay | +| Best use | Balanced responsiveness, crossover systems | Long-term trends, support/resistance | Short-term signals, momentum | + +## Limitations and Considerations + +* **Market conditions:** Still suboptimal in highly volatile or sideways markets where enhanced responsiveness may generate false signals +* **Lag factor:** While less than SMA, still introduces some lag in signal generation +* **Abrupt window exit:** The oldest price suddenly drops out of calculation when leaving the window, potentially causing small jumps +* **Step changes:** Linear weighting creates discrete steps in influence rather than a smooth decay +* **Complementary tools:** Best used with volume indicators and momentum oscillators for confirmation + +## References + +* Colby, Robert W. "The Encyclopedia of Technical Market Indicators." McGraw-Hill, 2002 +* Murphy, John J. "Technical Analysis of the Financial Markets." New York Institute of Finance, 1999 +* Kaufman, Perry J. "Trading Systems and Methods." Wiley, 2013 diff --git a/lib/averages/wma/WmaVector.Tests.cs b/lib/averages/wma/WmaVector.Tests.cs new file mode 100644 index 00000000..2cb70aaf --- /dev/null +++ b/lib/averages/wma/WmaVector.Tests.cs @@ -0,0 +1,410 @@ +using System.Linq; +using Xunit; +using QuanTAlib; + +namespace QuanTAlib.Tests; + +public class WmaVectorTests +{ + [Fact] + public void Initialization_WithPeriods_Works() + { + int[] periods = { 5, 10, 20 }; + var wmaVector = new WmaVector(periods); + + var res = wmaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + + Assert.Equal(3, res.Length); + Assert.Equal(100.0, res[0].Value, 1e-9); + Assert.Equal(100.0, res[1].Value, 1e-9); + Assert.Equal(100.0, res[2].Value, 1e-9); + } + + [Fact] + public void Initialization_WithZeroPeriod_ThrowsArgumentException() + { + int[] periods = { 10, 0, 20 }; + + Assert.Throws(() => new WmaVector(periods)); + } + + [Fact] + public void Initialization_WithNegativePeriod_ThrowsArgumentException() + { + int[] periods = { 10, -5, 20 }; + + Assert.Throws(() => new WmaVector(periods)); + } + + [Fact] + public void Calc_Streaming_MatchesSingleWma() + { + int[] periods = { 5, 10, 20 }; + var wmaVector = new WmaVector(periods); + var wmaSingles = periods.Select(p => new Wma(p)).ToArray(); + + var values = new double[] { 10, 20, 30, 40, 50, 40, 30, 20, 10 }; + var time = DateTime.UtcNow; + + foreach (var val in values) + { + var tVal = new TValue(time, val); + var multiRes = wmaVector.Update(tVal); + + for (int i = 0; i < periods.Length; i++) + { + var singleRes = wmaSingles[i].Update(tVal); + Assert.Equal(singleRes.Value, multiRes[i].Value, 1e-9); + Assert.Equal(singleRes.Time, multiRes[i].Time); + } + + time = time.AddMinutes(1); + } + } + + [Fact] + public void Calc_Series_MatchesSingleWma() + { + int[] periods = { 5, 10, 20 }; + var wmaVector = new WmaVector(periods); + + int len = 100; + var t = new System.Collections.Generic.List(len); + var v = new System.Collections.Generic.List(len); + var now = DateTime.UtcNow; + + for (int i = 0; i < len; i++) + { + t.Add(now.AddMinutes(i).Ticks); + v.Add(Math.Sin(i * 0.1) * 100); + } + + var series = new TSeries(t, v); + + var multiRes = wmaVector.Calculate(series); + + // Reset and recalculate for comparison + var wmaSingles = periods.Select(p => new Wma(p)).ToArray(); + for (int j = 0; j < len; j++) + { + var tVal = new TValue(new DateTime(t[j], DateTimeKind.Utc), v[j]); + for (int i = 0; i < periods.Length; i++) + { + var singleRes = wmaSingles[i].Update(tVal); + Assert.Equal(singleRes.Value, multiRes[i].Values[j], 1e-8); + } + } + } + + [Fact] + public void Calc_Series_MatchesStreaming() + { + int[] periods = { 5, 10, 20 }; + var wmaVectorBatch = new WmaVector(periods); + var wmaVectorStream = new WmaVector(periods); + + int len = 100; + var t = new System.Collections.Generic.List(len); + var v = new System.Collections.Generic.List(len); + var now = DateTime.UtcNow; + + for (int i = 0; i < len; i++) + { + t.Add(now.AddMinutes(i).Ticks); + v.Add(Math.Sin(i * 0.1) * 100); + } + + var series = new TSeries(t, v); + + var batchRes = wmaVectorBatch.Calculate(series); + + for (int i = 0; i < len; i++) + { + var tVal = new TValue(new DateTime(t[i], DateTimeKind.Utc), v[i]); + var streamRes = wmaVectorStream.Update(tVal); + + for (int j = 0; j < periods.Length; j++) + { + Assert.Equal(batchRes[j].Values[i], streamRes[j].Value, 1e-9); + } + } + } + + [Fact] + public void Calculate_Static_MatchesInstanceMethod() + { + int[] periods = { 5, 10, 20 }; + + int len = 50; + var t = new System.Collections.Generic.List(len); + var v = new System.Collections.Generic.List(len); + var now = DateTime.UtcNow; + + for (int i = 0; i < len; i++) + { + t.Add(now.AddMinutes(i).Ticks); + v.Add(Math.Sin(i * 0.1) * 100); + } + + var series = new TSeries(t, v); + + var instanceWma = new WmaVector(periods); + var instanceRes = instanceWma.Calculate(series); + + var staticRes = WmaVector.Calculate(series, periods); + + for (int i = 0; i < periods.Length; i++) + { + Assert.Equal(instanceRes[i].Count, staticRes[i].Count); + for (int j = 0; j < len; j++) + { + Assert.Equal(instanceRes[i].Values[j], staticRes[i].Values[j], 1e-9); + } + } + } + + [Fact] + public void Reset_ClearsState() + { + int[] periods = { 10 }; + var wmaVector = new WmaVector(periods); + + wmaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + wmaVector.Update(new TValue(DateTime.UtcNow, 200.0)); + wmaVector.Reset(); + + var res = wmaVector.Update(new TValue(DateTime.UtcNow, 50.0)); + + Assert.Equal(50.0, res[0].Value, 1e-9); + } + + [Fact] + public void Update_NaN_Input_UsesLastValidValue() + { + int[] periods = { 10, 20 }; + var wmaVector = new WmaVector(periods); + + wmaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + wmaVector.Update(new TValue(DateTime.UtcNow, 110.0)); + + var resultAfterNaN = wmaVector.Update(new TValue(DateTime.UtcNow, double.NaN)); + + foreach (var result in resultAfterNaN) + { + Assert.True(double.IsFinite(result.Value), $"Expected finite value but got {result.Value}"); + } + } + + [Fact] + public void Update_Infinity_Input_UsesLastValidValue() + { + int[] periods = { 10, 20 }; + var wmaVector = new WmaVector(periods); + + wmaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + wmaVector.Update(new TValue(DateTime.UtcNow, 110.0)); + + var resultAfterPosInf = wmaVector.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + foreach (var result in resultAfterPosInf) + { + Assert.True(double.IsFinite(result.Value)); + } + + var resultAfterNegInf = wmaVector.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); + foreach (var result in resultAfterNegInf) + { + Assert.True(double.IsFinite(result.Value)); + } + } + + [Fact] + public void Update_MultipleNaN_ContinuesWithLastValid() + { + int[] periods = { 5, 10 }; + var wmaVector = new WmaVector(periods); + + wmaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + wmaVector.Update(new TValue(DateTime.UtcNow, 110.0)); + wmaVector.Update(new TValue(DateTime.UtcNow, 120.0)); + + var r1 = wmaVector.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r2 = wmaVector.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r3 = wmaVector.Update(new TValue(DateTime.UtcNow, double.NaN)); + + foreach (var result in r1) Assert.True(double.IsFinite(result.Value)); + foreach (var result in r2) Assert.True(double.IsFinite(result.Value)); + foreach (var result in r3) Assert.True(double.IsFinite(result.Value)); + } + + [Fact] + public void Calculate_Series_HandlesNaN() + { + int[] periods = { 5, 10 }; + var wmaVector = new WmaVector(periods); + + var t = new System.Collections.Generic.List(); + var v = new System.Collections.Generic.List(); + var now = DateTime.UtcNow; + + t.Add(now.Ticks); v.Add(100.0); + t.Add(now.AddMinutes(1).Ticks); v.Add(110.0); + t.Add(now.AddMinutes(2).Ticks); v.Add(double.NaN); + t.Add(now.AddMinutes(3).Ticks); v.Add(120.0); + t.Add(now.AddMinutes(4).Ticks); v.Add(double.PositiveInfinity); + t.Add(now.AddMinutes(5).Ticks); v.Add(130.0); + + var series = new TSeries(t, v); + var results = wmaVector.Calculate(series); + + foreach (var periodResults in results) + { + foreach (var val in periodResults.Values) + { + Assert.True(double.IsFinite(val), $"Expected finite value but got {val}"); + } + } + } + + [Fact] + public void Reset_ClearsLastValidValue() + { + int[] periods = { 10 }; + var wmaVector = new WmaVector(periods); + + wmaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + wmaVector.Update(new TValue(DateTime.UtcNow, double.NaN)); + + wmaVector.Reset(); + + var result = wmaVector.Update(new TValue(DateTime.UtcNow, 50.0)); + Assert.Equal(50.0, result[0].Value, 1e-9); + } + + [Fact] + public void NaN_Handling_MatchesSingleWma() + { + int[] periods = { 5, 10, 20 }; + var wmaVector = new WmaVector(periods); + var wmaSingles = periods.Select(p => new Wma(p)).ToArray(); + + var values = new double[] { 10, 20, double.NaN, 40, double.PositiveInfinity, 60, 70 }; + var time = DateTime.UtcNow; + + foreach (var val in values) + { + var tVal = new TValue(time, val); + var multiRes = wmaVector.Update(tVal); + + for (int i = 0; i < periods.Length; i++) + { + var singleRes = wmaSingles[i].Update(tVal); + Assert.Equal(singleRes.Value, multiRes[i].Value, 1e-9); + } + + time = time.AddMinutes(1); + } + } + + [Fact] + public void Values_Property_UpdatesAfterUpdate() + { + int[] periods = { 5, 10 }; + var wmaVector = new WmaVector(periods); + + var result = wmaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + + Assert.Equal(result[0].Value, wmaVector.Values[0].Value); + Assert.Equal(result[1].Value, wmaVector.Values[1].Value); + } + + [Fact] + public void Values_Property_UpdatesAfterCalculate() + { + int[] periods = { 5, 10 }; + var wmaVector = new WmaVector(periods); + + var t = new System.Collections.Generic.List { 100, 200, 300 }; + var v = new System.Collections.Generic.List { 10.0, 20.0, 30.0 }; + var series = new TSeries(t, v); + + var results = wmaVector.Calculate(series); + + Assert.Equal(results[0].Last.Value, wmaVector.Values[0].Value, 1e-9); + Assert.Equal(results[1].Last.Value, wmaVector.Values[1].Value, 1e-9); + } + + [Fact] + public void Update_BarCorrection_WorksCorrectly() + { + int[] periods = { 3 }; + var wmaVector = new WmaVector(periods); + + wmaVector.Update(new TValue(DateTime.UtcNow, 10.0), isNew: true); + wmaVector.Update(new TValue(DateTime.UtcNow, 20.0), isNew: true); + wmaVector.Update(new TValue(DateTime.UtcNow, 30.0), isNew: true); + + // WMA(3) of 10,20,30 = (1*10 + 2*20 + 3*30) / 6 = 140/6 = 23.333... + var res1 = wmaVector.Values[0].Value; + Assert.Equal(140.0 / 6.0, res1, 1e-9); + + // Correct the last bar to 60 + var res2 = wmaVector.Update(new TValue(DateTime.UtcNow, 60.0), isNew: false); + + // WMA(3) of 10,20,60 = (1*10 + 2*20 + 3*60) / 6 = (10 + 40 + 180) / 6 = 230/6 = 38.333... + Assert.Equal(230.0 / 6.0, res2[0].Value, 1e-9); + } + + [Fact] + public void WMA_MatchesExpectedValues() + { + int[] periods = { 3 }; + var wmaVector = new WmaVector(periods); + + // Test sequence: 10, 20, 30, 40, 50 + // WMA(3) weights: [1, 2, 3], divisor = 6 + // Bar 1: 10 (only value) = 10 + // Bar 2: (1*10 + 2*20) / 3 = 50/3 = 16.666... + // Bar 3: (1*10 + 2*20 + 3*30) / 6 = 140/6 = 23.333... + // Bar 4: (1*20 + 2*30 + 3*40) / 6 = 200/6 = 33.333... + // Bar 5: (1*30 + 2*40 + 3*50) / 6 = 260/6 = 43.333... + double[] expected = [10.0, 50.0/3.0, 140.0/6.0, 200.0/6.0, 260.0/6.0]; + var values = new double[] { 10, 20, 30, 40, 50 }; + var time = DateTime.UtcNow; + + for (int i = 0; i < values.Length; i++) + { + var res = wmaVector.Update(new TValue(time, values[i])); + Assert.Equal(expected[i], res[0].Value, 1e-9); + time = time.AddMinutes(1); + } + } + + [Fact] + public void WMA_MoreWeightOnRecentValues() + { + int[] periods = { 3 }; + var wmaVector = new WmaVector(periods); + var smaVector = new SmaVector(periods); + + var values = new double[] { 10, 20, 100 }; // High recent value + var time = DateTime.UtcNow; + + TValue[] wmaRes = null!; + TValue[] smaRes = null!; + + foreach (var val in values) + { + var tVal = new TValue(time, val); + wmaRes = wmaVector.Update(tVal); + smaRes = smaVector.Update(tVal); + time = time.AddMinutes(1); + } + + // WMA should be higher than SMA because it weights the high recent value more + // SMA = (10 + 20 + 100) / 3 = 43.333... + // WMA = (1*10 + 2*20 + 3*100) / 6 = (10 + 40 + 300) / 6 = 58.333... + Assert.True(wmaRes[0].Value > smaRes[0].Value); + Assert.Equal(350.0 / 6.0, wmaRes[0].Value, 1e-9); + Assert.Equal(130.0 / 3.0, smaRes[0].Value, 1e-9); + } +} diff --git a/lib/averages/wma/WmaVector.cs b/lib/averages/wma/WmaVector.cs new file mode 100644 index 00000000..72d8b480 --- /dev/null +++ b/lib/averages/wma/WmaVector.cs @@ -0,0 +1,264 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// Multi-Period Weighted Moving Average (WMA) - O(1) optimized per period. +/// Calculates multiple WMAs with different periods for the same input series. +/// Uses dual running sums for O(1) complexity per update per period. +/// Uses last-value substitution for invalid inputs (NaN/Infinity). +/// +[SkipLocalsInit] +public class WmaVector +{ + private readonly int[] _periods; + private readonly double[] _divisors; + private readonly RingBuffer[] _buffers; + private readonly double[] _sums; // Simple sums for each period + private readonly double[] _wsums; // Weighted sums for each period + private readonly double[] _p_sums; // Saved simple sums for bar correction + private readonly double[] _p_wsums; // Saved weighted sums for bar correction + private readonly double[] _p_lastInputs; // Last inputs for bar correction + private readonly int _count; + private double _lastValidValue; + private double _p_lastValidValue; + + /// + /// Current WMA values for all periods. + /// + public ReadOnlySpan Values => _values; + + private readonly TValue[] _values; + + /// + /// Initializes WmaVector with specified periods. + /// + /// Array of periods (each must be > 0) + public WmaVector(int[] periods) + { + _count = periods.Length; + _periods = new int[_count]; + _divisors = new double[_count]; + _buffers = new RingBuffer[_count]; + _sums = new double[_count]; + _wsums = new double[_count]; + _p_sums = new double[_count]; + _p_wsums = new double[_count]; + _p_lastInputs = new double[_count]; + _values = new TValue[_count]; + + for (int i = 0; i < _count; i++) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(periods[i], 0); + _periods[i] = periods[i]; + _divisors[i] = periods[i] * (periods[i] + 1) * 0.5; + _buffers[i] = new RingBuffer(periods[i]); + } + } + + /// + /// Gets a valid input value, using last-value substitution for non-finite inputs. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double GetValidValue(double input) + { + if (double.IsFinite(input)) + { + _lastValidValue = input; + return input; + } + return _lastValidValue; + } + + /// + /// Resets all WMA states. + /// + public void Reset() + { + for (int i = 0; i < _count; i++) + { + _buffers[i].Clear(); + _sums[i] = 0; + _wsums[i] = 0; + _p_sums[i] = 0; + _p_wsums[i] = 0; + _p_lastInputs[i] = 0; + } + _lastValidValue = 0; + _p_lastValidValue = 0; + Array.Clear(_values); + } + + /// + /// Updates WMAs with the given value. + /// Uses last-value substitution: invalid inputs (NaN/Infinity) are replaced with + /// the last known good value, providing continuity in the output series. + /// O(1) complexity per period using dual running sums. + /// + /// Input value + /// True for new bar, false for update to current bar (default: true) + /// Array of WMA values + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue[] Update(TValue input, bool isNew = true) + { + if (isNew) + { + // Get valid value (this may update _lastValidValue) + double val = GetValidValue(input.Value); + + for (int i = 0; i < _count; i++) + { + int period = _periods[i]; + var buffer = _buffers[i]; + + if (buffer.IsFull) + { + // Buffer is full: O(1) update using dual running sums + double oldSum = _sums[i]; + double oldest = buffer.Oldest; + _sums[i] = _sums[i] - oldest + val; + _wsums[i] = _wsums[i] - oldSum + (period * val); + } + else + { + // Warmup phase: incrementally build sums + int count = buffer.Count + 1; + _sums[i] += val; + _wsums[i] += count * val; + } + + buffer.Add(val); + + // Save state AFTER this update for potential future corrections + _p_sums[i] = _sums[i]; + _p_wsums[i] = _wsums[i]; + _p_lastInputs[i] = val; + + // Calculate WMA + double currentDivisor = buffer.IsFull ? _divisors[i] : buffer.Count * (buffer.Count + 1) * 0.5; + _values[i] = new TValue(input.Time, _wsums[i] / currentDivisor); + } + + _p_lastValidValue = _lastValidValue; + } + else + { + // Bar correction: restore to state AFTER last isNew=true, then swap last value + _lastValidValue = _p_lastValidValue; + double val = GetValidValue(input.Value); + + for (int i = 0; i < _count; i++) + { + int period = _periods[i]; + var buffer = _buffers[i]; + + // Restore sums to state after last isNew=true + _sums[i] = _p_sums[i]; + _wsums[i] = _p_wsums[i]; + + // Correction: replace _p_lastInputs[i] with val + int weight = buffer.IsFull ? period : buffer.Count; + _sums[i] = _sums[i] - _p_lastInputs[i] + val; + _wsums[i] += weight * (val - _p_lastInputs[i]); + + buffer.UpdateNewest(val); + + // Calculate WMA + double currentDivisor = buffer.IsFull ? _divisors[i] : buffer.Count * (buffer.Count + 1) * 0.5; + _values[i] = new TValue(input.Time, _wsums[i] / currentDivisor); + } + } + + return _values; + } + + /// + /// Calculates WMAs for the entire series. + /// + /// Input series + /// Array of WMA series + public TSeries[] Calculate(TSeries source) + { + int len = source.Count; + var resultSeries = new TSeries[_count]; + + // Reset state for fresh calculation + Reset(); + + // Pre-allocate lists + var tLists = new List[_count]; + var vLists = new List[_count]; + + for (int i = 0; i < _count; i++) + { + tLists[i] = new List(len); + vLists[i] = new List(len); + CollectionsMarshal.SetCount(tLists[i], len); + CollectionsMarshal.SetCount(vLists[i], len); + } + + var sourceValues = source.Values; + var sourceTimes = source.Times; + + for (int t = 0; t < len; t++) + { + double val = sourceValues[t]; + long time = sourceTimes[t]; + + // Last-value substitution: replace non-finite inputs with last valid value + val = GetValidValue(val); + + for (int i = 0; i < _count; i++) + { + int period = _periods[i]; + var buffer = _buffers[i]; + + if (buffer.IsFull) + { + // Buffer is full: O(1) update + double oldSum = _sums[i]; + double oldest = buffer.Oldest; + _sums[i] = _sums[i] - oldest + val; + _wsums[i] = _wsums[i] - oldSum + (period * val); + } + else + { + // Warmup phase + int count = buffer.Count + 1; + _sums[i] += val; + _wsums[i] += count * val; + } + + buffer.Add(val); + + CollectionsMarshal.AsSpan(tLists[i])[t] = time; + double currentDivisor = buffer.IsFull ? _divisors[i] : buffer.Count * (buffer.Count + 1) * 0.5; + CollectionsMarshal.AsSpan(vLists[i])[t] = _wsums[i] / currentDivisor; + } + } + + // Create TSeries and update Values + for (int i = 0; i < _count; i++) + { + resultSeries[i] = new TSeries(tLists[i], vLists[i]); + var lastT = CollectionsMarshal.AsSpan(tLists[i])[len - 1]; + var lastV = CollectionsMarshal.AsSpan(vLists[i])[len - 1]; + _values[i] = new TValue(lastT, lastV); + } + + return resultSeries; + } + + /// + /// Calculates WMAs for the entire series using specified periods. + /// + /// Input series + /// Array of periods + /// Array of WMA series + public static TSeries[] Calculate(TSeries source, int[] periods) + { + var wmaVector = new WmaVector(periods); + return wmaVector.Calculate(source); + } +}