using TALib;
using Skender.Stock.Indicators;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
///
/// 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.
///
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(
closeData,
0..^0,
smaOutput,
out var outRange,
period);
Assert.Equal(TALib.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(
highData,
lowData,
closeData,
0..^0,
atrOutput,
out var outRange,
period);
Assert.Equal(TALib.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)");
}
}