Enhance validation tests for various indicators with external library comparisons

- Added detailed comments explaining the validation limitations for MMA and ZLEMA due to differences in algorithm implementations.
- Implemented validation tests for True Range against TALib and Tulip, ensuring directional agreement.
- Updated Ulcer Index validation to clarify differences in algorithmic approaches between QuanTAlib and Skender.
- Enhanced Ease of Movement tests to verify directional agreement with Tulip's EMV, noting differences in volume scaling.
- Expanded Klinger Volume Oscillator tests to validate against Skender and Tulip, focusing on directional agreement across multiple period configurations.
- Improved Negative Volume Index tests to compare percentage changes with Tulip, addressing differences in starting values.
- Updated Positive Volume Index tests to validate against Tulip, emphasizing percentage change comparisons.
- Enhanced Williams Accumulation/Distribution tests to verify directional agreement with Tulip, highlighting formula differences.
This commit is contained in:
Miha Kralj
2026-02-11 14:46:56 -08:00
parent 6d6259a47d
commit 75c6a9f135
51 changed files with 7893 additions and 1274 deletions
@@ -1,12 +1,14 @@
using TALib;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for AccBands indicator.
/// Note: Skender.Stock.Indicators, TA-Lib, Tulip, and OoplesFinance do not provide
/// AccBands implementation for cross-validation. These tests validate against
/// manual calculations and internal consistency across all API modes.
/// Note: TA-Lib provides ACCBANDS but uses a different formula (per-bar adaptive width
/// via High*(1+4*(H-L)/(H+L))) whereas QuanTAlib uses SMA-based band width.
/// The middle band (SMA of Close) matches exactly between both implementations.
/// Skender, Tulip, and OoplesFinance do not provide AccBands.
/// </summary>
public sealed class AccBandsValidationTests : IDisposable
{
@@ -378,4 +380,149 @@ public sealed class AccBandsValidationTests : IDisposable
_output.WriteLine("AccBands Prime method validated successfully");
}
[Fact]
public void Validate_Talib_MiddleBand_Batch()
{
// TALib ACCBANDS uses a different upper/lower formula (per-bar adaptive width via
// High*(1+4*(H-L)/(H+L))) but the MIDDLE band is SMA(Close) which matches exactly.
int[] periods = { 5, 10, 20, 50, 100 };
double[] high = _testData.HighPrices.ToArray();
double[] low = _testData.LowPrices.ToArray();
double[] close = _testData.ClosePrices.ToArray();
int len = close.Length;
double[] talibUpper = new double[len];
double[] talibMiddle = new double[len];
double[] talibLower = new double[len];
foreach (var period in periods)
{
// QuanTAlib AccBands (batch)
var (qMiddle, _, _) = AccBands.Batch(_testData.Bars, period, 2.0);
// TALib Accbands
var retCode = Functions.Accbands<double>(
high, low, close,
0..^0,
talibUpper, talibMiddle, talibLower,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.AccbandsLookback(period);
// Middle band = SMA(Close) in both implementations — should match exactly
ValidationHelper.VerifyData(qMiddle, talibMiddle, outRange, lookback);
}
_output.WriteLine("AccBands middle band validated successfully against TA-Lib");
}
[Fact]
public void Validate_Talib_MiddleBand_Span()
{
// Validate middle band match using Span API
int[] periods = { 5, 10, 20, 50, 100 };
double[] high = _testData.HighPrices.ToArray();
double[] low = _testData.LowPrices.ToArray();
double[] close = _testData.ClosePrices.ToArray();
int len = close.Length;
double[] talibUpper = new double[len];
double[] talibMiddle = new double[len];
double[] talibLower = new double[len];
foreach (var period in periods)
{
// QuanTAlib AccBands (Span API)
double[] qMiddle = new double[len];
double[] qUpper = new double[len];
double[] qLower = new double[len];
AccBands.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(),
qMiddle.AsSpan(), qUpper.AsSpan(), qLower.AsSpan(),
period, 2.0);
// TALib Accbands
var retCode = Functions.Accbands<double>(
high, low, close,
0..^0,
talibUpper, talibMiddle, talibLower,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.AccbandsLookback(period);
// Middle band = SMA(Close) — exact match
ValidationHelper.VerifyData(qMiddle, talibMiddle, outRange, lookback);
}
_output.WriteLine("AccBands Span middle band validated successfully against TA-Lib");
}
[Fact]
public void Validate_Talib_FormulaConventionDifference()
{
// Document and verify that upper/lower bands differ between implementations.
// TALib: Upper = SMA(High * (1 + 4*(H-L)/(H+L))), per-bar adaptive width
// QuanTAlib: Upper = SMA(High) + factor*(SMA(High)-SMA(Low)), SMA-based width
// Both are valid "Acceleration Bands" variants.
const int period = 20;
double[] high = _testData.HighPrices.ToArray();
double[] low = _testData.LowPrices.ToArray();
double[] close = _testData.ClosePrices.ToArray();
int len = close.Length;
double[] talibUpper = new double[len];
double[] talibMiddle = new double[len];
double[] talibLower = new double[len];
var retCode = Functions.Accbands<double>(
high, low, close,
0..^0,
talibUpper, talibMiddle, talibLower,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
var (qMiddle, qUpper, qLower) = AccBands.Batch(_testData.Bars, period, 2.0);
int lookback = Functions.AccbandsLookback(period);
int talibStart = outRange.Start.Value;
// Middle bands should match (both SMA of Close)
for (int i = lookback; i < qMiddle.Count && (i - talibStart) < len; i++)
{
int tIdx = i - talibStart;
if (tIdx >= 0 && tIdx < len && talibMiddle[tIdx] != 0)
{
Assert.Equal(qMiddle[i].Value, talibMiddle[tIdx], 1e-7);
}
}
// Upper/Lower bands should differ (different formulas) but maintain same structure
int structuralCount = 0;
for (int i = lookback; i < qMiddle.Count && (i - talibStart) < len; i++)
{
int tIdx = i - talibStart;
if (tIdx >= 0 && tIdx < len && talibUpper[tIdx] != 0)
{
// Both should have Upper > Middle > Lower
Assert.True(qUpper[i].Value > qMiddle[i].Value, $"Q: Upper > Middle at {i}");
Assert.True(qLower[i].Value < qMiddle[i].Value, $"Q: Lower < Middle at {i}");
Assert.True(talibUpper[tIdx] > talibMiddle[tIdx], $"TALib: Upper > Middle at {i}");
Assert.True(talibLower[tIdx] < talibMiddle[tIdx], $"TALib: Lower < Middle at {i}");
structuralCount++;
}
}
Assert.True(structuralCount > 100, $"Validated {structuralCount} bars structurally");
_output.WriteLine($"AccBands formula convention difference validated ({structuralCount} bars)");
}
}
@@ -0,0 +1,353 @@
using TALib;
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for AtrBands against external libraries.
/// AtrBands: Middle = SMA(Close), Upper/Lower = Middle ± ATR × multiplier.
/// TALib provides SMA and ATR sub-component validation.
/// Skender provides SMA and ATR sub-component validation.
/// </summary>
public sealed class AtrBandsValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public AtrBandsValidationTests(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();
}
}
// ═══════════════════════════════════════════════════════════════
// Internal Consistency Tests
// ═══════════════════════════════════════════════════════════════
[Fact]
public void Validate_AllModes_Consistency()
{
int[] periods = { 5, 10, 20, 50 };
double[] multipliers = { 1.0, 2.0, 2.5 };
foreach (int period in periods)
{
foreach (double multiplier in multipliers)
{
// Batch (static)
var (bMid, bUp, bLo) = AtrBands.Batch(_testData.Bars, period, multiplier);
// Streaming
var streaming = new AtrBands(period, multiplier);
var sMid = new TSeries();
var sUp = new TSeries();
var sLo = new TSeries();
foreach (var bar in _testData.Bars)
{
streaming.Update(bar);
sMid.Add(streaming.Last);
sUp.Add(streaming.Upper);
sLo.Add(streaming.Lower);
}
ValidationHelper.VerifySeriesEqual(bMid, sMid);
ValidationHelper.VerifySeriesEqual(bUp, sUp);
ValidationHelper.VerifySeriesEqual(bLo, sLo);
// Span
double[] high = _testData.HighPrices.ToArray();
double[] low = _testData.LowPrices.ToArray();
double[] close = _testData.ClosePrices.ToArray();
double[] spanMid = new double[high.Length];
double[] spanUp = new double[high.Length];
double[] spanLo = new double[high.Length];
AtrBands.Batch(
new AtrBands.AtrBandsInput(high.AsSpan(), low.AsSpan(), close.AsSpan()),
new AtrBands.AtrBandsOutput(spanMid.AsSpan(), spanUp.AsSpan(), spanLo.AsSpan()),
period, multiplier);
for (int i = 0; i < high.Length; i++)
{
Assert.Equal(bMid[i].Value, spanMid[i], 9);
Assert.Equal(bUp[i].Value, spanUp[i], 9);
Assert.Equal(bLo[i].Value, spanLo[i], 9);
}
}
}
_output.WriteLine("AtrBands mode consistency validated (batch/stream/span)");
}
[Fact]
public void Validate_BandSymmetry()
{
var (mid, up, lo) = AtrBands.Batch(_testData.Bars, 20, 2.0);
for (int i = 0; i < mid.Count; i++)
{
double upperWidth = up[i].Value - mid[i].Value;
double lowerWidth = mid[i].Value - lo[i].Value;
Assert.Equal(upperWidth, lowerWidth, 1e-10);
}
_output.WriteLine("AtrBands band symmetry validated");
}
[Fact]
public void Validate_LargeDataset_FiniteOutputs()
{
var (mid, up, lo) = AtrBands.Batch(_testData.Bars, 50, 2.0);
ValidationHelper.VerifyAllFinite(mid, startIndex: 0);
ValidationHelper.VerifyAllFinite(up, startIndex: 0);
ValidationHelper.VerifyAllFinite(lo, startIndex: 0);
for (int i = 1; i < mid.Count; i++)
{
Assert.True(up[i].Value >= lo[i].Value, $"Upper >= Lower at {i}");
}
_output.WriteLine("AtrBands large dataset validated");
}
[Fact]
public void Validate_MultiplierScaling()
{
double[] multipliers = { 1.0, 2.0, 3.0, 4.0 };
double[] widths = new double[multipliers.Length];
for (int i = 0; i < multipliers.Length; i++)
{
var ind = new AtrBands(20, multipliers[i]);
foreach (var bar in _testData.Bars)
{
ind.Update(bar);
}
widths[i] = ind.Upper.Value - ind.Lower.Value;
}
double baseWidth = widths[0];
for (int i = 1; i < multipliers.Length; i++)
{
double expected = baseWidth * multipliers[i];
Assert.Equal(expected, widths[i], 1e-9);
}
_output.WriteLine("AtrBands multiplier scaling validated");
}
// ═══════════════════════════════════════════════════════════════
// TALib Sub-Component Validation
// AtrBands middle band = SMA(Close, period) → validates against TALib SMA
// AtrBands band width ∝ ATR → validates ATR component against TALib ATR
// ═══════════════════════════════════════════════════════════════
[Fact]
public void Validate_Talib_SMA_MiddleBand()
{
int[] periods = { 5, 10, 20, 50, 100 };
double[] closeData = _testData.ClosePrices.ToArray();
double[] smaOutput = new double[closeData.Length];
foreach (var period in periods)
{
var (qMid, _, _) = AtrBands.Batch(_testData.Bars, period, 2.0);
var retCode = Functions.Sma<double>(
closeData,
0..^0,
smaOutput,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.SmaLookback(period);
ValidationHelper.VerifyData(qMid, smaOutput, outRange, lookback);
}
_output.WriteLine("AtrBands middle band validated against TALib SMA for all periods");
}
[Fact]
public void Validate_Talib_ATR_BandWidth()
{
// AtrBands: width = 2 × multiplier × ATR, so half-width = multiplier × ATR
// We validate that (Upper - Middle) / multiplier ≈ ATR from TALib
int[] periods = { 10, 20, 50 };
double multiplier = 2.0;
double[] highData = _testData.HighPrices.ToArray();
double[] lowData = _testData.LowPrices.ToArray();
double[] closeData = _testData.ClosePrices.ToArray();
double[] atrOutput = new double[closeData.Length];
foreach (var period in periods)
{
var (qMid, qUp, _) = AtrBands.Batch(_testData.Bars, period, multiplier);
var retCode = Functions.Atr<double>(
highData,
lowData,
closeData,
0..^0,
atrOutput,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.AtrLookback(period);
var (offset, _) = outRange.GetOffsetAndLength(atrOutput.Length);
// Compare extracted ATR from our bands vs TALib ATR
int count = qMid.Count;
int start = Math.Max(0, count - 100);
for (int i = start; i < count; i++)
{
double ourAtr = (qUp[i].Value - qMid[i].Value) / multiplier;
if (i < lookback)
{
continue;
}
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= atrOutput.Length)
{
continue;
}
double talibAtr = atrOutput[tIndex];
Assert.True(
Math.Abs(ourAtr - talibAtr) <= ValidationHelper.TalibTolerance,
$"ATR mismatch at {i}: QuanTAlib={ourAtr:G17}, TALib={talibAtr:G17}");
}
}
_output.WriteLine("AtrBands ATR component validated against TALib ATR for all periods");
}
// ═══════════════════════════════════════════════════════════════
// Skender Sub-Component Validation
// Middle band = SMA → validates against Skender GetSma()
// Band width ∝ ATR → validates against Skender GetAtr()
// ═══════════════════════════════════════════════════════════════
[Fact]
public void Validate_Skender_SMA_MiddleBand()
{
int[] periods = { 5, 10, 20, 50 };
foreach (var period in periods)
{
var (qMid, _, _) = AtrBands.Batch(_testData.Bars, period, 2.0);
var sResult = _testData.SkenderQuotes
.GetSma(period)
.ToList();
ValidationHelper.VerifyData(qMid, sResult, s => s.Sma);
}
_output.WriteLine("AtrBands middle band validated against Skender SMA for all periods");
}
[Fact]
public void Validate_Skender_ATR_BandWidth()
{
int[] periods = { 10, 20, 50 };
double multiplier = 2.0;
foreach (var period in periods)
{
var (qMid, qUp, _) = AtrBands.Batch(_testData.Bars, period, multiplier);
var sResult = _testData.SkenderQuotes
.GetAtr(period)
.ToList();
// Compare extracted ATR from our bands vs Skender ATR
int count = qMid.Count;
int start = Math.Max(0, count - 100);
for (int i = start; i < count; i++)
{
double ourAtr = (qUp[i].Value - qMid[i].Value) / multiplier;
double? skenderAtr = sResult[i].Atr;
if (!skenderAtr.HasValue)
{
continue;
}
Assert.True(
Math.Abs(ourAtr - skenderAtr.Value) <= ValidationHelper.SkenderTolerance,
$"ATR mismatch at {i}: QuanTAlib={ourAtr:G17}, Skender={skenderAtr.Value:G17}");
}
}
_output.WriteLine("AtrBands ATR component validated against Skender ATR for all periods");
}
[Fact]
public void Validate_Skender_BandStructure()
{
var period = 20;
var multiplier = 2.0;
var (qMid, qUp, qLo) = AtrBands.Batch(_testData.Bars, period, multiplier);
var smaResult = _testData.SkenderQuotes.GetSma(period).ToList();
var atrResult = _testData.SkenderQuotes.GetAtr(period).ToList();
// Compare only the last 100 fully-converged values to avoid
// warmup divergence between QuanTAlib and Skender ATR implementations
int count = qMid.Count;
int start = Math.Max(0, count - 100);
int matched = 0;
for (int i = start; i < count; i++)
{
if (!smaResult[i].Sma.HasValue || !atrResult[i].Atr.HasValue)
{
continue;
}
double expectedMid = smaResult[i].Sma!.Value;
double expectedAtr = atrResult[i].Atr!.Value;
double expectedUp = expectedMid + multiplier * expectedAtr;
double expectedLo = expectedMid - multiplier * expectedAtr;
Assert.True(
Math.Abs(qMid[i].Value - expectedMid) <= ValidationHelper.SkenderTolerance,
$"Middle mismatch at {i}: QuanTAlib={qMid[i].Value:G17}, Skender={expectedMid:G17}");
Assert.True(
Math.Abs(qUp[i].Value - expectedUp) <= ValidationHelper.SkenderTolerance,
$"Upper mismatch at {i}: QuanTAlib={qUp[i].Value:G17}, Skender={expectedUp:G17}");
Assert.True(
Math.Abs(qLo[i].Value - expectedLo) <= ValidationHelper.SkenderTolerance,
$"Lower mismatch at {i}: QuanTAlib={qLo[i].Value:G17}, Skender={expectedLo:G17}");
matched++;
}
Assert.True(matched >= 50, $"Expected at least 50 matched values, got {matched}");
_output.WriteLine($"AtrBands full band structure validated against Skender SMA+ATR ({matched} converged values)");
}
}
@@ -1,3 +1,4 @@
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
@@ -197,4 +198,133 @@ public sealed class DchannelValidationTests : IDisposable
_output.WriteLine("Dchannel large dataset validated");
}
[Fact]
public void Validate_Skender_Batch_UpperBand()
{
// Convention difference: Skender Donchian uses prior N bars [i-N, i-1] (excludes current bar)
// QuanTAlib Dchannel uses inclusive N bars [i-N+1, i] (includes current bar).
// Therefore: QuanTAlib[i] should match Skender[i+1] for converged values.
int[] periods = { 5, 10, 20, 50, 100 };
foreach (var period in periods)
{
var (_, qUp, _) = Dchannel.Batch(_testData.Bars, period);
var sResult = _testData.SkenderQuotes.GetDonchian(period).ToList();
int count = Math.Min(qUp.Count, sResult.Count);
int start = Math.Max(period + 1, count - 100);
for (int i = start; i < count - 1; i++)
{
double qValue = qUp[i].Value;
double? sValue = (double?)sResult[i + 1].UpperBand;
if (!sValue.HasValue)
{
continue;
}
Assert.True(
Math.Abs(qValue - sValue.Value) <= ValidationHelper.SkenderTolerance,
$"Period={period}, Mismatch at q[{i}] vs s[{i + 1}]: QuanTAlib={qValue:G17}, Skender={sValue.Value:G17}");
}
}
_output.WriteLine("Dchannel upper band validated against Skender GetDonchian (offset +1)");
}
[Fact]
public void Validate_Skender_Batch_LowerBand()
{
// Same offset convention: QuanTAlib[i] == Skender[i+1]
int[] periods = { 5, 10, 20, 50, 100 };
foreach (var period in periods)
{
var (_, _, qLo) = Dchannel.Batch(_testData.Bars, period);
var sResult = _testData.SkenderQuotes.GetDonchian(period).ToList();
int count = Math.Min(qLo.Count, sResult.Count);
int start = Math.Max(period + 1, count - 100);
for (int i = start; i < count - 1; i++)
{
double qValue = qLo[i].Value;
double? sValue = (double?)sResult[i + 1].LowerBand;
if (!sValue.HasValue)
{
continue;
}
Assert.True(
Math.Abs(qValue - sValue.Value) <= ValidationHelper.SkenderTolerance,
$"Period={period}, Mismatch at q[{i}] vs s[{i + 1}]: QuanTAlib={qValue:G17}, Skender={sValue.Value:G17}");
}
}
_output.WriteLine("Dchannel lower band validated against Skender GetDonchian (offset +1)");
}
[Fact]
public void Validate_Skender_Batch_Centerline()
{
// Same offset convention: QuanTAlib[i] == Skender[i+1]
int[] periods = { 5, 10, 20, 50, 100 };
foreach (var period in periods)
{
var (qMid, _, _) = Dchannel.Batch(_testData.Bars, period);
var sResult = _testData.SkenderQuotes.GetDonchian(period).ToList();
int count = Math.Min(qMid.Count, sResult.Count);
int start = Math.Max(period + 1, count - 100);
for (int i = start; i < count - 1; i++)
{
double qValue = qMid[i].Value;
double? sValue = (double?)sResult[i + 1].Centerline;
if (!sValue.HasValue)
{
continue;
}
Assert.True(
Math.Abs(qValue - sValue.Value) <= ValidationHelper.SkenderTolerance,
$"Period={period}, Mismatch at q[{i}] vs s[{i + 1}]: QuanTAlib={qValue:G17}, Skender={sValue.Value:G17}");
}
}
_output.WriteLine("Dchannel centerline validated against Skender GetDonchian (offset +1)");
}
[Fact]
public void Validate_Skender_Streaming_UpperBand()
{
// Same offset convention: QuanTAlib[i] == Skender[i+1]
int[] periods = { 10, 20, 50 };
foreach (var period in periods)
{
var dchannel = new Dchannel(period);
var qUpResults = new TSeries();
foreach (var bar in _testData.Bars)
{
dchannel.Update(bar);
qUpResults.Add(dchannel.Upper);
}
var sResult = _testData.SkenderQuotes.GetDonchian(period).ToList();
int count = Math.Min(qUpResults.Count, sResult.Count);
int start = Math.Max(period + 1, count - 100);
for (int i = start; i < count - 1; i++)
{
double qValue = qUpResults[i].Value;
double? sValue = (double?)sResult[i + 1].UpperBand;
if (!sValue.HasValue)
{
continue;
}
Assert.True(
Math.Abs(qValue - sValue.Value) <= ValidationHelper.SkenderTolerance,
$"Period={period}, Mismatch at q[{i}] vs s[{i + 1}]: QuanTAlib={qValue:G17}, Skender={sValue.Value:G17}");
}
}
_output.WriteLine("Dchannel streaming upper band validated against Skender GetDonchian (offset +1)");
}
}
+91
View File
@@ -1,3 +1,4 @@
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
@@ -281,4 +282,94 @@ public sealed class FcbValidationTests : IDisposable
_output.WriteLine("FCB band monotonicity validated");
}
[Fact]
public void Validate_Skender_BandStructure()
{
// Skender GetFcb(windowSpan) uses Williams fractal carry-forward:
// - windowSpan is half-width for 3-bar fractal detection (min=2)
// - UpperBand = last confirmed FractalBear (highest high carry-forward)
// - LowerBand = last confirmed FractalBull (lowest low carry-forward)
// - Results are decimal? (need cast to double)
//
// NOTE: Skender's UpperBand can be LOWER than LowerBand when the last
// bear fractal occurred at a lower price than the last bull fractal.
// This is a known property of fractal carry-forward algorithms.
//
// QuanTAlib Fcb(period) uses monotonic deques over a lookback window
// and always maintains Upper >= Lower ordering.
int windowSpan = 2;
var sResult = _testData.SkenderQuotes
.GetFcb(windowSpan)
.ToList();
// Verify Skender produces finite values
int validCount = 0;
for (int i = 0; i < sResult.Count; i++)
{
if (sResult[i].UpperBand.HasValue && sResult[i].LowerBand.HasValue)
{
double upper = (double)sResult[i].UpperBand!.Value;
double lower = (double)sResult[i].LowerBand!.Value;
Assert.True(double.IsFinite(upper), $"Skender Upper finite at bar {i}");
Assert.True(double.IsFinite(lower), $"Skender Lower finite at bar {i}");
Assert.True(upper > 0, $"Skender Upper positive at bar {i}");
Assert.True(lower > 0, $"Skender Lower positive at bar {i}");
validCount++;
}
}
Assert.True(validCount > 0, "Skender should produce some valid FCB values");
_output.WriteLine($"Skender FCB band structure validated ({validCount} valid bars with finite values)");
}
[Fact]
public void Validate_Skender_BothProduceChannels()
{
// Both QuanTAlib and Skender FCB should produce meaningful channels
// that track price structure. Verify both produce valid finite values.
//
// NOTE: Skender bands can cross (Upper < Lower) due to fractal
// carry-forward semantics, so we only validate finite positive values.
int windowSpan = 2;
int period = 20;
var sResult = _testData.SkenderQuotes
.GetFcb(windowSpan)
.ToList();
var (qMiddle, qUpper, qLower) = Fcb.Batch(_testData.Bars, period);
// After warmup, both should have valid bands
int qValidCount = 0;
int sValidCount = 0;
for (int i = period + 2; i < qMiddle.Count && i < sResult.Count; i++)
{
if (qUpper[i].Value > 0 && qLower[i].Value > 0)
{
Assert.True(qUpper[i].Value >= qLower[i].Value,
$"QuanTAlib Upper >= Lower at bar {i}");
qValidCount++;
}
if (sResult[i].UpperBand.HasValue && sResult[i].LowerBand.HasValue)
{
double sUpper = (double)sResult[i].UpperBand!.Value;
double sLower = (double)sResult[i].LowerBand!.Value;
Assert.True(double.IsFinite(sUpper) && sUpper > 0,
$"Skender Upper finite and positive at bar {i}");
Assert.True(double.IsFinite(sLower) && sLower > 0,
$"Skender Lower finite and positive at bar {i}");
sValidCount++;
}
}
Assert.True(qValidCount > 100, $"QuanTAlib produced {qValidCount} valid bars");
Assert.True(sValidCount > 100, $"Skender produced {sValidCount} valid bars");
_output.WriteLine($"FCB channel comparison: QuanTAlib={qValidCount}, Skender={sValidCount} valid bars");
}
}
@@ -383,84 +383,96 @@ public sealed class KchannelValidationTests : IDisposable
}
[Fact]
public void Validate_SkenderComparison_BandStructure()
public void Validate_Skender_MiddleBand()
{
// Skender uses ATR-based bands similar to our implementation
// Validate structural correctness: upper > middle > lower, symmetric bands
// Skender GetKeltner uses EMA center + ATR bands, same as QuanTAlib.
// IMPORTANT: Skender defaults atrPeriods=10, but QuanTAlib uses the same period
// for both EMA and ATR. We must pass atrPeriods=emaPeriods for exact comparison.
// Both use warmup compensation differently, so we skip early bars.
var skenderPeriod = 20;
var skenderMultiplier = 2.0;
int[] periods = { 5, 10, 20, 50 };
double multiplier = 2.0;
// Get Skender results (they use EMA middle + ATR bands)
var skenderResults = _testData.SkenderQuotes
.GetKeltner(skenderPeriod, skenderMultiplier)
.ToList();
// Get our results
var (ourMid, _, _) = Kchannel.Batch(_testData.Bars, skenderPeriod, skenderMultiplier);
// Both should have upper > middle > lower structure
int warmup = skenderPeriod * 2;
for (int i = warmup; i < ourMid.Count && i < skenderResults.Count; i++)
foreach (var period in periods)
{
var sk = skenderResults[i];
if (sk.UpperBand.HasValue && sk.LowerBand.HasValue && sk.Centerline.HasValue)
{
// Structural check
Assert.True(sk.UpperBand.Value > sk.Centerline.Value, $"Skender Upper > Middle at {i}");
Assert.True(sk.LowerBand.Value < sk.Centerline.Value, $"Skender Lower < Middle at {i}");
var (qMiddle, _, _) = Kchannel.Batch(_testData.Bars, period, multiplier);
// Both use symmetric ATR-based bands
double skWidth = sk.UpperBand.Value - sk.LowerBand.Value;
// Skender: atrPeriods = period to match QuanTAlib's single-period design
var sResult = _testData.SkenderQuotes
.GetKeltner(period, multiplier, period)
.ToList();
Assert.True(skWidth > 0, $"Skender width > 0 at {i}");
}
// Compare middle band (EMA of close) using ValidationHelper
ValidationHelper.VerifyData(qMiddle, sResult, s => s.Centerline);
}
_output.WriteLine($"Kchannel vs Skender structure validated (period={skenderPeriod}, mult={skenderMultiplier})");
_output.WriteLine("Kchannel middle band validated against Skender for all periods");
}
[Fact]
public void Validate_SkenderComparison_ApproximateMatch()
public void Validate_Skender_UpperBand()
{
// Note: Skender may use slightly different ATR/EMA warmup, so we check approximate match
// Our implementation uses sum/weight warmup compensation; Skender may not
int[] periods = { 5, 10, 20, 50 };
double multiplier = 2.0;
var skenderPeriod = 20;
var skenderMultiplier = 2.0;
foreach (var period in periods)
{
var (_, up, _) = Kchannel.Batch(_testData.Bars, period, multiplier);
var skenderResults = _testData.SkenderQuotes
.GetKeltner(skenderPeriod, skenderMultiplier)
var sResult = _testData.SkenderQuotes
.GetKeltner(period, multiplier, period)
.ToList();
ValidationHelper.VerifyData(up, sResult, s => s.UpperBand);
}
_output.WriteLine("Kchannel upper band validated against Skender for all periods");
}
[Fact]
public void Validate_Skender_LowerBand()
{
int[] periods = { 5, 10, 20, 50 };
double multiplier = 2.0;
foreach (var period in periods)
{
var (_, _, lo) = Kchannel.Batch(_testData.Bars, period, multiplier);
var sResult = _testData.SkenderQuotes
.GetKeltner(period, multiplier, period)
.ToList();
ValidationHelper.VerifyData(lo, sResult, s => s.LowerBand);
}
_output.WriteLine("Kchannel lower band validated against Skender for all periods");
}
[Fact]
public void Validate_Skender_BandStructure()
{
// Structural validation: upper > middle > lower, symmetric bands
var period = 20;
var multiplier = 2.0;
var sResult = _testData.SkenderQuotes
.GetKeltner(period, multiplier, period)
.ToList();
var (ourMid, _, _) = Kchannel.Batch(_testData.Bars, skenderPeriod, skenderMultiplier);
var (ourMid, ourUp, ourLo) = Kchannel.Batch(_testData.Bars, period, multiplier);
// Compare after significant warmup (values should converge)
int compareStart = skenderPeriod * 5; // Well past warmup
int closeCount = 0;
for (int i = compareStart; i < Math.Min(ourMid.Count, skenderResults.Count); i++)
int warmup = period * 2;
for (int i = warmup; i < ourMid.Count && i < sResult.Count; i++)
{
var sk = skenderResults[i];
if (sk.Centerline.HasValue)
var sk = sResult[i];
if (sk.UpperBand.HasValue && sk.LowerBand.HasValue && sk.Centerline.HasValue)
{
double midDiff = Math.Abs(ourMid[i].Value - sk.Centerline.Value);
double midPct = midDiff / Math.Max(1, Math.Abs(sk.Centerline.Value));
// After warmup, values should be within 5% (warmup methods may differ)
if (midPct < 0.05)
{
closeCount++;
}
Assert.True(sk.UpperBand.Value > sk.Centerline.Value, $"Skender Upper > Middle at {i}");
Assert.True(sk.LowerBand.Value < sk.Centerline.Value, $"Skender Lower < Middle at {i}");
Assert.True(ourUp[i].Value > ourMid[i].Value, $"Q Upper > Middle at {i}");
Assert.True(ourLo[i].Value < ourMid[i].Value, $"Q Lower < Middle at {i}");
}
}
// Most values should be close
int total = Math.Min(ourMid.Count, skenderResults.Count) - compareStart;
double closeRatio = (double)closeCount / total;
Assert.True(closeRatio > 0.9, $"Close ratio {closeRatio:P0} should be > 90%");
_output.WriteLine($"Kchannel vs Skender approximate match: {closeRatio:P0} within 5%");
_output.WriteLine($"Kchannel vs Skender band structure validated");
}
[Fact]
@@ -1,3 +1,4 @@
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
@@ -512,4 +513,66 @@ public sealed class MaenvValidationTests : IDisposable
_output.WriteLine("Maenv SMA ring buffer O(1) validated");
}
[Fact]
public void Validate_Skender_SMA_Centerline()
{
// Skender GetMaEnvelopes(lookbackPeriods, percentOffset, MaType.SMA)
// QuanTAlib Maenv(period, percentage, MaenvType.SMA)
// Both compute: Middle = SMA(Close), Upper = Middle + Middle*pct/100, Lower = Middle - Middle*pct/100
// For SMA type, results should match exactly.
int[] periods = { 5, 10, 20, 50 };
double percentage = 2.5;
foreach (var period in periods)
{
var (qMiddle, _, _) = Maenv.Batch(_testData.Data, period, percentage, MaenvType.SMA);
var sResult = _testData.SkenderQuotes
.GetMaEnvelopes(period, percentage, MaType.SMA)
.ToList();
ValidationHelper.VerifyData(qMiddle, sResult, s => s.Centerline);
}
_output.WriteLine("Maenv SMA centerline validated against Skender for all periods");
}
[Fact]
public void Validate_Skender_SMA_UpperEnvelope()
{
int[] periods = { 5, 10, 20, 50 };
double percentage = 2.5;
foreach (var period in periods)
{
var (_, qUpper, _) = Maenv.Batch(_testData.Data, period, percentage, MaenvType.SMA);
var sResult = _testData.SkenderQuotes
.GetMaEnvelopes(period, percentage, MaType.SMA)
.ToList();
ValidationHelper.VerifyData(qUpper, sResult, s => s.UpperEnvelope);
}
_output.WriteLine("Maenv SMA upper envelope validated against Skender for all periods");
}
[Fact]
public void Validate_Skender_SMA_LowerEnvelope()
{
int[] periods = { 5, 10, 20, 50 };
double percentage = 2.5;
foreach (var period in periods)
{
var (_, _, qLower) = Maenv.Batch(_testData.Data, period, percentage, MaenvType.SMA);
var sResult = _testData.SkenderQuotes
.GetMaEnvelopes(period, percentage, MaType.SMA)
.ToList();
ValidationHelper.VerifyData(qLower, sResult, s => s.LowerEnvelope);
}
_output.WriteLine("Maenv SMA lower envelope validated against Skender for all periods");
}
}
@@ -1,3 +1,4 @@
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
@@ -1,3 +1,4 @@
using TALib;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
@@ -533,4 +534,130 @@ public sealed class RegchannelValidationTests : IDisposable
_output.WriteLine("Regchannel stdDev formula validated");
}
// ═══════════════════════════════════════════════════════════════
// TALib Validation
// TALib LinearReg computes the linear regression value at the end
// of the lookback window — same as Regchannel's midline (centerline).
// ═══════════════════════════════════════════════════════════════
[Fact]
public void Validate_Talib_LinearReg_Centerline()
{
int[] periods = { 5, 10, 20, 50 };
double[] sourceData = _testData.RawData.ToArray();
double[] linregOutput = new double[sourceData.Length];
foreach (var period in periods)
{
var (qMid, _, _) = Regchannel.Batch(_testData.Data, period, 2.0);
var retCode = Functions.LinearReg<double>(
sourceData,
0..^0,
linregOutput,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.LinearRegLookback(period);
ValidationHelper.VerifyData(qMid, linregOutput, outRange, lookback);
}
_output.WriteLine("Regchannel centerline validated against TALib LinearReg for all periods");
}
[Fact]
public void Validate_Talib_LinearRegSlope()
{
int[] periods = { 5, 10, 20, 50 };
double[] sourceData = _testData.RawData.ToArray();
double[] slopeOutput = new double[sourceData.Length];
foreach (var period in periods)
{
// Stream Regchannel and collect slopes
var ind = new Regchannel(period, 2.0);
var slopes = new List<double>();
foreach (var tv in _testData.Data)
{
ind.Update(tv);
slopes.Add(ind.Slope);
}
var retCode = Functions.LinearRegSlope<double>(
sourceData,
0..^0,
slopeOutput,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.LinearRegSlopeLookback(period);
// Compare slopes from end of series (converged)
int count = slopes.Count;
int start = Math.Max(0, count - 100);
var (offset, _) = outRange.GetOffsetAndLength(slopeOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback)
{
continue;
}
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= slopeOutput.Length)
{
continue;
}
Assert.True(
Math.Abs(slopes[i] - slopeOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Slope mismatch at {i}: QuanTAlib={slopes[i]:G17}, TALib={slopeOutput[tIndex]:G17}");
}
}
_output.WriteLine("Regchannel slope validated against TALib LinearRegSlope for all periods");
}
[Fact]
public void Validate_Tulip_LinearReg_Centerline()
{
int[] periods = { 5, 10, 20, 50 };
double[] sourceData = _testData.RawData.ToArray();
foreach (var period in periods)
{
var (qMid, _, _) = Regchannel.Batch(_testData.Data, period, 2.0);
var linregIndicator = Tulip.Indicators.linreg;
double[][] inputs = { sourceData };
double[] options = { period };
double[][] outputs = { new double[sourceData.Length - period + 1] };
linregIndicator.Run(inputs, options, outputs);
var tLinreg = outputs[0];
int offset = period - 1; // Tulip output starts at index (period-1)
// Compare last 100 values
int count = qMid.Count;
int start = Math.Max(0, count - 100);
for (int i = start; i < count; i++)
{
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= tLinreg.Length)
{
continue;
}
Assert.True(
Math.Abs(qMid[i].Value - tLinreg[tIndex]) <= ValidationHelper.TulipTolerance,
$"Mismatch at {i}: QuanTAlib={qMid[i].Value:G17}, Tulip={tLinreg[tIndex]:G17}");
}
}
_output.WriteLine("Regchannel centerline validated against Tulip linreg for all periods");
}
}
@@ -1,3 +1,4 @@
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
@@ -543,4 +544,90 @@ public sealed class SdchannelValidationTests : IDisposable
_output.WriteLine($"Sdchannel stdDev formula validated: Slope={ind.Slope:F4}, StdDev={ind.StdDev:F4}");
}
// ═══════════════════════════════════════════════════════════════
// Skender.Stock.Indicators Validation
// NOTE: Skender's GetStdDevChannels uses a SEGMENTED approach
// (non-overlapping windows with a single regression per segment),
// while QuanTAlib's Sdchannel uses a ROLLING window approach
// (regression recomputed at every bar). These are fundamentally
// different algorithms, so exact value matching is not possible.
// We validate structural properties instead.
// ═══════════════════════════════════════════════════════════════
[Fact]
public void Validate_Skender_BandStructure()
{
// Both implementations should produce valid channel bands:
// Upper >= Centerline >= Lower, all finite after warmup
int period = 20;
double multiplier = 2.0;
// QuanTAlib rolling regression
var (qMid, qUp, qLo) = Sdchannel.Batch(_testData.Data, period, multiplier);
// Skender segmented regression
var sResult = _testData.SkenderQuotes
.GetStdDevChannels(period, multiplier)
.ToList();
// Both should have same count
Assert.Equal(qMid.Count, sResult.Count);
// Verify QuanTAlib structural integrity
for (int i = 0; i < qMid.Count; i++)
{
Assert.True(double.IsFinite(qMid[i].Value), $"QTAlib mid NaN at {i}");
Assert.True(qUp[i].Value >= qMid[i].Value - 1e-10, $"QTAlib Upper < Mid at {i}");
Assert.True(qLo[i].Value <= qMid[i].Value + 1e-10, $"QTAlib Lower > Mid at {i}");
}
// Verify Skender structural integrity (where values exist)
int skenderValidCount = 0;
for (int i = 0; i < sResult.Count; i++)
{
if (sResult[i].Centerline.HasValue)
{
skenderValidCount++;
double sMid = sResult[i].Centerline!.Value;
double sUp = sResult[i].UpperChannel!.Value;
double sLo = sResult[i].LowerChannel!.Value;
Assert.True(double.IsFinite(sMid), $"Skender mid NaN at {i}");
Assert.True(sUp >= sMid - 1e-10, $"Skender Upper < Mid at {i}");
Assert.True(sLo <= sMid + 1e-10, $"Skender Lower > Mid at {i}");
}
}
Assert.True(skenderValidCount > 0, "Skender should produce some valid values");
_output.WriteLine($"Sdchannel vs Skender structural validation passed " +
$"(QTAlib: {qMid.Count} bars, Skender valid: {skenderValidCount} bars). " +
$"Note: different algorithms (rolling vs segmented).");
}
[Fact]
public void Validate_Skender_BandSymmetry()
{
// Both implementations should produce symmetric bands around centerline
int period = 20;
double multiplier = 2.0;
// Skender segmented regression
var sResult = _testData.SkenderQuotes
.GetStdDevChannels(period, multiplier)
.ToList();
foreach (var r in sResult)
{
if (r.Centerline.HasValue)
{
double upperWidth = r.UpperChannel!.Value - r.Centerline.Value;
double lowerWidth = r.Centerline.Value - r.LowerChannel!.Value;
Assert.Equal(upperWidth, lowerWidth, 1e-10);
}
}
_output.WriteLine("Skender StdDevChannels band symmetry validated");
}
}
@@ -1,3 +1,4 @@
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
@@ -567,4 +568,96 @@ public sealed class StarchannelValidationTests : IDisposable
_output.WriteLine($"Starchannel vs Kchannel middle difference: {diff:F6}");
}
// ═══════════════════════════════════════════════════════════════
// Skender.Stock.Indicators Validation
// Skender GetStarcBands(smaPeriods, multiplier, atrPeriods)
// uses SMA centerline + ATR bands — same algorithm as QuanTAlib.
// We pass atrPeriods = smaPeriods to match QuanTAlib's single-period design.
// ═══════════════════════════════════════════════════════════════
[Fact]
public void Validate_Skender_Centerline()
{
int[] periods = { 5, 10, 20, 50 };
double multiplier = 2.0;
foreach (var period in periods)
{
var (qMid, _, _) = Starchannel.Batch(_testData.Bars, period, multiplier);
var sResult = _testData.SkenderQuotes
.GetStarcBands(period, multiplier, period)
.ToList();
ValidationHelper.VerifyData(qMid, sResult, s => s.Centerline);
}
_output.WriteLine("Starchannel centerline validated against Skender for all periods");
}
[Fact]
public void Validate_Skender_UpperBand()
{
int[] periods = { 5, 10, 20, 50 };
double multiplier = 2.0;
foreach (var period in periods)
{
var (_, qUp, _) = Starchannel.Batch(_testData.Bars, period, multiplier);
var sResult = _testData.SkenderQuotes
.GetStarcBands(period, multiplier, period)
.ToList();
ValidationHelper.VerifyData(qUp, sResult, s => s.UpperBand);
}
_output.WriteLine("Starchannel upper band validated against Skender for all periods");
}
[Fact]
public void Validate_Skender_LowerBand()
{
int[] periods = { 5, 10, 20, 50 };
double multiplier = 2.0;
foreach (var period in periods)
{
var (_, _, qLo) = Starchannel.Batch(_testData.Bars, period, multiplier);
var sResult = _testData.SkenderQuotes
.GetStarcBands(period, multiplier, period)
.ToList();
ValidationHelper.VerifyData(qLo, sResult, s => s.LowerBand);
}
_output.WriteLine("Starchannel lower band validated against Skender for all periods");
}
[Fact]
public void Validate_Skender_BandStructure()
{
var period = 20;
var multiplier = 2.0;
var sResult = _testData.SkenderQuotes
.GetStarcBands(period, multiplier, period)
.ToList();
var (qMid, qUp, qLo) = Starchannel.Batch(_testData.Bars, period, multiplier);
int warmup = period * 2;
for (int i = warmup; i < qMid.Count && i < sResult.Count; i++)
{
var sk = sResult[i];
if (sk.UpperBand.HasValue && sk.LowerBand.HasValue && sk.Centerline.HasValue)
{
Assert.True(sk.UpperBand.Value > sk.Centerline.Value, $"Skender Upper > Middle at {i}");
Assert.True(sk.LowerBand.Value < sk.Centerline.Value, $"Skender Lower < Middle at {i}");
Assert.True(qUp[i].Value > qMid[i].Value, $"Q Upper > Middle at {i}");
Assert.True(qLo[i].Value < qMid[i].Value, $"Q Lower < Middle at {i}");
}
}
_output.WriteLine("Starchannel vs Skender band structure validated");
}
}
@@ -1,3 +1,4 @@
using TALib;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
@@ -645,4 +646,127 @@ public sealed class TtmLrcValidationTests : IDisposable
_output.WriteLine("TtmLrc ±2σ vs Regchannel(multiplier=2) validated");
}
// ═══════════════════════════════════════════════════════════════
// TALib Validation
// TALib LinearReg computes the linear regression value at the end
// of the lookback window — same as TtmLrc's midline.
// ═══════════════════════════════════════════════════════════════
[Fact]
public void Validate_Talib_LinearReg_Midline()
{
int[] periods = { 10, 20, 50, 100 };
double[] sourceData = _testData.RawData.ToArray();
double[] linregOutput = new double[sourceData.Length];
foreach (var period in periods)
{
var (qMid, _, _, _, _) = TtmLrc.Batch(_testData.Data, period);
var retCode = Functions.LinearReg<double>(
sourceData,
0..^0,
linregOutput,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.LinearRegLookback(period);
ValidationHelper.VerifyData(qMid, linregOutput, outRange, lookback);
}
_output.WriteLine("TtmLrc midline validated against TALib LinearReg for all periods");
}
[Fact]
public void Validate_Talib_LinearRegSlope()
{
int[] periods = { 10, 20, 50, 100 };
double[] sourceData = _testData.RawData.ToArray();
double[] slopeOutput = new double[sourceData.Length];
foreach (var period in periods)
{
var ind = new TtmLrc(period);
var slopes = new List<double>();
foreach (var tv in _testData.Data)
{
ind.Update(tv);
slopes.Add(ind.Slope);
}
var retCode = Functions.LinearRegSlope<double>(
sourceData,
0..^0,
slopeOutput,
out var outRange,
period);
Assert.Equal(Core.RetCode.Success, retCode);
int lookback = Functions.LinearRegSlopeLookback(period);
var (offset, _) = outRange.GetOffsetAndLength(slopeOutput.Length);
int count = slopes.Count;
int start = Math.Max(0, count - 100);
for (int i = start; i < count; i++)
{
if (i < lookback)
{
continue;
}
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= slopeOutput.Length)
{
continue;
}
Assert.True(
Math.Abs(slopes[i] - slopeOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Slope mismatch at {i}: QuanTAlib={slopes[i]:G17}, TALib={slopeOutput[tIndex]:G17}");
}
}
_output.WriteLine("TtmLrc slope validated against TALib LinearRegSlope for all periods");
}
[Fact]
public void Validate_Tulip_LinearReg_Midline()
{
int[] periods = { 10, 20, 50, 100 };
double[] sourceData = _testData.RawData.ToArray();
foreach (var period in periods)
{
var (qMid, _, _, _, _) = TtmLrc.Batch(_testData.Data, period);
var linregIndicator = Tulip.Indicators.linreg;
double[][] inputs = { sourceData };
double[] options = { period };
double[][] outputs = { new double[sourceData.Length - period + 1] };
linregIndicator.Run(inputs, options, outputs);
var tLinreg = outputs[0];
int offset = period - 1;
int count = qMid.Count;
int start = Math.Max(0, count - 100);
for (int i = start; i < count; i++)
{
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= tLinreg.Length)
{
continue;
}
Assert.True(
Math.Abs(qMid[i].Value - tLinreg[tIndex]) <= ValidationHelper.TulipTolerance,
$"Mismatch at {i}: QuanTAlib={qMid[i].Value:G17}, Tulip={tLinreg[tIndex]:G17}");
}
}
_output.WriteLine("TtmLrc midline validated against Tulip linreg for all periods");
}
}