Add Standardize class for Z-Score normalization and update project files

- Implemented the Standardize class for calculating Z-Score normalization over a specified lookback period.
- Updated NDepend badge SVG files to reflect new metrics.
- Modified NDepend project files to reference the updated solution file name.
- Removed outdated documentation files related to indicator proposals and channel documentation remediation.
- Updated workspace configuration to point to the new solution file.
This commit is contained in:
Miha Kralj
2026-02-07 12:47:13 -08:00
parent 58f0812584
commit 915d7a007b
59 changed files with 12387 additions and 698 deletions
@@ -0,0 +1,315 @@
using TradingPlatform.BusinessLayer;
using Xunit;
namespace QuanTAlib.Tests;
public class TtmLrcIndicatorTests
{
[Fact]
public void Constructor_SetsDefaults()
{
var ind = new TtmLrcIndicator();
Assert.Equal(100, ind.Period);
Assert.Equal(PriceType.Close, ind.SourceType);
Assert.True(ind.ShowColdValues);
Assert.Equal("TTM LRC - Linear Regression Channel", ind.Name);
Assert.False(ind.SeparateWindow);
Assert.True(ind.OnBackGround);
}
[Fact]
public void MinHistoryDepths_EqualsPeriod()
{
var ind = new TtmLrcIndicator { Period = 50 };
Assert.Equal(50, ind.MinHistoryDepths);
}
[Fact]
public void ShortName_ReflectsParameters()
{
var ind = new TtmLrcIndicator { Period = 75 };
Assert.Contains("75", ind.ShortName, StringComparison.Ordinal);
}
[Fact]
public void Initialize_AddsFiveLineSeries()
{
var ind = new TtmLrcIndicator { Period = 20 };
ind.Initialize();
Assert.Equal(5, ind.LinesSeries.Count);
Assert.Equal("Midline", ind.LinesSeries[0].Name);
Assert.Equal("Upper1", ind.LinesSeries[1].Name);
Assert.Equal("Lower1", ind.LinesSeries[2].Name);
Assert.Equal("Upper2", ind.LinesSeries[3].Name);
Assert.Equal("Lower2", ind.LinesSeries[4].Name);
}
[Fact]
public void ProcessUpdate_Historical_ComputesValues()
{
var ind = new TtmLrcIndicator { Period = 5 };
ind.Initialize();
var now = DateTime.UtcNow;
ind.HistoricalData.AddBar(now, 100, 110, 90, 102);
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.Equal(1, ind.LinesSeries[0].Count);
for (int i = 0; i < 5; i++)
{
Assert.True(double.IsFinite(ind.LinesSeries[i].GetValue(0)), $"LinesSeries[{i}] should be finite");
}
}
[Fact]
public void ProcessUpdate_NewBar_Appends()
{
var ind = new TtmLrcIndicator { Period = 5 };
ind.Initialize();
var now = DateTime.UtcNow;
ind.HistoricalData.AddBar(now, 100, 110, 90, 102);
ind.HistoricalData.AddBar(now.AddMinutes(1), 102, 112, 92, 104);
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
ind.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, ind.LinesSeries[0].Count);
Assert.Equal(2, ind.LinesSeries[1].Count);
Assert.Equal(2, ind.LinesSeries[2].Count);
Assert.Equal(2, ind.LinesSeries[3].Count);
Assert.Equal(2, ind.LinesSeries[4].Count);
}
[Fact]
public void ProcessUpdate_NewTick_DoesNotThrow()
{
var ind = new TtmLrcIndicator { Period = 5 };
ind.Initialize();
var now = DateTime.UtcNow;
ind.HistoricalData.AddBar(now, 100, 105, 95, 102);
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
ind.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
Assert.Equal(2, ind.LinesSeries[0].Count);
}
[Fact]
public void MultipleUpdates_ProducesFiniteSeries()
{
var ind = new TtmLrcIndicator { Period = 10 };
ind.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
ind.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i);
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
for (int lineIdx = 0; lineIdx < 5; lineIdx++)
{
Assert.Equal(30, ind.LinesSeries[lineIdx].Count);
for (int i = 0; i < 30; i++)
{
Assert.True(double.IsFinite(ind.LinesSeries[lineIdx].GetValue(i)), $"LinesSeries[{lineIdx}][{i}] should be finite");
}
}
}
[Fact]
public void Bands_Order_Correct()
{
var ind = new TtmLrcIndicator { Period = 10 };
ind.Initialize();
var now = DateTime.UtcNow;
// Add some volatility to ensure non-zero stddev
for (int i = 0; i < 20; i++)
{
double price = 100 + Math.Sin(i * 0.5) * 10;
ind.HistoricalData.AddBar(now.AddMinutes(i), price, price + 5, price - 5, price, 1000);
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
double midline = ind.LinesSeries[0].GetValue(0);
double upper1 = ind.LinesSeries[1].GetValue(0);
double lower1 = ind.LinesSeries[2].GetValue(0);
double upper2 = ind.LinesSeries[3].GetValue(0);
double lower2 = ind.LinesSeries[4].GetValue(0);
// Upper2 >= Upper1 >= Midline >= Lower1 >= Lower2
Assert.True(upper2 >= upper1, $"Upper2 ({upper2}) should be >= Upper1 ({upper1})");
Assert.True(upper1 >= midline, $"Upper1 ({upper1}) should be >= Midline ({midline})");
Assert.True(midline >= lower1, $"Midline ({midline}) should be >= Lower1 ({lower1})");
Assert.True(lower1 >= lower2, $"Lower1 ({lower1}) should be >= Lower2 ({lower2})");
}
[Fact]
public void FirstBar_BandsCollapsed()
{
var ind = new TtmLrcIndicator { Period = 10 };
ind.Initialize();
var now = DateTime.UtcNow;
ind.HistoricalData.AddBar(now, 100, 110, 90, 100);
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double midline = ind.LinesSeries[0].GetValue(0);
double upper1 = ind.LinesSeries[1].GetValue(0);
double lower1 = ind.LinesSeries[2].GetValue(0);
double upper2 = ind.LinesSeries[3].GetValue(0);
double lower2 = ind.LinesSeries[4].GetValue(0);
// First bar: stddev = 0, so bands should be at midline
Assert.Equal(100.0, midline, 1e-10);
Assert.Equal(100.0, upper1, 1e-10);
Assert.Equal(100.0, lower1, 1e-10);
Assert.Equal(100.0, upper2, 1e-10);
Assert.Equal(100.0, lower2, 1e-10);
}
[Fact]
public void Bands_Symmetric_AroundMiddle()
{
var ind = new TtmLrcIndicator { Period = 10 };
ind.Initialize();
var now = DateTime.UtcNow;
var rng = new Random(42);
for (int i = 0; i < 20; i++)
{
double price = 100 + rng.NextDouble() * 20;
ind.HistoricalData.AddBar(now.AddMinutes(i), price, price + 5, price - 5, price);
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
double midline = ind.LinesSeries[0].GetValue(0);
double upper1 = ind.LinesSeries[1].GetValue(0);
double lower1 = ind.LinesSeries[2].GetValue(0);
double upper2 = ind.LinesSeries[3].GetValue(0);
double lower2 = ind.LinesSeries[4].GetValue(0);
double upper1Dist = upper1 - midline;
double lower1Dist = midline - lower1;
double upper2Dist = upper2 - midline;
double lower2Dist = midline - lower2;
Assert.Equal(upper1Dist, lower1Dist, 1e-10);
Assert.Equal(upper2Dist, lower2Dist, 1e-10);
Assert.Equal(upper2Dist, upper1Dist * 2, 1e-10);
}
[Fact]
public void LinearData_ZeroStdDev()
{
var ind = new TtmLrcIndicator { Period = 10 };
ind.Initialize();
var now = DateTime.UtcNow;
// Perfect linear data: y = 100 + 2*i
for (int i = 0; i < 20; i++)
{
double price = 100 + i * 2;
ind.HistoricalData.AddBar(now.AddMinutes(i), price, price, price, price);
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
double midline = ind.LinesSeries[0].GetValue(0);
double upper1 = ind.LinesSeries[1].GetValue(0);
double lower1 = ind.LinesSeries[2].GetValue(0);
double upper2 = ind.LinesSeries[3].GetValue(0);
double lower2 = ind.LinesSeries[4].GetValue(0);
// With perfect linear fit, stddev of residuals is 0
Assert.Equal(midline, upper1, 1e-9);
Assert.Equal(midline, lower1, 1e-9);
Assert.Equal(midline, upper2, 1e-9);
Assert.Equal(midline, lower2, 1e-9);
}
[Fact]
public void DifferentPriceTypes_Work()
{
var indClose = new TtmLrcIndicator { Period = 10, SourceType = PriceType.Close };
var indHigh = new TtmLrcIndicator { Period = 10, SourceType = PriceType.High };
indClose.Initialize();
indHigh.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indClose.HistoricalData.AddBar(now.AddMinutes(i), 100, 120, 80, 100);
indHigh.HistoricalData.AddBar(now.AddMinutes(i), 100, 120, 80, 100);
indClose.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
indHigh.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
double closeMidline = indClose.LinesSeries[0].GetValue(0);
double highMidline = indHigh.LinesSeries[0].GetValue(0);
Assert.True(highMidline > closeMidline, "High price type should produce higher midline than Close");
}
[Fact]
public void TrendingData_MiddleFollowsTrend()
{
var ind = new TtmLrcIndicator { Period = 10 };
ind.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double price = 100 + i * 2; // Strong uptrend
ind.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
// After warmup, midline should be close to the current regression line value
double midline = ind.LinesSeries[0].GetValue(0);
double lastPrice = 100 + 29 * 2; // 158
// Midline should be close to last price (within reasonable range for regression)
Assert.True(Math.Abs(midline - lastPrice) < 10, $"Midline ({midline}) should be close to last price ({lastPrice})");
}
[Fact]
public void Outer_Bands_Width_Double_Of_Inner()
{
var ind = new TtmLrcIndicator { Period = 10 };
ind.Initialize();
var now = DateTime.UtcNow;
var rng = new Random(42);
for (int i = 0; i < 20; i++)
{
double price = 100 + rng.NextDouble() * 30;
ind.HistoricalData.AddBar(now.AddMinutes(i), price, price + 5, price - 5, price);
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
}
double midline = ind.LinesSeries[0].GetValue(0);
double upper1 = ind.LinesSeries[1].GetValue(0);
double upper2 = ind.LinesSeries[3].GetValue(0);
double inner1Sigma = upper1 - midline;
double outer2Sigma = upper2 - midline;
// ±2σ bands should be exactly twice as wide as ±1σ bands
Assert.Equal(inner1Sigma * 2, outer2Sigma, 1e-10);
}
[Fact]
public void DefaultPeriod100_HigherWarmup()
{
var ind = new TtmLrcIndicator(); // Default period = 100
ind.Initialize();
Assert.Equal(100, ind.MinHistoryDepths);
}
}
+79
View File
@@ -0,0 +1,79 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
using static QuanTAlib.IndicatorExtensions;
namespace QuanTAlib;
/// <summary>
/// TtmLrc: TTM Linear Regression Channel - Quantower Indicator Adapter
/// John Carter's Linear Regression Channel with ±1σ and ±2σ standard deviation bands.
/// Middle = Linear regression line value at current bar
/// Upper1/Lower1 = ±1 standard deviation (68% price range)
/// Upper2/Lower2 = ±2 standard deviations (95% price range)
/// </summary>
public sealed class TtmLrcIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 10, minimum: 2, maximum: 500, increment: 1, decimalPlaces: 0)]
public int Period { get; set; } = 100;
[InputParameter("Price Type", sortIndex: 20)]
public PriceType SourceType { get; set; } = PriceType.Close;
[InputParameter("Show Cold Values", sortIndex: 100)]
public bool ShowColdValues { get; set; } = true;
private TtmLrc? _indicator;
public int MinHistoryDepths => Period;
public override string ShortName => $"TtmLrc({Period})";
public TtmLrcIndicator()
{
Name = "TTM LRC - Linear Regression Channel";
Description = "John Carter's Linear Regression Channel with ±1σ and ±2σ bands";
SeparateWindow = false;
OnBackGround = true;
}
protected override void OnInit()
{
_indicator = new TtmLrc(Period);
// Middle line (regression line)
AddLineSeries(new LineSeries("Midline", Color.DodgerBlue, 2, LineStyle.Solid));
// ±1 StdDev bands (inner bands)
AddLineSeries(new LineSeries("Upper1", Color.FromArgb(100, 255, 100), 1, LineStyle.Solid));
AddLineSeries(new LineSeries("Lower1", Color.FromArgb(255, 100, 100), 1, LineStyle.Solid));
// ±2 StdDev bands (outer bands)
AddLineSeries(new LineSeries("Upper2", Color.FromArgb(50, 200, 50), 1, LineStyle.Dash));
AddLineSeries(new LineSeries("Lower2", Color.FromArgb(200, 50, 50), 1, LineStyle.Dash));
}
protected override void OnUpdate(UpdateArgs args)
{
if (_indicator is null)
{
return;
}
var item = HistoricalData[0, SeekOriginHistory.End];
bool isNew = args.IsNewBar();
TValue input = new(
time: item.TimeLeft,
value: item[SourceType]
);
_indicator.Update(input, isNew);
bool isHot = _indicator.IsHot;
LinesSeries[0].SetValue(_indicator.Midline.Value, isHot, ShowColdValues);
LinesSeries[1].SetValue(_indicator.Upper1.Value, isHot, ShowColdValues);
LinesSeries[2].SetValue(_indicator.Lower1.Value, isHot, ShowColdValues);
LinesSeries[3].SetValue(_indicator.Upper2.Value, isHot, ShowColdValues);
LinesSeries[4].SetValue(_indicator.Lower2.Value, isHot, ShowColdValues);
}
}
+765
View File
@@ -0,0 +1,765 @@
using System;
using Xunit;
namespace QuanTAlib.Tests;
public class TtmLrcTests
{
private const double Epsilon = 1e-10;
#region Constructor Tests
[Fact]
public void Constructor_DefaultPeriod_SetsTo100()
{
var indicator = new TtmLrc();
Assert.Equal(100, indicator.WarmupPeriod);
Assert.Equal("TtmLrc(100)", indicator.Name);
}
[Fact]
public void Constructor_CustomPeriod_SetsCorrectly()
{
var indicator = new TtmLrc(50);
Assert.Equal(50, indicator.WarmupPeriod);
Assert.Equal("TtmLrc(50)", indicator.Name);
}
[Fact]
public void Constructor_PeriodOfOne_ThrowsException()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new TtmLrc(1));
}
[Fact]
public void Constructor_ZeroPeriod_ThrowsException()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new TtmLrc(0));
}
[Fact]
public void Constructor_NegativePeriod_ThrowsException()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new TtmLrc(-5));
}
#endregion
#region IsHot/Warmup Tests
[Fact]
public void IsHot_BeforeWarmup_ReturnsFalse()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
for (int i = 0; i < 9; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
Assert.False(indicator.IsHot, $"Should not be hot at point {i + 1}");
}
}
[Fact]
public void IsHot_AtExactWarmup_ReturnsTrue()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
}
Assert.True(indicator.IsHot);
}
[Fact]
public void IsHot_AfterWarmup_RemainsTrue()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
}
Assert.True(indicator.IsHot);
}
#endregion
#region Band Symmetry Tests
[Fact]
public void Bands_Symmetry_Upper1AndLower1EquidistantFromMiddle()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
var rng = new Random(42);
for (int i = 0; i < 15; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + rng.NextDouble() * 10), isNew: true);
}
double mid = indicator.Midline.Value;
double upper1 = indicator.Upper1.Value;
double lower1 = indicator.Lower1.Value;
double distUp = upper1 - mid;
double distDown = mid - lower1;
Assert.True(Math.Abs(distUp - distDown) < Epsilon, $"Upper1 and Lower1 should be equidistant from middle. Up: {distUp}, Down: {distDown}");
Assert.Equal(indicator.StdDev, distUp, 10);
}
[Fact]
public void Bands_Symmetry_Upper2AndLower2EquidistantFromMiddle()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
var rng = new Random(42);
for (int i = 0; i < 15; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + rng.NextDouble() * 10), isNew: true);
}
double mid = indicator.Midline.Value;
double upper2 = indicator.Upper2.Value;
double lower2 = indicator.Lower2.Value;
double distUp = upper2 - mid;
double distDown = mid - lower2;
Assert.True(Math.Abs(distUp - distDown) < Epsilon, $"Upper2 and Lower2 should be equidistant from middle. Up: {distUp}, Down: {distDown}");
Assert.Equal(2.0 * indicator.StdDev, distUp, 10);
}
[Fact]
public void Bands_Ordering_UpperGreaterThanMiddleGreaterThanLower()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
var rng = new Random(42);
for (int i = 0; i < 15; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + rng.NextDouble() * 10), isNew: true);
}
Assert.True(indicator.Upper2.Value >= indicator.Upper1.Value, "Upper2 should be >= Upper1");
Assert.True(indicator.Upper1.Value >= indicator.Midline.Value, "Upper1 should be >= Midline");
Assert.True(indicator.Midline.Value >= indicator.Lower1.Value, "Midline should be >= Lower1");
Assert.True(indicator.Lower1.Value >= indicator.Lower2.Value, "Lower1 should be >= Lower2");
}
#endregion
#region Linear Data Tests
[Fact]
public void LinearData_PerfectTrend_ZeroStdDev()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
// Perfect linear data: y = 100 + 2*x
for (int i = 0; i < 15; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + 2.0 * i), isNew: true);
}
Assert.True(indicator.IsHot);
Assert.True(Math.Abs(indicator.StdDev) < 1e-9, $"StdDev should be 0 for perfect linear data, got {indicator.StdDev}");
Assert.True(Math.Abs(indicator.Slope - 2.0) < 1e-9, $"Slope should be 2.0, got {indicator.Slope}");
Assert.True(Math.Abs(indicator.RSquared - 1.0) < 1e-9, $"R² should be 1.0 for perfect fit, got {indicator.RSquared}");
// All bands should equal midline when StdDev is 0
Assert.Equal(indicator.Midline.Value, indicator.Upper1.Value, 10);
Assert.Equal(indicator.Midline.Value, indicator.Lower1.Value, 10);
Assert.Equal(indicator.Midline.Value, indicator.Upper2.Value, 10);
Assert.Equal(indicator.Midline.Value, indicator.Lower2.Value, 10);
}
[Fact]
public void LinearData_PositiveSlope_SlopeIsPositive()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
for (int i = 0; i < 15; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + 5.0 * i), isNew: true);
}
Assert.True(indicator.Slope > 0, $"Slope should be positive for uptrend, got {indicator.Slope}");
}
[Fact]
public void LinearData_NegativeSlope_SlopeIsNegative()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
for (int i = 0; i < 15; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 - 3.0 * i), isNew: true);
}
Assert.True(indicator.Slope < 0, $"Slope should be negative for downtrend, got {indicator.Slope}");
}
[Fact]
public void FlatData_ZeroSlope()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
for (int i = 0; i < 15; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100), isNew: true);
}
Assert.True(Math.Abs(indicator.Slope) < 1e-10, $"Slope should be 0 for flat data, got {indicator.Slope}");
Assert.True(Math.Abs(indicator.StdDev) < 1e-10, $"StdDev should be 0 for constant data, got {indicator.StdDev}");
}
#endregion
#region R-Squared Tests
[Fact]
public void RSquared_PerfectFit_EqualsOne()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
for (int i = 0; i < 15; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + 2.0 * i), isNew: true);
}
Assert.True(Math.Abs(indicator.RSquared - 1.0) < 1e-9, $"R² should be 1.0 for perfect linear fit, got {indicator.RSquared}");
}
[Fact]
public void RSquared_RandomData_LessThanOne()
{
var indicator = new TtmLrc(20);
var now = DateTime.UtcNow;
var rng = new Random(42);
for (int i = 0; i < 30; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + rng.NextDouble() * 50), isNew: true);
}
Assert.True(indicator.RSquared < 1.0, $"R² should be less than 1.0 for random data, got {indicator.RSquared}");
Assert.True(indicator.RSquared >= 0.0, $"R² should be non-negative, got {indicator.RSquared}");
}
[Fact]
public void RSquared_ClampedBetweenZeroAndOne()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
var rng = new Random(123);
for (int i = 0; i < 20; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + rng.NextDouble() * 100 - 50), isNew: true);
Assert.True(indicator.RSquared >= 0.0 && indicator.RSquared <= 1.0, $"R² should be in [0,1], got {indicator.RSquared}");
}
}
#endregion
#region Bar Correction Tests
[Fact]
public void BarCorrection_IsNewFalse_RevertsToPreviousState()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
// Establish base state
for (int i = 0; i < 10; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
}
double originalMid = indicator.Midline.Value;
double originalSlope = indicator.Slope;
double originalStdDev = indicator.StdDev;
// Apply correction with new value
indicator.Update(new TValue(now.AddMinutes(9), 200), isNew: false);
// Should now have different values
Assert.NotEqual(originalMid, indicator.Midline.Value);
// Correct back to original value
indicator.Update(new TValue(now.AddMinutes(9), 100 + 9), isNew: false);
// Should be back to original state
Assert.Equal(originalMid, indicator.Midline.Value, 10);
Assert.Equal(originalSlope, indicator.Slope, 10);
Assert.Equal(originalStdDev, indicator.StdDev, 10);
}
[Fact]
public void BarCorrection_MultipleCorrections_MaintainsConsistentBase()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
for (int i = 0; i < 8; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i * 2), isNew: true);
}
double baseMid = indicator.Midline.Value;
// Multiple corrections
for (int j = 0; j < 5; j++)
{
indicator.Update(new TValue(now.AddMinutes(7), 150 + j * 10), isNew: false);
}
// Revert to original
indicator.Update(new TValue(now.AddMinutes(7), 100 + 7 * 2), isNew: false);
Assert.Equal(baseMid, indicator.Midline.Value, 10);
}
[Fact]
public void BarCorrection_AfterCorrection_NextNewBarUsesCorrectedState()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
for (int i = 0; i < 6; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
}
// Correct last bar
indicator.Update(new TValue(now.AddMinutes(5), 150), isNew: false);
// Verify correction applied
Assert.True(indicator.Midline.Value > 100, "Midline should reflect corrected spike value");
// Add new bar
indicator.Update(new TValue(now.AddMinutes(6), 160), isNew: true);
// Verify the correction persisted - the new state should be based on the corrected value
// By checking slope direction changed due to spike
Assert.True(indicator.Slope > 0, "Slope should be positive after spike correction");
}
#endregion
#region Batch vs Streaming Consistency
[Fact]
public void BatchVsStreaming_SameResults()
{
var streamingIndicator = new TtmLrc(20);
var now = DateTime.UtcNow;
var rng = new Random(42);
int count = 50;
var times = new List<long>(count);
var values = new List<double>(count);
for (int i = 0; i < count; i++)
{
long t = (now.AddMinutes(i)).Ticks;
double v = 100 + rng.NextDouble() * 20;
times.Add(t);
values.Add(v);
streamingIndicator.Update(new TValue(new DateTime(t, DateTimeKind.Utc), v), isNew: true);
}
var source = new TSeries(times, values);
var (bMid, bU1, bL1, bU2, bL2) = TtmLrc.Batch(source, 20);
// Compare streaming final values to batch final values
Assert.Equal(streamingIndicator.Midline.Value, bMid.Values[^1], 10);
Assert.Equal(streamingIndicator.Upper1.Value, bU1.Values[^1], 10);
Assert.Equal(streamingIndicator.Lower1.Value, bL1.Values[^1], 10);
Assert.Equal(streamingIndicator.Upper2.Value, bU2.Values[^1], 10);
Assert.Equal(streamingIndicator.Lower2.Value, bL2.Values[^1], 10);
}
[Fact]
public void Update_TSeries_ReturnsAllFiveBands()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
var rng = new Random(42);
int count = 20;
var times = new List<long>(count);
var values = new List<double>(count);
for (int i = 0; i < count; i++)
{
times.Add(now.AddMinutes(i).Ticks);
values.Add(100 + rng.NextDouble() * 10);
}
var source = new TSeries(times, values);
var (mid, u1, l1, u2, l2) = indicator.Update(source);
Assert.Equal(count, mid.Count);
Assert.Equal(count, u1.Count);
Assert.Equal(count, l1.Count);
Assert.Equal(count, u2.Count);
Assert.Equal(count, l2.Count);
}
[Fact]
public void Calculate_ReturnsIndicatorAndResults()
{
var now = DateTime.UtcNow;
var rng = new Random(42);
int count = 30;
var times = new List<long>(count);
var values = new List<double>(count);
for (int i = 0; i < count; i++)
{
times.Add(now.AddMinutes(i).Ticks);
values.Add(100 + rng.NextDouble() * 15);
}
var source = new TSeries(times, values);
var (results, indicator) = TtmLrc.Calculate(source, 15);
Assert.NotNull(indicator);
Assert.True(indicator.IsHot);
Assert.Equal(count, results.Midline.Count);
Assert.Equal(count, results.Upper1.Count);
Assert.Equal(count, results.Lower1.Count);
Assert.Equal(count, results.Upper2.Count);
Assert.Equal(count, results.Lower2.Count);
}
#endregion
#region NaN/Infinity Handling
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
}
// Capture pre-NaN state for verification
Assert.True(double.IsFinite(indicator.Midline.Value), "Midline should be finite before NaN");
// Add NaN
indicator.Update(new TValue(now.AddMinutes(5), double.NaN), isNew: true);
// Should still have valid output (using last valid value)
Assert.True(double.IsFinite(indicator.Midline.Value), "Midline should still be finite after NaN input");
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
for (int i = 0; i < 6; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
}
// Add positive infinity
indicator.Update(new TValue(now.AddMinutes(6), double.PositiveInfinity), isNew: true);
Assert.True(double.IsFinite(indicator.Midline.Value), "Midline should still be finite after Infinity input");
}
[Fact]
public void Batch_NaN_HandledGracefully()
{
var source = new List<double> { 100, 101, double.NaN, 103, 104, 105, 106 };
int len = source.Count;
Span<double> mid = stackalloc double[len];
Span<double> u1 = stackalloc double[len];
Span<double> l1 = stackalloc double[len];
Span<double> u2 = stackalloc double[len];
Span<double> l2 = stackalloc double[len];
TtmLrc.Batch(source.ToArray(), mid, u1, l1, u2, l2, 3);
// All outputs after first few should be finite
for (int i = 2; i < len; i++)
{
Assert.True(double.IsFinite(mid[i]), $"Midline[{i}] should be finite");
}
}
#endregion
#region Reset Tests
[Fact]
public void Reset_ClearsState()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i * 2), isNew: true);
}
Assert.True(indicator.IsHot);
Assert.True(indicator.Slope > 0);
indicator.Reset();
Assert.False(indicator.IsHot);
Assert.Equal(0, indicator.Slope);
Assert.Equal(0, indicator.StdDev);
Assert.Equal(0, indicator.RSquared);
}
[Fact]
public void Reset_AllowsReuse()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
}
double firstRunMid = indicator.Midline.Value;
indicator.Reset();
for (int i = 0; i < 10; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
}
// Results should be identical after reuse
Assert.Equal(firstRunMid, indicator.Midline.Value, 10);
}
#endregion
#region Prime Tests
[Fact]
public void Prime_InitializesFromSeries()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
var times = new List<long>(15);
var values = new List<double>(15);
for (int i = 0; i < 15; i++)
{
times.Add(now.AddMinutes(i).Ticks);
values.Add(100 + i * 2);
}
var source = new TSeries(times, values);
indicator.Prime(source);
Assert.True(indicator.IsHot);
Assert.True(Math.Abs(indicator.Slope - 2.0) < 1e-9);
}
[Fact]
public void Constructor_WithSource_AutoSubscribes()
{
var source = new TSeries();
var indicator = new TtmLrc(source, 5);
var now = DateTime.UtcNow;
for (int i = 0; i < 8; i++)
{
source.Add(new TValue(now.AddMinutes(i), 100 + i * 3), isNew: true);
}
Assert.True(indicator.IsHot);
Assert.True(Math.Abs(indicator.Slope - 3.0) < 1e-9);
}
#endregion
#region Edge Cases
[Fact]
public void EmptySeries_ReturnsEmptyResults()
{
var indicator = new TtmLrc(10);
var source = new TSeries();
var (mid, u1, l1, u2, l2) = indicator.Update(source);
Assert.True(mid.Count == 0, "Midline should be empty for empty source");
Assert.True(u1.Count == 0, "Upper1 should be empty for empty source");
Assert.True(l1.Count == 0, "Lower1 should be empty for empty source");
Assert.True(u2.Count == 0, "Upper2 should be empty for empty source");
Assert.True(l2.Count == 0, "Lower2 should be empty for empty source");
}
[Fact]
public void SingleValue_AllBandsEqual()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
indicator.Update(new TValue(now, 100), isNew: true);
Assert.Equal(100, indicator.Midline.Value);
Assert.Equal(100, indicator.Upper1.Value);
Assert.Equal(100, indicator.Lower1.Value);
Assert.Equal(100, indicator.Upper2.Value);
Assert.Equal(100, indicator.Lower2.Value);
}
[Fact]
public void TwoValues_CalculatesRegression()
{
var indicator = new TtmLrc(10);
var now = DateTime.UtcNow;
indicator.Update(new TValue(now, 100), isNew: true);
indicator.Update(new TValue(now.AddMinutes(1), 110), isNew: true);
// Slope should be 10 (rise of 10 over run of 1)
Assert.True(Math.Abs(indicator.Slope - 10.0) < 1e-9, $"Slope should be 10, got {indicator.Slope}");
// Midline at x=1 should be 110
Assert.True(Math.Abs(indicator.Midline.Value - 110.0) < 1e-9, $"Midline should be 110, got {indicator.Midline.Value}");
}
[Fact]
public void VerySmallPeriod_Period2_Works()
{
var indicator = new TtmLrc(2);
var now = DateTime.UtcNow;
indicator.Update(new TValue(now, 100), isNew: true);
indicator.Update(new TValue(now.AddMinutes(1), 120), isNew: true);
indicator.Update(new TValue(now.AddMinutes(2), 130), isNew: true);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.Midline.Value));
Assert.True(double.IsFinite(indicator.Slope));
}
[Fact]
public void LargePeriod_HandlesCorrectly()
{
var indicator = new TtmLrc(200);
var now = DateTime.UtcNow;
var rng = new Random(42);
for (int i = 0; i < 250; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + rng.NextDouble() * 50), isNew: true);
}
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.Midline.Value));
Assert.True(double.IsFinite(indicator.Slope));
Assert.True(indicator.RSquared >= 0 && indicator.RSquared <= 1);
}
#endregion
#region Batch Validation Tests
[Fact]
public void Batch_InvalidPeriod_ThrowsException()
{
double[] source = new double[10];
double[] mid = new double[10];
double[] u1 = new double[10];
double[] l1 = new double[10];
double[] u2 = new double[10];
double[] l2 = new double[10];
Assert.Throws<ArgumentOutOfRangeException>(() =>
TtmLrc.Batch(source, mid, u1, l1, u2, l2, 1));
}
[Fact]
public void Batch_OutputTooShort_ThrowsException()
{
double[] source = new double[10];
double[] mid = new double[5]; // Too short
double[] u1 = new double[10];
double[] l1 = new double[10];
double[] u2 = new double[10];
double[] l2 = new double[10];
Assert.Throws<ArgumentException>(() =>
TtmLrc.Batch(source, mid, u1, l1, u2, l2, 3));
}
#endregion
#region Pub/Sub Tests
[Fact]
public void Pub_FiredOnUpdate()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
int eventCount = 0;
void OnPub(object? sender, in TValueEventArgs args) { eventCount = eventCount + 1; }
indicator.Pub += OnPub;
for (int i = 0; i < 8; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i), isNew: true);
}
Assert.Equal(8, eventCount);
}
[Fact]
public void Pub_ReceivesCorrectValue()
{
var indicator = new TtmLrc(5);
var now = DateTime.UtcNow;
TValue? lastPubValue = null;
void OnPub(object? sender, in TValueEventArgs args) => lastPubValue = args.Value;
indicator.Pub += OnPub;
for (int i = 0; i < 8; i++)
{
indicator.Update(new TValue(now.AddMinutes(i), 100 + i * 2), isNew: true);
}
Assert.NotNull(lastPubValue);
Assert.Equal(indicator.Midline.Value, lastPubValue.Value.Value, 10);
}
#endregion
}
@@ -0,0 +1,648 @@
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class TtmLrcValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public TtmLrcValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose() => Dispose(true);
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
[Fact]
public void Validate_ManualCalculation_ThreePoints()
{
var series = new TSeries();
var t0 = DateTime.UtcNow;
// Points: (0,100), (1,120), (2,110)
series.Add(new TValue(t0, 100));
series.Add(new TValue(t0.AddMinutes(1), 120));
series.Add(new TValue(t0.AddMinutes(2), 110));
var ind = new TtmLrc(10);
// Bar 0: regression = 100, slope = 0, stdDev = 0
ind.Update(series[0]);
Assert.Equal(100.0, ind.Midline.Value, 1e-10);
Assert.Equal(0.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10);
// Bar 1: Two points (100, 120 at x=0,1)
// Perfect line through points: y = 100 + 20*x
ind.Update(series[1]);
Assert.Equal(120.0, ind.Midline.Value, 1e-10);
Assert.Equal(20.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10);
// Bar 2: Linear regression of (100, 120, 110)
// slope = 5, intercept = 105, regression at x=2 = 115
ind.Update(series[2]);
Assert.Equal(115.0, ind.Midline.Value, 1e-10);
Assert.Equal(5.0, ind.Slope, 1e-10);
// Residuals: 100-105=-5, 120-110=10, 110-115=-5
// StdDev = sqrt((25+100+25)/3) = sqrt(50)
double expectedStdDev = Math.Sqrt(50);
Assert.Equal(expectedStdDev, ind.StdDev, 1e-10);
// Verify ±1σ bands
Assert.Equal(115.0 + expectedStdDev, ind.Upper1.Value, 1e-10);
Assert.Equal(115.0 - expectedStdDev, ind.Lower1.Value, 1e-10);
// Verify ±2σ bands
Assert.Equal(115.0 + 2.0 * expectedStdDev, ind.Upper2.Value, 1e-10);
Assert.Equal(115.0 - 2.0 * expectedStdDev, ind.Lower2.Value, 1e-10);
_output.WriteLine("TtmLrc manual calculation validated");
}
[Fact]
public void Validate_LinearTrend_ZeroResiduals()
{
var series = new TSeries();
var t0 = DateTime.UtcNow;
// Perfect linear trend: 100, 110, 120, 130, 140
for (int i = 0; i < 5; i++)
{
series.Add(new TValue(t0.AddMinutes(i), 100 + i * 10));
}
var ind = new TtmLrc(5);
foreach (var tv in series)
{
ind.Update(tv);
}
// Perfect linear fit: slope = 10, no residuals
Assert.Equal(140.0, ind.Midline.Value, 1e-10);
Assert.Equal(10.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10);
Assert.Equal(1.0, ind.RSquared, 1e-10); // Perfect fit
// All bands = midline when stddev = 0
Assert.Equal(140.0, ind.Upper1.Value, 1e-10);
Assert.Equal(140.0, ind.Lower1.Value, 1e-10);
Assert.Equal(140.0, ind.Upper2.Value, 1e-10);
Assert.Equal(140.0, ind.Lower2.Value, 1e-10);
_output.WriteLine("TtmLrc linear trend validated");
}
[Fact]
public void Validate_ConstantValues_ZeroResiduals()
{
var series = new TSeries();
var t0 = DateTime.UtcNow;
// Constant values: 100, 100, 100, 100, 100
for (int i = 0; i < 5; i++)
{
series.Add(new TValue(t0.AddMinutes(i), 100));
}
var ind = new TtmLrc(5);
foreach (var tv in series)
{
ind.Update(tv);
}
// Constant: slope = 0, no residuals
Assert.Equal(100.0, ind.Midline.Value, 1e-10);
Assert.Equal(0.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10);
_output.WriteLine("TtmLrc constant values validated");
}
[Fact]
public void Validate_AllModes_Consistency()
{
int[] periods = { 5, 10, 20, 50 };
foreach (int period in periods)
{
// Batch (instance)
var inst = new TtmLrc(period);
var (bMid, bU1, bL1, bU2, bL2) = inst.Update(_testData.Data);
// Static batch
var (sMid, sU1, sL1, sU2, sL2) = TtmLrc.Batch(_testData.Data, period);
ValidationHelper.VerifySeriesEqual(bMid, sMid);
ValidationHelper.VerifySeriesEqual(bU1, sU1);
ValidationHelper.VerifySeriesEqual(bL1, sL1);
ValidationHelper.VerifySeriesEqual(bU2, sU2);
ValidationHelper.VerifySeriesEqual(bL2, sL2);
// Streaming
var streaming = new TtmLrc(period);
var sMidStream = new TSeries();
var sU1Stream = new TSeries();
var sL1Stream = new TSeries();
var sU2Stream = new TSeries();
var sL2Stream = new TSeries();
foreach (var tv in _testData.Data)
{
streaming.Update(tv);
sMidStream.Add(streaming.Midline);
sU1Stream.Add(streaming.Upper1);
sL1Stream.Add(streaming.Lower1);
sU2Stream.Add(streaming.Upper2);
sL2Stream.Add(streaming.Lower2);
}
ValidationHelper.VerifySeriesEqual(sMid, sMidStream);
ValidationHelper.VerifySeriesEqual(sU1, sU1Stream);
ValidationHelper.VerifySeriesEqual(sL1, sL1Stream);
ValidationHelper.VerifySeriesEqual(sU2, sU2Stream);
ValidationHelper.VerifySeriesEqual(sL2, sL2Stream);
// Span
double[] source = _testData.ClosePrices.ToArray();
double[] spanMid = new double[source.Length];
double[] spanU1 = new double[source.Length];
double[] spanL1 = new double[source.Length];
double[] spanU2 = new double[source.Length];
double[] spanL2 = new double[source.Length];
TtmLrc.Batch(source.AsSpan(), spanMid.AsSpan(), spanU1.AsSpan(), spanL1.AsSpan(), spanU2.AsSpan(), spanL2.AsSpan(), period);
for (int i = 0; i < source.Length; i++)
{
Assert.Equal(sMid[i].Value, spanMid[i], 9);
Assert.Equal(sU1[i].Value, spanU1[i], 9);
Assert.Equal(sL1[i].Value, spanL1[i], 9);
Assert.Equal(sU2[i].Value, spanU2[i], 9);
Assert.Equal(sL2[i].Value, spanL2[i], 9);
}
}
_output.WriteLine("TtmLrc mode consistency validated (batch/stream/span)");
}
[Fact]
public void Validate_EventingMode_MatchesBatch()
{
const int period = 20;
var pub = new TSeries();
var evtInd = new TtmLrc(pub, period);
var evtMid = new TSeries();
var evtU1 = new TSeries();
var evtL1 = new TSeries();
var evtU2 = new TSeries();
var evtL2 = new TSeries();
foreach (var tv in _testData.Data)
{
pub.Add(tv);
evtMid.Add(evtInd.Midline);
evtU1.Add(evtInd.Upper1);
evtL1.Add(evtInd.Lower1);
evtU2.Add(evtInd.Upper2);
evtL2.Add(evtInd.Lower2);
}
var (bMid, bU1, bL1, bU2, bL2) = TtmLrc.Batch(_testData.Data, period);
ValidationHelper.VerifySeriesEqual(bMid, evtMid);
ValidationHelper.VerifySeriesEqual(bU1, evtU1);
ValidationHelper.VerifySeriesEqual(bL1, evtL1);
ValidationHelper.VerifySeriesEqual(bU2, evtU2);
ValidationHelper.VerifySeriesEqual(bL2, evtL2);
_output.WriteLine("TtmLrc eventing mode validated");
}
[Fact]
public void Validate_Calculate_ReturnsHotIndicator()
{
const int period = 15;
var ((mid, u1, l1, u2, l2), ind) = TtmLrc.Calculate(_testData.Data, period);
Assert.True(ind.IsHot);
Assert.Equal(period, ind.WarmupPeriod);
Assert.Equal(mid.Last.Value, ind.Midline.Value, 1e-10);
Assert.Equal(u1.Last.Value, ind.Upper1.Value, 1e-10);
Assert.Equal(l1.Last.Value, ind.Lower1.Value, 1e-10);
Assert.Equal(u2.Last.Value, ind.Upper2.Value, 1e-10);
Assert.Equal(l2.Last.Value, ind.Lower2.Value, 1e-10);
// Continue streaming
var next = new TValue(DateTime.UtcNow, 100);
ind.Update(next);
Assert.True(ind.IsHot);
_output.WriteLine("TtmLrc Calculate validated");
}
[Fact]
public void Validate_Prime_MatchesBatch()
{
const int period = 25;
var (bMid, bU1, bL1, bU2, bL2) = TtmLrc.Batch(_testData.Data, period);
var primed = new TtmLrc(period);
var subset = new TSeries();
for (int i = 0; i < 200; i++)
{
subset.Add(_testData.Data[i]);
}
primed.Prime(subset);
for (int i = 200; i < _testData.Data.Count; i++)
{
primed.Update(_testData.Data[i]);
}
Assert.Equal(bMid.Last.Value, primed.Midline.Value, 1e-9);
Assert.Equal(bU1.Last.Value, primed.Upper1.Value, 1e-9);
Assert.Equal(bL1.Last.Value, primed.Lower1.Value, 1e-9);
Assert.Equal(bU2.Last.Value, primed.Upper2.Value, 1e-9);
Assert.Equal(bL2.Last.Value, primed.Lower2.Value, 1e-9);
_output.WriteLine("TtmLrc Prime validated against batch");
}
[Fact]
public void Validate_LargeDataset_FiniteOutputs()
{
var (mid, u1, l1, u2, l2) = TtmLrc.Batch(_testData.Data, 50);
ValidationHelper.VerifyAllFinite(mid, startIndex: 0);
ValidationHelper.VerifyAllFinite(u1, startIndex: 0);
ValidationHelper.VerifyAllFinite(l1, startIndex: 0);
ValidationHelper.VerifyAllFinite(u2, startIndex: 0);
ValidationHelper.VerifyAllFinite(l2, startIndex: 0);
// Band ordering: Upper2 >= Upper1 >= Middle >= Lower1 >= Lower2
for (int i = 0; i < mid.Count; i++)
{
Assert.True(u2[i].Value >= u1[i].Value, $"Upper2 >= Upper1 at {i}");
Assert.True(u1[i].Value >= mid[i].Value, $"Upper1 >= Middle at {i}");
Assert.True(l1[i].Value <= mid[i].Value, $"Lower1 <= Middle at {i}");
Assert.True(l2[i].Value <= l1[i].Value, $"Lower2 <= Lower1 at {i}");
}
_output.WriteLine("TtmLrc large dataset validated");
}
[Fact]
public void Validate_BandSymmetry_AllBars()
{
var ind = new TtmLrc(20);
var (mid, u1, l1, u2, l2) = ind.Update(_testData.Data);
for (int i = 0; i < mid.Count; i++)
{
// ±1σ symmetry
double upper1Width = u1[i].Value - mid[i].Value;
double lower1Width = mid[i].Value - l1[i].Value;
Assert.Equal(upper1Width, lower1Width, 1e-10);
// ±2σ symmetry
double upper2Width = u2[i].Value - mid[i].Value;
double lower2Width = mid[i].Value - l2[i].Value;
Assert.Equal(upper2Width, lower2Width, 1e-10);
// ±2σ should be exactly 2x ±1σ
Assert.Equal(upper2Width, upper1Width * 2, 1e-10);
}
_output.WriteLine("TtmLrc band symmetry validated for all bars");
}
[Fact]
public void Validate_RSquared_Range()
{
var ind = new TtmLrc(20);
foreach (var tv in _testData.Data)
{
ind.Update(tv);
Assert.True(ind.RSquared >= 0.0 && ind.RSquared <= 1.0, $"R² should be in [0,1], got {ind.RSquared}");
}
_output.WriteLine("TtmLrc R² range validated");
}
[Fact]
public void Validate_RSquared_PerfectFit()
{
var t0 = DateTime.UtcNow;
var ind = new TtmLrc(5);
// Feed perfect linear data
for (int i = 0; i < 10; i++)
{
ind.Update(new TValue(t0.AddMinutes(i), 100 + i * 5));
}
Assert.Equal(1.0, ind.RSquared, 1e-9);
Assert.Equal(0.0, ind.StdDev, 1e-9);
_output.WriteLine("TtmLrc R² perfect fit validated");
}
[Fact]
public void Validate_PeriodEffect_SmoothingAndSlope()
{
int[] periods = { 5, 10, 20, 50 };
double[] slopes = new double[periods.Length];
double[] middles = new double[periods.Length];
for (int i = 0; i < periods.Length; i++)
{
var ind = new TtmLrc(periods[i]);
foreach (var tv in _testData.Data)
{
ind.Update(tv);
}
slopes[i] = ind.Slope;
middles[i] = ind.Midline.Value;
}
// All should produce finite values
foreach (var s in slopes)
{
Assert.True(double.IsFinite(s));
}
foreach (var m in middles)
{
Assert.True(double.IsFinite(m));
}
_output.WriteLine("TtmLrc period effect validated");
}
[Fact]
public void Validate_StateRestoration_Iterative()
{
var ind = new TtmLrc(15);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
// Build up state
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
ind.Update(new TValue(bar.Time, bar.Close), isNew: true);
}
// Multiple corrections
var rememberedBar = gbm.Next(isNew: true);
var remembered = new TValue(rememberedBar.Time, rememberedBar.Close);
ind.Update(remembered, isNew: true);
double midBefore = ind.Midline.Value;
double u1Before = ind.Upper1.Value;
double l1Before = ind.Lower1.Value;
double u2Before = ind.Upper2.Value;
double l2Before = ind.Lower2.Value;
double slopeBefore = ind.Slope;
double stdDevBefore = ind.StdDev;
double rSquaredBefore = ind.RSquared;
for (int i = 0; i < 10; i++)
{
var corrected = gbm.Next(isNew: false);
ind.Update(new TValue(corrected.Time, corrected.Close), isNew: false);
}
// Restore with remembered value
ind.Update(remembered, isNew: false);
Assert.Equal(midBefore, ind.Midline.Value, 1e-6);
Assert.Equal(u1Before, ind.Upper1.Value, 1e-6);
Assert.Equal(l1Before, ind.Lower1.Value, 1e-6);
Assert.Equal(u2Before, ind.Upper2.Value, 1e-6);
Assert.Equal(l2Before, ind.Lower2.Value, 1e-6);
Assert.Equal(slopeBefore, ind.Slope, 1e-6);
Assert.Equal(stdDevBefore, ind.StdDev, 1e-6);
Assert.Equal(rSquaredBefore, ind.RSquared, 1e-6);
_output.WriteLine("TtmLrc state restoration validated");
}
[Fact]
public void Validate_BandWidthFormula()
{
var ind = new TtmLrc(20);
foreach (var tv in _testData.Data)
{
ind.Update(tv);
// ±1σ band width = 2 * stdDev
double expected1Width = 2 * ind.StdDev;
double actual1Width = ind.Upper1.Value - ind.Lower1.Value;
Assert.Equal(expected1Width, actual1Width, 1e-10);
// ±2σ band width = 4 * stdDev
double expected2Width = 4 * ind.StdDev;
double actual2Width = ind.Upper2.Value - ind.Lower2.Value;
Assert.Equal(expected2Width, actual2Width, 1e-10);
}
_output.WriteLine("TtmLrc band width formula validated");
}
[Fact]
public void Validate_SlopeDirection()
{
// Test uptrend detection
var uptrend = new TSeries();
var t0 = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
uptrend.Add(new TValue(t0.AddMinutes(i), 100 + i * 2 + (i % 3))); // Noisy uptrend
}
var indUp = new TtmLrc(10);
foreach (var tv in uptrend)
{
indUp.Update(tv);
}
Assert.True(indUp.Slope > 0, "Uptrend should have positive slope");
// Test downtrend detection
var downtrend = new TSeries();
for (int i = 0; i < 20; i++)
{
downtrend.Add(new TValue(t0.AddMinutes(i), 200 - i * 2 + (i % 3))); // Noisy downtrend
}
var indDown = new TtmLrc(10);
foreach (var tv in downtrend)
{
indDown.Update(tv);
}
Assert.True(indDown.Slope < 0, "Downtrend should have negative slope");
_output.WriteLine("TtmLrc slope direction validated");
}
[Fact]
public void Validate_SlidingWindow_Correctness()
{
const int period = 5;
var ind = new TtmLrc(period);
// Feed specific values
double[] values = { 100, 110, 120, 130, 140, 150, 160, 170 };
var t0 = DateTime.UtcNow;
foreach (double v in values)
{
ind.Update(new TValue(t0, v));
t0 = t0.AddMinutes(1);
}
// Window should contain last 5: 130,140,150,160,170
// Linear regression of 130,140,150,160,170 at x=0,1,2,3,4
// Perfect linear fit: slope = 10, intercept = 130
// regression at x=4 = 130 + 10*4 = 170
Assert.Equal(170.0, ind.Midline.Value, 1e-10);
Assert.Equal(10.0, ind.Slope, 1e-10);
Assert.Equal(0.0, ind.StdDev, 1e-10); // Perfect linear fit
Assert.Equal(1.0, ind.RSquared, 1e-10); // Perfect fit
_output.WriteLine("TtmLrc sliding window validated");
}
[Fact]
public void Validate_Residuals_NonLinearData()
{
// Test with data that doesn't fit a perfect line
var ind = new TtmLrc(4);
var t0 = DateTime.UtcNow;
// Values: 100, 120, 100, 120 (oscillating)
ind.Update(new TValue(t0, 100));
ind.Update(new TValue(t0.AddMinutes(1), 120));
ind.Update(new TValue(t0.AddMinutes(2), 100));
ind.Update(new TValue(t0.AddMinutes(3), 120));
// These values don't fit a line well, so stdDev should be significant
Assert.True(ind.StdDev > 5, "Oscillating data should have significant residuals");
Assert.True(ind.RSquared < 0.5, "Poor fit should have low R²");
// Bands should be wider than regression value
Assert.True(ind.Upper1.Value > ind.Midline.Value, "Upper1 > Midline with residuals");
Assert.True(ind.Lower1.Value < ind.Midline.Value, "Lower1 < Midline with residuals");
Assert.True(ind.Upper2.Value > ind.Upper1.Value, "Upper2 > Upper1 with residuals");
Assert.True(ind.Lower2.Value < ind.Lower1.Value, "Lower2 < Lower1 with residuals");
_output.WriteLine("TtmLrc residuals for non-linear data validated");
}
[Fact]
public void Validate_DefaultPeriod_Is100()
{
// TTM LRC spec says default period should be 100
var ind = new TtmLrc();
Assert.Equal(100, ind.WarmupPeriod);
Assert.Equal("TtmLrc(100)", ind.Name);
_output.WriteLine("TtmLrc default period 100 validated");
}
[Fact]
public void Validate_StdDev_Formula()
{
// Verify stdDev calculation: sqrt(sum(residual^2)/n)
var ind = new TtmLrc(5);
var t0 = DateTime.UtcNow;
// Known values for manual calculation
double[] values = { 100, 105, 98, 107, 102 };
foreach (double v in values)
{
ind.Update(new TValue(t0, v));
t0 = t0.AddMinutes(1);
}
// Slope should be positive (trend is slightly upward)
Assert.True(ind.Slope > 0 && ind.Slope < 5, $"Slope={ind.Slope} should be small positive");
// StdDev should be non-trivial since data doesn't fit perfectly
Assert.True(ind.StdDev > 0 && ind.StdDev < 10, $"StdDev={ind.StdDev} should be positive");
// R² should be moderate (not perfect fit)
Assert.True(ind.RSquared > 0 && ind.RSquared < 1, $"R²={ind.RSquared} should be between 0 and 1");
_output.WriteLine("TtmLrc stdDev formula validated");
}
[Fact]
public void Validate_CompareWithRegchannel_Midline()
{
// TtmLrc midline should match Regchannel middle (both use linear regression)
const int period = 20;
var ttmLrc = new TtmLrc(period);
var regchannel = new Regchannel(period, 1.0);
foreach (var tv in _testData.Data)
{
ttmLrc.Update(tv);
regchannel.Update(tv);
}
// Midlines should be identical
Assert.Equal(regchannel.Last.Value, ttmLrc.Midline.Value, 1e-9);
Assert.Equal(regchannel.Slope, ttmLrc.Slope, 1e-9);
Assert.Equal(regchannel.StdDev, ttmLrc.StdDev, 1e-9);
// TtmLrc ±1σ bands should match Regchannel with multiplier 1.0
Assert.Equal(regchannel.Upper.Value, ttmLrc.Upper1.Value, 1e-9);
Assert.Equal(regchannel.Lower.Value, ttmLrc.Lower1.Value, 1e-9);
_output.WriteLine("TtmLrc vs Regchannel midline validated");
}
[Fact]
public void Validate_CompareWithRegchannel_DoubleMultiplier()
{
// TtmLrc ±2σ bands should match Regchannel with multiplier 2.0
const int period = 20;
var ttmLrc = new TtmLrc(period);
var regchannel2x = new Regchannel(period, 2.0);
foreach (var tv in _testData.Data)
{
ttmLrc.Update(tv);
regchannel2x.Update(tv);
}
// ±2σ bands should match Regchannel(20, 2.0)
Assert.Equal(regchannel2x.Upper.Value, ttmLrc.Upper2.Value, 1e-9);
Assert.Equal(regchannel2x.Lower.Value, ttmLrc.Lower2.Value, 1e-9);
_output.WriteLine("TtmLrc ±2σ vs Regchannel(multiplier=2) validated");
}
}
+583
View File
@@ -0,0 +1,583 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// TTM_LRC: TTM Linear Regression Channel
/// John Carter's Linear Regression Channel with ±1σ and ±2σ standard deviation bands.
/// </summary>
/// <remarks>
/// The TTM LRC provides a clean, statistically-based price channel using linear regression
/// analysis. Unlike Bollinger Bands which measure volatility around a moving average, LRC
/// measures price deviation from the trend line, making it particularly useful for identifying
/// overbought/oversold conditions within a defined trend.
///
/// Calculation:
/// 1. Compute linear regression line: y = mx + b using least squares over N periods
/// 2. Calculate residuals: residual_i = y_i - predicted_i
/// 3. Compute standard deviation of residuals: σ = √(Σ(residual²) / N)
/// 4. Inner bands: ±1σ (68% of prices)
/// 5. Outer bands: ±2σ (95% of prices)
///
/// Key characteristics:
/// - Middle line is the linear regression endpoint (LSMA)
/// - Dual band pairs for statistical significance levels
/// - Slope indicates trend direction and strength
/// - R² indicates trend quality (higher = cleaner trend)
/// - Price at ±2σ suggests extreme deviation from trend
///
/// Sources:
/// John Carter's TTM Indicators
/// https://school.stockcharts.com/doku.php?id=technical_indicators:raff_regression_channel
/// </remarks>
[SkipLocalsInit]
public sealed class TtmLrc : ITValuePublisher
{
private readonly int _period;
// Precomputed constants for linear regression
private readonly double _sumX; // sum of x indices: 0 + 1 + ... + (n-1)
private readonly double _denominator; // n * sumX² - sumX²
// Ring buffer for values
private readonly double[] _buffer;
private double[]? _p_buffer;
[StructLayout(LayoutKind.Auto)]
private record struct State(
int Head,
int Count,
double LastValid,
double Slope,
double StdDev,
double RSquared,
bool IsHot);
private State _state;
private State _p_state;
private readonly TValuePublishedHandler _valueHandler;
public string Name { get; }
public int WarmupPeriod { get; }
/// <summary>
/// The linear regression line value (trend center)
/// </summary>
public TValue Midline { get; private set; }
/// <summary>
/// Upper band at +1 standard deviation
/// </summary>
public TValue Upper1 { get; private set; }
/// <summary>
/// Lower band at -1 standard deviation
/// </summary>
public TValue Lower1 { get; private set; }
/// <summary>
/// Upper band at +2 standard deviations
/// </summary>
public TValue Upper2 { get; private set; }
/// <summary>
/// Lower band at -2 standard deviations
/// </summary>
public TValue Lower2 { get; private set; }
/// <summary>
/// Primary output (Midline) for compatibility with AbstractBase
/// </summary>
public TValue Last => Midline;
public bool IsHot => _state.IsHot;
/// <summary>
/// The slope of the linear regression line (trend direction)
/// Positive = uptrend, Negative = downtrend
/// </summary>
public double Slope => _state.Slope;
/// <summary>
/// The standard deviation of residuals (price dispersion around trend)
/// </summary>
public double StdDev => _state.StdDev;
/// <summary>
/// Coefficient of determination (R²) measuring trend quality.
/// Range: 0 to 1. Higher values indicate a cleaner, more reliable trend.
/// R² > 0.8 suggests strong linear trend.
/// </summary>
public double RSquared => _state.RSquared;
public event TValuePublishedHandler? Pub;
/// <summary>
/// Initializes a new instance of the TTM Linear Regression Channel indicator.
/// </summary>
/// <param name="period">Lookback period for regression (default 100, must be > 1)</param>
public TtmLrc(int period = 100)
{
if (period <= 1)
{
throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 1.");
}
_period = period;
_buffer = new double[period];
_p_buffer = new double[period];
WarmupPeriod = period;
Name = $"TtmLrc({period})";
_valueHandler = HandleValue;
// Precompute constants
// sumX = 0 + 1 + ... + (n-1) = n(n-1)/2
_sumX = 0.5 * period * (period - 1);
// sumX² = 0² + 1² + ... + (n-1)² = (n-1)n(2n-1)/6
double sumX2 = (period - 1.0) * period * (2.0 * period - 1.0) / 6.0;
// denominator = n * sumX² - sumX²
_denominator = period * sumX2 - _sumX * _sumX;
Reset();
}
public TtmLrc(TSeries source, int period = 100) : this(period)
{
Prime(source);
source.Pub += _valueHandler;
}
private void HandleValue(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PubEvent(TValue value, bool isNew = true) =>
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_state = new State(0, 0, double.NaN, 0, 0, 0, false);
_p_state = _state;
Array.Fill(_buffer, 0.0);
_p_buffer = (double[])_buffer.Clone();
Midline = default;
Upper1 = default;
Lower1 = default;
Upper2 = default;
Lower2 = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValid(double value, bool isNew)
{
if (double.IsFinite(value))
{
// Always update LastValid on finite input (including bar corrections)
_state = _state with { LastValid = value };
return value;
}
return double.IsFinite(_state.LastValid) ? _state.LastValid : 0.0;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
Array.Copy(_buffer, _p_buffer!, _period);
}
else
{
_state = _p_state;
Array.Copy(_p_buffer!, _buffer, _period);
}
double value = GetValid(input.Value, isNew);
// Add to ring buffer
int count = _state.Count;
int head = _state.Head;
if (count < _period)
{
count++;
}
_buffer[head] = value;
int newHead = (head + 1) % _period;
if (isNew)
{
_state = _state with { Head = newHead, Count = count };
}
// Calculate linear regression and std dev of residuals
if (count <= 1)
{
Midline = new TValue(input.Time, value);
Upper1 = new TValue(input.Time, value);
Lower1 = new TValue(input.Time, value);
Upper2 = new TValue(input.Time, value);
Lower2 = new TValue(input.Time, value);
_state = _state with { Slope = 0, StdDev = 0, RSquared = 0 };
PubEvent(Midline, isNew);
return Midline;
}
// Build span of values in chronological order (oldest to newest)
Span<double> values = stackalloc double[count];
int readHead = (newHead - count + _period) % _period;
for (int i = 0; i < count; i++)
{
values[i] = _buffer[(readHead + i) % _period];
}
// Calculate sums for linear regression
double sumY = 0;
double sumXY = 0;
for (int i = 0; i < count; i++)
{
sumY += values[i];
sumXY += i * values[i];
}
double n = count;
double sx = _sumX;
double denom = _denominator;
// Adjust for partial window during warmup
if (count < _period)
{
sx = 0.5 * n * (n - 1);
double sx2 = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
denom = n * sx2 - sx * sx;
}
double slope, intercept, regression;
if (Math.Abs(denom) < 1e-10)
{
slope = 0;
intercept = sumY / n;
regression = intercept;
}
else
{
slope = (n * sumXY - sx * sumY) / denom;
intercept = (sumY - slope * sx) / n;
// Regression value at current point (x = count - 1)
regression = Math.FusedMultiplyAdd(slope, count - 1, intercept);
}
// Calculate standard deviation of residuals and R²
double sumResiduals2 = 0;
double meanY = sumY / n;
double ssTot = 0;
for (int i = 0; i < count; i++)
{
double predicted = Math.FusedMultiplyAdd(slope, i, intercept);
double residual = values[i] - predicted;
sumResiduals2 = Math.FusedMultiplyAdd(residual, residual, sumResiduals2);
double devFromMean = values[i] - meanY;
ssTot = Math.FusedMultiplyAdd(devFromMean, devFromMean, ssTot);
}
double stdDev = Math.Sqrt(sumResiduals2 / n);
// Compute R² (coefficient of determination)
double rSquared = ssTot > 1e-10 ? 1.0 - (sumResiduals2 / ssTot) : 0.0;
rSquared = Math.Clamp(rSquared, 0.0, 1.0);
if (!_state.IsHot && count >= WarmupPeriod)
{
_state = _state with { IsHot = true };
}
_state = _state with { Slope = slope, StdDev = stdDev, RSquared = rSquared };
Midline = new TValue(input.Time, regression);
Upper1 = new TValue(input.Time, regression + stdDev);
Lower1 = new TValue(input.Time, regression - stdDev);
Upper2 = new TValue(input.Time, regression + 2.0 * stdDev);
Lower2 = new TValue(input.Time, regression - 2.0 * stdDev);
PubEvent(Midline, isNew);
return Midline;
}
public (TSeries Midline, TSeries Upper1, TSeries Lower1, TSeries Upper2, TSeries Lower2) Update(TSeries source)
{
if (source.Count == 0)
{
return (new TSeries([], []), new TSeries([], []), new TSeries([], []), new TSeries([], []), new TSeries([], []));
}
int len = source.Count;
var tMid = new List<long>(len);
var vMid = new List<double>(len);
var vU1 = new List<double>(len);
var vL1 = new List<double>(len);
var vU2 = new List<double>(len);
var vL2 = new List<double>(len);
CollectionsMarshal.SetCount(tMid, len);
CollectionsMarshal.SetCount(vMid, len);
CollectionsMarshal.SetCount(vU1, len);
CollectionsMarshal.SetCount(vL1, len);
CollectionsMarshal.SetCount(vU2, len);
CollectionsMarshal.SetCount(vL2, len);
var tSpan = CollectionsMarshal.AsSpan(tMid);
var vMidSpan = CollectionsMarshal.AsSpan(vMid);
var vU1Span = CollectionsMarshal.AsSpan(vU1);
var vL1Span = CollectionsMarshal.AsSpan(vL1);
var vU2Span = CollectionsMarshal.AsSpan(vU2);
var vL2Span = CollectionsMarshal.AsSpan(vL2);
Batch(source.Values, vMidSpan, vU1Span, vL1Span, vU2Span, vL2Span, _period);
source.Times.CopyTo(tSpan);
// Prime internal state for continued streaming
Prime(source);
var lastTime = new DateTime(source.Times[^1], DateTimeKind.Utc);
Midline = new TValue(lastTime, vMidSpan[^1]);
Upper1 = new TValue(lastTime, vU1Span[^1]);
Lower1 = new TValue(lastTime, vL1Span[^1]);
Upper2 = new TValue(lastTime, vU2Span[^1]);
Lower2 = new TValue(lastTime, vL2Span[^1]);
return (
new TSeries(tMid, vMid),
new TSeries(new List<long>(tMid), vU1),
new TSeries(new List<long>(tMid), vL1),
new TSeries(new List<long>(tMid), vU2),
new TSeries(new List<long>(tMid), vL2)
);
}
public void Prime(TSeries source)
{
Reset();
if (source.Count == 0)
{
return;
}
for (int i = 0; i < source.Count; i++)
{
Update(source[i], isNew: true);
}
}
/// <summary>
/// Batch calculation using spans. Outputs midline and all four bands.
/// </summary>
public static void Batch(
ReadOnlySpan<double> source,
Span<double> midline,
Span<double> upper1,
Span<double> lower1,
Span<double> upper2,
Span<double> lower2,
int period)
{
if (period <= 1)
{
throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 1.");
}
if (midline.Length < source.Length ||
upper1.Length < source.Length ||
lower1.Length < source.Length ||
upper2.Length < source.Length ||
lower2.Length < source.Length)
{
throw new ArgumentException("Output spans must be at least as long as input", nameof(midline));
}
int len = source.Length;
if (len == 0)
{
return;
}
// Precompute constants for full period
double sumXFull = 0.5 * period * (period - 1);
double sumX2Full = (period - 1.0) * period * (2.0 * period - 1.0) / 6.0;
double denomFull = period * sumX2Full - sumXFull * sumXFull;
// Track last valid value for NaN substitution
double lastValid = double.NaN;
for (int i = 0; i < len; i++)
{
// Get valid value with last-valid substitution
double currentValue = source[i];
if (double.IsFinite(currentValue))
{
lastValid = currentValue;
}
else
{
currentValue = lastValid;
}
// If still NaN (no valid value seen yet), output NaN
if (!double.IsFinite(currentValue))
{
midline[i] = double.NaN;
upper1[i] = double.NaN;
lower1[i] = double.NaN;
upper2[i] = double.NaN;
lower2[i] = double.NaN;
continue;
}
int count = Math.Min(i + 1, period);
int start = i - count + 1;
if (count <= 1)
{
midline[i] = currentValue;
upper1[i] = currentValue;
lower1[i] = currentValue;
upper2[i] = currentValue;
lower2[i] = currentValue;
continue;
}
// Calculate sums for linear regression with NaN handling
double sumY = 0;
double sumXY = 0;
double lastValidInWindow = double.NaN;
for (int j = 0; j < count; j++)
{
double rawY = source[start + j];
double y;
if (double.IsFinite(rawY))
{
lastValidInWindow = rawY;
y = rawY;
}
else
{
y = double.IsFinite(lastValidInWindow) ? lastValidInWindow : 0.0;
}
sumY += y;
sumXY += j * y;
}
double n = count;
double sx, denom;
if (count < period)
{
sx = 0.5 * n * (n - 1);
double sx2 = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
denom = n * sx2 - sx * sx;
}
else
{
sx = sumXFull;
denom = denomFull;
}
double slope, intercept, regression;
if (Math.Abs(denom) < 1e-10)
{
slope = 0;
intercept = sumY / n;
regression = intercept;
}
else
{
slope = (n * sumXY - sx * sumY) / denom;
intercept = (sumY - slope * sx) / n;
regression = Math.FusedMultiplyAdd(slope, count - 1, intercept);
}
// Calculate standard deviation of residuals with NaN handling
double sumResiduals2 = 0;
lastValidInWindow = double.NaN;
for (int j = 0; j < count; j++)
{
double rawY = source[start + j];
double y;
if (double.IsFinite(rawY))
{
lastValidInWindow = rawY;
y = rawY;
}
else
{
y = double.IsFinite(lastValidInWindow) ? lastValidInWindow : 0.0;
}
double predicted = Math.FusedMultiplyAdd(slope, j, intercept);
double residual = y - predicted;
sumResiduals2 = Math.FusedMultiplyAdd(residual, residual, sumResiduals2);
}
double stdDev = Math.Sqrt(sumResiduals2 / n);
midline[i] = regression;
upper1[i] = regression + stdDev;
lower1[i] = regression - stdDev;
upper2[i] = regression + 2.0 * stdDev;
lower2[i] = regression - 2.0 * stdDev;
}
}
public static (TSeries Midline, TSeries Upper1, TSeries Lower1, TSeries Upper2, TSeries Lower2) Batch(TSeries source, int period = 100)
{
int len = source.Count;
var tMid = new List<long>(len);
var vMid = new List<double>(len);
var vU1 = new List<double>(len);
var vL1 = new List<double>(len);
var vU2 = new List<double>(len);
var vL2 = new List<double>(len);
CollectionsMarshal.SetCount(tMid, len);
CollectionsMarshal.SetCount(vMid, len);
CollectionsMarshal.SetCount(vU1, len);
CollectionsMarshal.SetCount(vL1, len);
CollectionsMarshal.SetCount(vU2, len);
CollectionsMarshal.SetCount(vL2, len);
Batch(source.Values,
CollectionsMarshal.AsSpan(vMid),
CollectionsMarshal.AsSpan(vU1),
CollectionsMarshal.AsSpan(vL1),
CollectionsMarshal.AsSpan(vU2),
CollectionsMarshal.AsSpan(vL2),
period);
source.Times.CopyTo(CollectionsMarshal.AsSpan(tMid));
return (
new TSeries(tMid, vMid),
new TSeries(new List<long>(tMid), vU1),
new TSeries(new List<long>(tMid), vL1),
new TSeries(new List<long>(tMid), vU2),
new TSeries(new List<long>(tMid), vL2)
);
}
public static ((TSeries Midline, TSeries Upper1, TSeries Lower1, TSeries Upper2, TSeries Lower2) Results, TtmLrc Indicator) Calculate(TSeries source, int period = 100)
{
var indicator = new TtmLrc(period);
var results = indicator.Update(source);
return (results, indicator);
}
}