using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Skender.Stock.Indicators;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
///
/// Validation tests for TSI (True Strength Index) against external libraries.
/// TSI = 100 × EMA(EMA(momentum, long), short) / EMA(EMA(|momentum|, long), short)
/// Signal line: EMA(TSI, signalPeriod)
///
/// Skender has GetTsi(). Ooples has CalculateTrueStrengthIndex().
///
public sealed class TsiValidationTests(ITestOutputHelper output) : IDisposable
{
private readonly ValidationTestData _testData = new();
private readonly ITestOutputHelper _output = output;
private bool _disposed;
private const int LongPeriod = 25;
private const int ShortPeriod = 13;
private const int SignalPeriod = 13;
public void Dispose()
{
Dispose(disposing: true);
}
private void Dispose(bool disposing)
{
if (_disposed) { return; }
_disposed = true;
if (disposing) { _testData?.Dispose(); }
}
#region Skender Validation
[Fact]
public void Tsi_MatchesSkender_Batch()
{
// QuanTAlib TSI
var qResult = Tsi.Batch(_testData.Data, LongPeriod, ShortPeriod, SignalPeriod);
// Skender TSI
var sResult = _testData.SkenderQuotes.GetTsi(LongPeriod, ShortPeriod, SignalPeriod).ToList();
// Compare last 100 records (skip warmup)
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Tsi);
_output.WriteLine("TSI Batch validated successfully against Skender");
}
[Fact]
public void Tsi_MatchesSkender_Streaming()
{
// QuanTAlib TSI (streaming)
var tsi = new Tsi(LongPeriod, ShortPeriod, SignalPeriod);
var qResults = new List();
foreach (var item in _testData.Data)
{
qResults.Add(tsi.Update(item).Value);
}
// Skender TSI
var sResult = _testData.SkenderQuotes.GetTsi(LongPeriod, ShortPeriod, SignalPeriod).ToList();
int count = qResults.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
if (sResult[i].Tsi is null) { continue; }
Assert.True(
Math.Abs(qResults[i] - sResult[i].Tsi!.Value) <= ValidationHelper.SkenderTolerance,
$"Mismatch at index {i}: QuanTAlib={qResults[i]:G17}, Skender={sResult[i].Tsi:G17}");
}
_output.WriteLine("TSI Streaming validated successfully against Skender");
}
[Theory]
[InlineData(13, 7, 7)]
[InlineData(25, 13, 13)]
[InlineData(40, 20, 10)]
public void Tsi_MatchesSkender_DifferentPeriods(int longPeriod, int shortPeriod, int signalPeriod)
{
var qResult = Tsi.Batch(_testData.Data, longPeriod, shortPeriod, signalPeriod);
var sResult = _testData.SkenderQuotes.GetTsi(longPeriod, shortPeriod, signalPeriod).ToList();
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Tsi);
}
#endregion
#region Ooples Validation
[Fact]
public void Tsi_MatchesOoples_Batch()
{
var ooplesData = _testData.SkenderQuotes.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
}).ToList();
// QuanTAlib TSI
var qResult = Tsi.Batch(_testData.Data, LongPeriod, ShortPeriod, SignalPeriod);
// Ooples TSI
var stockData = new StockData(ooplesData);
var oResult = stockData.CalculateTrueStrengthIndex(length1: LongPeriod, length2: ShortPeriod, signalLength: SignalPeriod);
var oValues = oResult.OutputValues.Values.First();
int count = qResult.Count;
int warmup = LongPeriod + ShortPeriod + SignalPeriod;
int start = Math.Max(warmup, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.True(
Math.Abs(qResult[i].Value - oValues[i]) <= ValidationHelper.OoplesTolerance,
$"Mismatch at index {i}: QuanTAlib={qResult[i].Value:G17}, Ooples={oValues[i]:G17}");
}
_output.WriteLine("TSI Batch validated successfully against Ooples");
}
#endregion
#region Formula Validation
[Fact]
public void Tsi_ConstantPositiveMomentum_ApproachesPositive100()
{
var tsi = new Tsi(3, 2, 2);
for (int i = 0; i < 50; i++)
{
tsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i * 2));
}
Assert.True(tsi.Last.Value > 95.0, $"Expected TSI > 95, got {tsi.Last.Value}");
}
[Fact]
public void Tsi_ConstantNegativeMomentum_ApproachesNegative100()
{
var tsi = new Tsi(3, 2, 2);
for (int i = 0; i < 50; i++)
{
tsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 200.0 - i * 2));
}
Assert.True(tsi.Last.Value < -95.0, $"Expected TSI < -95, got {tsi.Last.Value}");
}
[Fact]
public void Tsi_NoChange_ApproachesZero()
{
var tsi = new Tsi(3, 2, 2);
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
Assert.True(Math.Abs(tsi.Last.Value) < 1.0, $"Expected TSI ≈ 0, got {tsi.Last.Value}");
}
[Fact]
public void Tsi_SignalLagsMainLine()
{
var tsi = new Tsi(5, 3, 3);
var tsiValues = new List();
var signalValues = new List();
for (int i = 0; i < 20; i++)
{
double price = i < 10 ? 100.0 + i * 2 : 120.0 - (i - 10) * 2;
tsi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
tsiValues.Add(tsi.Last.Value);
signalValues.Add(tsi.Signal);
}
// Signal should lag TSI
var diff = tsiValues.Zip(signalValues, (t, s) => t - s).ToList();
double avgDiff = diff.Average();
double variance = diff.Average(d => (d - avgDiff) * (d - avgDiff));
Assert.True(variance > 0.001, "Signal should lag TSI, showing variance in differences");
}
[Fact]
public void Tsi_RangeIsBounded()
{
var tsi = new Tsi(LongPeriod, ShortPeriod, SignalPeriod);
const double epsilon = 1e-10;
foreach (var item in _testData.Data)
{
tsi.Update(item);
Assert.True(tsi.Last.Value >= -100 - epsilon && tsi.Last.Value <= 100 + epsilon,
$"TSI value {tsi.Last.Value} out of range [-100, 100]");
}
}
#endregion
#region Consistency Validation
[Fact]
public void Batch_MatchesStreaming_IdenticalResults()
{
// TSI uses triple EMA smoothing (long EMA → short EMA → signal EMA),
// so batch vs streaming modes diverge during warmup due to different
// initialization paths. Compare only well-converged tail values.
const double convergenceTolerance = 1e-6;
// Batch
var batchResult = Tsi.Batch(_testData.Data, LongPeriod, ShortPeriod, SignalPeriod);
// Streaming
var tsi = new Tsi(LongPeriod, ShortPeriod, SignalPeriod);
var streamingResults = new List();
foreach (var value in _testData.Data)
{
streamingResults.Add(tsi.Update(value).Value);
}
// Skip early warmup region where initialization paths diverge
int count = _testData.Data.Count;
int start = Math.Max(0, count - ValidationHelper.DefaultVerificationCount);
for (int i = start; i < count; i++)
{
Assert.True(
Math.Abs(batchResult.Values[i] - streamingResults[i]) <= convergenceTolerance,
$"Mismatch at index {i}: Batch={batchResult.Values[i]:G17}, Streaming={streamingResults[i]:G17}");
}
_output.WriteLine("TSI Batch vs Streaming consistency validated");
}
[Fact]
public void Tsi_ResetProducesIdenticalResults()
{
var tsi = new Tsi(LongPeriod, ShortPeriod, SignalPeriod);
// First run
foreach (var item in _testData.Data)
{
tsi.Update(item);
}
var firstValue = tsi.Last.Value;
var firstSignal = tsi.Signal;
tsi.Reset();
// Second run
foreach (var item in _testData.Data)
{
tsi.Update(item);
}
Assert.Equal(firstValue, tsi.Last.Value, 1e-10);
Assert.Equal(firstSignal, tsi.Signal, 1e-10);
}
#endregion
}