LSMA indicator with tests and documentation

This commit is contained in:
Miha Kralj
2025-12-09 14:19:33 -05:00
parent c1caaf36b4
commit 861571d249
7 changed files with 1123 additions and 1 deletions
+1 -1
View File
@@ -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 | |
+182
View File
@@ -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);
}
}
+69
View File
@@ -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);
}
}
+196
View File
@@ -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<ArgumentException>(() => new Lsma(0));
Assert.Throws<ArgumentException>(() => 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);
}
}
+166
View File
@@ -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<Quote> _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<Quote>)
_skenderQuotes = new List<Quote>();
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<double>();
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<EpmaResult> 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<double> qResults, List<EpmaResult> 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<EpmaResult> 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);
}
}
}
+420
View File
@@ -0,0 +1,420 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// LSMA: Least Squares Moving Average
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
[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;
/// <summary>
/// Display name for the indicator.
/// </summary>
public string Name { get; }
public event Action<TValue>? Pub;
/// <summary>
/// Creates LSMA with specified period and offset.
/// </summary>
/// <param name="period">Lookback period (must be > 0)</param>
/// <param name="offset">Offset from current bar (default 0). Positive values project into future.</param>
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);
}
/// <summary>
/// Current LSMA value.
/// </summary>
public TValue Last { get; private set; }
/// <summary>
/// True if the LSMA has enough data to produce valid results.
/// </summary>
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<long>(len);
var v = new List<double>(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);
}
/// <summary>
/// Calculates LSMA for the entire series using a new instance.
/// </summary>
public static TSeries Calculate(TSeries source, int period, int offset = 0)
{
var lsma = new Lsma(period, offset);
return lsma.Update(source);
}
/// <summary>
/// Calculates LSMA in-place, writing results to pre-allocated output span.
/// Zero-allocation method for maximum performance.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> 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<double> 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;
}
}
}
/// <summary>
/// Resets the LSMA state.
/// </summary>
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;
}
}
+89
View File
@@ -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/)