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,42 @@
using System;
using System.Collections.Generic;
using Skender.Stock.Indicators;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public class IchimokuValidationTests
public sealed class IchimokuValidationTests : IDisposable
{
private const double Precision = 1e-10;
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public IchimokuValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
_disposed = true;
if (disposing)
{
_testData?.Dispose();
}
}
#region Tenkan-sen Validation Tests
@@ -488,4 +518,143 @@ public class IchimokuValidationTests
}
#endregion
#region Skender Cross-Validation Tests
[Fact]
public void Validate_Skender_TenkanSen()
{
// Skender GetIchimoku returns IchimokuResult with TenkanSen (decimal?)
// Both use Donchian midpoint: (highest-high + lowest-low) / 2 over tenkanPeriod
var (qTenkan, _, _, _, _) = Ichimoku.Batch(_testData.Bars);
var sResult = _testData.SkenderQuotes.GetIchimoku(9, 26, 52).ToList();
int count = Math.Min(qTenkan.Count, sResult.Count);
int start = Math.Max(9, count - 100);
int matched = 0;
for (int i = start; i < count; i++)
{
double qValue = qTenkan[i].Value;
decimal? sValue = sResult[i].TenkanSen;
if (!sValue.HasValue || !double.IsFinite(qValue))
{
continue;
}
double diff = Math.Abs(qValue - (double)sValue.Value);
Assert.True(diff <= ValidationHelper.SkenderTolerance,
$"Tenkan mismatch at [{i}]: QuanTAlib={qValue:G17}, Skender={(double)sValue.Value:G17}, diff={diff:E3}");
matched++;
}
Assert.True(matched > 50, $"Only matched {matched} Tenkan values");
_output.WriteLine($"Ichimoku Tenkan validated against Skender ({matched} values matched)");
}
[Fact]
public void Validate_Skender_KijunSen()
{
var (_, qKijun, _, _, _) = Ichimoku.Batch(_testData.Bars);
var sResult = _testData.SkenderQuotes.GetIchimoku(9, 26, 52).ToList();
int count = Math.Min(qKijun.Count, sResult.Count);
int start = Math.Max(26, count - 100);
int matched = 0;
for (int i = start; i < count; i++)
{
double qValue = qKijun[i].Value;
decimal? sValue = sResult[i].KijunSen;
if (!sValue.HasValue || !double.IsFinite(qValue))
{
continue;
}
double diff = Math.Abs(qValue - (double)sValue.Value);
Assert.True(diff <= ValidationHelper.SkenderTolerance,
$"Kijun mismatch at [{i}]: QuanTAlib={qValue:G17}, Skender={(double)sValue.Value:G17}, diff={diff:E3}");
matched++;
}
Assert.True(matched > 50, $"Only matched {matched} Kijun values");
_output.WriteLine($"Ichimoku Kijun validated against Skender ({matched} values matched)");
}
[Fact]
public void Validate_Skender_SenkouSpanB()
{
// SenkouSpanB is the Donchian midpoint over the longest period (52)
// Note: Skender shifts SenkouB forward by displacement periods in its output array,
// so sResult[i].SenkouSpanB at index i is the value computed for bar (i - displacement).
// QuanTAlib does NOT apply displacement in its batch output.
// Therefore: QuanTAlib SenkouB[i] should match Skender SenkouSpanB[i + displacement].
var (_, _, _, qSenkouB, _) = Ichimoku.Batch(_testData.Bars);
var sResult = _testData.SkenderQuotes.GetIchimoku(9, 26, 52).ToList();
int displacement = 26;
int count = Math.Min(qSenkouB.Count, sResult.Count - displacement);
int start = Math.Max(52, count - 100);
int matched = 0;
for (int i = start; i < count; i++)
{
double qValue = qSenkouB[i].Value;
int sIdx = i + displacement;
if (sIdx >= sResult.Count)
{
break;
}
decimal? sValue = sResult[sIdx].SenkouSpanB;
if (!sValue.HasValue || !double.IsFinite(qValue))
{
continue;
}
double diff = Math.Abs(qValue - (double)sValue.Value);
Assert.True(diff <= ValidationHelper.SkenderTolerance,
$"SenkouB mismatch at q[{i}] vs s[{sIdx}]: QuanTAlib={qValue:G17}, Skender={(double)sValue.Value:G17}, diff={diff:E3}");
matched++;
}
Assert.True(matched > 30, $"Only matched {matched} SenkouB values");
_output.WriteLine($"Ichimoku SenkouB validated against Skender ({matched} values, offset +{displacement})");
}
[Fact]
public void Validate_Skender_ChikouSpan()
{
// Chikou Span = current close price (plotted backward by displacement)
// Both should agree that Chikou = Close at each bar
var (_, _, _, _, qChikou) = Ichimoku.Batch(_testData.Bars);
var sResult = _testData.SkenderQuotes.GetIchimoku(9, 26, 52).ToList();
int displacement = 26;
int count = Math.Min(qChikou.Count, sResult.Count);
int matched = 0;
// Skender stores ChikouSpan at index (i - displacement), i.e. sResult[i].ChikouSpan
// is the close of bar (i + displacement). QuanTAlib Chikou[i] = Close[i].
// So QuanTAlib Chikou[i] == Skender ChikouSpan[i - displacement] when i >= displacement.
for (int i = displacement; i < count; i++)
{
double qValue = qChikou[i].Value;
int sIdx = i - displacement;
decimal? sValue = sResult[sIdx].ChikouSpan;
if (!sValue.HasValue || !double.IsFinite(qValue))
{
continue;
}
double diff = Math.Abs(qValue - (double)sValue.Value);
Assert.True(diff <= ValidationHelper.SkenderTolerance,
$"Chikou mismatch at q[{i}] vs s[{sIdx}]: QuanTAlib={qValue:G17}, Skender={(double)sValue.Value:G17}, diff={diff:E3}");
matched++;
}
Assert.True(matched > 50, $"Only matched {matched} Chikou values");
_output.WriteLine($"Ichimoku Chikou validated against Skender ({matched} values matched)");
}
#endregion
}
+74 -3
View File
@@ -1,18 +1,21 @@
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for Qstick indicator.
/// Validates against manual formula calculations since Qstick is not
/// available in TA-Lib, Skender, Tulip, or Ooples.
/// Validates against manual formula calculations and Tulip Indicators qstick.
/// Qstick is not available in TA-Lib, Skender, or Ooples.
/// </summary>
public sealed class QstickValidationTests : IDisposable
{
private readonly ValidationTestData _data;
private readonly ITestOutputHelper _output;
public QstickValidationTests()
public QstickValidationTests(ITestOutputHelper output)
{
_output = output;
_data = new ValidationTestData();
}
@@ -350,4 +353,72 @@ public sealed class QstickValidationTests : IDisposable
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Tulip Indicators Cross-Validation
// ═══════════════════════════════════════════════════════════════════════════
[Fact]
public void Validate_Tulip_Qstick()
{
// Tulip qstick: inputs = {open[], close[]}, options = {period}, outputs = {qstick[]}
// Formula: SMA(close - open, period) — same as QuanTAlib Qstick with useEma=false
int period = 14;
double[] openData = _data.OpenPrices.ToArray();
double[] closeData = _data.ClosePrices.ToArray();
// QuanTAlib batch
var qSeries = Qstick.Batch(_data.Bars, period);
double[] qResult = new double[qSeries.Count];
for (int i = 0; i < qSeries.Count; i++)
{
qResult[i] = qSeries[i].Value;
}
// Tulip qstick
var indicator = Tulip.Indicators.qstick;
double[][] inputs = { openData, closeData };
double[] options = { period };
double[][] outputs = { new double[openData.Length] };
indicator.Run(inputs, options, outputs);
double[] tResult = outputs[0];
// Tulip output is shorter by (period-1) — lookback = period - 1
int lookback = period - 1;
ValidationHelper.VerifyData(qResult, tResult, lookback);
_output.WriteLine($"Qstick validated against Tulip Indicators (period={period})");
}
[Fact]
public void Validate_Tulip_Qstick_MultiplePeriods()
{
int[] periods = { 5, 10, 20, 50 };
foreach (int period in periods)
{
double[] openData = _data.OpenPrices.ToArray();
double[] closeData = _data.ClosePrices.ToArray();
var qSeries = Qstick.Batch(_data.Bars, period);
double[] qResult = new double[qSeries.Count];
for (int i = 0; i < qSeries.Count; i++)
{
qResult[i] = qSeries[i].Value;
}
var indicator = Tulip.Indicators.qstick;
double[][] inputs = { openData, closeData };
double[] options = { period };
double[][] outputs = { new double[openData.Length] };
indicator.Run(inputs, options, outputs);
double[] tResult = outputs[0];
int lookback = period - 1;
ValidationHelper.VerifyData(qResult, tResult, lookback);
}
_output.WriteLine("Qstick validated against Tulip for multiple periods (5, 10, 20, 50)");
}
}