using Xunit; namespace QuanTAlib.Tests; public class JbandsTests { [Fact] public void Jbands_Constructor_ValidatesInput() { Assert.Throws(() => new Jbands(0)); Assert.Throws(() => new Jbands(-5)); Assert.Throws(() => new Jbands(14, 0, double.NaN)); var j = new Jbands(14); Assert.Contains("Jbands", j.Name, StringComparison.OrdinalIgnoreCase); Assert.True(j.WarmupPeriod > 0); } [Fact] public void Jbands_InitialState_Defaults() { var j = new Jbands(14); Assert.Equal(0, j.Last.Value); Assert.Equal(0, j.Upper.Value); Assert.Equal(0, j.Lower.Value); Assert.False(j.IsHot); } [Fact] public void Jbands_FirstBar_AllBandsEqual() { var j = new Jbands(14); j.Update(new TValue(DateTime.UtcNow, 100.0)); Assert.Equal(100.0, j.Last.Value, 1e-10); Assert.Equal(100.0, j.Upper.Value, 1e-10); Assert.Equal(100.0, j.Lower.Value, 1e-10); } [Fact] public void Jbands_UpperBand_SnapToNewHigh() { var j = new Jbands(14); j.Update(new TValue(DateTime.UtcNow, 100.0)); j.Update(new TValue(DateTime.UtcNow, 105.0)); // Upper should snap to new high Assert.Equal(105.0, j.Upper.Value, 1e-10); } [Fact] public void Jbands_LowerBand_SnapToNewLow() { var j = new Jbands(14); j.Update(new TValue(DateTime.UtcNow, 100.0)); j.Update(new TValue(DateTime.UtcNow, 95.0)); // Lower should snap to new low Assert.Equal(95.0, j.Lower.Value, 1e-10); } [Fact] public void Jbands_BandsDecay_TowardPrice() { var j = new Jbands(14); // Create a spike then return to baseline j.Update(new TValue(DateTime.UtcNow, 100.0)); j.Update(new TValue(DateTime.UtcNow, 120.0)); // Upper snaps to 120 double upperAfterSpike = j.Upper.Value; // Feed lower prices - upper band should decay for (int i = 0; i < 20; i++) { j.Update(new TValue(DateTime.UtcNow, 100.0)); } // Upper band should have decayed toward price Assert.True(j.Upper.Value < upperAfterSpike); Assert.True(j.Upper.Value > 100.0); // But not below price yet } [Fact] public void Jbands_IsHot_TurnsTrueAfterWarmup() { var j = new Jbands(7); var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.05, seed: 42); int warmup = j.WarmupPeriod; for (int i = 0; i < warmup - 1; i++) { j.Update(new TValue(DateTime.UtcNow, gbm.Next().Close)); Assert.False(j.IsHot); } j.Update(new TValue(DateTime.UtcNow, gbm.Next().Close)); Assert.True(j.IsHot); } [Fact] public void Jbands_IsNewFalse_RestoresState() { var j = new Jbands(14); var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 7); TValue remembered = default; for (int i = 0; i < 50; i++) { remembered = new TValue(DateTime.UtcNow, gbm.Next().Close); j.Update(remembered, isNew: true); } double mid = j.Last.Value; double up = j.Upper.Value; double lo = j.Lower.Value; // Multiple corrections for (int i = 0; i < 5; i++) { var corrected = new TValue(DateTime.UtcNow, gbm.Next().Close); j.Update(corrected, isNew: false); } // Restore with original value j.Update(remembered, isNew: false); Assert.Equal(mid, j.Last.Value, 1e-10); Assert.Equal(up, j.Upper.Value, 1e-10); Assert.Equal(lo, j.Lower.Value, 1e-10); } [Fact] public void Jbands_NaN_UsesLastValid() { var j = new Jbands(14); j.Update(new TValue(DateTime.UtcNow, 100.0)); j.Update(new TValue(DateTime.UtcNow, 105.0)); var result = j.Update(new TValue(DateTime.UtcNow, double.NaN)); Assert.True(double.IsFinite(result.Value)); Assert.True(double.IsFinite(j.Upper.Value)); Assert.True(double.IsFinite(j.Lower.Value)); var result2 = j.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity)); Assert.True(double.IsFinite(result2.Value)); } [Fact] public void Jbands_Reset_Clears() { var j = new Jbands(14); for (int i = 0; i < 50; i++) { j.Update(new TValue(DateTime.UtcNow, 100 + i)); } j.Reset(); Assert.Equal(0, j.Last.Value); Assert.Equal(0, j.Upper.Value); Assert.Equal(0, j.Lower.Value); Assert.False(j.IsHot); j.Update(new TValue(DateTime.UtcNow, 50.0)); Assert.Equal(50.0, j.Last.Value); } [Fact] public void Jbands_BatchVsStreaming_Match() { var jStream = new Jbands(14, 0, 0.45); var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 42); var series = new TSeries(); for (int i = 0; i < 200; i++) { var bar = gbm.Next(isNew: true); series.Add(bar.Time, bar.Close); jStream.Update(new TValue(bar.Time, bar.Close), isNew: true); } double expectedMid = jStream.Last.Value; double expectedUp = jStream.Upper.Value; double expectedLo = jStream.Lower.Value; var (midBatch, upBatch, loBatch) = Jbands.Batch(series, 14, 0, 0.45); Assert.Equal(expectedMid, midBatch.Last.Value, 1e-10); Assert.Equal(expectedUp, upBatch.Last.Value, 1e-10); Assert.Equal(expectedLo, loBatch.Last.Value, 1e-10); } [Fact] public void Jbands_SpanCalculate_ValidatesArgs() { double[] source = [100, 105, 110]; double[] middle = new double[3]; double[] upper = new double[3]; double[] lower = new double[3]; double[] shortOut = new double[2]; Assert.Throws(() => Jbands.Calculate(source.AsSpan(), shortOut.AsSpan(), upper.AsSpan(), lower.AsSpan(), 14)); Assert.Throws(() => Jbands.Calculate(source.AsSpan(), middle.AsSpan(), shortOut.AsSpan(), lower.AsSpan(), 14)); Assert.Throws(() => Jbands.Calculate(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), shortOut.AsSpan(), 14)); } [Fact] public void Jbands_SpanCalculate_MatchesStreaming() { var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 123); double[] source = new double[100]; for (int i = 0; i < source.Length; i++) source[i] = gbm.Next().Close; double[] middle = new double[100]; double[] upper = new double[100]; double[] lower = new double[100]; Jbands.Calculate(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 14); var jStream = new Jbands(14); for (int i = 0; i < source.Length; i++) { jStream.Update(new TValue(DateTime.UtcNow, source[i]), isNew: true); } Assert.Equal(jStream.Last.Value, middle[^1], 1e-10); Assert.Equal(jStream.Upper.Value, upper[^1], 1e-10); Assert.Equal(jStream.Lower.Value, lower[^1], 1e-10); } [Fact] public void Jbands_Event_Publishes() { var j = new Jbands(14); bool fired = false; j.Pub += (object? sender, in TValueEventArgs args) => fired = true; j.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true); Assert.True(fired); } [Fact] public void Jbands_Chaining_Works() { var src = new TSeries(); var j = new Jbands(14); var downstream = new Sma(j, 5); for (int i = 0; i < 100; i++) { var val = new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i * 0.5); src.Add(val.Time, val.Value); j.Update(val, isNew: true); } Assert.True(downstream.IsHot); Assert.True(double.IsFinite(downstream.Last.Value)); } [Fact] public void Jbands_MiddleBand_MatchesJma() { // Verify that middle band matches standalone JMA var jbands = new Jbands(14, 0, 0.45); var jma = new Jma(14, 0, 0.45); var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 999); for (int i = 0; i < 200; i++) { double price = gbm.Next().Close; var tv = new TValue(DateTime.UtcNow, price); jbands.Update(tv, isNew: true); jma.Update(tv, isNew: true); } Assert.Equal(jma.Last.Value, jbands.Last.Value, 1e-10); } [Fact] public void Jbands_Phase_AffectsBehavior() { var jNeutral = new Jbands(14, 0); var jPositive = new Jbands(14, 50); var jNegative = new Jbands(14, -50); var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.15, seed: 42); for (int i = 0; i < 100; i++) { double price = gbm.Next().Close; var tv = new TValue(DateTime.UtcNow, price); jNeutral.Update(tv, isNew: true); jPositive.Update(tv, isNew: true); jNegative.Update(tv, isNew: true); } // Different phase settings should produce different JMA values Assert.NotEqual(jNeutral.Last.Value, jPositive.Last.Value, 1e-6); Assert.NotEqual(jNeutral.Last.Value, jNegative.Last.Value, 1e-6); } [Fact] public void Jbands_UpperAlwaysAboveLower() { var j = new Jbands(14); var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.2, seed: 77); for (int i = 0; i < 500; i++) { j.Update(new TValue(DateTime.UtcNow, gbm.Next().Close), isNew: true); Assert.True(j.Upper.Value >= j.Lower.Value); } } }