using Skender.Stock.Indicators;
using TALib;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
///
/// Validation tests for CCI (Commodity Channel Index) against external libraries.
/// CCI = (Typical Price - SMA of TP) / (0.015 × Mean Deviation)
/// where TP = (High + Low + Close) / 3
///
/// TALib, Tulip, Skender, and Ooples all implement CCI.
///
public sealed class CciValidationTests(ITestOutputHelper output) : IDisposable
{
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
private const int TestPeriod = 20;
public void Dispose()
{
Dispose(disposing: true);
}
private void Dispose(bool disposing)
{
if (_disposed) { return; }
_disposed = true;
if (disposing) { _testData?.Dispose(); }
}
#region TALib Validation
[Fact]
public void Cci_MatchesTalib_Batch()
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
// QuanTAlib CCI
var qResult = Cci.Batch(_testData.Bars, TestPeriod);
// TALib CCI
double[] tOutput = new double[high.Length];
var retCode = TALib.Functions.Cci(high, low, close, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = TALib.Functions.CciLookback(TestPeriod);
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResult[i].Value - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qResult[i].Value:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine("CCI Batch validated successfully against TALib");
}
[Fact]
public void Cci_MatchesTalib_Streaming()
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
// QuanTAlib CCI (streaming)
var cci = new Cci(TestPeriod);
var qResults = new List();
foreach (var bar in _testData.Bars)
{
qResults.Add(cci.Update(bar).Value);
}
// TALib CCI
double[] tOutput = new double[high.Length];
var retCode = TALib.Functions.Cci(high, low, close, 0..^0, tOutput, out var outRange, TestPeriod);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = TALib.Functions.CciLookback(TestPeriod);
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResults[i] - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine("CCI Streaming validated successfully against TALib");
}
[Theory]
[InlineData(5)]
[InlineData(10)]
[InlineData(14)]
[InlineData(20)]
[InlineData(50)]
public void Cci_MatchesTalib_DifferentPeriods(int period)
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
var qResult = Cci.Batch(_testData.Bars, period);
double[] tOutput = new double[high.Length];
var retCode = TALib.Functions.Cci(high, low, close, 0..^0, tOutput, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = TALib.Functions.CciLookback(period);
int count = qResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
var (offset, length) = outRange.GetOffsetAndLength(tOutput.Length);
for (int i = start; i < count; i++)
{
if (i < lookback) { continue; }
int tIndex = i - offset;
if (tIndex < 0 || tIndex >= length) { continue; }
Assert.True(
Math.Abs(qResult[i].Value - tOutput[tIndex]) <= ValidationHelper.TalibTolerance,
$"Period {period}, index {i}: QuanTAlib={qResult[i].Value:G17}, TALib={tOutput[tIndex]:G17}");
}
_output.WriteLine($"CCI period={period} validated against TALib");
}
#endregion
#region Tulip Validation
[Fact]
public void Cci_MatchesTulip_Batch()
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
var qResult = Cci.Batch(_testData.Bars, TestPeriod);
// Tulip CCI
var cciIndicator = Tulip.Indicators.cci;
double[][] inputs = [high, low, close];
double[] options = [TestPeriod];
int lookback = cciIndicator.Start(options);
double[][] outputs = [new double[high.Length - lookback]];
cciIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
// Compare after warmup
for (int i = 0; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], qResult[qIdx].Value, 1e-6);
}
_output.WriteLine("CCI Batch validated successfully against Tulip");
}
[Fact]
public void Cci_MatchesTulip_Streaming()
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
// QuanTAlib streaming
var cci = new Cci(TestPeriod);
var qResults = new List();
foreach (var bar in _testData.Bars)
{
qResults.Add(cci.Update(bar).Value);
}
// Tulip CCI
var cciIndicator = Tulip.Indicators.cci;
double[][] inputs = [high, low, close];
double[] options = [TestPeriod];
int lookback = cciIndicator.Start(options);
double[][] outputs = [new double[high.Length - lookback]];
cciIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
for (int i = 0; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], qResults[qIdx], 1e-6);
}
_output.WriteLine("CCI Streaming validated successfully against Tulip");
}
[Theory]
[InlineData(5)]
[InlineData(10)]
[InlineData(14)]
[InlineData(50)]
public void Cci_MatchesTulip_DifferentPeriods(int period)
{
double[] high = _testData.Bars.Select(b => b.High).ToArray();
double[] low = _testData.Bars.Select(b => b.Low).ToArray();
double[] close = _testData.Bars.Select(b => b.Close).ToArray();
var qResult = Cci.Batch(_testData.Bars, period);
var cciIndicator = Tulip.Indicators.cci;
double[][] inputs = [high, low, close];
double[] options = [period];
int lookback = cciIndicator.Start(options);
double[][] outputs = [new double[high.Length - lookback]];
cciIndicator.Run(inputs, options, outputs);
double[] tulipResult = outputs[0];
for (int i = 0; i < tulipResult.Length; i++)
{
int qIdx = i + lookback;
Assert.Equal(tulipResult[i], qResult[qIdx].Value, 1e-6);
}
}
#endregion
#region Skender Validation
[Fact]
public void Cci_MatchesSkender_Batch()
{
var qResult = Cci.Batch(_testData.Bars, TestPeriod);
var sResult = _testData.SkenderQuotes.GetCci(TestPeriod).ToList();
// Compare last 100 records
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Cci);
_output.WriteLine("CCI Batch validated successfully against Skender");
}
[Fact]
public void Cci_MatchesSkender_Streaming()
{
var cci = new Cci(TestPeriod);
var qResults = new List();
foreach (var bar in _testData.Bars)
{
qResults.Add(cci.Update(bar).Value);
}
var sResult = _testData.SkenderQuotes.GetCci(TestPeriod).ToList();
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
if (sResult[i].Cci is null) { continue; }
Assert.True(
Math.Abs(qResults[i] - sResult[i].Cci!.Value) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, Skender={sResult[i].Cci:G17}");
}
_output.WriteLine("CCI Streaming validated successfully against Skender");
}
[Theory]
[InlineData(5)]
[InlineData(14)]
[InlineData(50)]
public void Cci_MatchesSkender_DifferentPeriods(int period)
{
var qResult = Cci.Batch(_testData.Bars, period);
var sResult = _testData.SkenderQuotes.GetCci(period).ToList();
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Cci);
}
#endregion
// NOTE: Ooples CCI validation removed — OoplesFinance.StockIndicators uses a
// fundamentally different internal mean-deviation calculation that diverges up to
// ~10.6 from the standard CCI formula. TALib, Tulip, and Skender all match at 1e-6+,
// confirming QuanTAlib's CCI correctness via the standard algorithm.
#region Mathematical Validation
[Fact]
public void Cci_ManualCalculation_MatchesExpected()
{
int period = 5;
var bars = new TBarSeries();
var baseTime = DateTime.UtcNow.Ticks;
var timeStep = TimeSpan.FromMinutes(1).Ticks;
double[] highs = [22, 24, 23, 25, 26, 27, 26, 28, 27, 29];
double[] lows = [20, 22, 21, 23, 24, 25, 24, 26, 25, 27];
double[] closes = [21, 23, 22, 24, 25, 26, 25, 27, 26, 28];
for (int i = 0; i < highs.Length; i++)
{
bars.Add(new TBar(
baseTime + (i * timeStep),
21.0 + i, // open
highs[i],
lows[i],
closes[i],
1000)); // volume
}
var cci = new Cci(period);
var qResult = cci.Update(bars);
// Manual calculation for last value (index 9)
double tp5 = (27.0 + 25.0 + 26.0) / 3.0;
double tp6 = (26.0 + 24.0 + 25.0) / 3.0;
double tp7 = (28.0 + 26.0 + 27.0) / 3.0;
double tp8 = (27.0 + 25.0 + 26.0) / 3.0;
double tp9 = (29.0 + 27.0 + 28.0) / 3.0;
double smaTP = (tp5 + tp6 + tp7 + tp8 + tp9) / 5.0;
double meanDev = (Math.Abs(tp5 - smaTP) + Math.Abs(tp6 - smaTP) + Math.Abs(tp7 - smaTP) + Math.Abs(tp8 - smaTP) + Math.Abs(tp9 - smaTP)) / 5.0;
double expectedCci = (tp9 - smaTP) / (0.015 * meanDev);
Assert.Equal(expectedCci, qResult[9].Value, 1e-10);
}
[Fact]
public void Cci_FlatMarket_HandlesGracefully()
{
var bars = new TBarSeries();
var baseTime = DateTime.UtcNow.Ticks;
var timeStep = TimeSpan.FromMinutes(1).Ticks;
for (int i = 0; i < 30; i++)
{
bars.Add(new TBar(baseTime + (i * timeStep), 100, 100, 100, 100, 1000));
}
var cci = new Cci(10);
var result = cci.Update(bars);
// In flat market, deviation = 0 → should handle gracefully
for (int i = 10; i < result.Count; i++)
{
Assert.True(double.IsFinite(result[i].Value) || result[i].Value == 0,
$"CCI at index {i} should be finite or zero, got {result[i].Value}");
}
}
[Fact]
public void Batch_MatchesStreaming_IdenticalResults()
{
// Batch
var batchResult = Cci.Batch(_testData.Bars, TestPeriod);
// Streaming
var cci = new Cci(TestPeriod);
var streamingResults = new List();
foreach (var bar in _testData.Bars)
{
streamingResults.Add(cci.Update(bar).Value);
}
Assert.Equal(batchResult.Count, streamingResults.Count);
int count = batchResult.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.Equal(batchResult[i].Value, streamingResults[i], 1e-10);
}
_output.WriteLine("CCI Batch vs Streaming consistency validated");
}
[Fact]
public void Cci_Correction_Recomputes()
{
var ind = new Cci(20);
long t0 = TimeSpan.TicksPerSecond;
// Build state well past warmup
for (int i = 0; i < 50; i++)
{
double p = 100.0 + (i * 0.5);
ind.Update(new TBar(t0 + (i * TimeSpan.TicksPerSecond), p, p + 1, p - 1, p, 1000));
}
// Anchor bar
long anchorTime = t0 + (50 * TimeSpan.TicksPerSecond);
var anchorBar = new TBar(anchorTime, 125.0, 126.0, 124.0, 125.0, 1000);
ind.Update(anchorBar, isNew: true);
double anchorResult = ind.Last.Value;
// Correction with dramatically different bar
var corruptBar = new TBar(anchorTime, 1250.0, 1260.0, 1240.0, 1250.0, 1000);
ind.Update(corruptBar, isNew: false);
Assert.NotEqual(anchorResult, ind.Last.Value);
// Correction back to original — must exactly restore
ind.Update(anchorBar, isNew: false);
Assert.Equal(anchorResult, ind.Last.Value, 1e-9);
}
#endregion
}