using Skender.Stock.Indicators; using TALib; namespace QuanTAlib.Test; using QuanTAlib.Tests; using Xunit; /// /// Validation tests for TR (True Range). /// TR = max(High - Low, |High - prevClose|, |Low - prevClose|) /// First bar uses High - Low only. /// public class TrValidationTests { private static TBarSeries GenerateTestData(int count = 100) { var gbm = new GBM(seed: 42); return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); } // === Mathematical Validation === /// /// Validates the TR formula: max(H-L, |H-pC|, |L-pC|) /// [Fact] public void Tr_Formula_IsCorrect() { double high = 105.0; double low = 95.0; double prevClose = 100.0; double tr1 = high - low; // 10 double tr2 = Math.Abs(high - prevClose); // 5 double tr3 = Math.Abs(low - prevClose); // 5 double expected = Math.Max(tr1, Math.Max(tr2, tr3)); // 10 Assert.Equal(10.0, expected, 10); } /// /// Validates TR with gap up scenario. /// Gap up: prevClose below current Low, so |H-pC| > H-L /// [Fact] public void Tr_GapUp_CapturesGap() { double high = 115.0; double low = 110.0; double prevClose = 100.0; // Gap up from 100 to 110-115 double tr1 = high - low; // 5 double tr2 = Math.Abs(high - prevClose); // 15 double tr3 = Math.Abs(low - prevClose); // 10 double expected = Math.Max(tr1, Math.Max(tr2, tr3)); // 15 Assert.Equal(15.0, expected, 10); Assert.True(expected > tr1, "TR should capture the gap, exceeding H-L range"); } /// /// Validates TR with gap down scenario. /// Gap down: prevClose above current High, so |L-pC| > H-L /// [Fact] public void Tr_GapDown_CapturesGap() { double high = 95.0; double low = 90.0; double prevClose = 110.0; // Gap down from 110 to 90-95 double tr1 = high - low; // 5 double tr2 = Math.Abs(high - prevClose); // 15 double tr3 = Math.Abs(low - prevClose); // 20 double expected = Math.Max(tr1, Math.Max(tr2, tr3)); // 20 Assert.Equal(20.0, expected, 10); Assert.True(expected > tr1, "TR should capture the gap, exceeding H-L range"); } /// /// Validates TR when prevClose is within H-L range (no gap). /// In this case TR = H - L /// [Fact] public void Tr_NoGap_EqualsHighMinusLow() { double high = 105.0; double low = 95.0; double prevClose = 100.0; // Within range double tr1 = high - low; // 10 double tr2 = Math.Abs(high - prevClose); // 5 double tr3 = Math.Abs(low - prevClose); // 5 double expected = Math.Max(tr1, Math.Max(tr2, tr3)); // 10 Assert.Equal(tr1, expected, 10); } /// /// Validates first bar uses H - L only. /// [Fact] public void Tr_FirstBar_UsesHighMinusLow() { var tr = new Tr(); var bar = new TBar(DateTime.UtcNow.Ticks, 100, 110, 90, 105, 1000); var result = tr.Update(bar); Assert.Equal(20.0, result.Value, 10); // 110 - 90 = 20 } /// /// Validates second bar uses full TR formula. /// [Fact] public void Tr_SecondBar_UsesFullFormula() { var tr = new Tr(); // First bar: close at 100 var bar1 = new TBar(DateTime.UtcNow.Ticks, 98, 102, 98, 100, 1000); tr.Update(bar1); // Second bar: gap up, H=115, L=110, pC=100 var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1).Ticks, 110, 115, 110, 113, 1000); var result = tr.Update(bar2); // TR = max(5, 15, 10) = 15 Assert.Equal(15.0, result.Value, 10); } // === Streaming Validation === /// /// Validates streaming calculation matches manual calculation. /// [Fact] public void Tr_StreamingMatchesManual() { var tr = new Tr(); var bars = GenerateTestData(50); double? prevClose = null; for (int i = 0; i < bars.Count; i++) { var bar = bars[i]; var result = tr.Update(bar); double expected; if (prevClose == null) { expected = bar.High - bar.Low; } else { double tr1 = bar.High - bar.Low; double tr2 = Math.Abs(bar.High - prevClose.Value); double tr3 = Math.Abs(bar.Low - prevClose.Value); expected = Math.Max(tr1, Math.Max(tr2, tr3)); } Assert.Equal(expected, result.Value, 10); prevClose = bar.Close; } } /// /// Validates batch calculation matches streaming. /// [Fact] public void Tr_BatchMatchesStreaming() { var bars = GenerateTestData(100); // Streaming var streamingTr = new Tr(); var streamingResults = new double[bars.Count]; for (int i = 0; i < bars.Count; i++) { streamingResults[i] = streamingTr.Update(bars[i]).Value; } // Batch var batchOutput = new double[bars.Count]; Tr.Batch(bars, batchOutput); // Compare all values for (int i = 0; i < bars.Count; i++) { Assert.Equal(streamingResults[i], batchOutput[i], 10); } } /// /// Validates TBarSeries batch matches streaming. /// [Fact] public void Tr_TBarSeriesBatchMatchesStreaming() { var bars = GenerateTestData(100); // Streaming var streamingTr = new Tr(); for (int i = 0; i < bars.Count; i++) { streamingTr.Update(bars[i]); } // Batch via TBarSeries var batchResult = Tr.Batch(bars); Assert.Equal(streamingTr.Last.Value, batchResult.Last.Value, 10); } /// /// Validates span-based batch matches streaming. /// [Fact] public void Tr_SpanBatchMatchesStreaming() { var bars = GenerateTestData(100); // Streaming var streamingTr = new Tr(); for (int i = 0; i < bars.Count; i++) { streamingTr.Update(bars[i]); } // Extract OHLC var highs = new double[bars.Count]; var lows = new double[bars.Count]; var closes = new double[bars.Count]; for (int i = 0; i < bars.Count; i++) { highs[i] = bars[i].High; lows[i] = bars[i].Low; closes[i] = bars[i].Close; } // Span batch var output = new double[bars.Count]; Tr.Batch(highs, lows, closes, output); Assert.Equal(streamingTr.Last.Value, output[^1], 10); } // === Property Validation === /// /// Validates TR is always non-negative. /// [Fact] public void Tr_Output_IsNonNegative() { var bars = GenerateTestData(100); var tr = new Tr(); for (int i = 0; i < bars.Count; i++) { var result = tr.Update(bars[i]); Assert.True(result.Value >= 0, $"TR should be non-negative at bar {i}"); } } /// /// Validates TR >= High - Low for all bars (since it's the max of three components). /// [Fact] public void Tr_GreaterOrEqualToHighMinusLow() { var bars = GenerateTestData(100); var tr = new Tr(); for (int i = 0; i < bars.Count; i++) { var bar = bars[i]; var result = tr.Update(bar); double hlRange = bar.High - bar.Low; Assert.True(result.Value >= hlRange - 1e-10, $"TR should be >= H-L at bar {i}. TR={result.Value}, H-L={hlRange}"); } } /// /// Validates TR output is always finite. /// [Fact] public void Tr_Output_IsFinite() { var bars = GenerateTestData(100); var tr = new Tr(); for (int i = 0; i < bars.Count; i++) { var result = tr.Update(bars[i]); Assert.True(double.IsFinite(result.Value), $"TR should be finite at bar {i}"); } } // === Edge Cases === /// /// Validates handling of flat bars (H = L). /// [Fact] public void Tr_FlatBars_HandledCorrectly() { var tr = new Tr(); // First bar: flat var bar1 = new TBar(DateTime.UtcNow.Ticks, 100, 100, 100, 100, 1000); var result1 = tr.Update(bar1); Assert.Equal(0.0, result1.Value, 10); // Second bar: flat but different price (gap) var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1).Ticks, 105, 105, 105, 105, 1000); var result2 = tr.Update(bar2); Assert.Equal(5.0, result2.Value, 10); // |105-100| = 5 } /// /// Validates handling of very large gaps. /// [Fact] public void Tr_LargeGaps_HandledCorrectly() { var tr = new Tr(); // First bar at 100 var bar1 = new TBar(DateTime.UtcNow.Ticks, 100, 101, 99, 100, 1000); tr.Update(bar1); // Second bar with huge gap up var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1).Ticks, 200, 202, 198, 200, 1000); var result = tr.Update(bar2); // TR = max(4, 102, 98) = 102 Assert.Equal(102.0, result.Value, 10); } /// /// Validates handling of very small ranges. /// [Fact] public void Tr_SmallRanges_HandledCorrectly() { var tr = new Tr(); for (int i = 0; i < 10; i++) { var bar = new TBar( DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 100.001, 99.999, 100.0, 1000 ); var result = tr.Update(bar); Assert.True(double.IsFinite(result.Value)); Assert.True(result.Value >= 0); } } /// /// Validates bar correction works correctly. /// [Fact] public void Tr_BarCorrection_WorksCorrectly() { var tr = new Tr(); var bars = GenerateTestData(20); // Feed initial bars for (int i = 0; i < 15; i++) { tr.Update(bars[i], isNew: true); } // Add new bar tr.Update(bars[15], isNew: true); double afterNew = tr.Last.Value; // Correct with different bar (much larger range) var correctedBar = new TBar( bars[15].Time, 100, 200, 50, 150, 1000 ); tr.Update(correctedBar, isNew: false); double afterCorrection = tr.Last.Value; // Restore original tr.Update(bars[15], isNew: false); double afterRestore = tr.Last.Value; Assert.NotEqual(afterNew, afterCorrection); Assert.Equal(afterNew, afterRestore, 10); } /// /// Validates iterative corrections converge. /// [Fact] public void Tr_IterativeCorrections_Converge() { var tr = new Tr(); var bars = GenerateTestData(20); // Feed bars for (int i = 0; i < 15; i++) { tr.Update(bars[i], isNew: true); } // Multiple corrections on same bar for (int j = 0; j < 5; j++) { var tempBar = new TBar( bars[14].Time, 100 + j, 110 + j, 90 + j, 105 + j, 1000 ); tr.Update(tempBar, isNew: false); } // Final correction back to original tr.Update(bars[14], isNew: false); double afterCorrections = tr.Last.Value; // Fresh calculation var trFresh = new Tr(); for (int i = 0; i < 15; i++) { trFresh.Update(bars[i], isNew: true); } double freshValue = trFresh.Last.Value; Assert.Equal(freshValue, afterCorrections, 10); } /// /// Validates Reset clears state completely. /// [Fact] public void Tr_Reset_ClearsState() { var tr = new Tr(); var bars = GenerateTestData(30); // Feed bars for (int i = 0; i < 20; i++) { tr.Update(bars[i]); } // Reset tr.Reset(); // State should be cleared Assert.False(tr.IsHot); Assert.Equal(default, tr.Last); // Feed bars again for (int i = 0; i < 10; i++) { tr.Update(bars[i]); } // Fresh indicator var trFresh = new Tr(); for (int i = 0; i < 10; i++) { trFresh.Update(bars[i]); } Assert.Equal(trFresh.Last.Value, tr.Last.Value, 10); } // === Consistency Tests === /// /// Validates stability over repeated runs with same seed. /// [Fact] public void Tr_Stability_ConsistentOverRepeatedRuns() { var results = new List(); for (int run = 0; run < 3; run++) { var gbm = new GBM(seed: 42); var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var tr = new Tr(); for (int i = 0; i < bars.Count; i++) { tr.Update(bars[i]); } results.Add(tr.Last.Value); } Assert.Equal(results[0], results[1], 15); Assert.Equal(results[1], results[2], 15); } /// /// Validates TR responds to volatility regime changes. /// [Fact] public void Tr_RespondsToVolatilityChange() { var tr = new Tr(); var lowVolResults = new List(); var highVolResults = new List(); // Low volatility regime for (int i = 0; i < 20; i++) { var bar = new TBar( DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 101.0, 99.0, 100.0, 1000 ); lowVolResults.Add(tr.Update(bar).Value); } // High volatility regime for (int i = 20; i < 40; i++) { var bar = new TBar( DateTime.UtcNow.AddMinutes(i).Ticks, 100.0, 110.0, 90.0, 100.0, 1000 ); highVolResults.Add(tr.Update(bar).Value); } double avgLowVol = lowVolResults.Skip(1).Average(); // Skip first (no gap reference) double avgHighVol = highVolResults.Average(); Assert.True(avgHighVol > avgLowVol * 5, $"High vol TR ({avgHighVol:F2}) should be much larger than low vol ({avgLowVol:F2})"); } // === WarmupPeriod Validation === /// /// Validates WarmupPeriod is 1 (TR is hot immediately). /// [Fact] public void Tr_WarmupPeriod_IsOne() { var tr = new Tr(); Assert.Equal(1, tr.WarmupPeriod); } /// /// Validates IsHot is true after first bar. /// [Fact] public void Tr_IsHot_AfterFirstBar() { var tr = new Tr(); Assert.False(tr.IsHot); var bar = new TBar(DateTime.UtcNow.Ticks, 100, 105, 95, 102, 1000); tr.Update(bar); Assert.True(tr.IsHot); } // === NaN/Infinity Handling === /// /// Validates NaN high uses last valid value. /// [Fact] public void Tr_NaNHigh_UsesLastValid() { var tr = new Tr(); var bar1 = new TBar(DateTime.UtcNow.Ticks, 100, 105, 95, 100, 1000); tr.Update(bar1); var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1).Ticks, 100, double.NaN, 95, 100, 1000); var result = tr.Update(bar2); Assert.True(double.IsFinite(result.Value)); } /// /// Validates NaN low uses last valid value. /// [Fact] public void Tr_NaNLow_UsesLastValid() { var tr = new Tr(); var bar1 = new TBar(DateTime.UtcNow.Ticks, 100, 105, 95, 100, 1000); tr.Update(bar1); var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1).Ticks, 100, 105, double.NaN, 100, 1000); var result = tr.Update(bar2); Assert.True(double.IsFinite(result.Value)); } /// /// Validates NaN close uses last valid value. /// [Fact] public void Tr_NaNClose_UsesLastValid() { var tr = new Tr(); var bar1 = new TBar(DateTime.UtcNow.Ticks, 100, 105, 95, 100, 1000); tr.Update(bar1); var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1).Ticks, 100, 105, 95, double.NaN, 1000); var result = tr.Update(bar2); Assert.True(double.IsFinite(result.Value)); } /// /// Validates Infinity values are handled. /// [Fact] public void Tr_Infinity_UsesLastValid() { var tr = new Tr(); var bar1 = new TBar(DateTime.UtcNow.Ticks, 100, 105, 95, 100, 1000); tr.Update(bar1); var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1).Ticks, 100, double.PositiveInfinity, 95, 100, 1000); var result = tr.Update(bar2); Assert.True(double.IsFinite(result.Value)); } /// /// Validates batch handles NaN values. /// [Fact] public void Tr_BatchNaN_HandledCorrectly() { var highs = new double[] { 105, 106, double.NaN, 108, 109 }; var lows = new double[] { 95, 96, 97, double.NaN, 99 }; var closes = new double[] { 100, 101, 102, 103, double.NaN }; var output = new double[5]; Tr.Batch(highs, lows, closes, output); for (int i = 0; i < output.Length; i++) { Assert.True(double.IsFinite(output[i]), $"Output at index {i} should be finite"); Assert.True(output[i] >= 0, $"Output at index {i} should be non-negative"); } } // === External Library Validation === [Fact] public void Validate_Talib_TrueRange() { var bars = GenerateTestData(500); double[] high = bars.Select(b => b.High).ToArray(); double[] low = bars.Select(b => b.Low).ToArray(); double[] close = bars.Select(b => b.Close).ToArray(); double[] output = new double[high.Length]; var retCode = Functions.TRange(high, low, close, 0..^0, output, out var outRange); Assert.Equal(TALib.Core.RetCode.Success, retCode); int lookback = Functions.TRangeLookback(); // Batch comparison double[] qOutput = new double[high.Length]; Tr.Batch(high, low, close, qOutput); // Use ValidationHelper for correct TALib index mapping ValidationHelper.VerifyData(qOutput, output, outRange, lookback); } [Fact] public void Validate_Tulip_TrueRange() { var bars = GenerateTestData(500); double[] high = bars.Select(b => b.High).ToArray(); double[] low = bars.Select(b => b.Low).ToArray(); double[] close = bars.Select(b => b.Close).ToArray(); var trIndicator = Tulip.Indicators.tr; double[][] inputs = { high, low, close }; double[] options = Array.Empty(); int lookback = trIndicator.Start(options); double[][] outputs = { new double[high.Length - lookback] }; trIndicator.Run(inputs, options, outputs); double[] qOutput = new double[high.Length]; Tr.Batch(high, low, close, qOutput); int tulipLen = outputs[0].Length; int count = Math.Min(tulipLen, 100); int start = tulipLen - count; for (int i = start; i < tulipLen; i++) { int qIdx = lookback + i; Assert.True( Math.Abs(qOutput[qIdx] - outputs[0][i]) <= 1e-7, $"TR mismatch at {qIdx}: QuanTAlib={qOutput[qIdx]:G17}, Tulip={outputs[0][i]:G17}"); } } // === Skender Validation === [Fact] public void Validate_Skender_Batch() { var data = new ValidationTestData(); var tr = new global::QuanTAlib.Tr(); var qResult = tr.Update(data.Bars); var sResult = data.SkenderQuotes.GetTr().ToList(); ValidationHelper.VerifyData(qResult, sResult, s => s.Tr, tolerance: ValidationHelper.SkenderTolerance); } [Fact] public void Validate_Skender_Streaming() { var data = new ValidationTestData(); var tr = new global::QuanTAlib.Tr(); var qResults = new List(); foreach (var bar in data.Bars) { qResults.Add(tr.Update(bar).Value); } var sResult = data.SkenderQuotes.GetTr().ToList(); ValidationHelper.VerifyData(qResults, sResult, s => s.Tr, tolerance: ValidationHelper.SkenderTolerance); } [Fact] public void Validate_Skender_Span() { var data = new ValidationTestData(); double[] high = data.HighPrices.ToArray(); double[] low = data.LowPrices.ToArray(); double[] close = data.ClosePrices.ToArray(); var output = new double[high.Length]; global::QuanTAlib.Tr.Batch(high, low, close, output); var sResult = data.SkenderQuotes.GetTr().ToList(); ValidationHelper.VerifyData(output, sResult, s => s.Tr, tolerance: ValidationHelper.SkenderTolerance); } }