mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-18 10:38:05 +00:00
LSMA indicator with tests and documentation
This commit is contained in:
@@ -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 | |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
Reference in New Issue
Block a user