From b4b272bf00ecfab6967ee75b1e8ecc62f607aec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=B1nar=20Aksoy?= Date: Tue, 7 Apr 2026 16:33:36 +0300 Subject: [PATCH] Extend LeastSquaresMovingAverage to accept a benchmark reference symbol Add new constructors that accept a reference Symbol, allowing LSMA to regress the target against a benchmark instead of time. Add corresponding LSMA overload in QCAlgorithm.Indicators.cs following the Alpha indicator pattern for dual-symbol registration. Use Symbol.None and RollingWindow(0) as defaults to simplify null checks. Use SafeDecimalCast and tuple deconstruction for Fit.Line results. Resolves #6984 --- Algorithm/QCAlgorithm.Indicators.cs | 20 ++++ Indicators/LeastSquaresMovingAverage.cs | 86 +++++++++++++-- .../LeastSquaresMovingAverageTests.cs | 100 +++++++++++++++++- 3 files changed, 198 insertions(+), 8 deletions(-) diff --git a/Algorithm/QCAlgorithm.Indicators.cs b/Algorithm/QCAlgorithm.Indicators.cs index e51f0c0961b0..8caf49538df9 100644 --- a/Algorithm/QCAlgorithm.Indicators.cs +++ b/Algorithm/QCAlgorithm.Indicators.cs @@ -1365,6 +1365,26 @@ public LeastSquaresMovingAverage LSMA(Symbol symbol, int period, Resolution? res return leastSquaresMovingAverage; } + /// + /// Creates and registers a new Least Squares Moving Average instance with a reference symbol. + /// The regression is performed against the reference symbol values instead of time. + /// + /// The symbol whose LSMA we seek. + /// The reference symbol to regress against. + /// The LSMA period. Normally 14. + /// The resolution. + /// Selects a value from the BaseData to send into the indicator, if null defaults to casting the input value to a TradeBar. + /// A LeastSquaredMovingAverage configured with the specified period and reference + [DocumentationAttribute(Indicators)] + public LeastSquaresMovingAverage LSMA(Symbol symbol, Symbol reference, int period, Resolution? resolution = null, Func selector = null) + { + var name = CreateIndicatorName(symbol, $"LSMA({period},{reference})", resolution); + var leastSquaresMovingAverage = new LeastSquaresMovingAverage(name, reference, period); + InitializeIndicator(leastSquaresMovingAverage, resolution, selector, symbol, reference); + + return leastSquaresMovingAverage; + } + /// /// Creates a new LinearWeightedMovingAverage indicator. This indicator will linearly distribute /// the weights across the periods. diff --git a/Indicators/LeastSquaresMovingAverage.cs b/Indicators/LeastSquaresMovingAverage.cs index c946d6bf1fa9..308423e19477 100644 --- a/Indicators/LeastSquaresMovingAverage.cs +++ b/Indicators/LeastSquaresMovingAverage.cs @@ -24,6 +24,8 @@ namespace QuantConnect.Indicators /// The Least Squares Moving Average (LSMA) first calculates a least squares regression line /// over the preceding time periods, and then projects it forward to the current period. In /// essence, it calculates what the value would be if the regression line continued. + /// When a reference symbol is provided, the regression is performed against the reference + /// values instead of time. /// Source: https://rtmath.net/assets/docs/finanalysis/html/b3fab79c-f4b2-40fb-8709-fdba43cdb363.htm /// public class LeastSquaresMovingAverage : WindowIndicator, IIndicatorWarmUpPeriodProvider @@ -33,6 +35,16 @@ public class LeastSquaresMovingAverage : WindowIndicator, II /// private readonly double[] _t; + /// + /// The reference symbol to regress against. + /// + private readonly Symbol _referenceSymbol = Symbol.None; + + /// + /// Rolling window of reference symbol data points. + /// + private readonly RollingWindow _referenceWindow = new(0); + /// /// The point where the regression line crosses the y-axis (price-axis) /// @@ -48,6 +60,11 @@ public class LeastSquaresMovingAverage : WindowIndicator, II /// public int WarmUpPeriod => Period; + /// + /// Gets a flag indicating when this indicator is ready and fully initialized + /// + public override bool IsReady => base.IsReady && _referenceWindow.IsReady; + /// /// Initializes a new instance of the class. /// @@ -70,6 +87,47 @@ public LeastSquaresMovingAverage(int period) { } + /// + /// Initializes a new instance of the class + /// with a reference symbol for regression. + /// + /// The name of this indicator + /// The reference symbol to regress against + /// The number of data points to hold in the window + public LeastSquaresMovingAverage(string name, Symbol referenceSymbol, int period) + : this(name, period) + { + _referenceSymbol = referenceSymbol; + _referenceWindow = new RollingWindow(period); + } + + /// + /// Initializes a new instance of the class + /// with a reference symbol for regression. + /// + /// The reference symbol to regress against + /// The number of data points to hold in the window + public LeastSquaresMovingAverage(Symbol referenceSymbol, int period) + : this($"LSMA({period},{referenceSymbol})", referenceSymbol, period) + { + } + + /// + /// Computes the next value of this indicator from the given state + /// + /// The input given to the indicator + /// A new value for this indicator + protected override decimal ComputeNextValue(IndicatorDataPoint input) + { + if (input.Symbol == _referenceSymbol) + { + _referenceWindow.Add(input); + return Current.Value; + } + + return base.ComputeNextValue(input); + } + /// /// Computes the next value of this indicator from the given state /// @@ -88,13 +146,28 @@ protected override decimal ComputeNextValue(IReadOnlyWindow .OrderBy(i => i.EndTime) .Select(i => Convert.ToDouble(i.Value)) .ToArray(); - // Fit OLS - var ols = Fit.Line(x: _t, y: series); - Intercept.Update(input.EndTime, (decimal)ols.Item1); - Slope.Update(input.EndTime, (decimal)ols.Item2); + + var x = (decimal)Period; + double intercept, slope; + if (_referenceWindow.Size != 0 && _referenceWindow.IsReady) + { + var xValues = _referenceWindow + .OrderBy(i => i.EndTime) + .Select(i => Convert.ToDouble(i.Value)) + .ToArray(); + x = _referenceWindow[0].Value; + (intercept, slope) = Fit.Line(x: xValues, y: series); + } + else + { + (intercept, slope) = Fit.Line(x: _t, y: series); + } + + Intercept.Update(input.EndTime, intercept.SafeDecimalCast()); + Slope.Update(input.EndTime, slope.SafeDecimalCast()); // Calculate the fitted value corresponding to the input - return Intercept.Current.Value + Slope.Current.Value * Period; + return Intercept.Current.Value + Slope.Current.Value * x; } /// @@ -104,7 +177,8 @@ public override void Reset() { Intercept.Reset(); Slope.Reset(); + _referenceWindow.Reset(); base.Reset(); } } -} \ No newline at end of file +} diff --git a/Tests/Indicators/LeastSquaresMovingAverageTests.cs b/Tests/Indicators/LeastSquaresMovingAverageTests.cs index da9b5be5c8a0..1e6de690a186 100644 --- a/Tests/Indicators/LeastSquaresMovingAverageTests.cs +++ b/Tests/Indicators/LeastSquaresMovingAverageTests.cs @@ -1,4 +1,4 @@ -/* +/* * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. * @@ -108,5 +108,101 @@ public override void WarmsUpProperly() indicator.Update(time.AddMinutes(period.Value - 1), Prices[period.Value - 1]); Assert.IsTrue(indicator.IsReady); } + + [Test] + public void WithReferenceIsNotReadyUntilBothWindowsFull() + { + var reference = Symbols.SPY; + var lsma = new LeastSquaresMovingAverage("LSMA", reference, 5); + var time = DateTime.Now; + + for (var i = 0; i < 5; i++) + { + lsma.Update(new IndicatorDataPoint(Symbols.AAPL, time.AddMinutes(i), 100m + i)); + } + + Assert.IsFalse(lsma.IsReady, "Should not be ready without reference data"); + + for (var i = 0; i < 4; i++) + { + lsma.Update(new IndicatorDataPoint(reference, time.AddMinutes(i), 200m + i)); + } + + Assert.IsFalse(lsma.IsReady, "Should not be ready with insufficient reference data"); + + lsma.Update(new IndicatorDataPoint(reference, time.AddMinutes(4), 204m)); + Assert.IsTrue(lsma.IsReady, "Should be ready when both windows are full"); + } + + [Test] + public void WithReferenceRegressesAgainstBenchmark() + { + var target = Symbols.AAPL; + var reference = Symbols.SPY; + var lsma = new LeastSquaresMovingAverage("LSMA", reference, 5); + var time = DateTime.Now; + + // y = 2*x + 1 (target = 2*reference + 1) + // reference: 1, 2, 3, 4, 5 + // target: 3, 5, 7, 9, 11 + for (var i = 0; i < 5; i++) + { + var refValue = (decimal)(i + 1); + var targetValue = 2m * refValue + 1m; + lsma.Update(new IndicatorDataPoint(target, time.AddMinutes(i), targetValue)); + lsma.Update(new IndicatorDataPoint(reference, time.AddMinutes(i), refValue)); + } + + Assert.IsTrue(lsma.IsReady); + + // slope should be 2, intercept should be 1 + Assert.AreEqual(2.0, (double)lsma.Slope.Current.Value, 0.0001); + Assert.AreEqual(1.0, (double)lsma.Intercept.Current.Value, 0.0001); + + // projected value = intercept + slope * latest_reference = 1 + 2*5 = 11 + Assert.AreEqual(11.0, (double)lsma.Current.Value, 0.0001); + } + + [Test] + public void WithReferenceResetsProperly() + { + var target = Symbols.AAPL; + var reference = Symbols.SPY; + var lsma = new LeastSquaresMovingAverage("LSMA", reference, 3); + var time = DateTime.Now; + + for (var i = 0; i < 3; i++) + { + lsma.Update(new IndicatorDataPoint(target, time.AddMinutes(i), 10m + i)); + lsma.Update(new IndicatorDataPoint(reference, time.AddMinutes(i), 20m + i)); + } + + Assert.IsTrue(lsma.IsReady); + + lsma.Reset(); + + Assert.IsFalse(lsma.IsReady); + Assert.AreEqual(0m, lsma.Current.Value); + Assert.AreEqual(0m, lsma.Intercept.Current.Value); + Assert.AreEqual(0m, lsma.Slope.Current.Value); + } + + [Test] + public void WithoutReferenceBehavesIdentically() + { + var withRef = new LeastSquaresMovingAverage(20); + var without = new LeastSquaresMovingAverage(20); + var time = DateTime.Now; + + for (var i = 0; i < Prices.Length; i++) + { + withRef.Update(time.AddMinutes(i), Prices[i]); + without.Update(time.AddMinutes(i), Prices[i]); + + Assert.AreEqual( + Math.Round(without.Current.Value, 4), + Math.Round(withRef.Current.Value, 4)); + } + } } -} \ No newline at end of file +}