Files
QuanTAlib/lib/momentum/tsi/Tsi.Tests.cs
T

477 lines
13 KiB
C#

using Xunit;
namespace QuanTAlib.Tests;
public class TsiTests
{
private const double Epsilon = 1e-10;
// ==================== CONSTRUCTION ====================
[Fact]
public void Constructor_DefaultParameters()
{
var tsi = new Tsi();
Assert.Equal("Tsi(25,13,13)", tsi.Name);
}
[Fact]
public void Constructor_CustomParameters()
{
var tsi = new Tsi(20, 10, 7);
Assert.Equal("Tsi(20,10,7)", tsi.Name);
}
[Fact]
public void Constructor_MinimumPeriod()
{
var tsi = new Tsi(1, 1, 1);
Assert.Equal("Tsi(1,1,1)", tsi.Name);
}
[Fact]
public void Constructor_ZeroLongPeriod_ThrowsException()
{
Assert.Throws<ArgumentException>(() => new Tsi(0, 13, 13));
}
[Fact]
public void Constructor_ZeroShortPeriod_ThrowsException()
{
Assert.Throws<ArgumentException>(() => new Tsi(25, 0, 13));
}
[Fact]
public void Constructor_ZeroSignalPeriod_ThrowsException()
{
Assert.Throws<ArgumentException>(() => new Tsi(25, 13, 0));
}
[Fact]
public void Constructor_NegativePeriods_ThrowsException()
{
Assert.Throws<ArgumentException>(() => new Tsi(-25, 13, 13));
Assert.Throws<ArgumentException>(() => new Tsi(25, -13, 13));
Assert.Throws<ArgumentException>(() => new Tsi(25, 13, -13));
}
// ==================== BASIC CALCULATIONS ====================
[Fact]
public void Update_ConstantPrice_ZeroTsi()
{
var tsi = new Tsi(3, 2, 2);
double constantPrice = 100.0;
// Feed constant prices
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), constantPrice));
}
// TSI should be 0 when no price change
Assert.True(Math.Abs(tsi.Last.Value) < 1.0);
}
[Fact]
public void Update_RisingPrices_PositiveTsi()
{
var tsi = new Tsi(5, 3, 3);
// Feed rising prices
for (int i = 0; i < 30; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i));
}
// TSI should be positive (approaching +100) for consistent rising prices
Assert.True(tsi.Last.Value > 50);
}
[Fact]
public void Update_FallingPrices_NegativeTsi()
{
var tsi = new Tsi(5, 3, 3);
// Feed falling prices
for (int i = 0; i < 30; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 200.0 - i));
}
// TSI should be negative (approaching -100) for consistent falling prices
Assert.True(tsi.Last.Value < -50);
}
[Fact]
public void Update_BoundedOutput()
{
var tsi = new Tsi(3, 2, 2);
var bars = new GBM(seed: 42).Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Feed GBM prices
for (int i = 0; i < 100; i++)
{
tsi.Update(bars.Close[i]);
// TSI should always be between -100 and +100
Assert.True(tsi.Last.Value >= -100.0 && tsi.Last.Value <= 100.0);
}
}
[Fact]
public void Signal_PropertyReturnsSignalLine()
{
var tsi = new Tsi(5, 3, 3);
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i * 0.5));
}
// Signal should be a smoothed version of TSI
// It should exist and be within TSI range
Assert.True(tsi.Signal >= -100.0 && tsi.Signal <= 100.0);
}
// ==================== IsHot ====================
[Fact]
public void IsHot_InitiallyFalse()
{
var tsi = new Tsi(5, 3, 3);
Assert.False(tsi.IsHot);
}
[Fact]
public void IsHot_TrueAfterWarmup()
{
var tsi = new Tsi(5, 3, 3);
// Feed enough data to warm up all EMAs
for (int i = 0; i < 50; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i * 0.5));
}
Assert.True(tsi.IsHot);
}
// ==================== STATE MANAGEMENT ====================
[Fact]
public void Update_BarCorrection_RestoresState()
{
var tsi = new Tsi(5, 3, 3);
// Initial values - building up momentum history
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i * 0.5));
}
// Update with new bar (large spike)
tsi.Update(new TValue(DateTime.Now.AddMinutes(20), 180.0), isNew: true);
var valueAfterSpike = tsi.Last.Value;
// Correct the bar to smaller value (isNew=false)
tsi.Update(new TValue(DateTime.Now.AddMinutes(20), 105.0), isNew: false);
var valueAfterCorrection = tsi.Last.Value;
// The spike value should be higher than the corrected value
// because spike has larger positive momentum
Assert.True(valueAfterSpike > valueAfterCorrection,
$"Spike ({valueAfterSpike}) should be greater than corrected ({valueAfterCorrection})");
}
[Fact]
public void Reset_ClearsState()
{
var tsi = new Tsi(5, 3, 3);
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i));
}
Assert.NotEqual(default, tsi.Last);
Assert.True(tsi.IsHot);
tsi.Reset();
Assert.Equal(default, tsi.Last);
Assert.False(tsi.IsHot);
}
// ==================== SERIES ====================
[Fact]
public void Update_TSeries_ReturnsCorrectLength()
{
var source = new TSeries();
for (int i = 0; i < 50; i++)
{
source.Add(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i * 0.5));
}
var result = Tsi.Batch(source);
Assert.Equal(source.Count, result.Count);
}
[Fact]
public void Batch_MatchesStreamingCalculation()
{
var bars = new GBM(seed: 42).Fetch(60, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var source = bars.Close;
// Batch calculation
var batchResult = Tsi.Batch(source, 5, 3, 3);
// Streaming calculation
var tsi = new Tsi(5, 3, 3);
var streamingResult = new List<double>();
foreach (var value in source)
{
streamingResult.Add(tsi.Update(value).Value);
}
// Compare results
for (int i = 0; i < source.Count; i++)
{
Assert.Equal(batchResult.Values[i], streamingResult[i], 6);
}
}
// ==================== EDGE CASES ====================
[Fact]
public void Update_SingleValue_ReturnsZero()
{
var tsi = new Tsi(5, 3, 3);
var result = tsi.Update(new TValue(DateTime.Now, 100.0));
// First value has no momentum
Assert.Equal(0, result.Value);
}
[Fact]
public void Update_LargePriceSwing_HandlesCorrectly()
{
var tsi = new Tsi(5, 3, 3);
// Stable prices
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0));
}
// Large price swing
tsi.Update(new TValue(DateTime.Now.AddMinutes(21), 200.0));
// Should handle without overflow/underflow
Assert.True(!double.IsNaN(tsi.Last.Value));
Assert.True(!double.IsInfinity(tsi.Last.Value));
}
[Fact]
public void Update_NegativePrices_HandlesCorrectly()
{
var tsi = new Tsi(5, 3, 3);
// Negative prices (like temperature or P&L)
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), -10.0 + i * 0.5));
}
Assert.True(!double.IsNaN(tsi.Last.Value));
Assert.True(tsi.Last.Value >= -100.0 && tsi.Last.Value <= 100.0);
}
[Fact]
public void Update_VerySmallPriceChanges_HandlesCorrectly()
{
var tsi = new Tsi(5, 3, 3);
// Very small price changes
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i * 1e-8));
}
Assert.True(!double.IsNaN(tsi.Last.Value));
}
// ==================== PRIME ====================
[Fact]
public void Prime_InitializesState()
{
var tsi = new Tsi(5, 3, 3);
double[] primeData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110];
tsi.Prime(primeData);
Assert.NotEqual(default, tsi.Last);
}
[Fact]
public void Prime_SameAsSequentialUpdates()
{
var tsi1 = new Tsi(5, 3, 3);
var tsi2 = new Tsi(5, 3, 3);
double[] data = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110];
// Prime
tsi1.Prime(data);
// Sequential updates
foreach (var value in data)
{
tsi2.Update(new TValue(DateTime.MinValue, value));
}
Assert.Equal(tsi1.Last.Value, tsi2.Last.Value, 10);
}
// ==================== CALCULATE ====================
[Fact]
public void Calculate_Static_MatchesBatch()
{
var bars = new GBM(seed: 42).Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] source = bars.CloseValues.ToArray();
double[] output = new double[50];
Tsi.Batch(source, output, 5, 3);
var series = new TSeries();
for (int i = 0; i < 50; i++)
{
series.Add(new TValue(DateTime.Now.AddMinutes(i), source[i]));
}
var batchResult = Tsi.Batch(series, 5, 3, 3);
for (int i = 10; i < 50; i++)
{
Assert.Equal(output[i], batchResult.Values[i], 6);
}
}
[Fact]
public void Calculate_LengthMismatch_ThrowsException()
{
double[] source = new double[10];
double[] output = new double[5];
Assert.Throws<ArgumentException>(() => Tsi.Batch(source, output));
}
[Fact]
public void Calculate_ZeroPeriod_ThrowsException()
{
double[] source = new double[10];
double[] output = new double[10];
Assert.Throws<ArgumentException>(() => Tsi.Batch(source, output, 0, 3));
Assert.Throws<ArgumentException>(() => Tsi.Batch(source, output, 5, 0));
}
[Fact]
public void Calculate_EmptyArrays_DoesNotThrow()
{
double[] source = [];
double[] output = [];
var exception = Record.Exception(() => Tsi.Batch(source, output));
Assert.Null(exception);
}
// ==================== EVENT HANDLING ====================
[Fact]
public void PubEvent_TriggersOnUpdate()
{
var tsi = new Tsi(5, 3, 3);
TValue? receivedValue = null;
bool isNewReceived = false;
tsi.Pub += (object? sender, in TValueEventArgs args) =>
{
receivedValue = args.Value;
isNewReceived = args.IsNew;
};
tsi.Update(new TValue(DateTime.Now, 100.0));
Assert.NotNull(receivedValue);
Assert.True(isNewReceived);
}
[Fact]
public void PubSubscription_ReceivesUpdates()
{
var source = new TSeries();
var tsi = new Tsi(source, 5, 3, 3);
var receivedValues = new List<TValue>();
tsi.Pub += (object? sender, in TValueEventArgs args) => receivedValues.Add(args.Value);
for (int i = 0; i < 20; i++)
{
source.Add(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i * 0.5));
}
Assert.Equal(20, receivedValues.Count);
}
// ==================== TYPICAL TRADING SCENARIOS ====================
[Fact]
public void TrendChange_ZeroCrossover()
{
var tsi = new Tsi(5, 3, 3);
// Rising prices
for (int i = 0; i < 15; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), 100.0 + i * 2));
}
Assert.True(tsi.Last.Value > 0);
// Falling prices
for (int i = 0; i < 20; i++)
{
tsi.Update(new TValue(DateTime.Now.AddMinutes(15 + i), 128.0 - i * 2));
}
Assert.True(tsi.Last.Value < 0);
}
[Fact]
public void SignalLineCrossover_DetectsMomentumChange()
{
var tsi = new Tsi(5, 3, 3);
var tsiValues = new List<double>();
var signalValues = new List<double>();
// Rising then falling prices - clearer trend change
for (int i = 0; i < 40; i++)
{
double price = i < 20
? 100.0 + i * 2 // Rising
: 140.0 - (i - 20) * 2; // Falling
tsi.Update(new TValue(DateTime.Now.AddMinutes(i), price));
tsiValues.Add(tsi.Last.Value);
signalValues.Add(tsi.Signal);
}
// When momentum reverses, TSI leads signal and crosses below
// Or verify TSI goes from positive to negative (zero crossover)
bool foundZeroCross = false;
for (int i = 20; i < tsiValues.Count; i++)
{
if (tsiValues[i - 1] > 0 && tsiValues[i] <= 0)
{
foundZeroCross = true;
break;
}
}
// After the trend reverses, TSI should cross zero
Assert.True(foundZeroCross || tsiValues[^1] < tsiValues[19],
$"TSI should decline after trend reversal: TSI at peak={tsiValues[19]:F2}, TSI at end={tsiValues[^1]:F2}");
}
}