mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-17 18:18:04 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user