Files
QuanTAlib/lib/momentum/tsi/tests/Tsi.Validation.Tests.cs
T
Miha Kralj 060649192f docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files
- Remove 'C# Implementation Considerations' sections from 34 indicator .md files
- Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.)
- Move test files into tests/ subdirectories for consistent project structure
- Add trader-focused bullet points to indicator documentation
2026-03-12 12:34:16 -07:00

277 lines
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Skender.Stock.Indicators;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// 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().
/// </summary>
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<double>();
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<double>();
var signalValues = new List<double>();
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<double>();
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
}