Files
QuanTAlib/lib/oscillators/stochrsi/Stochrsi.Validation.Tests.cs
T
Miha Kralj 951842acca Add validation tests for various volume and momentum indicators
- Introduced Massi validation tests to ensure mathematical properties hold for the Mass Index indicator.
- Added Va validation tests for Volume Accumulation, checking for finite outputs and correct accumulation behavior.
- Implemented Vf validation tests for Volume Force, verifying outputs for rising and falling prices, and ensuring batch and streaming results match.
- Created Vo validation tests for Volume Oscillator, confirming behavior with constant, increasing, and decreasing volumes.
- Developed Vroc validation tests for Volume Rate of Change, validating outputs for constant volume and changes in volume.
- Updated project file to include new momentum indicators (MACD and RSI) in the compilation.
2026-02-12 19:43:09 -08:00

409 lines
13 KiB
C#

using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Skender.Stock.Indicators;
using TALib;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// StochRSI validation tests.
/// Cross-validates against Skender.Stock.Indicators.GetStochRsi,
/// TALib.NETCore StochRsi, OoplesFinance, and self-consistency checks.
/// </summary>
public sealed class StochrsiValidationTests : IDisposable
{
private readonly ValidationTestData _data = new();
private readonly ITestOutputHelper _output;
private bool _disposed;
public StochrsiValidationTests(ITestOutputHelper output)
{
_output = output;
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_data.Dispose();
_disposed = true;
}
}
private static TSeries GenerateCloseSeries(int count, int seed = 42)
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
return bars.Close;
}
// --- A) Streaming vs Batch self-consistency ---
[Fact]
public void Streaming_Matches_Batch()
{
var close = GenerateCloseSeries(300);
const int rsiLen = 14;
const int stochLen = 14;
const int kSmooth = 3;
const int dSmooth = 3;
// Streaming
var ind = new Stochrsi(rsiLen, stochLen, kSmooth, dSmooth);
for (int i = 0; i < close.Count; i++)
{
ind.Update(new TValue(close.Times[i], close.Values[i]));
}
double streamK = ind.K;
// Batch
var batchResult = Stochrsi.Batch(close, rsiLen, stochLen, kSmooth, dSmooth);
Assert.Equal(streamK, batchResult[^1].Value, 1e-10);
}
// --- B) Span matches TSeries ---
[Fact]
public void Span_Matches_TSeries()
{
var close = GenerateCloseSeries(200);
const int rsiLen = 14;
const int stochLen = 14;
const int kSmooth = 3;
const int dSmooth = 3;
var tsResult = Stochrsi.Batch(close, rsiLen, stochLen, kSmooth, dSmooth);
double[] closeArr = close.Values.ToArray();
var spanOut = new double[close.Count];
Stochrsi.Batch(closeArr.AsSpan(), spanOut.AsSpan(), rsiLen, stochLen, kSmooth, dSmooth);
for (int i = 0; i < close.Count; i++)
{
Assert.Equal(tsResult.Values[i], spanOut[i], 12);
}
}
// --- C) Cross-validation with Skender ---
[Fact]
public void Skender_Batch_Validates()
{
// Skender GetStochRsi(rsiPeriod, stochPeriod, signalPeriod, smaPeriods)
// signalPeriod = dSmooth, smaPeriods = kSmooth
const int rsiLen = 14;
const int stochLen = 14;
const int kSmooth = 3;
const int dSmooth = 3;
var qKD = new Stochrsi(rsiLen, stochLen, kSmooth, dSmooth).UpdateKD(_data.Data);
var skResults = _data.SkenderQuotes.GetStochRsi(rsiLen, stochLen, dSmooth, kSmooth).ToList();
// Skip warmup — compare converged values
int warmup = rsiLen + stochLen + kSmooth + dSmooth;
int totalCompared = 0;
int mismatches = 0;
for (int i = warmup; i < _data.Data.Count; i++)
{
double? skK = skResults[i].StochRsi;
double? skD = skResults[i].Signal;
if (skK.HasValue && skD.HasValue)
{
totalCompared++;
double errK = Math.Abs(qKD.K.Values[i] - skK.Value);
double errD = Math.Abs(qKD.D.Values[i] - skD.Value);
if (errK > 1e-6 || errD > 1e-6)
{
mismatches++;
}
}
}
Assert.True(totalCompared > 0, "No Skender results to compare");
double mismatchRate = (double)mismatches / totalCompared;
_output.WriteLine($"Skender batch: {totalCompared} compared, {mismatches} mismatches ({mismatchRate:P2})");
Assert.True(mismatchRate < 0.05, $"Mismatch rate {mismatchRate:P2} exceeds 5% threshold ({mismatches}/{totalCompared})");
}
[Fact]
public void Skender_Streaming_Validates()
{
const int rsiLen = 14;
const int stochLen = 14;
const int kSmooth = 3;
const int dSmooth = 3;
var ind = new Stochrsi(rsiLen, stochLen, kSmooth, dSmooth);
var qK = new List<double>();
var qD = new List<double>();
for (int i = 0; i < _data.Data.Count; i++)
{
ind.Update(new TValue(_data.Data.Times[i], _data.Data.Values[i]));
qK.Add(ind.K);
qD.Add(ind.D);
}
var skResults = _data.SkenderQuotes.GetStochRsi(rsiLen, stochLen, dSmooth, kSmooth).ToList();
int warmup = rsiLen + stochLen + kSmooth + dSmooth;
int totalCompared = 0;
int mismatches = 0;
for (int i = warmup; i < _data.Data.Count; i++)
{
double? skK = skResults[i].StochRsi;
double? skD = skResults[i].Signal;
if (skK.HasValue && skD.HasValue)
{
totalCompared++;
double errK = Math.Abs(qK[i] - skK.Value);
double errD = Math.Abs(qD[i] - skD.Value);
if (errK > 1e-6 || errD > 1e-6)
{
mismatches++;
}
}
}
Assert.True(totalCompared > 0, "No Skender results to compare");
double mismatchRate = (double)mismatches / totalCompared;
_output.WriteLine($"Skender streaming: {totalCompared} compared, {mismatches} mismatches ({mismatchRate:P2})");
Assert.True(mismatchRate < 0.05, $"Mismatch rate {mismatchRate:P2} exceeds 5% ({mismatches}/{totalCompared})");
}
// --- D) Cross-validation with TALib ---
[Fact]
public void TALib_StochRsi_Validates()
{
// TALib StochRsi: timePeriod=rsiLen, fastK_Period=stochLen, fastD_Period=dSmooth
// TALib does NOT smooth K (equivalent to kSmooth=1)
const int rsiLen = 14;
const int stochLen = 14;
const int dSmooth = 3;
double[] closeData = _data.RawData.ToArray();
double[] taK = new double[closeData.Length];
double[] taD = new double[closeData.Length];
var retCode = TALib.Functions.StochRsi(closeData.AsSpan(), 0..^0,
taK, taD, out var outRange, rsiLen, stochLen, dSmooth);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
var (offset, length) = outRange.GetOffsetAndLength(taK.Length);
// Our indicator with kSmooth=1 to match TALib (no K smoothing)
var ind = new Stochrsi(rsiLen, stochLen, kSmooth: 1, dSmooth);
var qK = new List<double>();
var qD = new List<double>();
for (int i = 0; i < _data.Data.Count; i++)
{
ind.Update(new TValue(_data.Data.Times[i], _data.Data.Values[i]));
qK.Add(ind.K);
qD.Add(ind.D);
}
int matched = 0;
int mismatches = 0;
for (int j = 0; j < length; j++)
{
int qi = j + offset;
matched++;
double errK = Math.Abs(qK[qi] - taK[j]);
double errD = Math.Abs(qD[qi] - taD[j]);
if (errK > 1e-6 || errD > 1e-6)
{
mismatches++;
}
}
Assert.True(matched > 0, "No TALib results to compare");
double mismatchRate = (double)mismatches / matched;
_output.WriteLine($"TALib: {matched} compared, {mismatches} mismatches ({mismatchRate:P2})");
Assert.True(mismatchRate < 0.05, $"TALib mismatch rate {mismatchRate:P2} exceeds 5% ({mismatches}/{matched})");
}
// --- E) Cross-validation with Ooples ---
// Ooples CalculateStochasticRelativeStrengthIndex uses a fundamentally different
// algorithm (EMA-based smoothing, different RSI seeding). Not directly comparable
// to TradingView/Skender convention. Validated via Skender and TALib instead.
[Fact]
public void Ooples_StochRsi_Produces_Output()
{
var ooplesData = _data.SkenderQuotes.Select(q => new TickerData
{
Date = q.Date,
Close = (double)q.Close,
High = (double)q.High,
Low = (double)q.Low,
Open = (double)q.Open,
Volume = (double)q.Volume,
}).ToList();
var stockData = new StockData(ooplesData);
var oResult = stockData.CalculateStochasticRelativeStrengthIndex();
var oValues = oResult.OutputValues.Values.First();
// Verify Ooples produces output (smoke test — algorithms differ)
Assert.True(oValues.Count > 0, "Ooples should produce StochRSI output");
int finiteCount = 0;
for (int i = 50; i < oValues.Count; i++)
{
if (double.IsFinite(oValues[i]))
{
finiteCount++;
}
}
_output.WriteLine($"Ooples StochRSI: {oValues.Count} values, {finiteCount} finite after warmup");
Assert.True(finiteCount > 0, "Ooples should produce finite StochRSI values");
}
// --- F) Determinism ---
[Fact]
public void Deterministic_Across_Runs()
{
var close = GenerateCloseSeries(200, seed: 99);
const int rsiLen = 14;
const int stochLen = 14;
const int kSmooth = 3;
const int dSmooth = 3;
var r1 = Stochrsi.Batch(close, rsiLen, stochLen, kSmooth, dSmooth);
var r2 = Stochrsi.Batch(close, rsiLen, stochLen, kSmooth, dSmooth);
for (int i = 0; i < close.Count; i++)
{
Assert.Equal(r1.Values[i], r2.Values[i], 15);
}
}
// --- G) Different parameters produce different results ---
[Fact]
public void Different_Periods_Produce_Different_Results()
{
var close = GenerateCloseSeries(200);
var r1 = Stochrsi.Batch(close, rsiLength: 7, stochLength: 7, kSmooth: 3, dSmooth: 3);
var r2 = Stochrsi.Batch(close, rsiLength: 21, stochLength: 21, kSmooth: 3, dSmooth: 3);
bool anyDifferent = false;
for (int i = 50; i < 200; i++)
{
if (Math.Abs(r1.Values[i] - r2.Values[i]) > 0.01)
{
anyDifferent = true;
break;
}
}
Assert.True(anyDifferent);
}
// --- H) Calculate returns hot indicator ---
[Fact]
public void Calculate_Returns_Hot_Indicator()
{
var close = GenerateCloseSeries(200);
const int rsiLen = 14;
const int stochLen = 14;
const int kSmooth = 3;
const int dSmooth = 3;
var (results, indicator) = Stochrsi.Calculate(close, rsiLen, stochLen, kSmooth, dSmooth);
Assert.Equal(200, results.Count);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.K));
Assert.True(double.IsFinite(indicator.D));
}
// --- I) Range validation (values should be 0-100) ---
[Fact]
public void Values_Within_0_100_Range()
{
var close = GenerateCloseSeries(500);
const int rsiLen = 14;
const int stochLen = 14;
const int kSmooth = 3;
const int dSmooth = 3;
var kd = new Stochrsi(rsiLen, stochLen, kSmooth, dSmooth).UpdateKD(close);
int warmup = rsiLen + stochLen + kSmooth + dSmooth;
for (int i = warmup; i < close.Count; i++)
{
double k = kd.K.Values[i];
double d = kd.D.Values[i];
Assert.True(k >= -0.01 && k <= 100.01,
$"K value {k} out of range at index {i}");
Assert.True(d >= -0.01 && d <= 100.01,
$"D value {d} out of range at index {i}");
}
}
// --- J) Skender span validation ---
[Fact]
public void Skender_Span_Validates()
{
const int rsiLen = 14;
const int stochLen = 14;
const int kSmooth = 3;
const int dSmooth = 3;
double[] closeData = _data.RawData.ToArray();
var spanOut = new double[closeData.Length];
Stochrsi.Batch(closeData.AsSpan(), spanOut.AsSpan(), rsiLen, stochLen, kSmooth, dSmooth);
var skResults = _data.SkenderQuotes.GetStochRsi(rsiLen, stochLen, dSmooth, kSmooth).ToList();
int warmup = rsiLen + stochLen + kSmooth + dSmooth;
int totalCompared = 0;
int mismatches = 0;
for (int i = warmup; i < closeData.Length; i++)
{
double? skK = skResults[i].StochRsi;
if (skK.HasValue)
{
totalCompared++;
double err = Math.Abs(spanOut[i] - skK.Value);
if (err > 1e-6)
{
mismatches++;
}
}
}
Assert.True(totalCompared > 0, "No Skender results to compare");
double mismatchRate = (double)mismatches / totalCompared;
_output.WriteLine($"Skender span: {totalCompared} compared, {mismatches} mismatches ({mismatchRate:P2})");
Assert.True(mismatchRate < 0.05, $"Skender span mismatch rate {mismatchRate:P2} exceeds 5% ({mismatches}/{totalCompared})");
}
}