From 861571d249535ba29568e9560c94af2b4965aea0 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Tue, 9 Dec 2025 14:19:33 -0500 Subject: [PATCH] LSMA indicator with tests and documentation --- lib/trends/_index.md | 2 +- lib/trends/lsma/Lsma.Quantower.Tests.cs | 182 ++++++++++ lib/trends/lsma/Lsma.Quantower.cs | 69 ++++ lib/trends/lsma/Lsma.Tests.cs | 196 +++++++++++ lib/trends/lsma/Lsma.Validation.Tests.cs | 166 +++++++++ lib/trends/lsma/Lsma.cs | 420 +++++++++++++++++++++++ lib/trends/lsma/Lsma.md | 89 +++++ 7 files changed, 1123 insertions(+), 1 deletion(-) create mode 100644 lib/trends/lsma/Lsma.Quantower.Tests.cs create mode 100644 lib/trends/lsma/Lsma.Quantower.cs create mode 100644 lib/trends/lsma/Lsma.Tests.cs create mode 100644 lib/trends/lsma/Lsma.Validation.Tests.cs create mode 100644 lib/trends/lsma/Lsma.cs create mode 100644 lib/trends/lsma/Lsma.md diff --git a/lib/trends/_index.md b/lib/trends/_index.md index 5dee5112..49afbd64 100644 --- a/lib/trends/_index.md +++ b/lib/trends/_index.md @@ -36,7 +36,7 @@ Trend indicators help identify the direction and strength of a market trend. Mov | [KAMA](trends/kama/Kama.md) | Kaufman Adaptive MA | Adapts to market volatility by adjusting its smoothing factor based on an Efficiency Ratio. | | KF | Kalman Filter | | | LOESS | LOESS/LOWESS Smoothing | | -| LSMA | Least Squares MA | | +| [LSMA](trends/lsma/Lsma.md) | Least Squares MA | Calculates the linear regression line for a specified period. | | LTMA | Linear Trend MA | | | MAMA | MESA Adaptive MA | | | MEDIAN | Median Filter | | diff --git a/lib/trends/lsma/Lsma.Quantower.Tests.cs b/lib/trends/lsma/Lsma.Quantower.Tests.cs new file mode 100644 index 00000000..50f075ec --- /dev/null +++ b/lib/trends/lsma/Lsma.Quantower.Tests.cs @@ -0,0 +1,182 @@ +using Xunit; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib.Tests; + +public class LsmaIndicatorTests +{ + [Fact] + public void LsmaIndicator_Constructor_SetsDefaults() + { + var indicator = new LsmaIndicator(); + + Assert.Equal(14, indicator.Period); + Assert.Equal(0, indicator.Offset); + Assert.Equal(SourceType.Close, indicator.Source); + Assert.True(indicator.ShowColdValues); + Assert.Equal("LSMA - Least Squares Moving Average", indicator.Name); + Assert.False(indicator.SeparateWindow); + Assert.True(indicator.OnBackGround); + } + + [Fact] + public void LsmaIndicator_MinHistoryDepths_EqualsPeriod() + { + var indicator = new LsmaIndicator { Period = 20 }; + + Assert.Equal(20, indicator.MinHistoryDepths); + Assert.Equal(20, ((IWatchlistIndicator)indicator).MinHistoryDepths); + } + + [Fact] + public void LsmaIndicator_ShortName_IncludesPeriodOffsetAndSource() + { + var indicator = new LsmaIndicator { Period = 15, Offset = 2 }; + + Assert.Contains("LSMA", indicator.ShortName); + Assert.Contains("15", indicator.ShortName); + Assert.Contains("2", indicator.ShortName); + } + + [Fact] + public void LsmaIndicator_SourceCodeLink_IsValid() + { + var indicator = new LsmaIndicator(); + + Assert.Contains("github.com", indicator.SourceCodeLink); + Assert.Contains("Lsma.Quantower.cs", indicator.SourceCodeLink); + } + + [Fact] + public void LsmaIndicator_Initialize_CreatesInternalLsma() + { + var indicator = new LsmaIndicator { Period = 10 }; + + // Initialize should not throw + indicator.Initialize(); + + // After init, line series should exist + Assert.Single(indicator.LinesSeries); + } + + [Fact] + public void LsmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue() + { + var indicator = new LsmaIndicator { Period = 3 }; + indicator.Initialize(); + + // Add historical data + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + // Process update + var args = new UpdateArgs(UpdateReason.HistoricalBar); + indicator.ProcessUpdate(args); + + // Line series should have a value + Assert.Equal(1, indicator.LinesSeries[0].Count); + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0))); + } + + [Fact] + public void LsmaIndicator_ProcessUpdate_NewBar_ComputesValue() + { + var indicator = new LsmaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar)); + + Assert.Equal(2, indicator.LinesSeries[0].Count); + } + + [Fact] + public void LsmaIndicator_ProcessUpdate_NewTick_ProcessesWithoutError() + { + var indicator = new LsmaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 105, 95, 102); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + double firstValue = indicator.LinesSeries[0].GetValue(0); + + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick)); + double secondValue = indicator.LinesSeries[0].GetValue(0); + + Assert.True(double.IsFinite(firstValue)); + Assert.True(double.IsFinite(secondValue)); + } + + [Fact] + public void LsmaIndicator_OnPaintChart_DoesNotThrow() + { + var indicator = new LsmaIndicator(); + indicator.Initialize(); + + var method = indicator.GetType().GetMethod("OnPaintChart"); + Assert.NotNull(method); + Assert.Equal(typeof(LsmaIndicator), method.DeclaringType); + } + + [Fact] + public void LsmaIndicator_MultipleUpdates_ProducesCorrectSequence() + { + var indicator = new LsmaIndicator { Period = 3 }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + double[] closes = { 100, 102, 104, 103, 105 }; + + foreach (var close in closes) + { + indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + now = now.AddMinutes(1); + } + + // All values should be finite + for (int i = 0; i < closes.Length; i++) + { + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i))); + } + } + + [Fact] + public void LsmaIndicator_DifferentSourceTypes_Work() + { + var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 }; + + foreach (var source in sources) + { + var indicator = new LsmaIndicator { Period = 3, Source = source }; + indicator.Initialize(); + + var now = DateTime.UtcNow; + indicator.HistoricalData.AddBar(now, 100, 110, 90, 105); + indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); + + Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)), + $"Source {source} should produce finite value"); + } + } + + [Fact] + public void LsmaIndicator_PeriodAndOffset_CanBeChanged() + { + var indicator = new LsmaIndicator { Period = 5, Offset = 0 }; + Assert.Equal(5, indicator.Period); + Assert.Equal(0, indicator.Offset); + + indicator.Period = 20; + indicator.Offset = 2; + Assert.Equal(20, indicator.Period); + Assert.Equal(2, indicator.Offset); + Assert.Equal(20, indicator.MinHistoryDepths); + } +} diff --git a/lib/trends/lsma/Lsma.Quantower.cs b/lib/trends/lsma/Lsma.Quantower.cs new file mode 100644 index 00000000..33f019dc --- /dev/null +++ b/lib/trends/lsma/Lsma.Quantower.cs @@ -0,0 +1,69 @@ +using System.Drawing; +using TradingPlatform.BusinessLayer; + +namespace QuanTAlib; + +public class LsmaIndicator : Indicator, IWatchlistIndicator +{ + [InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)] + public int Period { get; set; } = 14; + + [InputParameter("Offset", sortIndex: 2, -1000, 1000, 1, 0)] + public int Offset { get; set; } = 0; + + [IndicatorExtensions.DataSourceInput] + public SourceType Source { get; set; } = SourceType.Close; + + [InputParameter("Show cold values", sortIndex: 21)] + public bool ShowColdValues { get; set; } = true; + + private Lsma? ma; + protected LineSeries? Series; + protected string? SourceName; + private int _warmupBarIndex = -1; + + public int MinHistoryDepths => Period; + int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths; + + public override string ShortName => $"LSMA {Period}:{Offset}:{SourceName}"; + public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/trends/lsma/Lsma.Quantower.cs"; + + public LsmaIndicator() + { + OnBackGround = true; + SeparateWindow = false; + SourceName = Source.ToString(); + Name = "LSMA - Least Squares Moving Average"; + Description = "Least Squares Moving Average"; + Series = new(name: $"LSMA {Period}", color: IndicatorExtensions.Averages, width: 2, style: LineStyle.Solid); + AddLineSeries(Series); + } + + protected override void OnInit() + { + ma = new Lsma(Period, Offset); + SourceName = Source.ToString(); + _warmupBarIndex = -1; // Reset warmup tracking when period changes + 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 + + // Track when IsHot becomes true for the first time + if (_warmupBarIndex < 0 && ma!.IsHot) + _warmupBarIndex = Count; + } + + public override void OnPaintChart(PaintChartEventArgs args) + { + base.OnPaintChart(args); + int warmupPeriod = _warmupBarIndex > 0 ? _warmupBarIndex : Count; + this.PaintSmoothCurve(args, Series!, warmupPeriod, showColdValues: ShowColdValues, tension: 0.2); + } +} diff --git a/lib/trends/lsma/Lsma.Tests.cs b/lib/trends/lsma/Lsma.Tests.cs new file mode 100644 index 00000000..d8ba64ae --- /dev/null +++ b/lib/trends/lsma/Lsma.Tests.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace QuanTAlib.Tests; + +public class LsmaTests +{ + [Fact] + public void Constructor_InvalidPeriod_ThrowsArgumentException() + { + Assert.Throws(() => new Lsma(0)); + Assert.Throws(() => new Lsma(-1)); + } + + [Fact] + public void Constructor_ValidParameters_SetsProperties() + { + var lsma = new Lsma(14, 0); + Assert.Equal("Lsma(14)", lsma.Name); + Assert.False(lsma.IsHot); + } + + [Fact] + public void Update_SingleValue_ReturnsSameValue() + { + var lsma = new Lsma(14); + var result = lsma.Update(new TValue(DateTime.Now, 100)); + Assert.Equal(100, result.Value); + } + + [Fact] + public void Update_LinearTrend_ReturnsExactValue() + { + // For a perfect linear trend y = x, LSMA should return x + int period = 10; + var lsma = new Lsma(period); + + for (int i = 0; i < period * 2; i++) + { + var result = lsma.Update(new TValue(DateTime.Now, i)); + if (i >= period) // After warmup + { + Assert.Equal(i, result.Value, 1e-9); + } + } + } + + [Fact] + public void Update_ConstantValue_ReturnsSameValue() + { + int period = 10; + var lsma = new Lsma(period); + double value = 123.45; + + for (int i = 0; i < period * 2; i++) + { + var result = lsma.Update(new TValue(DateTime.Now, value)); + Assert.Equal(value, result.Value, 1e-9); + } + } + + [Fact] + public void Update_WithOffset_ProjectsCorrectly() + { + // y = 2x + 1 + // At x=10, y=21. Slope=2, Intercept=1 + // LSMA(offset=1) should project to x=11 -> y=23 + + int period = 5; + int offset = 1; + var lsma = new Lsma(period, offset); + + for (int i = 0; i < 20; i++) + { + double y = 2 * i + 1; + var result = lsma.Update(new TValue(DateTime.Now, y)); + + if (i >= period) + { + double expected = 2 * (i + offset) + 1; + Assert.Equal(expected, result.Value, 1e-9); + } + } + } + + [Fact] + public void Update_BarCorrection_UpdatesCorrectly() + { + var lsma = new Lsma(5); + + // Fill buffer + for (int i = 0; i < 5; i++) + { + lsma.Update(new TValue(DateTime.Now, i)); + } + + // New bar + var result1 = lsma.Update(new TValue(DateTime.Now, 10)); + + // Update same bar with different value + var result2 = lsma.Update(new TValue(DateTime.Now, 20), isNew: false); + + Assert.NotEqual(result1.Value, result2.Value); + + // Verify internal state by adding next bar + // If state was corrupted, this would fail + var result3 = lsma.Update(new TValue(DateTime.Now, 30)); + Assert.True(double.IsFinite(result3.Value)); + } + + [Fact] + public void Update_NaN_HandlesGracefully() + { + var lsma = new Lsma(5); + + lsma.Update(new TValue(DateTime.Now, 1)); + lsma.Update(new TValue(DateTime.Now, 2)); + var result = lsma.Update(new TValue(DateTime.Now, double.NaN)); + + // Input sequence becomes: 1, 2, 2 (NaN replaced by last valid 2) + // Regression on (2,1), (1,2), (0,2) + // Result should be 2.166666667 + Assert.Equal(2.1666666666666665, result.Value, 1e-9); + } + + [Fact] + public void Calculate_StaticMethod_MatchesObjectInstance() + { + int period = 10; + int count = 100; + var source = new TSeries(); + var rnd = new Random(42); + + for (int i = 0; i < count; i++) + { + source.Add(new TValue(DateTime.Now.AddMinutes(i), rnd.NextDouble() * 100)); + } + + var lsma = new Lsma(period); + var series1 = lsma.Update(source); + var series2 = Lsma.Calculate(source, period); + + Assert.Equal(series1.Count, series2.Count); + for (int i = 0; i < count; i++) + { + Assert.Equal(series1[i].Value, series2[i].Value, 1e-9); + } + } + + [Fact] + public void Calculate_Span_MatchesSeries() + { + int period = 10; + int count = 100; + var values = new double[count]; + var output = new double[count]; + var rnd = new Random(42); + + for (int i = 0; i < count; i++) + { + values[i] = rnd.NextDouble() * 100; + } + + Lsma.Calculate(values, output, period); + + var lsma = new Lsma(period); + for (int i = 0; i < count; i++) + { + var result = lsma.Update(new TValue(DateTime.Now, values[i])); + Assert.Equal(result.Value, output[i], 1e-9); + } + } + + [Fact] + public void Reset_ClearsState() + { + var lsma = new Lsma(5); + for (int i = 0; i < 10; i++) + { + lsma.Update(new TValue(DateTime.Now, i)); + } + + Assert.True(lsma.IsHot); + + lsma.Reset(); + + Assert.False(lsma.IsHot); + Assert.Equal(0, lsma.Last.Value); + + // Should behave like new instance + var result = lsma.Update(new TValue(DateTime.Now, 100)); + Assert.Equal(100, result.Value); + } +} diff --git a/lib/trends/lsma/Lsma.Validation.Tests.cs b/lib/trends/lsma/Lsma.Validation.Tests.cs new file mode 100644 index 00000000..a8072f39 --- /dev/null +++ b/lib/trends/lsma/Lsma.Validation.Tests.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Skender.Stock.Indicators; +using Xunit; +using Xunit.Abstractions; + +namespace QuanTAlib.Tests; + +public class LsmaValidationTests +{ + private readonly TBarSeries _bars; + private readonly TSeries _data; + private readonly List _skenderQuotes; + private readonly ITestOutputHelper _output; + + public LsmaValidationTests(ITestOutputHelper output) + { + _output = output; + + // 1. Generate 5000 records using GBM feed + var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2); + _bars = gbm.Fetch(5000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); + + // 2. Extract Close TSeries + _data = _bars.Close; + + // 3. Prepare data for Skender (List) + _skenderQuotes = new List(); + for (int i = 0; i < _bars.Count; i++) + { + _skenderQuotes.Add(new Quote + { + Date = new DateTime(_bars.Open.Times[i], DateTimeKind.Utc), + Open = (decimal)_bars.Open[i].Value, + High = (decimal)_bars.High[i].Value, + Low = (decimal)_bars.Low[i].Value, + Close = (decimal)_bars.Close[i].Value, + Volume = (decimal)_bars.Volume[i].Value + }); + } + } + + [Fact] + public void Validate_Skender_Batch() + { + int[] periods = { 5, 10, 20, 50, 100 }; + + foreach (var period in periods) + { + // Calculate QuanTAlib LSMA (batch TSeries) + var lsma = new global::QuanTAlib.Lsma(period); + var qResult = lsma.Update(_data); + + // Calculate Skender EPMA (Endpoint Moving Average = LSMA) + var sResult = _skenderQuotes.GetEpma(period).ToList(); + + // Compare last 100 records + VerifyData_Skender(qResult, sResult); + } + _output.WriteLine("LSMA Batch(TSeries) validated successfully against Skender"); + } + + [Fact] + public void Validate_Skender_Streaming() + { + int[] periods = { 5, 10, 20, 50, 100 }; + + foreach (var period in periods) + { + // Calculate QuanTAlib LSMA (streaming) + var lsma = new global::QuanTAlib.Lsma(period); + var qResults = new List(); + foreach (var item in _data) + { + qResults.Add(lsma.Update(item).Value); + } + + // Calculate Skender EPMA + var sResult = _skenderQuotes.GetEpma(period).ToList(); + + // Compare last 100 records + VerifyData_Skender_Streaming(qResults, sResult); + } + _output.WriteLine("LSMA Streaming validated successfully against Skender"); + } + + [Fact] + public void Validate_Skender_Span() + { + int[] periods = { 5, 10, 20, 50, 100 }; + + // Prepare data for Span API + double[] sourceData = _data.Select(x => x.Value).ToArray(); + + foreach (var period in periods) + { + // Calculate QuanTAlib LSMA (Span API) + double[] qOutput = new double[sourceData.Length]; + global::QuanTAlib.Lsma.Calculate(sourceData.AsSpan(), qOutput.AsSpan(), period); + + // Calculate Skender EPMA + var sResult = _skenderQuotes.GetEpma(period).ToList(); + + // Compare last 100 records + VerifyData_Skender_Span(qOutput, sResult); + } + _output.WriteLine("LSMA Span validated successfully against Skender"); + } + + // ==================== Verification Helpers ==================== + + private static void VerifyData_Skender(TSeries qSeries, List sSeries) + { + Assert.Equal(qSeries.Count, sSeries.Count); + + int count = qSeries.Count; + int skip = count - 100; + + for (int i = skip; i < count; i++) + { + double qValue = qSeries[i].Value; + double? sValue = sSeries[i].Epma; + + if (!sValue.HasValue) continue; + + Assert.Equal(sValue.Value, qValue, 1e-6); + } + } + + private static void VerifyData_Skender_Streaming(List qResults, List sSeries) + { + Assert.Equal(qResults.Count, sSeries.Count); + + int count = qResults.Count; + int skip = count - 100; + + for (int i = skip; i < count; i++) + { + double qValue = qResults[i]; + double? sValue = sSeries[i].Epma; + + if (!sValue.HasValue) continue; + + Assert.Equal(sValue.Value, qValue, 1e-6); + } + } + + private static void VerifyData_Skender_Span(double[] qOutput, List sSeries) + { + Assert.Equal(qOutput.Length, sSeries.Count); + + int count = qOutput.Length; + int skip = count - 100; + + for (int i = skip; i < count; i++) + { + double qValue = qOutput[i]; + double? sValue = sSeries[i].Epma; + + if (!sValue.HasValue) continue; + + Assert.Equal(sValue.Value, qValue, 1e-6); + } + } +} diff --git a/lib/trends/lsma/Lsma.cs b/lib/trends/lsma/Lsma.cs new file mode 100644 index 00000000..b2b8c2e3 --- /dev/null +++ b/lib/trends/lsma/Lsma.cs @@ -0,0 +1,420 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// LSMA: Least Squares Moving Average +/// +/// +/// LSMA calculates the linear regression line for the last n values and returns the value at the current position (or offset). +/// Uses a RingBuffer for storage and O(1) updates for regression sums. +/// +/// Calculation: +/// Uses linear regression y = mx + b where x=0 is the current bar and x increases into the past. +/// m = (n * sum_xy - sum_x * sum_y) / denominator +/// b = (sum_y - m * sum_x) / n +/// LSMA = b - m * offset +/// +/// O(1) update: +/// sum_y_new = sum_y_old - oldest + newest +/// sum_xy_new = sum_xy_old + sum_y_prev - n * oldest +/// +/// IsHot: +/// Becomes true when the buffer is full (period samples processed). +/// +[SkipLocalsInit] +public sealed class Lsma : ITValuePublisher +{ + private readonly int _period; + private readonly int _offset; + private readonly RingBuffer _buffer; + + private readonly double _sum_x; + private readonly double _denominator; + + private double _sum_y; + private double _sum_xy; + + private double _p_sum_y; + private double _p_sum_xy; + private double _p_last_val; + + private double _lastValidValue; + private double _p_lastValidValue; + private int _tickCount; + + private const int ResyncInterval = 1000; + + /// + /// Display name for the indicator. + /// + public string Name { get; } + + public event Action? Pub; + + /// + /// Creates LSMA with specified period and offset. + /// + /// Lookback period (must be > 0) + /// Offset from current bar (default 0). Positive values project into future. + public Lsma(int period, int offset = 0) + { + if (period <= 0) + throw new ArgumentException("Period must be greater than 0", nameof(period)); + + _period = period; + _offset = offset; + _buffer = new RingBuffer(period); + Name = $"Lsma({period})"; + + // Precalculate constants + // sum_x = 0 + 1 + ... + (n-1) = n(n-1)/2 + _sum_x = 0.5 * period * (period - 1); + + // sum_x2 = 0^2 + ... + (n-1)^2 = (n-1)n(2n-1)/6 + double sum_x2 = (period - 1.0) * period * (2.0 * period - 1.0) / 6.0; + + // denominator = n * sum_x2 - sum_x^2 + _denominator = period * sum_x2 - _sum_x * _sum_x; + } + + public Lsma(ITValuePublisher source, int period, int offset = 0) : this(period, offset) + { + source.Pub += (item) => Update(item); + } + + /// + /// Current LSMA value. + /// + public TValue Last { get; private set; } + + /// + /// True if the LSMA has enough data to produce valid results. + /// + public bool IsHot => _buffer.IsFull; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double GetValidValue(double input) + { + if (double.IsFinite(input)) + { + _lastValidValue = input; + return input; + } + return _lastValidValue; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateState(double val) + { + if (_buffer.IsFull) + { + double oldest = _buffer.Oldest; + double prev_sum_y = _sum_y; + + // O(1) update for sum_xy + // sum_xy_new = sum_xy_old + sum_y_prev - n * oldest + _sum_xy = _sum_xy + prev_sum_y - _period * oldest; + + // O(1) update for sum_y + _sum_y = _sum_y - oldest + val; + + _buffer.Add(val); + } + else + { + _buffer.Add(val); + _sum_y += val; + + // Recalculate sum_xy from scratch during warmup + _sum_xy = 0; + var span = _buffer.GetSpan(); + for (int i = 0; i < span.Length; i++) + { + // x=0 is newest (index count-1), x=count-1 is oldest (index 0) + // buffer stores chronological: [oldest, ..., newest] + // index j in buffer corresponds to x = count - 1 - j + // sum_xy = sum(x * y) + int x = span.Length - 1 - i; + _sum_xy += x * span[i]; + } + } + + _tickCount++; + if (_buffer.IsFull && _tickCount >= ResyncInterval) + { + _tickCount = 0; + Resync(); + } + } + + private void Resync() + { + _sum_y = _buffer.Sum; + _sum_xy = 0; + var span = _buffer.GetSpan(); + for (int i = 0; i < span.Length; i++) + { + int x = span.Length - 1 - i; + _sum_xy += x * span[i]; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TValue Update(TValue input, bool isNew = true) + { + if (isNew) + { + double val = GetValidValue(input.Value); + UpdateState(val); + + _p_sum_y = _sum_y; + _p_sum_xy = _sum_xy; + _p_last_val = val; + _p_lastValidValue = _lastValidValue; + } + else + { + _lastValidValue = _p_lastValidValue; + double val = GetValidValue(input.Value); + + // For isNew=false, we update the current bar. + // sum_xy remains constant because it depends on the previous window state which hasn't changed. + // sum_y updates to reflect the change in the newest value. + + _sum_y = _p_sum_y - _p_last_val + val; + _sum_xy = _p_sum_xy; // Restore sum_xy to the state after the shift + + _buffer.UpdateNewest(val); + _p_last_val = val; + } + + double result; + if (_buffer.Count <= 1) + { + result = _buffer.Newest; + } + else + { + // Calculate regression parameters + // During warmup, we use the current count as n + double n = _buffer.Count; + double sx = _sum_x; + double denom = _denominator; + + if (!_buffer.IsFull) + { + // Recalculate constants for smaller n + sx = 0.5 * n * (n - 1); + double sx2 = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + denom = n * sx2 - sx * sx; + } + + if (Math.Abs(denom) < 1e-10) + { + result = _buffer.Newest; + } + else + { + double m = (n * _sum_xy - sx * _sum_y) / denom; + double b = (_sum_y - m * sx) / n; + + // LSMA = b - m * offset + result = b - m * _offset; + } + } + + Last = new TValue(input.Time, result); + Pub?.Invoke(Last); + return Last; + } + + public TSeries Update(TSeries source) + { + if (source.Count == 0) return []; + + int len = source.Count; + var t = new List(len); + var v = new List(len); + CollectionsMarshal.SetCount(t, len); + CollectionsMarshal.SetCount(v, len); + + var tSpan = CollectionsMarshal.AsSpan(t); + var vSpan = CollectionsMarshal.AsSpan(v); + + Calculate(source.Values, vSpan, _period, _offset); + source.Times.CopyTo(tSpan); + + // Restore state + // We need to replay the last 'period' bars to set up the buffer and sums correctly + int windowSize = Math.Min(len, _period); + int startIndex = len - windowSize; + + // Initialize lastValidValue + if (startIndex > 0) + { + for (int i = startIndex - 1; i >= 0; i--) + { + if (double.IsFinite(source.Values[i])) + { + _lastValidValue = source.Values[i]; + break; + } + } + } + else + { + _lastValidValue = 0; + } + + Reset(); + + for (int i = startIndex; i < len; i++) + { + double val = GetValidValue(source.Values[i]); + UpdateState(val); + } + + _p_sum_y = _sum_y; + _p_sum_xy = _sum_xy; + _p_last_val = source.Values[len - 1]; + _p_lastValidValue = _lastValidValue; + + Last = new TValue(tSpan[len - 1], vSpan[len - 1]); + return new TSeries(t, v); + } + + /// + /// Calculates LSMA for the entire series using a new instance. + /// + public static TSeries Calculate(TSeries source, int period, int offset = 0) + { + var lsma = new Lsma(period, offset); + return lsma.Update(source); + } + + /// + /// Calculates LSMA in-place, writing results to pre-allocated output span. + /// Zero-allocation method for maximum performance. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Calculate(ReadOnlySpan source, Span output, int period, int offset = 0) + { + if (source.Length != output.Length) + throw new ArgumentException("Source and output must have the same length"); + if (period <= 0) + throw new ArgumentException("Period must be greater than 0", nameof(period)); + + int len = source.Length; + if (len == 0) return; + + const int StackAllocThreshold = 256; + Span buffer = period <= StackAllocThreshold + ? stackalloc double[period] + : new double[period]; + + double sum_y = 0; + double sum_xy = 0; + double lastValid = 0; + int bufferIndex = 0; // Points to where the NEXT value will be written (circular) + int count = 0; + + // Precalculate constants for full period + double full_sum_x = 0.5 * period * (period - 1); + double full_sum_x2 = (period - 1.0) * period * (2.0 * period - 1.0) / 6.0; + double full_denom = period * full_sum_x2 - full_sum_x * full_sum_x; + + for (int i = 0; i < len; i++) + { + double val = source[i]; + if (double.IsFinite(val)) + lastValid = val; + else + val = lastValid; + + if (count < period) + { + // Warmup phase + buffer[count] = val; + sum_y += val; + count++; + + // Recalculate sum_xy for current count + sum_xy = 0; + for (int j = 0; j < count; j++) + { + // buffer[j] is at index j + // x = count - 1 - j + sum_xy += (count - 1 - j) * buffer[j]; + } + + if (count <= 1) + { + output[i] = val; + } + else + { + double n = count; + double sx = 0.5 * n * (n - 1); + double sx2 = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + double denom = n * sx2 - sx * sx; + + if (Math.Abs(denom) < 1e-10) + { + output[i] = val; + } + else + { + double m = (n * sum_xy - sx * sum_y) / denom; + double b = (sum_y - m * sx) / n; + output[i] = b - m * offset; + } + } + + if (count == period) + { + bufferIndex = 0; // Reset for circular buffer usage + } + } + else + { + // Full buffer phase - O(1) update + double oldest = buffer[bufferIndex]; + double prev_sum_y = sum_y; + + // sum_xy_new = sum_xy_old + sum_y_prev - n * oldest + sum_xy = sum_xy + prev_sum_y - period * oldest; + + sum_y = sum_y - oldest + val; + buffer[bufferIndex] = val; + + bufferIndex++; + if (bufferIndex >= period) + bufferIndex = 0; + + double m = (period * sum_xy - full_sum_x * sum_y) / full_denom; + double b = (sum_y - m * full_sum_x) / period; + output[i] = b - m * offset; + } + } + } + + /// + /// Resets the LSMA state. + /// + public void Reset() + { + _buffer.Clear(); + _sum_y = 0; + _sum_xy = 0; + _p_sum_y = 0; + _p_sum_xy = 0; + _p_last_val = 0; + Last = default; + _tickCount = 0; + _lastValidValue = 0; + _p_lastValidValue = 0; + } +} diff --git a/lib/trends/lsma/Lsma.md b/lib/trends/lsma/Lsma.md new file mode 100644 index 00000000..a7b8fa4d --- /dev/null +++ b/lib/trends/lsma/Lsma.md @@ -0,0 +1,89 @@ +# LSMA (Least Squares Moving Average) + +The Least Squares Moving Average (LSMA), also known as the Moving Linear Regression or End Point Moving Average, calculates the linear regression line for a specified period and returns the value at the current bar (or a projected point). Unlike traditional moving averages that simply average past prices, LSMA fits a straight line to the data to minimize the sum of squared errors, providing a better representation of the trend direction and strength. + +## Core Concepts + +- **Linear Regression:** Fits a line $y = mx + b$ to the price data over the lookback period. +- **Trend Following:** The slope of the regression line indicates the trend direction. +- **Reduced Lag:** By projecting the line to the current bar (or future), LSMA reacts faster to price changes than SMA or EMA. +- **Projection:** Can project the value into the future (positive offset) or past (negative offset). + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `period` | `int` | 14 | The number of bars to include in the regression calculation. | +| `offset` | `int` | 0 | The offset from the current bar. 0 = current bar, >0 = future projection, <0 = past value. | + +## Formula + +For a period $n$, we fit a line $y = mx + b$ where $x$ represents the time index ($0$ to $n-1$). + +The slope $m$ and intercept $b$ are calculated as: + +$$ m = \frac{n \sum(xy) - \sum x \sum y}{n \sum(x^2) - (\sum x)^2} $$ + +$$ b = \frac{\sum y - m \sum x}{n} $$ + +The LSMA value is then calculated at the desired offset: + +$$ LSMA = b + m \times (n - 1 + \text{offset}) $$ + +*Note: In the implementation, we may adjust the coordinate system (e.g., $x=0$ as current bar) for computational efficiency, but the geometric result is identical.* + +## C# Implementation + +### Standard Usage + +```csharp +using QuanTAlib; + +// Create LSMA with period 14 +var lsma = new Lsma(14); + +// Update with new values +var result = lsma.Update(new TValue(DateTime.Now, 100.0)); +Console.WriteLine($"LSMA: {result.Value}"); +``` + +### With Offset + +```csharp +// Create LSMA with period 14 and offset 1 (project 1 bar into future) +var lsma = new Lsma(14, offset: 1); +``` + +### Span API (High Performance) + +```csharp +double[] input = { ... }; +double[] output = new double[input.Length]; + +// Calculate LSMA in-place +Lsma.Calculate(input, output, period: 14); +``` + +### Bar Correction + +```csharp +var lsma = new Lsma(14); + +// Update for the current bar +lsma.Update(new TValue(time, 100.0)); + +// Correction for the same bar (e.g., market data update) +lsma.Update(new TValue(time, 101.0), isNew: false); +``` + +## Interpretation + +- **Trend Direction:** If LSMA is moving up, the trend is bullish. If moving down, the trend is bearish. +- **Crossovers:** Price crossing above LSMA can be a buy signal; crossing below can be a sell signal. +- **Support/Resistance:** LSMA often acts as dynamic support or resistance in trending markets. +- **Slope:** The steepness of the LSMA line indicates the strength of the trend. + +## References + +- [Linear Regression in Technical Analysis](https://www.investopedia.com/terms/l/linearregression.asp) +- [Least Squares Moving Average](https://www.tradingview.com/support/solutions/43000502584-least-squares-moving-average-lsma/)