diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ade320b3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig for QuanTAlib +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.cs] +# Suppress SonarQube S3776 - Cognitive Complexity +# High-performance SIMD code intentionally has complex control flow +dotnet_diagnostic.S3776.severity = none diff --git a/lib/averages/ema/Ema.md b/lib/averages/ema/Ema.md index e3b11ddd..2fcf4795 100644 --- a/lib/averages/ema/Ema.md +++ b/lib/averages/ema/Ema.md @@ -1,7 +1,5 @@ # EMA: Exponential Moving Average -[Pine Script Implementation of EMA](https://github.com/mihakralj/pinescript/blob/main/indicators/trends_IIR/ema.pine) - ## Overview and Purpose The Exponential Moving Average (EMA) is a fundamental technical indicator that calculates the average price over a specific period while giving more weight to recent price data. Introduced in the 1950s, EMA has become one of the most widely used technical indicators in financial markets due to its balance of responsiveness and stability. @@ -127,10 +125,11 @@ var results = ema.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 -- Both scalar (`Ema`) and SIMD (`EmaVector`) implementations use identical logic -- `Reset()` clears the last valid value, so the next valid input establishes a new baseline + +* When `NaN`, `PositiveInfinity`, or `NegativeInfinity` is encountered, the last valid value is substituted +* This provides output continuity instead of propagating invalid values +* Both scalar (`Ema`) and SIMD (`EmaVector`) implementations use identical logic +* `Reset()` clears the last valid value, so the next valid input establishes a new baseline ### Performance Characteristics diff --git a/lib/averages/sma/Sma.Notebook.dib b/lib/averages/sma/Sma.Notebook.dib new file mode 100644 index 00000000..fd4ff04d --- /dev/null +++ b/lib/averages/sma/Sma.Notebook.dib @@ -0,0 +1,359 @@ +#!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. **Vectorized Operations**: Calculating multiple SMAs simultaneously. + +#!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. Vectorized SMA (Multiple Periods) + +`SmaVector` allows calculating multiple SMAs (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 SMA (Periods: {string.Join(", ", periods)}) ---"); + +var smaVectorBatch = new SmaVector(periods); +var vectorBatchResults = smaVectorBatch.Calculate(closeSeries); + +for (int i = 0; i < periods.Length; i++) +{ + Console.WriteLine($"SMA({periods[i]}) Last Value: {vectorBatchResults[i].Last().Value:F2}"); +} + +#!markdown + +### Vectorized Streaming + +#!csharp + +Console.WriteLine($"\n--- Vectorized Streaming SMA (Periods: {string.Join(", ", periods)}) ---"); + +var smaVectorStream = new SmaVector(periods); +TValue[] lastVectorVal = null; + +foreach(var item in closeSeries) +{ + lastVectorVal = smaVectorStream.Update(item); +} + +for (int i = 0; i < periods.Length; i++) +{ + Console.WriteLine($"SMA({periods[i]}) Last Value: {lastVectorVal[i].Value:F2}"); +} + +// 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 `Sma` and `SmaVector` 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 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)})"); +} + +#!csharp + +Console.WriteLine("\n--- Vectorized SMA with Invalid Values ---"); + +int[] periodsNaN = { 5, 10 }; +var smaVectorNaN = new SmaVector(periodsNaN); + +// Feed values including invalid ones +var inputsNaN = new double[] { 100, 110, double.NaN, 120, double.PositiveInfinity, 130 }; +var time = DateTime.Now; + +foreach (var val in inputsNaN) +{ + var results = smaVectorNaN.Update(new TValue(time, val)); + var inputStr = double.IsFinite(val) ? val.ToString("F2") : val.ToString(); + Console.WriteLine($"Input: {inputStr,-10} → SMA(5): {results[0].Value:F2}, SMA(10): {results[1].Value:F2}"); + time = time.AddMinutes(1); +} + +Console.WriteLine("\nAll outputs are finite - invalid inputs were substituted with last valid values."); + +#!markdown + +## 6. 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.Tests.cs b/lib/averages/sma/Sma.Tests.cs new file mode 100644 index 00000000..62be6891 --- /dev/null +++ b/lib/averages/sma/Sma.Tests.cs @@ -0,0 +1,362 @@ +using System; +using Xunit; +using QuanTAlib; + +namespace QuanTAlib.Tests; + +public class SmaTests +{ + [Fact] + public void Sma_Constructor_ValidatesInput() + { + Assert.Throws(() => new Sma(0)); + Assert.Throws(() => new Sma(-1)); + + var sma = new Sma(10); + Assert.NotNull(sma); + } + + [Fact] + public void Sma_Calc_ReturnsValue() + { + var sma = new Sma(10); + + Assert.Equal(0, sma.Value.Value); + + TValue result = sma.Update(new TValue(DateTime.UtcNow, 100)); + + Assert.True(result.Value > 0); + Assert.Equal(result.Value, sma.Value.Value); + } + + [Fact] + public void Sma_FirstValue_ReturnsItself() + { + var sma = new Sma(10); + + TValue result = sma.Update(new TValue(DateTime.UtcNow, 100)); + + Assert.Equal(100.0, result.Value, 1e-10); + } + + [Fact] + public void Sma_Calc_IsNew_AcceptsParameter() + { + var sma = new Sma(10); + + sma.Update(new TValue(DateTime.UtcNow, 100), isNew: true); + double value1 = sma.Value; + + sma.Update(new TValue(DateTime.UtcNow, 200), isNew: true); + double value2 = sma.Value; + + // Values should change with new bars + Assert.NotEqual(value1, value2); + } + + [Fact] + public void Sma_Calc_IsNew_False_UpdatesValue() + { + var sma = new Sma(10); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 110), isNew: true); + double beforeUpdate = sma.Value; + + sma.Update(new TValue(DateTime.UtcNow, 120), isNew: false); + double afterUpdate = sma.Value; + + // Update should change the value + Assert.NotEqual(beforeUpdate, afterUpdate); + } + + [Fact] + public void Sma_Reset_ClearsState() + { + var sma = new Sma(10); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 105)); + double valueBefore = sma.Value; + + sma.Reset(); + + Assert.Equal(0, sma.Value.Value); + + // After reset, should accept new values + sma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.NotEqual(0, sma.Value.Value); + Assert.NotEqual(valueBefore, sma.Value.Value); + } + + [Fact] + public void Sma_Properties_Accessible() + { + var sma = new Sma(10); + + Assert.Equal(0, sma.Value.Value); + Assert.False(sma.IsHot); + + sma.Update(new TValue(DateTime.UtcNow, 100)); + + Assert.NotEqual(0, sma.Value.Value); + } + + [Fact] + public void Sma_IsHot_BecomesTrueWhenBufferFull() + { + var sma = new Sma(5); + + Assert.False(sma.IsHot); + + for (int i = 1; i <= 4; i++) + { + sma.Update(new TValue(DateTime.UtcNow, i * 10)); + Assert.False(sma.IsHot); + } + + sma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.True(sma.IsHot); + } + + [Fact] + public void Sma_CalculatesCorrectAverage() + { + var sma = new Sma(5); + + sma.Update(new TValue(DateTime.UtcNow, 10)); + sma.Update(new TValue(DateTime.UtcNow, 20)); + sma.Update(new TValue(DateTime.UtcNow, 30)); + sma.Update(new TValue(DateTime.UtcNow, 40)); + sma.Update(new TValue(DateTime.UtcNow, 50)); + + // SMA(5) of 10,20,30,40,50 = 150/5 = 30 + Assert.Equal(30.0, sma.Value.Value, 1e-10); + } + + [Fact] + public void Sma_SlidingWindow_Works() + { + var sma = new Sma(3); + + sma.Update(new TValue(DateTime.UtcNow, 10)); + sma.Update(new TValue(DateTime.UtcNow, 20)); + sma.Update(new TValue(DateTime.UtcNow, 30)); + + // SMA(3) of 10,20,30 = 60/3 = 20 + Assert.Equal(20.0, sma.Value.Value, 1e-10); + + sma.Update(new TValue(DateTime.UtcNow, 40)); + + // SMA(3) of 20,30,40 = 90/3 = 30 + Assert.Equal(30.0, sma.Value.Value, 1e-10); + + sma.Update(new TValue(DateTime.UtcNow, 50)); + + // SMA(3) of 30,40,50 = 120/3 = 40 + Assert.Equal(40.0, sma.Value.Value, 1e-10); + } + + [Fact] + public void Sma_IterativeCorrections_RestoreToOriginalState() + { + var sma = new Sma(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); + sma.Update(tenthInput, isNew: true); + } + + // Remember SMA state after 10 values + double smaAfterTen = sma.Value; + + // Generate 9 corrections with isNew=false (different values) + for (int i = 0; i < 9; i++) + { + var bar = gbm.Next(isNew: false); + sma.Update(new TValue(bar.Time, bar.Close), isNew: false); + } + + // Feed the remembered 10th input again with isNew=false + TValue finalSma = sma.Update(tenthInput, isNew: false); + + // SMA should match the original state after 10 values + Assert.Equal(smaAfterTen, finalSma.Value, 1e-10); + } + + [Fact] + public void Sma_BatchCalc_MatchesIterativeCalc() + { + var smaIterative = new Sma(10); + var smaBatch = new Sma(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(smaIterative.Update(item)); + } + + // Calculate batch + var batchResults = smaBatch.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 Sma_Result_ImplicitConversionToDouble() + { + var sma = new Sma(10); + sma.Update(new TValue(DateTime.UtcNow, 100)); + + // This should compile and work because TValue has implicit conversion to double + double result = sma.Value; + + Assert.Equal(100.0, result, 1e-10); + } + + [Fact] + public void Sma_NaN_Input_UsesLastValidValue() + { + var sma = new Sma(5); + + // Feed some valid values + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 110)); + + // Feed NaN - should use last valid value (110) + var resultAfterNaN = sma.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 Sma_Infinity_Input_UsesLastValidValue() + { + var sma = new Sma(5); + + // Feed some valid values + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 110)); + + // Feed positive infinity - should use last valid value + var resultAfterPosInf = sma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + Assert.True(double.IsFinite(resultAfterPosInf.Value)); + + // Feed negative infinity - should use last valid value + var resultAfterNegInf = sma.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity)); + Assert.True(double.IsFinite(resultAfterNegInf.Value)); + } + + [Fact] + public void Sma_MultipleNaN_ContinuesWithLastValid() + { + var sma = new Sma(5); + + // Feed valid values + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, 110)); + sma.Update(new TValue(DateTime.UtcNow, 120)); + + // Feed multiple NaN values + var r1 = sma.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r2 = sma.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r3 = sma.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 Sma_BatchCalc_HandlesNaN() + { + var sma = new Sma(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 = sma.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 Sma_Reset_ClearsLastValidValue() + { + var sma = new Sma(5); + + // Feed values including NaN + sma.Update(new TValue(DateTime.UtcNow, 100)); + sma.Update(new TValue(DateTime.UtcNow, double.NaN)); + + // Reset + sma.Reset(); + + // After reset, first valid value should establish new baseline + var result = sma.Update(new TValue(DateTime.UtcNow, 50)); + Assert.Equal(50.0, result.Value, 1e-10); + } + + [Fact] + public void Sma_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 = Sma.Calculate(series, 3); + + Assert.Equal(5, results.Count); + // SMA(3) for last value: (30+40+50)/3 = 40 + Assert.Equal(40.0, results.Last.Value, 1e-10); + } + + [Fact] + public void Sma_Period1_ReturnsInputValues() + { + var sma = new Sma(1); + + Assert.Equal(100.0, sma.Update(new TValue(DateTime.UtcNow, 100)).Value, 1e-10); + Assert.Equal(200.0, sma.Update(new TValue(DateTime.UtcNow, 200)).Value, 1e-10); + Assert.Equal(150.0, sma.Update(new TValue(DateTime.UtcNow, 150)).Value, 1e-10); + } +} diff --git a/lib/averages/sma/Sma.Validation.Tests.cs b/lib/averages/sma/Sma.Validation.Tests.cs new file mode 100644 index 00000000..bd0eeaf6 --- /dev/null +++ b/lib/averages/sma/Sma.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 SmaValidationTests +{ + private readonly TBarSeries _bars; + private readonly TSeries _data; + private readonly List _skenderQuotes; + private readonly ITestOutputHelper _output; + + public SmaValidationTests(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 SMA + var sma = new global::QuanTAlib.Sma(period); + var qResult = sma.Update(_data); + + // Calculate Skender SMA + var sResult = _skenderQuotes.GetSma(period).ToList(); + + // Compare last 100 records + VerifyData(qResult, sResult); + } + _output.WriteLine("SMA 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 SMA + var sma = new global::QuanTAlib.Sma(period); + var qResult = sma.Update(_data); + + // Calculate TA-Lib SMA + var retCode = TALib.Functions.Sma(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.SmaLookback(period); + + // Compare last 100 records + VerifyData_Talib(qResult, output, outRange, lookback); + } + _output.WriteLine("SMA 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 SMA + var sma = new global::QuanTAlib.Sma(period); + var qResult = sma.Update(_data); + + // Calculate Tulip SMA - Tulip returns fewer elements (skips lookback) + var smaIndicator = Tulip.Indicators.sma; + double[][] inputs = { tData }; + double[] options = { (double)period }; + int lookback = period - 1; + double[][] outputs = { new double[tData.Length - lookback] }; + + smaIndicator.Run(inputs, options, outputs); + var tResult = outputs[0]; + + // Compare last 100 records (accounting for lookback offset) + VerifyData_Tulip(qResult, tResult, lookback); + } + _output.WriteLine("SMA 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].Sma; + + // 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/sma/Sma.cs b/lib/averages/sma/Sma.cs new file mode 100644 index 00000000..d4d637b2 --- /dev/null +++ b/lib/averages/sma/Sma.cs @@ -0,0 +1,220 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// SMA: Simple Moving Average +/// +/// +/// SMA calculates the arithmetic mean of the last N values. +/// Uses a RingBuffer for storage and manual running sum for O(1) operations. +/// +/// Key characteristics: +/// - Equal weighting of all values in the period +/// - No lag bias - responds equally to all values in window +/// - Smooth output with good noise reduction +/// - O(1) time complexity for both update and bar correction +/// - O(1) space complexity for state save/restore (scalars only) +/// +/// Calculation method: +/// SMA = Sum(values in period) / period +/// +/// 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://stockcharts.com/school/doku.php?id=chart_school:technical_indicators:moving_averages +/// - https://www.investopedia.com/terms/s/sma.asp +/// +[SkipLocalsInit] +public sealed class Sma +{ + private readonly int _period; + private readonly RingBuffer _buffer; + + // Running sum maintained separately for O(1) bar correction + private double _sum; + private double _p_sum; // Sum AFTER last isNew=true (for correction restore) + 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 SMA with specified period. + /// + /// Number of values to average (must be > 0) + public Sma(int period) + { + if (period <= 0) + throw new ArgumentException("Period must be greater than 0", nameof(period)); + + _period = period; + _buffer = new RingBuffer(period); + Name = $"Sma({period})"; + WarmupPeriod = period; + } + + /// + /// Current SMA value. + /// + public TValue Value { get; private set; } + + /// + /// True if the SMA has enough data to produce valid results. + /// SMA 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 SMA 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 SMA 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); + + // Calculate what to remove from sum (oldest value if buffer full) + double removedValue = _buffer.Count == _buffer.Capacity ? _buffer.Oldest : 0.0; + + // Update sum: remove oldest, add newest + _sum = _sum - removedValue + val; + + // Update buffer + _buffer.Add(val); + + // Save state AFTER this update for potential future corrections + _p_sum = _sum; + _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); + + // _p_sum is the sum AFTER the last isNew=true completed + // _p_lastInput is the value that was added on last isNew=true + // We want: new_sum = _p_sum - _p_lastInput + val + _sum = _p_sum - _p_lastInput + val; + + // Update buffer's newest value + _buffer.UpdateNewest(val); + } + + double result = _sum / _buffer.Count; + Value = new TValue(input.Time, result); + return Value; + } + + /// + /// Updates SMA with the entire series. + /// + /// Input series + /// SMA 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 buffer and sum for batch processing + var localBuffer = new RingBuffer(_period); + double localSum = 0; + + for (int i = 0; i < len; i++) + { + // Last-value substitution: replace non-finite inputs with last valid value + double val = GetValidValue(sourceValues[i]); + + // Remove oldest if buffer full + double removedValue = localBuffer.Count == localBuffer.Capacity ? localBuffer.Oldest : 0.0; + localSum = localSum - removedValue + val; + + localBuffer.Add(val); + + tSpan[i] = sourceTimes[i]; + vSpan[i] = localSum / localBuffer.Count; + } + + // Update instance state to the final state + // Copy buffer contents (needed for future streaming updates) + _buffer.CopyFrom(localBuffer); + _sum = localSum; + _p_sum = localSum; + _p_lastInput = sourceValues[len - 1]; + + Value = new TValue(tSpan[len - 1], vSpan[len - 1]); + return new TSeries(t, v); + } + + /// + /// Calculates SMA for the entire series using a new instance. + /// + /// Input series + /// SMA period + /// SMA series + public static TSeries Calculate(TSeries source, int period) + { + var sma = new Sma(period); + return sma.Update(source); + } + + /// + /// Resets the SMA state. + /// + public void Reset() + { + _buffer.Clear(); + _sum = 0; + _p_sum = 0; + _p_lastInput = 0; + _lastValidValue = 0; + _p_lastValidValue = 0; + Value = default; + } +} diff --git a/lib/averages/sma/Sma.md b/lib/averages/sma/Sma.md new file mode 100644 index 00000000..0208aefa --- /dev/null +++ b/lib/averages/sma/Sma.md @@ -0,0 +1,204 @@ +# SMA: Simple Moving Average + +## Overview and Purpose + +The Simple Moving Average (SMA) is one of the most fundamental and widely used technical indicators in financial analysis. It calculates the arithmetic mean of a selected range of prices over a specified number of periods. Developed in the early days of technical analysis, the SMA provides traders with a straightforward method to identify trends by smoothing price data and filtering out short-term fluctuations. + +Unlike the Exponential Moving Average (EMA) which gives more weight to recent data, the SMA treats all data points in the window equally. This equal weighting makes the SMA particularly intuitive to understand, as it simply represents the average price over the specified time period. Due to its simplicity and effectiveness, it remains a cornerstone indicator that forms the basis for numerous other technical analysis tools. + +## Core Concepts + +* **Equal weighting:** SMA gives equal importance to each price point in the calculation period, unlike weighted averages that emphasize certain data points +* **Noise reduction:** Smooths price fluctuations to help identify the underlying trend direction +* **Timeframe flexibility:** Effective across all timeframes, with shorter periods for short-term analysis and longer periods for identifying major trends +* **Foundation indicator:** Serves as the mathematical basis for Bollinger Bands, moving average envelopes, and other derived indicators + +The core principle of SMA is its unbiased approach to price data. By treating all prices within the lookback period with equal importance, SMA creates a balanced view of recent market activity. This equal weighting makes the SMA particularly intuitive to understand, as it simply represents the average price over the specified time period. + +## Common Settings and Parameters + +| Parameter | Default | Function | When to Adjust | +|-----------|---------|----------|---------------| +| Period | 20 | 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 trend following strategies, consider using two SMAs with different periods (e.g., 50 and 200) – crossovers between these can identify significant trend changes while filtering out minor fluctuations. This "golden cross" (50 crossing above 200) and "death cross" (50 crossing below 200) are among the most watched signals in technical analysis. + +## Calculation and Mathematical Foundation + +**Simplified explanation:** +SMA adds up the prices for a specific number of periods and divides by that number. For example, a 10-period SMA adds the last 10 closing prices and divides by 10 to find the average. + +**Technical formula:** +The standard calculation: +$$SMA = \frac{P_1 + P_2 + ... + P_n}{n} = \frac{1}{n}\sum_{i=1}^{n}P_i$$ + +An optimized recursive calculation used in the implementation: +$$SMA_t = SMA_{t-1} + \frac{P_t - P_{t-n}}{n}$$ + +Where: + +* $P_1, P_2, ..., P_n$ are price values in the lookback window +* $n$ is the period length +* $P_{t-n}$ is the oldest price leaving the window + +> 🔍 **Technical Note:** The SMA has a precisely defined lag of $(n-1)/2$ periods, meaning a 21-period SMA lags behind price by 10 bars. This consistent, deterministic lag makes its behavior predictable across all market conditions. The implementation uses a running sum approach for O(1) update complexity regardless of period length. + +## C# Implementation + +The library provides two implementations: a standard scalar version and a multi-period vector version for calculating multiple SMAs simultaneously. + +### Single SMA (`Sma`) + +The `Sma` class calculates a single simple moving average with O(1) update complexity. + +```csharp +using QuanTAlib; + +// Initialize with period 10 +var sma = new Sma(10); + +// Streaming update +TValue result = sma.Update(new TValue(time, price)); +Console.WriteLine($"Current SMA: {result.Value}"); + +// Access properties +Console.WriteLine($"Name: {sma.Name}"); // "Sma(10)" +Console.WriteLine($"WarmupPeriod: {sma.WarmupPeriod}"); // 10 +Console.WriteLine($"IsHot: {sma.IsHot}"); // true when buffer is full + +// Batch calculation +TSeries source = ...; +TSeries results = Sma.Calculate(source, 10); +``` + +### Multi-Period SMA (`SmaVector`) + +The `SmaVector` class calculates multiple SMAs with different periods on the same input series simultaneously. + +```csharp +using QuanTAlib; + +// Initialize with multiple periods +int[] periods = { 5, 10, 20 }; +var smaVector = new SmaVector(periods); + +// Streaming update +TValue[] results = smaVector.Update(new TValue(time, price)); + +// Access values +Console.WriteLine($"SMA(5): {results[0].Value}"); +Console.WriteLine($"SMA(10): {results[1].Value}"); +Console.WriteLine($"SMA(20): {results[2].Value}"); + +// Batch calculation +TSeries source = ...; +TSeries[] seriesResults = smaVector.Calculate(source); +``` + +### Bar Correction (isNew Parameter) + +Both `Sma` and `SmaVector` support intra-bar updates for real-time trading systems: + +```csharp +var sma = new Sma(10); + +// Process historical bars +for (int i = 0; i < historicalBars.Count; i++) +{ + sma.Update(historicalBars[i], isNew: true); +} + +// Real-time: receive initial tick for new bar +sma.Update(new TValue(time, 100.5), isNew: true); + +// Real-time: price updates within same bar +sma.Update(new TValue(time, 101.0), isNew: false); // O(1) correction +sma.Update(new TValue(time, 100.8), isNew: false); // O(1) correction + +// Bar closes, next bar starts +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. + +### Handling Invalid Values (NaN/Infinity) + +Both `Sma` and `SmaVector` use **last-value substitution** for handling invalid inputs: + +```csharp +var sma = new Sma(10); + +// Valid values establish baseline +sma.Update(new TValue(time, 100)); +sma.Update(new TValue(time, 110)); + +// NaN or Infinity inputs are replaced with last valid value (110) +var result = sma.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 = sma.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) | Running sum: `sum = sum - oldest + newest` | +| 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) | 6 doubles for bar correction | + +The implementation uses: + +* **Running sum** for O(1) 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 + +SMA can be used in various trading strategies: + +* **Trend identification:** The direction of SMA indicates the prevailing trend +* **Signal generation:** Crossovers between price and SMA generate basic trade signals +* **Support/resistance levels:** SMA can act as dynamic support during uptrends and resistance during downtrends +* **Multiple timeframe analysis:** Using SMAs with different periods can confirm trends across different timeframes +* **Moving average crossovers:** When a shorter-period SMA crosses above a longer-period SMA, it signals a potential uptrend (and vice versa) + +### SMA vs EMA Comparison + +| Aspect | SMA | EMA | +|--------|-----|-----| +| Weighting | Equal for all values | Recent values weighted more | +| Lag | Higher: $(n-1)/2$ bars | Lower due to recent weighting | +| Sensitivity | Slower to react | Faster reaction to changes | +| Noise | Better noise filtering | More responsive but noisier | +| Sudden changes | Abrupt when oldest value exits | Smooth exponential decay | +| Best use | Long-term trends, support/resistance | Short-term signals, momentum | + +## Limitations and Considerations + +* **Market conditions:** Less effective in choppy, sideways markets where price oscillates around the average +* **Lag factor:** Significant lag in responding to rapid price changes means SMA will always be late to signal reversals +* **Equal weighting:** Treats recent and older prices equally, which may not reflect current market dynamics +* **Sudden changes:** When a price point leaves the calculation window, it can cause abrupt changes in the SMA +* **Complementary tools:** Best used with momentum oscillators, volume indicators, or other trend confirmation tools + +## References + +1. Edwards, R.D. and Magee, J. (2007). *Technical Analysis of Stock Trends*. CRC Press. +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. diff --git a/lib/averages/sma/SmaVector.Tests.cs b/lib/averages/sma/SmaVector.Tests.cs new file mode 100644 index 00000000..903bb9bb --- /dev/null +++ b/lib/averages/sma/SmaVector.Tests.cs @@ -0,0 +1,374 @@ +using System.Linq; +using Xunit; +using QuanTAlib; + +namespace QuanTAlib.Tests; + +public class SmaVectorTests +{ + [Fact] + public void Initialization_WithPeriods_Works() + { + int[] periods = { 5, 10, 20 }; + var smaVector = new SmaVector(periods); + + var res = smaVector.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 SmaVector(periods)); + } + + [Fact] + public void Initialization_WithNegativePeriod_ThrowsArgumentException() + { + int[] periods = { 10, -5, 20 }; + + Assert.Throws(() => new SmaVector(periods)); + } + + [Fact] + public void Calc_Streaming_MatchesSingleSma() + { + int[] periods = { 5, 10, 20 }; + var smaVector = new SmaVector(periods); + var smaSingles = periods.Select(p => new Sma(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 = smaVector.Update(tVal); + + for (int i = 0; i < periods.Length; i++) + { + var singleRes = smaSingles[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_MatchesSingleSma() + { + int[] periods = { 5, 10, 20 }; + var smaVector = new SmaVector(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 = smaVector.Calculate(series); + + // Reset and recalculate for comparison + var smaSingles = periods.Select(p => new Sma(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 = smaSingles[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 smaVectorBatch = new SmaVector(periods); + var smaVectorStream = new SmaVector(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 = smaVectorBatch.Calculate(series); + + for (int i = 0; i < len; i++) + { + var tVal = new TValue(new DateTime(t[i], DateTimeKind.Utc), v[i]); + var streamRes = smaVectorStream.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 instanceSma = new SmaVector(periods); + var instanceRes = instanceSma.Calculate(series); + + var staticRes = SmaVector.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 smaVector = new SmaVector(periods); + + smaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + smaVector.Update(new TValue(DateTime.UtcNow, 200.0)); + smaVector.Reset(); + + var res = smaVector.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 smaVector = new SmaVector(periods); + + smaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + smaVector.Update(new TValue(DateTime.UtcNow, 110.0)); + + var resultAfterNaN = smaVector.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 smaVector = new SmaVector(periods); + + smaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + smaVector.Update(new TValue(DateTime.UtcNow, 110.0)); + + var resultAfterPosInf = smaVector.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); + foreach (var result in resultAfterPosInf) + { + Assert.True(double.IsFinite(result.Value)); + } + + var resultAfterNegInf = smaVector.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 smaVector = new SmaVector(periods); + + smaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + smaVector.Update(new TValue(DateTime.UtcNow, 110.0)); + smaVector.Update(new TValue(DateTime.UtcNow, 120.0)); + + var r1 = smaVector.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r2 = smaVector.Update(new TValue(DateTime.UtcNow, double.NaN)); + var r3 = smaVector.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 smaVector = new SmaVector(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 = smaVector.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 smaVector = new SmaVector(periods); + + smaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + smaVector.Update(new TValue(DateTime.UtcNow, double.NaN)); + + smaVector.Reset(); + + var result = smaVector.Update(new TValue(DateTime.UtcNow, 50.0)); + Assert.Equal(50.0, result[0].Value, 1e-9); + } + + [Fact] + public void NaN_Handling_MatchesSingleSma() + { + int[] periods = { 5, 10, 20 }; + var smaVector = new SmaVector(periods); + var smaSingles = periods.Select(p => new Sma(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 = smaVector.Update(tVal); + + for (int i = 0; i < periods.Length; i++) + { + var singleRes = smaSingles[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 smaVector = new SmaVector(periods); + + var result = smaVector.Update(new TValue(DateTime.UtcNow, 100.0)); + + Assert.Equal(result[0].Value, smaVector.Values[0].Value); + Assert.Equal(result[1].Value, smaVector.Values[1].Value); + } + + [Fact] + public void Values_Property_UpdatesAfterCalculate() + { + int[] periods = { 5, 10 }; + var smaVector = new SmaVector(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 = smaVector.Calculate(series); + + Assert.Equal(results[0].Last.Value, smaVector.Values[0].Value, 1e-9); + Assert.Equal(results[1].Last.Value, smaVector.Values[1].Value, 1e-9); + } + + [Fact] + public void Update_BarCorrection_WorksCorrectly() + { + int[] periods = { 3 }; + var smaVector = new SmaVector(periods); + + smaVector.Update(new TValue(DateTime.UtcNow, 10.0), isNew: true); + smaVector.Update(new TValue(DateTime.UtcNow, 20.0), isNew: true); + smaVector.Update(new TValue(DateTime.UtcNow, 30.0), isNew: true); + + var res1 = smaVector.Values[0].Value; + Assert.Equal(20.0, res1, 1e-9); // (10+20+30)/3 = 20 + + // Correct the last bar + var res2 = smaVector.Update(new TValue(DateTime.UtcNow, 60.0), isNew: false); + + Assert.Equal(30.0, res2[0].Value, 1e-9); // (10+20+60)/3 = 30 + } + + [Fact] + public void SMA_MatchesExpectedValues() + { + int[] periods = { 3 }; + var smaVector = new SmaVector(periods); + + // Test sequence: 10, 20, 30, 40, 50 + // Expected SMA(3): 10, 15, 20, 30, 40 + var expected = new double[] { 10, 15, 20, 30, 40 }; + var values = new double[] { 10, 20, 30, 40, 50 }; + var time = DateTime.UtcNow; + + for (int i = 0; i < values.Length; i++) + { + var res = smaVector.Update(new TValue(time, values[i])); + Assert.Equal(expected[i], res[0].Value, 1e-9); + time = time.AddMinutes(1); + } + } +} diff --git a/lib/averages/sma/SmaVector.cs b/lib/averages/sma/SmaVector.cs new file mode 100644 index 00000000..7e0ac6da --- /dev/null +++ b/lib/averages/sma/SmaVector.cs @@ -0,0 +1,185 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// Multi-Period Simple Moving Average (SMA) - SIMD optimized. +/// Calculates multiple SMAs with different periods for the same input series in parallel. +/// Uses last-value substitution for invalid inputs (NaN/Infinity). +/// +[SkipLocalsInit] +public class SmaVector +{ + private readonly RingBuffer[] _buffers; + private readonly RingBuffer[] _p_buffers; // Previous state for bar correction + private readonly int _count; + private double _lastValidValue; + + /// + /// Current SMA values for all periods. + /// + public ReadOnlySpan Values => _values; + + private readonly TValue[] _values; + + /// + /// Initializes SmaVector with specified periods. + /// + /// Array of periods (each must be > 0) + public SmaVector(int[] periods) + { + _count = periods.Length; + _buffers = new RingBuffer[_count]; + _p_buffers = new RingBuffer[_count]; + _values = new TValue[_count]; + + for (int i = 0; i < _count; i++) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(periods[i], 0); + _buffers[i] = new RingBuffer(periods[i]); + _p_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 SMA states. + /// + public void Reset() + { + for (int i = 0; i < _count; i++) + { + _buffers[i].Clear(); + _p_buffers[i].Clear(); + } + _lastValidValue = 0; + Array.Clear(_values); + } + + /// + /// Updates SMAs 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. + /// + /// Input value + /// True for new bar, false for update to current bar (default: true) + /// Array of SMA values + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue[] Update(TValue input, bool isNew = true) + { + if (isNew) + { + // Save current state for potential bar correction + for (int i = 0; i < _count; i++) + { + _p_buffers[i].CopyFrom(_buffers[i]); + } + } + else + { + // Restore previous state for bar correction + for (int i = 0; i < _count; i++) + { + _buffers[i].CopyFrom(_p_buffers[i]); + } + } + + // Last-value substitution: replace non-finite inputs with last valid value + double val = GetValidValue(input.Value); + + // Update each buffer and calculate SMA + for (int i = 0; i < _count; i++) + { + _buffers[i].Add(val); + _values[i] = new TValue(input.Time, _buffers[i].Average); + } + + return _values; + } + + /// + /// Calculates SMAs for the entire series. + /// + /// Input series + /// Array of SMA series + public TSeries[] Calculate(TSeries source) + { + int len = source.Count; + var resultSeries = new TSeries[_count]; + + // Reset state for fresh calculation + for (int i = 0; i < _count; i++) + { + _buffers[i].Clear(); + } + _lastValidValue = 0; + + // 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++) + { + _buffers[i].Add(val); + CollectionsMarshal.AsSpan(tLists[i])[t] = time; + CollectionsMarshal.AsSpan(vLists[i])[t] = _buffers[i].Average; + } + } + + // 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 SMAs for the entire series using specified periods. + /// + /// Input series + /// Array of periods + /// Array of SMA series + public static TSeries[] Calculate(TSeries source, int[] periods) + { + var smaVector = new SmaVector(periods); + return smaVector.Calculate(source); + } +} diff --git a/lib/core/ringbuffer/RingBuffer.Tests.cs b/lib/core/ringbuffer/RingBuffer.Tests.cs new file mode 100644 index 00000000..367d19b9 --- /dev/null +++ b/lib/core/ringbuffer/RingBuffer.Tests.cs @@ -0,0 +1,726 @@ +using System; +using System.Collections; +using Xunit; +using QuanTAlib; + +namespace QuanTAlib.Tests; + +public class RingBufferTests +{ + [Fact] + public void Constructor_ValidCapacity_CreatesBuffer() + { + var buffer = new RingBuffer(10); + + Assert.Equal(10, buffer.Capacity); + Assert.Equal(0, buffer.Count); + Assert.False(buffer.IsFull); + Assert.Equal(0, buffer.Sum); + Assert.Equal(0, buffer.Average); + } + + [Fact] + public void Constructor_ZeroCapacity_ThrowsArgumentException() + { + Assert.Throws(() => new RingBuffer(0)); + } + + [Fact] + public void Constructor_NegativeCapacity_ThrowsArgumentException() + { + Assert.Throws(() => new RingBuffer(-1)); + } + + [Fact] + public void Add_SingleValue_UpdatesState() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + + Assert.Equal(1, buffer.Count); + Assert.Equal(10.0, buffer.Sum); + Assert.Equal(10.0, buffer.Average); + Assert.Equal(10.0, buffer.Newest); + Assert.Equal(10.0, buffer.Oldest); + Assert.False(buffer.IsFull); + } + + [Fact] + public void Add_MultipleValues_UpdatesState() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + Assert.Equal(3, buffer.Count); + Assert.Equal(60.0, buffer.Sum); + Assert.Equal(20.0, buffer.Average); + Assert.Equal(30.0, buffer.Newest); + Assert.Equal(10.0, buffer.Oldest); + Assert.False(buffer.IsFull); + } + + [Fact] + public void Add_FillBuffer_BecomesFullAndWraps() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + Assert.Equal(3, buffer.Count); + Assert.True(buffer.IsFull); + Assert.Equal(60.0, buffer.Sum); + Assert.Equal(20.0, buffer.Average); + + // Add one more - should remove 10.0 + double removed = buffer.Add(40.0); + + Assert.Equal(10.0, removed); + Assert.Equal(3, buffer.Count); + Assert.True(buffer.IsFull); + Assert.Equal(90.0, buffer.Sum); // 20 + 30 + 40 + Assert.Equal(30.0, buffer.Average); + Assert.Equal(40.0, buffer.Newest); + Assert.Equal(20.0, buffer.Oldest); + } + + [Fact] + public void Add_MultipleWraps_MaintainsCorrectState() + { + var buffer = new RingBuffer(3); + + // Fill and wrap multiple times + for (int i = 1; i <= 10; i++) + { + buffer.Add(i * 10.0); + } + + // Should contain: 80, 90, 100 + Assert.Equal(3, buffer.Count); + Assert.True(buffer.IsFull); + Assert.Equal(270.0, buffer.Sum); // 80 + 90 + 100 + Assert.Equal(90.0, buffer.Average); + Assert.Equal(100.0, buffer.Newest); + Assert.Equal(80.0, buffer.Oldest); + } + + [Fact] + public void UpdateNewest_ModifiesLastValue() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + Assert.Equal(60.0, buffer.Sum); + + buffer.UpdateNewest(35.0); + + Assert.Equal(65.0, buffer.Sum); // 10 + 20 + 35 + Assert.Equal(35.0, buffer.Newest); + Assert.Equal(3, buffer.Count); + } + + [Fact] + public void UpdateNewest_EmptyBuffer_DoesNothing() + { + var buffer = new RingBuffer(5); + + buffer.UpdateNewest(100.0); // Should not throw + + Assert.Equal(0, buffer.Count); + Assert.Equal(0, buffer.Sum); + } + + [Fact] + public void Indexer_AccessesCorrectValues() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + // Index 0 = oldest, Index 2 = newest + Assert.Equal(10.0, buffer[0]); + Assert.Equal(20.0, buffer[1]); + Assert.Equal(30.0, buffer[2]); + } + + [Fact] + public void Indexer_AfterWrap_AccessesCorrectValues() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + buffer.Add(40.0); // Wraps, removes 10 + + // Should contain: 20, 30, 40 + Assert.Equal(20.0, buffer[0]); + Assert.Equal(30.0, buffer[1]); + Assert.Equal(40.0, buffer[2]); + } + + [Fact] + public void Indexer_OutOfRange_ThrowsException() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + + // Valid indices are 0 and 1 (2 elements) + // Index 2 should throw ArgumentOutOfRangeException + Assert.Throws(() => _ = buffer[(Index)2]); + Assert.Throws(() => _ = buffer[(Index)10]); + } + + [Fact] + public void Clear_ResetsState() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + buffer.Clear(); + + Assert.Equal(0, buffer.Count); + Assert.Equal(0, buffer.Sum); + Assert.Equal(0, buffer.Average); + Assert.False(buffer.IsFull); + } + + [Fact] + public void Clear_AllowsReuse() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + buffer.Clear(); + + buffer.Add(100.0); + + Assert.Equal(1, buffer.Count); + Assert.Equal(100.0, buffer.Sum); + Assert.Equal(100.0, buffer.Newest); + } + + [Fact] + public void Clone_CreatesIndependentCopy() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + var clone = buffer.Clone(); + + // Verify clone has same state + Assert.Equal(buffer.Count, clone.Count); + Assert.Equal(buffer.Sum, clone.Sum); + Assert.Equal(buffer.Newest, clone.Newest); + Assert.Equal(buffer.Oldest, clone.Oldest); + + // Modify original - clone should be unaffected + buffer.Add(40.0); + + Assert.Equal(4, buffer.Count); + Assert.Equal(3, clone.Count); + Assert.Equal(100.0, buffer.Sum); + Assert.Equal(60.0, clone.Sum); + } + + [Fact] + public void CopyFrom_CopiesState() + { + var source = new RingBuffer(5); + var target = new RingBuffer(5); + + source.Add(10.0); + source.Add(20.0); + source.Add(30.0); + + target.Add(100.0); // Different initial state + + target.CopyFrom(source); + + Assert.Equal(source.Count, target.Count); + Assert.Equal(source.Sum, target.Sum); + Assert.Equal(source.Newest, target.Newest); + Assert.Equal(source.Oldest, target.Oldest); + + // Verify independence after copy + source.Add(40.0); + Assert.NotEqual(source.Sum, target.Sum); + } + + [Fact] + public void CopyFrom_DifferentCapacity_ThrowsException() + { + var source = new RingBuffer(5); + var target = new RingBuffer(10); + + Assert.Throws(() => target.CopyFrom(source)); + } + + [Fact] + public void GetSpan_ReturnsChronologicalOrder() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + var span = buffer.GetSpan(); + + Assert.Equal(3, span.Length); + Assert.Equal(10.0, span[0]); + Assert.Equal(20.0, span[1]); + Assert.Equal(30.0, span[2]); + } + + [Fact] + public void GetSpan_AfterWrap_ReturnsChronologicalOrder() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + buffer.Add(40.0); + buffer.Add(50.0); + + var span = buffer.GetSpan(); + + // Should be: 30, 40, 50 + Assert.Equal(3, span.Length); + Assert.Equal(30.0, span[0]); + Assert.Equal(40.0, span[1]); + Assert.Equal(50.0, span[2]); + } + + [Fact] + public void GetSpan_EmptyBuffer_ReturnsEmpty() + { + var buffer = new RingBuffer(5); + + var span = buffer.GetSpan(); + + Assert.True(span.IsEmpty); + } + + [Fact] + public void Min_ReturnsMinimumValue() + { + var buffer = new RingBuffer(5); + + buffer.Add(30.0); + buffer.Add(10.0); + buffer.Add(50.0); + buffer.Add(20.0); + buffer.Add(40.0); + + Assert.Equal(10.0, buffer.Min()); + } + + [Fact] + public void Max_ReturnsMaximumValue() + { + var buffer = new RingBuffer(5); + + buffer.Add(30.0); + buffer.Add(10.0); + buffer.Add(50.0); + buffer.Add(20.0); + buffer.Add(40.0); + + Assert.Equal(50.0, buffer.Max()); + } + + [Fact] + public void Min_EmptyBuffer_ReturnsNaN() + { + var buffer = new RingBuffer(5); + + Assert.True(double.IsNaN(buffer.Min())); + } + + [Fact] + public void Max_EmptyBuffer_ReturnsNaN() + { + var buffer = new RingBuffer(5); + + Assert.True(double.IsNaN(buffer.Max())); + } + + [Fact] + public void Enumerator_IteratesInChronologicalOrder() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + var values = new List(); + foreach (var v in buffer) + { + values.Add(v); + } + + Assert.Equal(3, values.Count); + Assert.Equal(10.0, values[0]); + Assert.Equal(20.0, values[1]); + Assert.Equal(30.0, values[2]); + } + + [Fact] + public void Enumerator_AfterWrap_IteratesInChronologicalOrder() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + buffer.Add(40.0); + buffer.Add(50.0); + + var values = new List(); + foreach (var v in buffer) + { + values.Add(v); + } + + // Should be: 30, 40, 50 + Assert.Equal(3, values.Count); + Assert.Equal(30.0, values[0]); + Assert.Equal(40.0, values[1]); + Assert.Equal(50.0, values[2]); + } + + [Fact] + public void Add_WithIsNew_WorksCorrectly() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0, isNew: true); + buffer.Add(20.0, isNew: true); + buffer.Add(25.0, isNew: false); // Should update 20.0 to 25.0 + + Assert.Equal(2, buffer.Count); + Assert.Equal(25.0, buffer.Newest); + Assert.Equal(35.0, buffer.Sum); // 10 + 25 + } + + [Fact] + public void Indexer_WithIndexType_SupportsFromEnd() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + Assert.Equal(30.0, buffer[^1]); // Newest + Assert.Equal(20.0, buffer[^2]); + Assert.Equal(10.0, buffer[^3]); // Oldest + } + + [Fact] + public void Newest_EmptyBuffer_ReturnsZero() + { + var buffer = new RingBuffer(5); + + Assert.Equal(0, buffer.Newest); + } + + [Fact] + public void Oldest_EmptyBuffer_ReturnsZero() + { + var buffer = new RingBuffer(5); + + Assert.Equal(0, buffer.Oldest); + } + + [Fact] + public void Average_EmptyBuffer_ReturnsZero() + { + var buffer = new RingBuffer(5); + + Assert.Equal(0, buffer.Average); + } + + [Fact] + public void GetInternalSpan_ReturnsFullBuffer() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + + var span = buffer.GetInternalSpan(); + + Assert.Equal(5, span.Length); // Full capacity, not count + } + + [Fact] + public void ToArray_AfterWrap_ReturnsChronologicalOrder() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + buffer.Add(40.0); // Wraps + + var arr = buffer.ToArray(); + + Assert.Equal(3, arr.Length); + Assert.Equal(20.0, arr[0]); + Assert.Equal(30.0, arr[1]); + Assert.Equal(40.0, arr[2]); + } + + [Fact] + public void CopyTo_AfterWrap_CopiesInChronologicalOrder() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + buffer.Add(40.0); // Wraps + + var dest = new double[5]; + buffer.CopyTo(dest, 1); + + Assert.Equal(0, dest[0]); // Untouched + Assert.Equal(20.0, dest[1]); + Assert.Equal(30.0, dest[2]); + Assert.Equal(40.0, dest[3]); + Assert.Equal(0, dest[4]); // Untouched + } + + [Fact] + public void Indexer_Set_UpdatesValueAndSum() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + Assert.Equal(60.0, buffer.Sum); + + buffer[(Index)1] = 25.0; // Change 20.0 to 25.0 + + Assert.Equal(65.0, buffer.Sum); + Assert.Equal(25.0, buffer[1]); + } + + [Fact] + public void Indexer_SetFromEnd_UpdatesValueAndSum() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + + buffer[^1] = 35.0; // Change newest (30.0) to 35.0 + + Assert.Equal(65.0, buffer.Sum); + Assert.Equal(35.0, buffer[^1]); + } + + [Fact] + public void Min_LargeBuffer_UsesSimd() + { + var buffer = new RingBuffer(100); + + for (int i = 0; i < 100; i++) + { + buffer.Add(i + 1); // 1 to 100 + } + + Assert.Equal(1.0, buffer.Min()); + } + + [Fact] + public void Max_LargeBuffer_UsesSimd() + { + var buffer = new RingBuffer(100); + + for (int i = 0; i < 100; i++) + { + buffer.Add(i + 1); // 1 to 100 + } + + Assert.Equal(100.0, buffer.Max()); + } + + [Fact] + public void ToArray_EmptyBuffer_ReturnsEmpty() + { + var buffer = new RingBuffer(5); + + var arr = buffer.ToArray(); + + Assert.Empty(arr); + } + + [Fact] + public void CopyTo_EmptyBuffer_DoesNothing() + { + var buffer = new RingBuffer(5); + double[] dest = [1.0, 2.0, 3.0]; + + buffer.CopyTo(dest, 0); + + Assert.Equal(1.0, dest[0]); + Assert.Equal(2.0, dest[1]); + Assert.Equal(3.0, dest[2]); + } + + [Fact] + public void InternalBuffer_ReturnsSpan() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0); + + var span = buffer.InternalBuffer; + + Assert.Equal(5, span.Length); + Assert.Equal(10.0, span[0]); + } + + [Fact] + public void Enumerator_Reset_AllowsReIteration() + { + var buffer = new RingBuffer(3); + buffer.Add(10.0); + buffer.Add(20.0); + + var enumerator = buffer.GetEnumerator(); + + // First iteration + Assert.True(enumerator.MoveNext()); + Assert.Equal(10.0, enumerator.Current); + Assert.True(enumerator.MoveNext()); + Assert.Equal(20.0, enumerator.Current); + Assert.False(enumerator.MoveNext()); + + // Reset and iterate again + enumerator.Reset(); + Assert.True(enumerator.MoveNext()); + Assert.Equal(10.0, enumerator.Current); + + enumerator.Dispose(); // Coverage for Dispose + } + + [Fact] + public void IEnumerable_GetEnumerator_Works() + { + var buffer = new RingBuffer(3); + buffer.Add(10.0); + buffer.Add(20.0); + + IEnumerable enumerable = buffer; + var values = new List(); + foreach (var v in enumerable) + { + values.Add(v); + } + + Assert.Equal(2, values.Count); + Assert.Equal(10.0, values[0]); + Assert.Equal(20.0, values[1]); + } + + [Fact] + public void IEnumerable_NonGeneric_GetEnumerator_Works() + { + var buffer = new RingBuffer(3); + buffer.Add(10.0); + buffer.Add(20.0); + + IEnumerable enumerable = buffer; + var values = new List(); + foreach (var v in enumerable) + { + values.Add((double)v); + } + + Assert.Equal(2, values.Count); + } + + [Fact] + public void Indexer_Set_AfterWrap_UpdatesCorrectly() + { + var buffer = new RingBuffer(3); + + buffer.Add(10.0); + buffer.Add(20.0); + buffer.Add(30.0); + buffer.Add(40.0); // Wraps - now has 20, 30, 40 + + buffer[(Index)0] = 25.0; // Change oldest (20.0) to 25.0 + + Assert.Equal(95.0, buffer.Sum); // 25 + 30 + 40 + Assert.Equal(25.0, buffer[0]); + } + + [Fact] + public void Add_WithIsNew_EmptyBuffer_AddsValue() + { + var buffer = new RingBuffer(5); + + buffer.Add(10.0, isNew: false); // isNew=false but buffer empty, should still add + + Assert.Equal(1, buffer.Count); + Assert.Equal(10.0, buffer.Newest); + } + + [Fact] + public void BarCorrection_Workflow() + { + // Simulate bar correction (isNew=false) workflow + var buffer = new RingBuffer(3); + var backup = new RingBuffer(3); + + // Add values as new bars + buffer.Add(10.0); + backup.CopyFrom(buffer); + + buffer.Add(20.0); + backup.CopyFrom(buffer); + + buffer.Add(30.0); + backup.CopyFrom(buffer); + + double avgBeforeCorrection = buffer.Average; + + // Simulate correction (isNew=false) + buffer.CopyFrom(backup); // Restore previous state + buffer.UpdateNewest(35.0); // Update with corrected value + + // Average should reflect the correction + Assert.Equal(21.666666666666668, buffer.Average, 1e-10); + Assert.NotEqual(avgBeforeCorrection, buffer.Average); + } +} diff --git a/lib/core/ringbuffer/RingBuffer.cs b/lib/core/ringbuffer/RingBuffer.cs new file mode 100644 index 00000000..0f92a190 --- /dev/null +++ b/lib/core/ringbuffer/RingBuffer.cs @@ -0,0 +1,468 @@ +using System.Collections; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// A high-performance circular buffer for double values optimized for SIMD operations. +/// Uses pinned memory and maintains running sum for O(1) average calculations. +/// +/// +/// Key characteristics: +/// - Fixed capacity set at construction +/// - Pinned memory for SIMD compatibility +/// - O(1) Add and Sum operations via running sum +/// - SIMD-accelerated Min/Max operations +/// - Direct span access when buffer is contiguous +/// - Thread-unsafe for maximum performance +/// +[SkipLocalsInit] +public sealed class RingBuffer : IEnumerable +{ + private readonly double[] _buffer; + private readonly int _capacity; + private int _head; // Next write position (also start position when full) + private int _count; // Current number of elements + private double _sum; // Running sum of all elements + + /// + /// Creates a new RingBuffer with the specified capacity. + /// Uses pinned memory for SIMD compatibility. + /// + /// Maximum number of elements (must be > 0) + public RingBuffer(int capacity) + { + if (capacity <= 0) + throw new ArgumentException("Capacity must be greater than 0", nameof(capacity)); + + _capacity = capacity; + _buffer = GC.AllocateArray(capacity, pinned: true); + _head = 0; + _count = 0; + _sum = 0; + } + + /// + /// Maximum number of elements the buffer can hold. + /// + public int Capacity + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _capacity; + } + + /// + /// Current number of elements in the buffer. + /// + public int Count + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _count; + } + + /// + /// True if the buffer is full (Count == Capacity). + /// + public bool IsFull + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _count == _capacity; + } + + /// + /// Running sum of all elements in the buffer. + /// O(1) operation using maintained running sum. + /// + public double Sum + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _sum; + } + + /// + /// Average of all elements in the buffer. + /// Returns 0 if buffer is empty. + /// + public double Average + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _count > 0 ? _sum / _count : 0; + } + + /// + /// Gets the newest (most recently added) value. + /// + public double Newest + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + if (_count == 0) return 0; + int idx = (_head - 1 + _capacity) % _capacity; + return _buffer[idx]; + } + } + + /// + /// Gets the oldest value in the buffer. + /// + public double Oldest + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + if (_count == 0) return 0; + // When full, _head points to oldest; otherwise start is 0 + int start = _count == _capacity ? _head : 0; + return _buffer[start]; + } + } + + /// + /// Gets a read-only span over the internal buffer array for direct SIMD access. + /// + public ReadOnlySpan InternalBuffer + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _buffer.AsSpan(); + } + + /// + /// Adds a value to the buffer. + /// If full, the oldest value is overwritten and its value is subtracted from the sum. + /// Returns the value that was removed (0 if buffer was not full). + /// + /// Value to add + /// The removed oldest value, or 0 if buffer was not full + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public double Add(double value) + { + double removed = 0; + + if (_count == _capacity) + { + // Buffer is full: remove oldest value from sum + removed = _buffer[_head]; + _sum -= removed; + } + else + { + _count++; + } + + _buffer[_head] = value; + _sum += value; + _head = (_head + 1) % _capacity; + + return removed; + } + + /// + /// Adds a value with support for bar correction semantics. + /// + /// Value to add + /// True for new bar, false for update to current bar + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Add(double value, bool isNew) + { + if (isNew || _count == 0) + { + Add(value); + } + else + { + UpdateNewest(value); + } + } + + /// + /// Updates the newest (most recently added) value. + /// This is used for bar correction (isNew=false semantics). + /// + /// New value to replace the newest + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void UpdateNewest(double value) + { + if (_count == 0) return; + + int idx = (_head - 1 + _capacity) % _capacity; + double oldValue = _buffer[idx]; + _sum -= oldValue; + _sum += value; + _buffer[idx] = value; + } + + /// + /// Gets or sets element at the specified index (0 = oldest, Count-1 = newest). + /// Supports negative indexing via Index type. + /// + public double this[Index index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => GetAt(index); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => SetAt(index, value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double GetAt(Index index) + { + int actualIndex = index.IsFromEnd ? _count - index.Value : index.Value; + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)actualIndex, (uint)_count); + + int start = _count == _capacity ? _head : 0; + int bufferIdx = (start + actualIndex) % _capacity; + return _buffer[bufferIdx]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SetAt(Index index, double value) + { + int actualIndex = index.IsFromEnd ? _count - index.Value : index.Value; + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)actualIndex, (uint)_count); + + int start = _count == _capacity ? _head : 0; + int bufferIdx = (start + actualIndex) % _capacity; + + _sum -= _buffer[bufferIdx]; + _sum += value; + _buffer[bufferIdx] = value; + } + + /// + /// Returns a span over the buffer contents. + /// If buffer is contiguous, returns direct span (SIMD-friendly). + /// If wrapped, returns span over a copy. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ReadOnlySpan GetSpan() + { + if (_count == 0) return ReadOnlySpan.Empty; + + int start = _count == _capacity ? _head : 0; + + // Check if contiguous (no wrap) + if (start + _count <= _capacity) + { + return new ReadOnlySpan(_buffer, start, _count); + } + + // Wrapped - need to copy + return new ReadOnlySpan(ToArray()); + } + + /// + /// Returns a span over the entire internal buffer (for advanced SIMD use). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ReadOnlySpan GetInternalSpan() => _buffer.AsSpan(); + + /// + /// Returns the maximum value in the buffer using SIMD acceleration. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public double Max() + { + if (_count == 0) return double.NaN; + return MaxSimd(); + } + + /// + /// Returns the minimum value in the buffer using SIMD acceleration. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public double Min() + { + if (_count == 0) return double.NaN; + return MinSimd(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private double MaxSimd() + { + var span = GetSpan(); + var vectorSize = Vector.Count; + var maxVector = new Vector(double.MinValue); + + int i = 0; + ref double spanRef = ref MemoryMarshal.GetReference(span); + + for (; i <= span.Length - vectorSize; i += vectorSize) + { + maxVector = Vector.Max(maxVector, Unsafe.As>(ref Unsafe.Add(ref spanRef, i))); + } + + double max = double.MinValue; + for (int j = 0; j < vectorSize; j++) + { + max = Math.Max(max, maxVector[j]); + } + + for (; i < span.Length; i++) + { + max = Math.Max(max, span[i]); + } + + return max; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private double MinSimd() + { + var span = GetSpan(); + var vectorSize = Vector.Count; + var minVector = new Vector(double.MaxValue); + + int i = 0; + ref double spanRef = ref MemoryMarshal.GetReference(span); + + for (; i <= span.Length - vectorSize; i += vectorSize) + { + minVector = Vector.Min(minVector, Unsafe.As>(ref Unsafe.Add(ref spanRef, i))); + } + + double min = double.MaxValue; + for (int j = 0; j < vectorSize; j++) + { + min = Math.Min(min, minVector[j]); + } + + for (; i < span.Length; i++) + { + min = Math.Min(min, span[i]); + } + + return min; + } + + /// + /// Clears all elements from the buffer. + /// + public void Clear() + { + Array.Clear(_buffer, 0, _buffer.Length); + _head = 0; + _count = 0; + _sum = 0; + } + + /// + /// Copies the buffer elements to a new array in chronological order. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public double[] ToArray() + { + if (_count == 0) return Array.Empty(); + + double[] array = new double[_count]; + CopyTo(array, 0); + return array; + } + + /// + /// Copies elements to destination array starting at destinationIndex. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void CopyTo(double[] destination, int destinationIndex) + { + if (_count == 0) return; + + int start = _count == _capacity ? _head : 0; + + if (start + _count <= _capacity) + { + Array.Copy(_buffer, start, destination, destinationIndex, _count); + } + else + { + int firstPartLength = _capacity - start; + Array.Copy(_buffer, start, destination, destinationIndex, firstPartLength); + Array.Copy(_buffer, 0, destination, destinationIndex + firstPartLength, _count - firstPartLength); + } + } + + /// + /// Creates a copy of the current state for bar correction support. + /// + public RingBuffer Clone() + { + var clone = new RingBuffer(_capacity); + Array.Copy(_buffer, clone._buffer, _capacity); + clone._head = _head; + clone._count = _count; + clone._sum = _sum; + return clone; + } + + /// + /// Copies state from another RingBuffer. + /// Both buffers must have the same capacity. + /// + public void CopyFrom(RingBuffer source) + { + if (source._capacity != _capacity) + throw new ArgumentException("Source buffer must have same capacity", nameof(source)); + + Array.Copy(source._buffer, _buffer, _capacity); + _head = source._head; + _count = source._count; + _sum = source._sum; + } + + /// + /// Returns an enumerator that iterates through the buffer in chronological order. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Enumerator GetEnumerator() => new(this); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + /// High-performance enumerator for the RingBuffer. + /// + public struct Enumerator : IEnumerator + { + private readonly RingBuffer _buffer; + private readonly int _start; + private readonly int _count; + private int _index; + private double _current; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Enumerator(RingBuffer buffer) + { + _buffer = buffer; + _count = buffer._count; + _start = buffer._count == buffer._capacity ? buffer._head : 0; + _index = -1; + _current = default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + if (_index + 1 >= _count) + return false; + + _index++; + int bufferIdx = (_start + _index) % _buffer._capacity; + _current = _buffer._buffer[bufferIdx]; + return true; + } + + public double Current => _current; + object IEnumerator.Current => Current; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + { + _index = -1; + _current = default; + } + + public void Dispose() { } + } +}