From 1fb05ff1f0c10edeaef4c3b4f823447012a55c93 Mon Sep 17 00:00:00 2001
From: 0xpinara <191243209+0xpinara@users.noreply.github.com>
Date: Wed, 26 Aug 2026 11:10:47 -0700
Subject: [PATCH] Add a Least Squares Moving Average with a benchmark reference
---
Algorithm/QCAlgorithm.Indicators.cs | 21 +
.../LeastSquaresMovingAverageWithReference.cs | 122 ++++++
Tests/Algorithm/AlgorithmIndicatorsTests.cs | 56 +++
...tSquaresMovingAverageWithReferenceTests.cs | 394 ++++++++++++++++++
Tests/TestData/bi_datatest.csv | 102 ++---
5 files changed, 644 insertions(+), 51 deletions(-)
create mode 100644 Indicators/LeastSquaresMovingAverageWithReference.cs
create mode 100644 Tests/Indicators/LeastSquaresMovingAverageWithReferenceTests.cs
diff --git a/Algorithm/QCAlgorithm.Indicators.cs b/Algorithm/QCAlgorithm.Indicators.cs
index e86ffd96b864..e399deb7ed30 100644
--- a/Algorithm/QCAlgorithm.Indicators.cs
+++ b/Algorithm/QCAlgorithm.Indicators.cs
@@ -1364,6 +1364,27 @@ public LeastSquaresMovingAverage LSMA(Symbol symbol, int period, Resolution? res
return leastSquaresMovingAverage;
}
+ ///
+ /// Creates a Least Squares Moving Average indicator for the given target symbol in relation with
+ /// the reference used, that is, the regression line of the target prices on the reference prices.
+ /// The indicator will be automatically updated on the given resolution.
+ ///
+ /// The target symbol whose LSMA we want
+ /// The reference symbol to regress the target symbol on
+ /// The period of the LSMA indicator
+ /// The resolution
+ /// Selects a value from the BaseData to send into the indicator, if null defaults to casting the input value to a TradeBar
+ /// The LeastSquaresMovingAverageWithReference indicator for the given parameters
+ [DocumentationAttribute(Indicators)]
+ public LeastSquaresMovingAverageWithReference LSMA(Symbol target, Symbol reference, int period, Resolution? resolution = null, Func selector = null)
+ {
+ var name = CreateIndicatorName(QuantConnect.Symbol.None, $"LSMA({period})", resolution);
+ var leastSquaresMovingAverage = new LeastSquaresMovingAverageWithReference(name, target, reference, period);
+ InitializeIndicator(leastSquaresMovingAverage, resolution, selector, target, reference);
+
+ return leastSquaresMovingAverage;
+ }
+
///
/// Creates a new LinearWeightedMovingAverage indicator. This indicator will linearly distribute
/// the weights across the periods.
diff --git a/Indicators/LeastSquaresMovingAverageWithReference.cs b/Indicators/LeastSquaresMovingAverageWithReference.cs
new file mode 100644
index 000000000000..c8672f26a383
--- /dev/null
+++ b/Indicators/LeastSquaresMovingAverageWithReference.cs
@@ -0,0 +1,122 @@
+/*
+ * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
+ * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+using System;
+using System.Linq;
+using MathNet.Numerics;
+using QuantConnect.Data.Market;
+
+namespace QuantConnect.Indicators
+{
+ ///
+ /// The Least Squares Moving Average (LSMA) of a target in relation with a reference fits a least
+ /// squares regression line of the target close prices on the reference close prices over the given
+ /// period, instead of on the time index used by . It then
+ /// returns the value the regression line takes for the most recent reference price, which is the
+ /// price the target is expected to have given where the reference is trading.
+ ///
+ /// It is common practice to use the SPX index as the reference, so that the indicator describes
+ /// the target price in terms of the overall market level.
+ ///
+ /// The indicator only updates when both assets have a price for a time step. When a bar is missing
+ /// for one of the assets, the indicator value fills forward to improve the accuracy of the indicator.
+ ///
+ public class LeastSquaresMovingAverageWithReference : DualSymbolIndicator
+ {
+ ///
+ /// The point where the regression line crosses the y-axis (target price axis)
+ ///
+ public IndicatorBase Intercept { get; }
+
+ ///
+ /// The regression line slope, the target price change per unit of reference price change
+ ///
+ public IndicatorBase Slope { get; }
+
+ ///
+ /// Creates a new LeastSquaresMovingAverageWithReference indicator with the specified name,
+ /// target, reference and period values
+ ///
+ /// The name of this indicator
+ /// The target symbol of this indicator
+ /// The reference symbol of this indicator
+ /// The period of this indicator
+ public LeastSquaresMovingAverageWithReference(string name, Symbol targetSymbol, Symbol referenceSymbol, int period)
+ : base(name, targetSymbol, referenceSymbol, period)
+ {
+ // Assert the period is greater than one, otherwise the regression line can not be fitted
+ if (period < 2)
+ {
+ throw new ArgumentException($"Period parameter for LeastSquaresMovingAverageWithReference indicator must be greater than 1 but was {period}.");
+ }
+
+ Intercept = new Identity(name + "_Intercept");
+ Slope = new Identity(name + "_Slope");
+ }
+
+ ///
+ /// Creates a new LeastSquaresMovingAverageWithReference indicator with the specified target,
+ /// reference and period values
+ ///
+ /// The target symbol of this indicator
+ /// The reference symbol of this indicator
+ /// The period of this indicator
+ public LeastSquaresMovingAverageWithReference(Symbol targetSymbol, Symbol referenceSymbol, int period)
+ : this($"LSMA({period})", targetSymbol, referenceSymbol, period)
+ {
+ }
+
+ ///
+ /// Computes the value the regression line of the target on the reference takes for the
+ /// most recent reference price
+ ///
+ protected override decimal ComputeIndicator()
+ {
+ // Until both windows are full, the indicator returns the target price, like the LSMA does
+ if (!IsReady)
+ {
+ return TargetDataPoints[0].Close;
+ }
+
+ // Both windows only hold the data points of the time steps both symbols have a price for,
+ // so the target and the reference prices pair up by index
+ var referencePrices = ReferenceDataPoints.Select(x => (double)x.Close).ToArray();
+ var targetPrices = TargetDataPoints.Select(x => (double)x.Close).ToArray();
+ var (intercept, slope) = Fit.Line(x: referencePrices, y: targetPrices);
+
+ // The regression line is undefined when the reference price does not change over the period
+ if (intercept.IsNaNOrInfinity() || slope.IsNaNOrInfinity())
+ {
+ return TargetDataPoints[0].Close;
+ }
+
+ var endTime = TargetDataPoints[0].EndTime;
+ Intercept.Update(endTime, intercept.SafeDecimalCast());
+ Slope.Update(endTime, slope.SafeDecimalCast());
+
+ return Intercept.Current.Value + Slope.Current.Value * ReferenceDataPoints[0].Close;
+ }
+
+ ///
+ /// Resets this indicator and all sub-indicators (Intercept, Slope)
+ ///
+ public override void Reset()
+ {
+ Intercept.Reset();
+ Slope.Reset();
+ base.Reset();
+ }
+ }
+}
diff --git a/Tests/Algorithm/AlgorithmIndicatorsTests.cs b/Tests/Algorithm/AlgorithmIndicatorsTests.cs
index dd0764671c4d..2d3b43d1ab48 100644
--- a/Tests/Algorithm/AlgorithmIndicatorsTests.cs
+++ b/Tests/Algorithm/AlgorithmIndicatorsTests.cs
@@ -275,6 +275,62 @@ public void BetaCalculation()
Assert.AreEqual(new DateTime(2013, 10, 10, 16, 0, 0), lastPoint.Current.EndTime);
}
+ [Test]
+ public void LeastSquaresMovingAverageWithReferenceCalculation()
+ {
+ var period = 10;
+ var referenceSymbol = Symbol.Create("IBM", SecurityType.Equity, Market.USA);
+ var indicator = new LeastSquaresMovingAverageWithReference(_equity, referenceSymbol, period);
+ _algorithm.SetDateTime(new DateTime(2013, 10, 11));
+
+ // Fit the target closes on the reference closes of the last period time steps both symbols
+ // have a price for, using the ordinary least squares closed form
+ var targetCloses = new List();
+ var referenceCloses = new List();
+ foreach (var slice in _algorithm.History(new[] { _equity, referenceSymbol }, TimeSpan.FromDays(50), Resolution.Daily))
+ {
+ if (slice.Bars.ContainsKey(_equity) && slice.Bars.ContainsKey(referenceSymbol))
+ {
+ targetCloses.Add((double)slice.Bars[_equity].Close);
+ referenceCloses.Add((double)slice.Bars[referenceSymbol].Close);
+ }
+ }
+ var target = targetCloses.TakeLast(period).ToList();
+ var reference = referenceCloses.TakeLast(period).ToList();
+ var sumX = reference.Sum();
+ var sumY = target.Sum();
+ var expectedSlope = (period * reference.Zip(target, (x, y) => x * y).Sum() - sumX * sumY)
+ / (period * reference.Sum(x => x * x) - sumX * sumX);
+ var expectedIntercept = (sumY - expectedSlope * sumX) / period;
+ var expectedValue = expectedIntercept + expectedSlope * reference[^1];
+
+ var indicatorValues = _algorithm.IndicatorHistory(indicator, new[] { _equity, referenceSymbol }, TimeSpan.FromDays(50), Resolution.Daily);
+
+ Assert.AreEqual(expectedSlope, (double)indicator.Slope.Current.Value, 1e-6);
+ Assert.AreEqual(expectedIntercept, (double)indicator.Intercept.Current.Value, 1e-6);
+ Assert.AreEqual(expectedValue, (double)indicator.Current.Value, 1e-6);
+ Assert.AreEqual(new DateTime(2013, 10, 10, 16, 0, 0), indicator.Current.EndTime);
+
+ // The indicator history is taken on the first of the two updates each time step gets, so
+ // its last row holds the value the indicator had before the last pair of prices was fit
+ var lastPoint = indicatorValues.Last();
+ Assert.AreEqual(new DateTime(2013, 10, 10, 16, 0, 0), lastPoint.Current.EndTime);
+ Assert.AreEqual(indicator.Previous.Value, lastPoint.Current.Value);
+ }
+
+ [Test]
+ public void LeastSquaresMovingAverageWithReferenceIsWarmedUpByTheAlgorithm()
+ {
+ var referenceSymbol = _algorithm.AddEquity("IBM").Symbol;
+
+ var indicator = _algorithm.LSMA(_equity, referenceSymbol, 10, Resolution.Daily);
+
+ Assert.AreEqual("LSMA(10,day)", indicator.Name);
+ Assert.IsTrue(indicator.IsReady);
+ Assert.AreNotEqual(0m, indicator.Current.Value);
+ Assert.AreNotEqual(0m, indicator.Slope.Current.Value);
+ }
+
[TestCase(Language.Python)]
[TestCase(Language.CSharp)]
public void IndicatorsPassingHistory(Language language)
diff --git a/Tests/Indicators/LeastSquaresMovingAverageWithReferenceTests.cs b/Tests/Indicators/LeastSquaresMovingAverageWithReferenceTests.cs
new file mode 100644
index 000000000000..67789695ab8c
--- /dev/null
+++ b/Tests/Indicators/LeastSquaresMovingAverageWithReferenceTests.cs
@@ -0,0 +1,394 @@
+/*
+ * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
+ * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+using NUnit.Framework;
+using QuantConnect.Data.Consolidators;
+using QuantConnect.Data.Market;
+using QuantConnect.Indicators;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using static QuantConnect.Tests.Indicators.TestHelper;
+
+namespace QuantConnect.Tests.Indicators
+{
+ ///
+ /// The expected values of the LSMAWithReference column of bi_datatest.csv were computed with
+ /// numpy.polyfit, fitting the AMZN closes on the SPX closes of each five point window and
+ /// evaluating the resulting line at the latest SPX close.
+ ///
+ [TestFixture]
+ public class LeastSquaresMovingAverageWithReferenceTests : CommonIndicatorTests
+ {
+ protected override string TestFileName => "bi_datatest.csv";
+
+ protected override string TestColumnName => "LSMAWithReference";
+
+ private DateTime _reference = new DateTime(2020, 1, 1);
+
+ protected override IndicatorBase CreateIndicator()
+ {
+ Symbol targetSymbol = "AMZN 2T";
+ Symbol referenceSymbol = "SPX 2T";
+ if (SymbolList.Count > 1)
+ {
+ targetSymbol = SymbolList[0];
+ referenceSymbol = SymbolList[1];
+ }
+ return new LeastSquaresMovingAverageWithReference("testLSMAWithReferenceIndicator", targetSymbol, referenceSymbol, 5);
+ }
+
+ protected override List GetSymbols()
+ {
+ return [Symbols.SPY, Symbols.AAPL];
+ }
+
+ [Test]
+ public override void TimeMovesForward()
+ {
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.IBM, Symbols.SPY, 5);
+
+ for (var i = 10; i > 0; i--)
+ {
+ indicator.Update(new TradeBar() { Symbol = Symbols.IBM, Low = 1, High = 2, Volume = 100, Close = 500, Time = _reference.AddDays(1 + i) });
+ indicator.Update(new TradeBar() { Symbol = Symbols.SPY, Low = 1, High = 2, Volume = 100, Close = 500, Time = _reference.AddDays(1 + i) });
+ }
+
+ Assert.AreEqual(2, indicator.Samples);
+ }
+
+ [Test]
+ public override void WarmsUpProperly()
+ {
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.IBM, Symbols.SPY, 5);
+ var period = ((IIndicatorWarmUpPeriodProvider)indicator).WarmUpPeriod;
+
+ for (var i = 0; i < period; i++)
+ {
+ var startTime = _reference.AddDays(1 + i);
+ var endTime = startTime.AddDays(1);
+ indicator.Update(new TradeBar() { Symbol = Symbols.IBM, Low = 1, High = 2, Volume = 100, Close = 500 + i, Time = startTime, EndTime = endTime });
+ Assert.IsFalse(indicator.IsReady, $"ready after the target bar of index {i}");
+ indicator.Update(new TradeBar() { Symbol = Symbols.SPY, Low = 1, High = 2, Volume = 100, Close = 400 + 2 * i, Time = startTime, EndTime = endTime });
+ }
+
+ Assert.IsTrue(indicator.IsReady);
+ Assert.AreEqual(2 * period, indicator.Samples);
+ }
+
+ [Test]
+ public override void WorksWithLowValues()
+ {
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.IBM, Symbols.SPY, 5);
+
+ var random = new Random();
+ for (var i = 0; i < 20; i++)
+ {
+ var startTime = _reference.AddDays(1 + i);
+ var endTime = startTime.AddDays(1);
+ var targetValue = (decimal)(random.NextDouble() * 0.000000000000000000000000000001);
+ var referenceValue = (decimal)(random.NextDouble() * 0.000000000000000000000000000001);
+ Assert.DoesNotThrow(() =>
+ {
+ indicator.Update(new TradeBar() { Symbol = Symbols.IBM, Low = targetValue, High = targetValue, Open = targetValue, Close = targetValue, Time = startTime, EndTime = endTime });
+ indicator.Update(new TradeBar() { Symbol = Symbols.SPY, Low = referenceValue, High = referenceValue, Open = referenceValue, Close = referenceValue, Time = startTime, EndTime = endTime });
+ });
+ }
+ }
+
+ [Test]
+ public override void TracksPreviousState()
+ {
+ var period = 5;
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.SPY, Symbols.AAPL, period);
+ var previousValue = indicator.Current.Value;
+
+ for (var i = 1; i < 2 * period; i++)
+ {
+ var startTime = _reference.AddDays(1 + i);
+ var endTime = startTime.AddDays(1);
+ indicator.Update(new TradeBar() { Symbol = Symbols.SPY, Low = 1, High = 2, Volume = 100, Close = 1000 + i * 10, Time = startTime, EndTime = endTime });
+ indicator.Update(new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 1000 + (i * 15), Time = startTime, EndTime = endTime });
+
+ Assert.AreEqual(previousValue, indicator.Previous.Value);
+
+ previousValue = indicator.Current.Value;
+ }
+ }
+
+ [Test]
+ public override void IndicatorShouldHaveSymbolAfterUpdates()
+ {
+ var period = 5;
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.SPY, Symbols.AAPL, period);
+
+ for (var i = 0; i < 2 * period; i++)
+ {
+ var startTime = _reference.AddDays(1 + i);
+ var endTime = startTime.AddDays(1);
+ // The value takes the symbol of the update it was computed on
+ indicator.Update(new TradeBar() { Symbol = Symbols.SPY, Low = 1, High = 2, Volume = 100, Close = 1000 + i * 10, Time = startTime, EndTime = endTime });
+ Assert.AreEqual(Symbols.SPY, indicator.Current.Symbol);
+
+ indicator.Update(new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 1000 + (i * 15), Time = startTime, EndTime = endTime });
+ Assert.AreEqual(Symbols.AAPL, indicator.Current.Symbol);
+ }
+ }
+
+ [Test]
+ public override void AcceptsRenkoBarsAsInput()
+ {
+ var indicator = CreateIndicator();
+ var targetRenkoConsolidator = new RenkoConsolidator(10m);
+ var referenceRenkoConsolidator = new RenkoConsolidator(10m);
+ targetRenkoConsolidator.DataConsolidated += (sender, renkoBar) =>
+ {
+ Assert.DoesNotThrow(() => indicator.Update(renkoBar));
+ };
+
+ referenceRenkoConsolidator.DataConsolidated += (sender, renkoBar) =>
+ {
+ Assert.DoesNotThrow(() => indicator.Update(renkoBar));
+ };
+
+ foreach (var parts in GetCsvFileStream(TestFileName))
+ {
+ var tradebar = parts.GetTradeBar();
+ if (tradebar.Symbol.Value == "AMZN")
+ {
+ targetRenkoConsolidator.Update(tradebar);
+ }
+ else
+ {
+ referenceRenkoConsolidator.Update(tradebar);
+ }
+ }
+
+ Assert.IsTrue(indicator.IsReady);
+ Assert.AreNotEqual(0, indicator.Samples);
+ targetRenkoConsolidator.Dispose();
+ referenceRenkoConsolidator.Dispose();
+ }
+
+ [Test]
+ public override void AcceptsVolumeRenkoBarsAsInput()
+ {
+ var indicator = CreateIndicator();
+ var targetVolumeRenkoConsolidator = new VolumeRenkoConsolidator(1000000);
+ var referenceVolumeRenkoConsolidator = new VolumeRenkoConsolidator(1000000000);
+ targetVolumeRenkoConsolidator.DataConsolidated += (sender, renkoBar) =>
+ {
+ Assert.DoesNotThrow(() => indicator.Update(renkoBar));
+ };
+
+ referenceVolumeRenkoConsolidator.DataConsolidated += (sender, renkoBar) =>
+ {
+ Assert.DoesNotThrow(() => indicator.Update(renkoBar));
+ };
+
+ foreach (var parts in GetCsvFileStream(TestFileName))
+ {
+ var tradebar = parts.GetTradeBar();
+ if (tradebar.Symbol.Value == "AMZN")
+ {
+ targetVolumeRenkoConsolidator.Update(tradebar);
+ }
+ else
+ {
+ referenceVolumeRenkoConsolidator.Update(tradebar);
+ }
+ }
+
+ Assert.IsTrue(indicator.IsReady);
+ Assert.AreNotEqual(0, indicator.Samples);
+ targetVolumeRenkoConsolidator.Dispose();
+ referenceVolumeRenkoConsolidator.Dispose();
+ }
+
+ [Test]
+ public void AcceptsQuoteBarsAsInput()
+ {
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.IBM, Symbols.SPY, 5);
+
+ // The target is worth twice the reference plus one at every time step
+ for (var i = 0; i < 10; i++)
+ {
+ var time = _reference.AddDays(1 + i);
+ var referenceValue = 100 + i;
+ var targetValue = 2 * referenceValue + 1;
+ indicator.Update(new QuoteBar { Symbol = Symbols.IBM, Ask = new Bar(1, 2, 1, targetValue), Bid = new Bar(1, 2, 1, targetValue), Time = time });
+ indicator.Update(new QuoteBar { Symbol = Symbols.SPY, Ask = new Bar(1, 2, 1, referenceValue), Bid = new Bar(1, 2, 1, referenceValue), Time = time });
+ }
+
+ Assert.IsTrue(indicator.IsReady);
+ Assert.AreEqual(2d, (double)indicator.Slope.Current.Value, 1e-9);
+ Assert.AreEqual(2 * 109 + 1, (double)indicator.Current.Value, 1e-9);
+ }
+
+ [Test]
+ public void ValidateCalculation()
+ {
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.AAPL, Symbols.SPX, 3);
+
+ var bars = new List()
+ {
+ new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 10, Time = _reference.AddDays(1), EndTime = _reference.AddDays(2) },
+ new TradeBar() { Symbol = Symbols.SPX, Low = 1, High = 2, Volume = 100, Close = 35, Time = _reference.AddDays(1), EndTime = _reference.AddDays(2) },
+ new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 2, Time = _reference.AddDays(2), EndTime = _reference.AddDays(3) },
+ new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 15, Time = _reference.AddDays(3), EndTime = _reference.AddDays(4) },
+ new TradeBar() { Symbol = Symbols.SPX, Low = 1, High = 2, Volume = 100, Close = 80, Time = _reference.AddDays(3), EndTime = _reference.AddDays(4) },
+ new TradeBar() { Symbol = Symbols.SPX, Low = 1, High = 2, Volume = 100, Close = 4, Time = _reference.AddDays(4), EndTime = _reference.AddDays(5) },
+ new TradeBar() { Symbol = Symbols.SPX, Low = 1, High = 2, Volume = 100, Close = 37, Time = _reference.AddDays(5), EndTime = _reference.AddDays(6) },
+ new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 90, Time = _reference.AddDays(5), EndTime = _reference.AddDays(6) },
+ new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 105, Time = _reference.AddDays(6), EndTime = _reference.AddDays(7) },
+ new TradeBar() { Symbol = Symbols.SPX, Low = 1, High = 2, Volume = 100, Close = 302, Time = _reference.AddDays(6), EndTime = _reference.AddDays(7) },
+ };
+
+ foreach (var bar in bars)
+ {
+ indicator.Update(bar);
+ }
+
+ // Only the time steps both symbols have a price for are paired up, and only the last
+ // three of them are held by the indicator windows
+ var closeAAPL = new List() { 15, 90, 105 };
+ var closeSPX = new List() { 80, 37, 302 };
+
+ // Fitting closeAAPL on closeSPX with the ordinary least squares closed form
+ var count = closeSPX.Count;
+ var sumX = closeSPX.Sum();
+ var sumY = closeAAPL.Sum();
+ var sumXy = closeSPX.Zip(closeAAPL, (x, y) => x * y).Sum();
+ var sumXx = closeSPX.Sum(x => x * x);
+ var expectedSlope = (count * sumXy - sumX * sumY) / (count * sumXx - sumX * sumX);
+ var expectedIntercept = (sumY - expectedSlope * sumX) / count;
+ var expectedValue = expectedIntercept + expectedSlope * closeSPX[^1];
+
+ Assert.AreEqual(expectedSlope, (double)indicator.Slope.Current.Value, 1e-9);
+ Assert.AreEqual(expectedIntercept, (double)indicator.Intercept.Current.Value, 1e-9);
+ Assert.AreEqual(expectedValue, (double)indicator.Current.Value, 1e-9);
+ }
+
+ [Test]
+ public void ProjectsTheReferenceWithALinearRelationship()
+ {
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.AAPL, Symbols.SPX, 5);
+
+ // The target is worth twice the reference plus one at every time step
+ for (var i = 0; i < 10; i++)
+ {
+ var startTime = _reference.AddDays(1 + i);
+ var endTime = startTime.AddDays(1);
+ var referenceValue = 100 + i;
+ indicator.Update(new TradeBar() { Symbol = Symbols.SPX, Low = 1, High = 2, Volume = 100, Close = referenceValue, Time = startTime, EndTime = endTime });
+ indicator.Update(new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 2 * referenceValue + 1, Time = startTime, EndTime = endTime });
+ }
+
+ Assert.AreEqual(2d, (double)indicator.Slope.Current.Value, 1e-9);
+ Assert.AreEqual(1d, (double)indicator.Intercept.Current.Value, 1e-9);
+ Assert.AreEqual(2 * 109 + 1, (double)indicator.Current.Value, 1e-9);
+ }
+
+ [Test]
+ public void ReturnsTheTargetPriceWhenTheReferenceDoesNotChange()
+ {
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.AAPL, Symbols.SPX, 5);
+
+ for (var i = 0; i < 10; i++)
+ {
+ var startTime = _reference.AddDays(1 + i);
+ var endTime = startTime.AddDays(1);
+ indicator.Update(new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 200 + i, Time = startTime, EndTime = endTime });
+ indicator.Update(new TradeBar() { Symbol = Symbols.SPX, Low = 1, High = 2, Volume = 100, Close = 100, Time = startTime, EndTime = endTime });
+ }
+
+ // The regression line can not be fitted, so the indicator falls back to the target price
+ Assert.AreEqual(209m, indicator.Current.Value);
+ Assert.AreEqual(0m, indicator.Slope.Current.Value);
+ Assert.AreEqual(0m, indicator.Intercept.Current.Value);
+ }
+
+ [Test]
+ public void WorksWithDifferentTimeZones()
+ {
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.SPY, Symbols.BTCUSD, 5);
+
+ for (var i = 0; i < 10; i++)
+ {
+ var startTime = _reference.AddDays(1 + i);
+ var endTime = startTime.AddDays(1);
+ indicator.Update(new TradeBar() { Symbol = Symbols.SPY, Low = 1, High = 2, Volume = 100, Close = 2 * (100 + i) + 1, Time = startTime, EndTime = endTime });
+ indicator.Update(new TradeBar() { Symbol = Symbols.BTCUSD, Low = 1, High = 2, Volume = 100, Close = 100 + i, Time = startTime, EndTime = endTime });
+ }
+
+ Assert.IsTrue(indicator.IsReady);
+ Assert.AreEqual(2d, (double)indicator.Slope.Current.Value, 1e-9);
+ Assert.AreEqual(2 * 109 + 1, (double)indicator.Current.Value, 1e-9);
+ }
+
+ [Test]
+ public void PairsPricesByTimeRegardlessOfArrivalOrder()
+ {
+ var targetFirst = new LeastSquaresMovingAverageWithReference(Symbols.AAPL, Symbols.SPX, 5);
+ var referenceFirst = new LeastSquaresMovingAverageWithReference(Symbols.AAPL, Symbols.SPX, 5);
+
+ for (var i = 0; i < 10; i++)
+ {
+ var startTime = _reference.AddDays(1 + i);
+ var endTime = startTime.AddDays(1);
+ var targetBar = new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 200 + i * 3, Time = startTime, EndTime = endTime };
+ var referenceBar = new TradeBar() { Symbol = Symbols.SPX, Low = 1, High = 2, Volume = 100, Close = 100 + i, Time = startTime, EndTime = endTime };
+
+ targetFirst.Update(targetBar);
+ targetFirst.Update(referenceBar);
+
+ referenceFirst.Update(referenceBar);
+ referenceFirst.Update(targetBar);
+ }
+
+ Assert.IsTrue(targetFirst.IsReady);
+ Assert.AreEqual(targetFirst.Current.Value, referenceFirst.Current.Value);
+ }
+
+ [Test]
+ public void DoesNotPairPricesFromDifferentTimes()
+ {
+ var indicator = new LeastSquaresMovingAverageWithReference(Symbols.AAPL, Symbols.SPX, 5);
+
+ for (var i = 0; i < 5; i++)
+ {
+ var startTime = _reference.AddDays(1 + i);
+ var endTime = startTime.AddDays(1);
+ indicator.Update(new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 200 + i * 3, Time = startTime, EndTime = endTime });
+ indicator.Update(new TradeBar() { Symbol = Symbols.SPX, Low = 1, High = 2, Volume = 100, Close = 100 + i, Time = startTime, EndTime = endTime });
+ }
+
+ var lastValue = indicator.Current.Value;
+
+ // The target bar of the next time step leaves the reference behind, so no price is paired up
+ var lastStartTime = _reference.AddDays(6);
+ indicator.Update(new TradeBar() { Symbol = Symbols.AAPL, Low = 1, High = 2, Volume = 100, Close = 500, Time = lastStartTime, EndTime = lastStartTime.AddDays(1) });
+
+ Assert.AreEqual(lastValue, indicator.Current.Value);
+ }
+
+ [Test]
+ public void ThrowsOnPeriodBelowTwo()
+ {
+ Assert.Throws(() =>
+ new LeastSquaresMovingAverageWithReference(Symbols.AAPL, Symbols.SPX, 1));
+ }
+ }
+}
diff --git a/Tests/TestData/bi_datatest.csv b/Tests/TestData/bi_datatest.csv
index f314105b4b9f..62b0cd0600a6 100644
--- a/Tests/TestData/bi_datatest.csv
+++ b/Tests/TestData/bi_datatest.csv
@@ -1,51 +1,51 @@
-Symbol,Date,High,Low,Open,Close,Volume,Adj Close,Beta
-AMZN,20211004,3279.989990234375,3176.25,3279.389892578125,3189.780029296875,4523100,3189.780029296875,0
-SPX,20211004,4355.509765625,4278.93994140625,4348.83984375,4300.4599609375,3110560000,4300.4599609375,0
-AMZN,20211005,3260.72998046875,3202.4599609375,3204.5,3221.0,3269200,3221.0,0
-SPX,20211005,4369.22998046875,4309.8701171875,4309.8701171875,4345.72021484375,2967400000,4345.72021484375,0
-AMZN,20211006,3264.340087890625,3198.6201171875,3213.530029296875,3262.010009765625,2533000,3262.010009765625,0
-SPX,20211006,4365.56982421875,4290.490234375,4319.56982421875,4363.5498046875,3219590000,4363.5498046875,-0.458533447
-AMZN,20211007,3325.75,3283.06005859375,3291.5400390625,3302.429931640625,2409100,3302.429931640625,-0.458533447
-SPX,20211007,4429.97021484375,4383.72998046875,4383.72998046875,4399.759765625,3096080000,4399.759765625,-0.409675968
-AMZN,20211008,3321.429931640625,3288.199951171875,3317.0,3288.6201171875,1995500,3288.6201171875,-0.409675968
-SPX,20211008,4412.02001953125,4386.22021484375,4406.509765625,4391.33984375,2401890000,4391.33984375,1.166105454
-AMZN,20211011,3292.590087890625,3238.10009765625,3275.0,3246.300048828125,2034200,3246.300048828125,1.166105454
-SPX,20211011,4415.8798828125,4360.58984375,4385.43994140625,4361.18994140625,2580000000,4361.18994140625,1.46640552
-AMZN,20211012,3267.530029296875,3236.280029296875,3257.0,3247.330078125,1819600,3247.330078125,1.46640552
-SPX,20211012,4374.89013671875,4342.08984375,4368.31005859375,4350.64990234375,2608150000,4350.64990234375,1.750343086
-AMZN,20211013,3288.3798828125,3261.090087890625,3269.7099609375,3284.280029296875,2420100,3284.280029296875,1.750343086
-SPX,20211013,4372.8701171875,4329.919921875,4358.009765625,4363.7998046875,2926460000,4363.7998046875,1.727848937
-AMZN,20211014,3312.60009765625,3290.780029296875,3302.449951171875,3299.860107421875,2109500,3299.860107421875,1.727848937
-SPX,20211014,4439.72998046875,4386.75,4386.75,4438.259765625,2642920000,4438.259765625,0.621509074
-AMZN,20211015,3410.419921875,3304.0,3311.419921875,3409.02001953125,5175100,3409.02001953125,0.621509074
-SPX,20211015,4475.81982421875,4447.68994140625,4447.68994140625,4471.3701171875,3000560000,4471.3701171875,0.922922917
-AMZN,20211018,3449.169921875,3385.10009765625,3388.360107421875,3446.739990234375,3174100,3446.739990234375,0.922922917
-SPX,20211018,4488.75,4447.47021484375,4463.72021484375,4486.4599609375,2683540000,4486.4599609375,0.25307576
-AMZN,20211019,3454.68994140625,3422.0,3434.2900390625,3444.14990234375,2386100,3444.14990234375,0.25307576
-SPX,20211019,4520.39990234375,4496.41015625,4497.33984375,4519.6298828125,2531210000,4519.6298828125,-0.484044822
-AMZN,20211020,3462.860107421875,3400.3701171875,3452.659912109375,3415.06005859375,2139800,3415.06005859375,-0.484044822
-SPX,20211020,4540.8701171875,4524.39990234375,4524.419921875,4536.18994140625,2671560000,4536.18994140625,0.157447516
-AMZN,20211021,3440.280029296875,3403.0,3414.25,3435.010009765625,1881400,3435.010009765625,0.157447516
-SPX,20211021,4551.43994140625,4526.89013671875,4532.240234375,4549.77978515625,3016950000,4549.77978515625,3.070627551
-AMZN,20211022,3429.840087890625,3331.300048828125,3421.0,3335.550048828125,3139100,3335.550048828125,3.070627551
-SPX,20211022,4559.669921875,4524.0,4546.1201171875,4544.89990234375,3062810000,4544.89990234375,3.293313624
-AMZN,20211025,3347.800048828125,3297.699951171875,3335.0,3320.3701171875,2226000,3320.3701171875,3.293313624
-SPX,20211025,4572.6201171875,4537.35986328125,4553.68994140625,4566.47998046875,3250210000,4566.47998046875,3.196949447
-AMZN,20211026,3416.1201171875,3343.97998046875,3349.510009765625,3376.070068359375,2698300,3376.070068359375,3.196949447
-SPX,20211026,4598.52978515625,4569.169921875,4578.68994140625,4574.7900390625,2866500000,4574.7900390625,3.703064397
-AMZN,20211027,3437.0,3371.449951171875,3388.0,3392.489990234375,2702200,3392.489990234375,3.703064397
-SPX,20211027,4584.56982421875,4551.66015625,4580.22021484375,4551.68017578125,3259510000,4551.68017578125,0.626560824
-AMZN,20211028,3479.0,3386.0,3402.10009765625,3446.570068359375,5708700,3446.570068359375,0.626560824
-SPX,20211028,4597.5498046875,4562.83984375,4562.83984375,4596.419921875,3197560000,4596.419921875,1.278764178
-AMZN,20211029,3374.820068359375,3273.320068359375,3300.02001953125,3372.429931640625,6469500,3372.429931640625,1.278764178
-SPX,20211029,4608.080078125,4567.58984375,4572.8701171875,4605.3798828125,3632260000,4605.3798828125,0.588131043
-AMZN,20211101,3375.860107421875,3292.02001953125,3361.800048828125,3318.110107421875,3608900,3318.110107421875,0.588131043
-SPX,20211101,4620.33984375,4595.06005859375,4610.6201171875,4613.669921875,2924000000,4613.669921875,0.826491586
-AMZN,20211102,3331.1201171875,3283.550048828125,3315.010009765625,3312.75,2627600,3312.75,0.826491586
-SPX,20211102,4635.14990234375,4613.33984375,4613.33984375,4630.64990234375,3309690000,4630.64990234375,0.887785578
-AMZN,20211103,3394.919921875,3297.52001953125,3309.0,3384.0,3397200,3384.0,0.887785578
-SPX,20211103,4663.4599609375,4621.18994140625,4630.64990234375,4660.56982421875,3339440000,4660.56982421875,4.895868782
-AMZN,20211104,3498.6298828125,3365.0,3370.0,3477.0,5346300,3477.0,4.895868782
-SPX,20211104,4683.0,4662.58984375,4662.93017578125,4680.06005859375,3332940000,4680.06005859375,9.779665289
-AMZN,20211105,3566.25,3476.97998046875,3477.0,3549.01953125,2937769,3549.01953125,9.779665289
-SPX,20211105,4718.5,4699.259765625,4699.259765625,4701.85986328125,1462781000,4701.85986328125,8.761816471
\ No newline at end of file
+Symbol,Date,High,Low,Open,Close,Volume,Adj Close,Beta,LSMAWithReference
+AMZN,20211004,3279.989990234375,3176.25,3279.389892578125,3189.780029296875,4523100,3189.780029296875,0,0.000000000
+SPX,20211004,4355.509765625,4278.93994140625,4348.83984375,4300.4599609375,3110560000,4300.4599609375,0,3189.780029297
+AMZN,20211005,3260.72998046875,3202.4599609375,3204.5,3221.0,3269200,3221.0,0,3189.780029297
+SPX,20211005,4369.22998046875,4309.8701171875,4309.8701171875,4345.72021484375,2967400000,4345.72021484375,0,3221.000000000
+AMZN,20211006,3264.340087890625,3198.6201171875,3213.530029296875,3262.010009765625,2533000,3262.010009765625,0,3221.000000000
+SPX,20211006,4365.56982421875,4290.490234375,4319.56982421875,4363.5498046875,3219590000,4363.5498046875,-0.458533447,3262.010009766
+AMZN,20211007,3325.75,3283.06005859375,3291.5400390625,3302.429931640625,2409100,3302.429931640625,-0.458533447,3262.010009766
+SPX,20211007,4429.97021484375,4383.72998046875,4383.72998046875,4399.759765625,3096080000,4399.759765625,-0.409675968,3302.429931641
+AMZN,20211008,3321.429931640625,3288.199951171875,3317.0,3288.6201171875,1995500,3288.6201171875,-0.409675968,3302.429931641
+SPX,20211008,4412.02001953125,4386.22021484375,4406.509765625,4391.33984375,2401890000,4391.33984375,1.166105454,3288.922756555
+AMZN,20211011,3292.590087890625,3238.10009765625,3275.0,3246.300048828125,2034200,3246.300048828125,1.166105454,3288.922756555
+SPX,20211011,4415.8798828125,4360.58984375,4385.43994140625,4361.18994140625,2580000000,4361.18994140625,1.46640552,3248.233118518
+AMZN,20211012,3267.530029296875,3236.280029296875,3257.0,3247.330078125,1819600,3247.330078125,1.46640552,3248.233118518
+SPX,20211012,4374.89013671875,4342.08984375,4368.31005859375,4350.64990234375,2608150000,4350.64990234375,1.750343086,3242.974531805
+AMZN,20211013,3288.3798828125,3261.090087890625,3269.7099609375,3284.280029296875,2420100,3284.280029296875,1.750343086,3242.974531805
+SPX,20211013,4372.8701171875,4329.919921875,4358.009765625,4363.7998046875,2926460000,4363.7998046875,1.727848937,3263.800718699
+AMZN,20211014,3312.60009765625,3290.780029296875,3302.449951171875,3299.860107421875,2109500,3299.860107421875,1.727848937,3263.800718699
+SPX,20211014,4439.72998046875,4386.75,4386.75,4438.259765625,2642920000,4438.259765625,0.621509074,3305.502789252
+AMZN,20211015,3410.419921875,3304.0,3311.419921875,3409.02001953125,5175100,3409.02001953125,0.621509074,3305.502789252
+SPX,20211015,4475.81982421875,4447.68994140625,4447.68994140625,4471.3701171875,3000560000,4471.3701171875,0.922922917,3379.397047644
+AMZN,20211018,3449.169921875,3385.10009765625,3388.360107421875,3446.739990234375,3174100,3446.739990234375,0.922922917,3379.397047644
+SPX,20211018,4488.75,4447.47021484375,4463.72021484375,4486.4599609375,2683540000,4486.4599609375,0.25307576,3419.241436736
+AMZN,20211019,3454.68994140625,3422.0,3434.2900390625,3444.14990234375,2386100,3444.14990234375,0.25307576,3419.241436736
+SPX,20211019,4520.39990234375,4496.41015625,4497.33984375,4519.6298828125,2531210000,4519.6298828125,-0.484044822,3453.155478136
+AMZN,20211020,3462.860107421875,3400.3701171875,3452.659912109375,3415.06005859375,2139800,3415.06005859375,-0.484044822,3453.155478136
+SPX,20211020,4540.8701171875,4524.39990234375,4524.419921875,4536.18994140625,2671560000,4536.18994140625,0.157447516,3454.822602296
+AMZN,20211021,3440.280029296875,3403.0,3414.25,3435.010009765625,1881400,3435.010009765625,0.157447516,3454.822602296
+SPX,20211021,4551.43994140625,4526.89013671875,4532.240234375,4549.77978515625,3016950000,4549.77978515625,3.070627551,3433.056998135
+AMZN,20211022,3429.840087890625,3331.300048828125,3421.0,3335.550048828125,3139100,3335.550048828125,3.070627551,3433.056998135
+SPX,20211022,4559.669921875,4524.0,4546.1201171875,4544.89990234375,3062810000,4544.89990234375,3.293313624,3398.815314759
+AMZN,20211025,3347.800048828125,3297.699951171875,3335.0,3320.3701171875,2226000,3320.3701171875,3.293313624,3398.815314759
+SPX,20211025,4572.6201171875,4537.35986328125,4553.68994140625,4566.47998046875,3250210000,4566.47998046875,3.196949447,3334.489412197
+AMZN,20211026,3416.1201171875,3343.97998046875,3349.510009765625,3376.070068359375,2698300,3376.070068359375,3.196949447,3334.489412197
+SPX,20211026,4598.52978515625,4569.169921875,4578.68994140625,4574.7900390625,2866500000,4574.7900390625,3.703064397,3350.672032735
+AMZN,20211027,3437.0,3371.449951171875,3388.0,3392.489990234375,2702200,3392.489990234375,3.703064397,3350.672032735
+SPX,20211027,4584.56982421875,4551.66015625,4580.22021484375,4551.68017578125,3259510000,4551.68017578125,0.626560824,3376.891332611
+AMZN,20211028,3479.0,3386.0,3402.10009765625,3446.570068359375,5708700,3446.570068359375,0.626560824,3376.891332611
+SPX,20211028,4597.5498046875,4562.83984375,4562.83984375,4596.419921875,3197560000,4596.419921875,1.278764178,3423.423944006
+AMZN,20211029,3374.820068359375,3273.320068359375,3300.02001953125,3372.429931640625,6469500,3372.429931640625,1.278764178,3423.423944006
+SPX,20211029,4608.080078125,4567.58984375,4572.8701171875,4605.3798828125,3632260000,4605.3798828125,0.588131043,3400.611389422
+AMZN,20211101,3375.860107421875,3292.02001953125,3361.800048828125,3318.110107421875,3608900,3318.110107421875,0.588131043,3400.611389422
+SPX,20211101,4620.33984375,4595.06005859375,4610.6201171875,4613.669921875,2924000000,4613.669921875,0.826491586,3365.476087161
+AMZN,20211102,3331.1201171875,3283.550048828125,3315.010009765625,3312.75,2627600,3312.75,0.826491586,3365.476087161
+SPX,20211102,4635.14990234375,4613.33984375,4613.33984375,4630.64990234375,3309690000,4630.64990234375,0.887785578,3334.598669514
+AMZN,20211103,3394.919921875,3297.52001953125,3309.0,3384.0,3397200,3384.0,0.887785578,3334.598669514
+SPX,20211103,4663.4599609375,4621.18994140625,4630.64990234375,4660.56982421875,3339440000,4660.56982421875,4.895868782,3343.285091967
+AMZN,20211104,3498.6298828125,3365.0,3370.0,3477.0,5346300,3477.0,4.895868782,3343.285091967
+SPX,20211104,4683.0,4662.58984375,4662.93017578125,4680.06005859375,3332940000,4680.06005859375,9.779665289,3440.447882539
+AMZN,20211105,3566.25,3476.97998046875,3477.0,3549.01953125,2937769,3549.01953125,9.779665289,3440.447882539
+SPX,20211105,4718.5,4699.259765625,4699.259765625,4701.85986328125,1462781000,4701.85986328125,8.761816471,3531.706432825
\ No newline at end of file