using Xunit; namespace QuanTAlib.Tests; /// /// Validation tests for TTM Squeeze against known values and mathematical properties. /// public class TtmSqueezeValidationTests { private const double Precision = 1e-10; #region Squeeze Detection Validation [Fact] public void SqueezeOn_TightRangeBars_BbInsideKc() { // When price range is very tight, BB bands should contract faster than KC // because BB uses stddev while KC uses ATR (which has minimum = high - low) var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Very tight range bars - stddev will be near 0 for (int i = 0; i < 10; i++) { squeeze.Update(new TBar(baseTime + i * 60000, 100.0, 100.01, 99.99, 100.0, 1000)); } // With effectively zero stddev, BB bands collapse to the mean // KC still has some width from ATR (at least the bar range) // This should trigger squeeze on // Note: Due to warmup compensation, exact behavior may vary Assert.True(squeeze.IsHot); } [Fact] public void Momentum_PriceEqualsMidline_ZeroDeviation() { var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Price bars where close is always at the center of the range // Donchian midline = (high + low) / 2, and close = midline for (int i = 0; i < 5; i++) { double high = 105; double low = 95; double close = (high + low) / 2; // exactly at midline squeeze.Update(new TBar(baseTime + i * 60000, 100, high, low, close, 1000)); } // Momentum should be near zero since price = midline Assert.True(Math.Abs(squeeze.Momentum.Value) < 1.0); } [Fact] public void Momentum_PriceAboveMidline_PositiveDeviation() { var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Price bars where close is moving above the donchian midline // Start with balanced range, then consistently close near high squeeze.Update(new TBar(baseTime, 100, 110, 90, 100, 1000)); // midline = 100 squeeze.Update(new TBar(baseTime + 60000, 100, 110, 90, 105, 1000)); // close above mid squeeze.Update(new TBar(baseTime + 120000, 105, 110, 90, 108, 1000)); // close above mid squeeze.Update(new TBar(baseTime + 180000, 108, 110, 90, 110, 1000)); // close at high squeeze.Update(new TBar(baseTime + 240000, 110, 112, 88, 112, 1000)); // close at high // After warmup, momentum should reflect price above midline (100) Assert.True(squeeze.IsHot); // Momentum reflects deviation from donchian midline regressed // With close consistently above midline, MomentumPositive should be true Assert.True(squeeze.MomentumPositive); } [Fact] public void Momentum_PriceBelowMidline_NegativeDeviation() { var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Price bars where close is moving below the donchian midline // Start with balanced range, then consistently close near low squeeze.Update(new TBar(baseTime, 100, 110, 90, 100, 1000)); // midline = 100 squeeze.Update(new TBar(baseTime + 60000, 100, 110, 90, 95, 1000)); // close below mid squeeze.Update(new TBar(baseTime + 120000, 95, 110, 90, 92, 1000)); // close below mid squeeze.Update(new TBar(baseTime + 180000, 92, 110, 90, 90, 1000)); // close at low squeeze.Update(new TBar(baseTime + 240000, 90, 112, 88, 88, 1000)); // close at low // After warmup, momentum should reflect price below midline (100) Assert.True(squeeze.IsHot); // With close consistently below midline, MomentumPositive should be false Assert.False(squeeze.MomentumPositive); } #endregion #region Linear Regression Validation [Fact] public void Momentum_LinearDeviation_CorrectSlope() { var squeeze = new TtmSqueeze(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5, momPeriod: 5); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Create bars where deviation from midline increases linearly // This tests the linear regression component for (int i = 0; i < 10; i++) { // Fixed range, but close moves away from midline double high = 110; double low = 90; double midline = 100; // (110 + 90) / 2 double close = midline + (i * 2); // 100, 102, 104, ... squeeze.Update(new TBar(baseTime + i * 60000, 100, high, low, close, 1000)); } // Momentum should be strongly positive with rising trend Assert.True(squeeze.Momentum.Value > 10); Assert.True(squeeze.MomentumRising); } #endregion #region Color Coding Validation [Fact] public void ColorCode_AllFourStates_AreReachable() { var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var colorsSeen = new System.Collections.Generic.HashSet(); // Uptrend (rising above zero - cyan = 0) for (int i = 0; i < 5; i++) { squeeze.Update(new TBar(baseTime + i * 60000, 100 + i * 2, 105 + i * 2, 95 + i * 2, 103 + i * 2, 1000)); colorsSeen.Add(squeeze.ColorCode); } // Now weakening but still positive (falling above zero - blue = 1) for (int i = 5; i < 10; i++) { squeeze.Update(new TBar(baseTime + i * 60000, 115, 118, 112, 114, 1000)); colorsSeen.Add(squeeze.ColorCode); } // Downtrend (falling below zero - red = 2) for (int i = 10; i < 15; i++) { squeeze.Update(new TBar(baseTime + i * 60000, 100 - (i - 10) * 3, 102 - (i - 10) * 3, 95 - (i - 10) * 3, 97 - (i - 10) * 3, 1000)); colorsSeen.Add(squeeze.ColorCode); } // Recovering but still negative (rising below zero - yellow = 3) for (int i = 15; i < 20; i++) { squeeze.Update(new TBar(baseTime + i * 60000, 80, 85, 78, 82, 1000)); colorsSeen.Add(squeeze.ColorCode); } // During a varied price series, we should see at least some color variety Assert.True(colorsSeen.Count >= 1); } [Fact] public void ColorCode_Cyan_WhenRisingAboveZero() { var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Strong uptrend to ensure positive and rising momentum for (int i = 0; i < 10; i++) { squeeze.Update(new TBar(baseTime + i * 60000, 100 + i * 5, 105 + i * 5, 95 + i * 5, 103 + i * 5, 1000)); } if (squeeze.MomentumPositive && squeeze.MomentumRising) { Assert.Equal(0, squeeze.ColorCode); // Cyan } } [Fact] public void ColorCode_Red_WhenFallingBelowZero() { var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Strong downtrend to ensure negative and falling momentum for (int i = 0; i < 10; i++) { squeeze.Update(new TBar(baseTime + i * 60000, 100 - i * 5, 105 - i * 5, 95 - i * 5, 97 - i * 5, 1000)); } if (!squeeze.MomentumPositive && !squeeze.MomentumRising) { Assert.Equal(2, squeeze.ColorCode); // Red } } #endregion #region Squeeze Fired Validation [Fact] public void SqueezeFired_TransitionFromOnToOff_Detected() { var squeeze = new TtmSqueeze(bbPeriod: 3, bbMult: 2.0, kcPeriod: 3, kcMult: 1.5, momPeriod: 3); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); int squeezeFiredCount = 0; // Start with tight range to build squeeze for (int i = 0; i < 5; i++) { squeeze.Update(new TBar(baseTime + i * 60000, 100, 100.1, 99.9, 100, 1000)); if (squeeze.SqueezeFired) { squeezeFiredCount++; } } // Then sudden expansion for (int i = 5; i < 10; i++) { double volatility = (i - 4) * 5; squeeze.Update(new TBar(baseTime + i * 60000, 100, 100 + volatility, 100 - volatility, 100 + volatility - 2, 1000)); if (squeeze.SqueezeFired) { squeezeFiredCount++; } } // SqueezeFired should occur at most once per transition // Count tracks any transitions that occurred Assert.True(squeezeFiredCount >= 0, "SqueezeFired should be trackable"); } #endregion #region Batch vs Streaming Consistency [Fact] public void Batch_MatchesStreaming_IdenticalResults() { var source = new TBarSeries(); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); for (int i = 0; i < 50; i++) { double price = 100 + Math.Sin(i * 0.2) * 10; double high = price + 2; double low = price - 2; source.Add(new TBar(baseTime + i * 60000, price, high, low, price + 0.5, 1000)); } // Batch calculation var (batchResults, _) = TtmSqueeze.Calculate(source, bbPeriod: 10, bbMult: 2.0, kcPeriod: 10, kcMult: 1.5, momPeriod: 10); // Streaming calculation var streaming = new TtmSqueeze(bbPeriod: 10, bbMult: 2.0, kcPeriod: 10, kcMult: 1.5, momPeriod: 10); var streamingResults = new System.Collections.Generic.List(); for (int i = 0; i < source.Count; i++) { streaming.Update(source[i], isNew: true); streamingResults.Add(streaming.Momentum.Value); } // Results should match Assert.Equal(source.Count, batchResults.Count); for (int i = 0; i < source.Count; i++) { Assert.Equal(streamingResults[i], batchResults[i].Value, Precision); } } #endregion #region Edge Cases [Fact] public void Update_SingleBar_ProducesFiniteOutput() { var squeeze = new TtmSqueeze(bbPeriod: 20, bbMult: 2.0, kcPeriod: 20, kcMult: 1.5, momPeriod: 20); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); squeeze.Update(new TBar(baseTime, 100, 105, 95, 102, 1000)); Assert.True(double.IsFinite(squeeze.Momentum.Value)); Assert.False(squeeze.IsHot); } [Fact] public void Update_ConstantPrice_ZeroVariance() { var squeeze = new TtmSqueeze(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5, momPeriod: 5); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // All bars identical for (int i = 0; i < 10; i++) { squeeze.Update(new TBar(baseTime + i * 60000, 100, 100, 100, 100, 1000)); } Assert.True(double.IsFinite(squeeze.Momentum.Value)); // With constant price, donchian midline = price, so momentum should be near 0 Assert.True(Math.Abs(squeeze.Momentum.Value) < 0.01); } [Fact] public void Update_ExtremeVolatility_HandledGracefully() { var squeeze = new TtmSqueeze(bbPeriod: 5, bbMult: 2.0, kcPeriod: 5, kcMult: 1.5, momPeriod: 5); long baseTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); for (int i = 0; i < 10; i++) { double range = (i + 1) * 100; // Increasing volatility squeeze.Update(new TBar(baseTime + i * 60000, 100, 100 + range, 100 - range, 100 + range / 2, 1000)); } Assert.True(double.IsFinite(squeeze.Momentum.Value)); Assert.InRange(squeeze.ColorCode, 0, 3); } [Fact] public void TtmSqueeze_Correction_Recomputes() { var ind = new TtmSqueeze(); var t0 = new DateTime(946_684_800_000_000_0L, DateTimeKind.Utc); // Build state well past warmup for (int i = 0; i < 100; i++) { double p = 100.0 + 10.0 * Math.Sin(2.0 * Math.PI * i / 20.0); ind.Update(new TBar(t0.AddMinutes(i), p, p + 2, p - 2, p, 1000), isNew: true); } // Anchor bar var anchorTime = t0.AddMinutes(100); const double anchorClose = 105.5; ind.Update(new TBar(anchorTime, anchorClose, anchorClose + 2, anchorClose - 2, anchorClose, 1000), isNew: true); double anchorMomentum = ind.Momentum.Value; // Correction with a dramatically different price — Momentum must change ind.Update(new TBar(anchorTime, anchorClose * 10, (anchorClose + 2) * 10, (anchorClose - 2) * 10, anchorClose * 10, 1000), isNew: false); Assert.NotEqual(anchorMomentum, ind.Momentum.Value); // Correction back to original price — must exactly restore original Momentum ind.Update(new TBar(anchorTime, anchorClose, anchorClose + 2, anchorClose - 2, anchorClose, 1000), isNew: false); Assert.Equal(anchorMomentum, ind.Momentum.Value, 1e-9); } #endregion }