namespace QuanTAlib.Tests; public class ApzTests { private readonly GBM _gbm; private readonly TBarSeries _bars; public ApzTests() { _gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42); _bars = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); } #region Constructor Tests [Fact] public void Constructor_ValidatesInput() { Assert.Throws(() => new Apz(0)); Assert.Throws(() => new Apz(-1)); Assert.Throws(() => new Apz(10, 0)); Assert.Throws(() => new Apz(10, -1)); } [Fact] public void Constructor_ValidBoundaryValues() { var apz1 = new Apz(1); Assert.NotNull(apz1); Assert.Equal(1, apz1.WarmupPeriod); var apz2 = new Apz(100, 0.5); Assert.NotNull(apz2); Assert.Equal(100, apz2.WarmupPeriod); } [Fact] public void Constructor_SetsName() { var apz = new Apz(20, 2.5); Assert.Contains("Apz", apz.Name, StringComparison.Ordinal); Assert.Contains("20", apz.Name, StringComparison.Ordinal); Assert.Contains("2.50", apz.Name, StringComparison.Ordinal); } #endregion #region Basic Functionality Tests [Fact] public void Calc_ReturnsValue() { var apz = new Apz(10); var bar = _bars[0]; var result = apz.Update(bar); Assert.True(double.IsFinite(result.Value)); Assert.Equal(result.Value, apz.Last.Value); Assert.True(double.IsFinite(apz.Upper.Value)); Assert.True(double.IsFinite(apz.Lower.Value)); } [Fact] public void FirstValue_ReturnsExpected() { var apz = new Apz(10); var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000); var result = apz.Update(bar); // First value should be close to the input price (with warmup compensation) Assert.True(double.IsFinite(result.Value)); // Upper should be greater than middle Assert.True(apz.Upper.Value > apz.Last.Value); // Lower should be less than middle Assert.True(apz.Lower.Value < apz.Last.Value); } [Fact] public void Properties_Accessible() { var apz = new Apz(10); Assert.Equal(0, apz.Last.Value); Assert.False(apz.IsHot); Assert.Contains("Apz", apz.Name, StringComparison.Ordinal); Assert.Equal(10, apz.WarmupPeriod); apz.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000)); Assert.NotEqual(0, apz.Last.Value); } [Fact] public void BandRelationships_Maintained() { var apz = new Apz(10, 2.0); for (int i = 0; i < 20; i++) { apz.Update(_bars[i]); if (double.IsFinite(apz.Last.Value)) { // Upper band should always be >= middle Assert.True(apz.Upper.Value >= apz.Last.Value, $"Upper {apz.Upper.Value} should be >= Middle {apz.Last.Value} at bar {i}"); // Lower band should always be <= middle Assert.True(apz.Lower.Value <= apz.Last.Value, $"Lower {apz.Lower.Value} should be <= Middle {apz.Last.Value} at bar {i}"); } } } #endregion #region State Management & Bar Correction Tests [Fact] public void Calc_IsNew_AcceptsParameter() { var apz = new Apz(10); apz.Update(_bars[0], isNew: true); double value1 = apz.Last.Value; apz.Update(_bars[1], isNew: true); double value2 = apz.Last.Value; Assert.NotEqual(value1, value2); } [Fact] public void Calc_IsNew_False_UpdatesValue() { var apz = new Apz(10); apz.Update(_bars[0]); apz.Update(_bars[1], isNew: true); double beforeUpdate = apz.Last.Value; // Update same bar with different value var modifiedBar = new TBar(_bars[1].Time, _bars[1].Open, _bars[1].High + 10, _bars[1].Low, _bars[1].Close + 5, _bars[1].Volume); apz.Update(modifiedBar, isNew: false); double afterUpdate = apz.Last.Value; Assert.NotEqual(beforeUpdate, afterUpdate); } [Fact] public void IterativeCorrections_RestoreToOriginalState() { var apz = new Apz(5); // Feed 10 new bars TBar tenthBar = default; for (int i = 0; i < 10; i++) { tenthBar = _bars[i]; apz.Update(tenthBar, isNew: true); } // Remember state after 10 bars double stateAfterTen = apz.Last.Value; double upperAfterTen = apz.Upper.Value; double lowerAfterTen = apz.Lower.Value; // Generate 9 corrections with isNew=false (different values) for (int i = 0; i < 9; i++) { var correctionBar = new TBar(tenthBar.Time, tenthBar.Open + i, tenthBar.High + i * 2, tenthBar.Low - i, tenthBar.Close + i, tenthBar.Volume); apz.Update(correctionBar, isNew: false); } // Feed the remembered 10th bar again with isNew=false apz.Update(tenthBar, isNew: false); // State should match the original state after 10 bars Assert.Equal(stateAfterTen, apz.Last.Value, precision: 10); Assert.Equal(upperAfterTen, apz.Upper.Value, precision: 10); Assert.Equal(lowerAfterTen, apz.Lower.Value, precision: 10); } [Fact] public void Reset_ClearsState() { var apz = new Apz(10); apz.Update(_bars[0]); apz.Update(_bars[1]); _ = apz.Last.Value; // Verify value exists before reset apz.Reset(); Assert.Equal(0, apz.Last.Value); Assert.False(apz.IsHot); // After reset, should accept new values apz.Update(_bars[0]); Assert.NotEqual(0, apz.Last.Value); } #endregion #region Warmup & Convergence Tests [Fact] public void IsHot_BecomesTrueEventually() { var apz = new Apz(5); Assert.False(apz.IsHot); // Feed enough data for (int i = 0; i < 100; i++) { apz.Update(_bars[i]); } Assert.True(apz.IsHot); } [Fact] public void IsHot_IsPeriodDependent() { int[] periods = [5, 10, 20, 50]; int[] stepsToHot = new int[periods.Length]; for (int p = 0; p < periods.Length; p++) { var apz = new Apz(periods[p]); int steps = 0; while (!apz.IsHot && steps < 500) { apz.Update(_bars[steps]); steps++; } stepsToHot[p] = steps; } // Larger periods should take more steps to become hot // (due to beta^2 decay being slower) Assert.True(stepsToHot[0] <= stepsToHot[1]); Assert.True(stepsToHot[1] <= stepsToHot[2]); Assert.True(stepsToHot[2] <= stepsToHot[3]); } [Fact] public void WarmupPeriod_IsSetCorrectly() { var apz = new Apz(25); Assert.Equal(25, apz.WarmupPeriod); } #endregion #region Robustness (NaN/Infinity) Tests [Fact] public void NaN_Input_UsesLastValidValue() { var apz = new Apz(5); apz.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000)); apz.Update(new TBar(DateTime.UtcNow, 110, 115, 105, 110, 1000)); var resultAfterNaN = apz.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 1000)); Assert.True(double.IsFinite(resultAfterNaN.Value)); Assert.True(double.IsFinite(apz.Upper.Value)); Assert.True(double.IsFinite(apz.Lower.Value)); } [Fact] public void Infinity_Input_UsesLastValidValue() { var apz = new Apz(5); apz.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000)); apz.Update(new TBar(DateTime.UtcNow, 110, 115, 105, 110, 1000)); var resultAfterPosInf = apz.Update(new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity, 1000)); Assert.True(double.IsFinite(resultAfterPosInf.Value)); var resultAfterNegInf = apz.Update(new TBar(DateTime.UtcNow, double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity, 1000)); Assert.True(double.IsFinite(resultAfterNegInf.Value)); } [Fact] public void MultipleNaN_ContinuesWithLastValid() { var apz = new Apz(5); apz.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000)); apz.Update(new TBar(DateTime.UtcNow, 110, 115, 105, 110, 1000)); apz.Update(new TBar(DateTime.UtcNow, 120, 125, 115, 120, 1000)); var r1 = apz.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 1000)); var r2 = apz.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 1000)); var r3 = apz.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 1000)); Assert.True(double.IsFinite(r1.Value)); Assert.True(double.IsFinite(r2.Value)); Assert.True(double.IsFinite(r3.Value)); } [Fact] public void BatchCalc_HandlesNaN() { double[] high = [105, 115, double.NaN, 125, 135]; double[] low = [95, 105, double.NaN, 115, 125]; double[] close = [100, 110, double.NaN, 120, 130]; double[] middle = new double[5]; double[] upper = new double[5]; double[] lower = new double[5]; Apz.Batch(high, low, close, new Apz.BatchOutputs(middle, upper, lower), 3); foreach (var val in middle) { Assert.True(double.IsFinite(val), $"Expected finite value but got {val}"); } } [Fact] public void FirstBar_AllNaN_ReturnsNaN() { var apz = new Apz(5); var result = apz.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 1000)); Assert.True(double.IsNaN(result.Value)); } #endregion #region Consistency Tests [Fact] public void BatchCalc_MatchesIterativeCalc() { var apzIterative = new Apz(10); var apzBatch = new Apz(10); // Calculate iteratively var iterativeMiddle = new List(); var iterativeUpper = new List(); var iterativeLower = new List(); foreach (var bar in _bars) { apzIterative.Update(bar); iterativeMiddle.Add(apzIterative.Last.Value); iterativeUpper.Add(apzIterative.Upper.Value); iterativeLower.Add(apzIterative.Lower.Value); } // Calculate batch var (batchMiddle, batchUpper, batchLower) = apzBatch.Update(_bars); // Compare Assert.Equal(iterativeMiddle.Count, batchMiddle.Count); for (int i = 0; i < iterativeMiddle.Count; i++) { Assert.Equal(iterativeMiddle[i], batchMiddle[i].Value, precision: 9); Assert.Equal(iterativeUpper[i], batchUpper[i].Value, precision: 9); Assert.Equal(iterativeLower[i], batchLower[i].Value, precision: 9); } } [Fact] public void AllModes_ProduceSameResult() { const int period = 10; double multiplier = 2.0; // 1. Batch Mode (static method) var (batchMiddle, batchUpper, batchLower) = Apz.Batch(_bars, period, multiplier); double expectedMiddle = batchMiddle.Last.Value; double expectedUpper = batchUpper.Last.Value; double expectedLower = batchLower.Last.Value; // 2. Span Mode (static method with spans) double[] highArr = _bars.High.Values.ToArray(); double[] lowArr = _bars.Low.Values.ToArray(); double[] closeArr = _bars.Close.Values.ToArray(); double[] middleSpan = new double[_bars.Count]; double[] upperSpan = new double[_bars.Count]; double[] lowerSpan = new double[_bars.Count]; Apz.Batch(highArr, lowArr, closeArr, new Apz.BatchOutputs(middleSpan, upperSpan, lowerSpan), period, multiplier); double spanMiddle = middleSpan[^1]; double spanUpper = upperSpan[^1]; double spanLower = lowerSpan[^1]; // 3. Streaming Mode (instance, one bar at a time) var streamingApz = new Apz(period, multiplier); foreach (var bar in _bars) { streamingApz.Update(bar); } double streamingMiddle = streamingApz.Last.Value; double streamingUpper = streamingApz.Upper.Value; double streamingLower = streamingApz.Lower.Value; // 4. Eventing Mode (chained via TBarSeries) var pubSource = new TBarSeries(); var eventingApz = new Apz(pubSource, period, multiplier); foreach (var bar in _bars) { pubSource.Add(bar); } double eventingMiddle = eventingApz.Last.Value; double eventingUpper = eventingApz.Upper.Value; double eventingLower = eventingApz.Lower.Value; // Assert all modes produce identical results Assert.Equal(expectedMiddle, spanMiddle, precision: 9); Assert.Equal(expectedMiddle, streamingMiddle, precision: 9); Assert.Equal(expectedMiddle, eventingMiddle, precision: 9); Assert.Equal(expectedUpper, spanUpper, precision: 9); Assert.Equal(expectedUpper, streamingUpper, precision: 9); Assert.Equal(expectedUpper, eventingUpper, precision: 9); Assert.Equal(expectedLower, spanLower, precision: 9); Assert.Equal(expectedLower, streamingLower, precision: 9); Assert.Equal(expectedLower, eventingLower, precision: 9); } [Fact] public void StaticBatch_Works() { var (middle, upper, lower) = Apz.Batch(_bars, 10); Assert.Equal(_bars.Count, middle.Count); Assert.Equal(_bars.Count, upper.Count); Assert.Equal(_bars.Count, lower.Count); Assert.True(double.IsFinite(middle.Last.Value)); } #endregion #region Span API Tests [Fact] public void SpanBatch_ValidatesInput() { double[] high = [105, 115, 125]; double[] low = [95, 105, 115]; double[] close = [100, 110, 120]; double[] middle = new double[3]; double[] upper = new double[3]; double[] lower = new double[3]; double[] wrongSizeOutput = new double[2]; double[] wrongSizeInput = [100, 110]; // Period must be > 0 Assert.Throws(() => Apz.Batch(high, low, close, new Apz.BatchOutputs(middle, upper, lower), 0)); Assert.Throws(() => Apz.Batch(high, low, close, new Apz.BatchOutputs(middle, upper, lower), -1)); // Multiplier must be > 0 Assert.Throws(() => Apz.Batch(high, low, close, new Apz.BatchOutputs(middle, upper, lower), 3, 0)); Assert.Throws(() => Apz.Batch(high, low, close, new Apz.BatchOutputs(middle, upper, lower), 3, -1)); // Output must be same length as input Assert.Throws(() => Apz.Batch(high, low, close, new Apz.BatchOutputs(wrongSizeOutput, upper, lower), 3)); // Input arrays must have same length Assert.Throws(() => Apz.Batch(wrongSizeInput, low, close, new Apz.BatchOutputs(middle, upper, lower), 3)); } [Fact] public void SpanBatch_MatchesTSeriesBatch() { int period = 10; double multiplier = 2.0; var (tseriesMiddle, tseriesUpper, tseriesLower) = Apz.Batch(_bars, period, multiplier); double[] highArr = _bars.High.Values.ToArray(); double[] lowArr = _bars.Low.Values.ToArray(); double[] closeArr = _bars.Close.Values.ToArray(); double[] spanMiddle = new double[_bars.Count]; double[] spanUpper = new double[_bars.Count]; double[] spanLower = new double[_bars.Count]; Apz.Batch(highArr, lowArr, closeArr, new Apz.BatchOutputs(spanMiddle, spanUpper, spanLower), period, multiplier); for (int i = 0; i < _bars.Count; i++) { Assert.Equal(tseriesMiddle[i].Value, spanMiddle[i], precision: 10); Assert.Equal(tseriesUpper[i].Value, spanUpper[i], precision: 10); Assert.Equal(tseriesLower[i].Value, spanLower[i], precision: 10); } } [Fact] public void SpanBatch_ZeroAllocation() { double[] high = new double[10000]; double[] low = new double[10000]; double[] close = new double[10000]; double[] middle = new double[10000]; double[] upper = new double[10000]; double[] lower = new double[10000]; var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42); for (int i = 0; i < high.Length; i++) { var bar = gbm.Next(); high[i] = bar.High; low[i] = bar.Low; close[i] = bar.Close; } // Warm up Apz.Batch(high, low, close, new Apz.BatchOutputs(middle, upper, lower), 100); // Verify method completes without OOM or stack overflow Assert.True(double.IsFinite(middle[^1])); Assert.True(double.IsFinite(upper[^1])); Assert.True(double.IsFinite(lower[^1])); } [Fact] public void SpanBatch_Period1_Works() { double[] high = [105, 115, 125]; double[] low = [95, 105, 115]; double[] close = [100, 110, 120]; double[] middle = new double[3]; double[] upper = new double[3]; double[] lower = new double[3]; Apz.Batch(high, low, close, new Apz.BatchOutputs(middle, upper, lower), 1); foreach (var val in middle) { Assert.True(double.IsFinite(val)); } } [Fact] public void SpanBatch_EmptyInput_DoesNotThrow() { double[] high = []; double[] low = []; double[] close = []; double[] middle = []; double[] upper = []; double[] lower = []; // Should not throw var ex = Record.Exception(() => Apz.Batch(high, low, close, new Apz.BatchOutputs(middle, upper, lower), 10)); Assert.Null(ex); } #endregion #region Prime Tests [Fact] public void Prime_SetsStateCorrectly() { var apz = new Apz(10); apz.Prime(_bars); Assert.True(apz.IsHot); Assert.True(double.IsFinite(apz.Last.Value)); Assert.True(double.IsFinite(apz.Upper.Value)); Assert.True(double.IsFinite(apz.Lower.Value)); // Verify it continues correctly var nextBar = _gbm.Next(); apz.Update(nextBar); Assert.True(double.IsFinite(apz.Last.Value)); } [Fact] public void Prime_WithEmptySeries_DoesNotThrow() { var apz = new Apz(10); var emptySeries = new TBarSeries(); // Should not throw apz.Prime(emptySeries); Assert.False(apz.IsHot); } #endregion #region Calculate Method Tests [Fact] public void Calculate_ReturnsCorrectResultsAndHotIndicator() { var ((middle, upper, lower), indicator) = Apz.Calculate(_bars, 10); // Check results Assert.Equal(_bars.Count, middle.Count); Assert.True(double.IsFinite(middle.Last.Value)); // Check indicator state Assert.True(indicator.IsHot); Assert.Equal(middle.Last.Value, indicator.Last.Value, precision: 10); Assert.Equal(upper.Last.Value, indicator.Upper.Value, precision: 10); Assert.Equal(lower.Last.Value, indicator.Lower.Value, precision: 10); // Verify indicator continues correctly var nextBar = _gbm.Next(); indicator.Update(nextBar); Assert.True(double.IsFinite(indicator.Last.Value)); } #endregion #region Chainability Tests [Fact] public void Chainability_Works() { var source = new TBarSeries(); var apz = new Apz(source, 10); source.Add(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000)); Assert.True(double.IsFinite(apz.Last.Value)); } [Fact] public void Pub_EventFires() { var apz = new Apz(10); bool eventFired = false; apz.Pub += (object? sender, in TValueEventArgs args) => eventFired = true; apz.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000)); Assert.True(eventFired); } #endregion #region Algorithm-Specific Tests [Fact] public void DoubleSmoothing_ProducesSmoothOutput() { // Use a larger period for more smoothing effect var apz = new Apz(50); var results = new List(); // Feed volatile data - use more bars to allow convergence for (int i = 0; i < 200; i++) { apz.Update(_bars[i]); results.Add(apz.Last.Value); } // Calculate average change in output (skip warmup period) int startIdx = 60; // Skip warmup double sumChanges = 0; for (int i = startIdx + 1; i < results.Count; i++) { sumChanges += Math.Abs(results[i] - results[i - 1]); } double avgChange = sumChanges / (results.Count - startIdx - 1); // Calculate average change in input for same period double sumInputChanges = 0; for (int i = startIdx + 1; i < 200; i++) { sumInputChanges += Math.Abs(_bars[i].Close - _bars[i - 1].Close); } double avgInputChange = sumInputChanges / (200 - startIdx - 1); // Double-smoothed output should be smoother than input Assert.True(avgChange < avgInputChange, $"Double-smoothed output ({avgChange:F4}) should be smoother than input ({avgInputChange:F4})"); } [Fact] public void SqrtPeriod_AffectsSmoothing() { // With sqrt(period), larger periods have proportionally less smoothing // than standard EMA var apz4 = new Apz(4); // sqrt(4) = 2, alpha = 2/(2+1) = 0.667 var apz100 = new Apz(100); // sqrt(100) = 10, alpha = 2/(10+1) = 0.182 // Feed same data for (int i = 0; i < 50; i++) { apz4.Update(_bars[i]); apz100.Update(_bars[i]); } // Both should have finite values Assert.True(double.IsFinite(apz4.Last.Value)); Assert.True(double.IsFinite(apz100.Last.Value)); // Period 100 should be smoother (closer to mean) // and take longer to become hot Assert.True(apz4.IsHot); // Period 100 may or may not be hot after 50 bars } [Fact] public void MultiplierAffectsBandWidth() { var apz1 = new Apz(10, 1.0); var apz2 = new Apz(10, 2.0); var apz3 = new Apz(10, 3.0); for (int i = 0; i < 50; i++) { apz1.Update(_bars[i]); apz2.Update(_bars[i]); apz3.Update(_bars[i]); } // All should have same middle Assert.Equal(apz1.Last.Value, apz2.Last.Value, precision: 10); Assert.Equal(apz2.Last.Value, apz3.Last.Value, precision: 10); // Band widths should scale with multiplier double width1 = apz1.Upper.Value - apz1.Lower.Value; double width2 = apz2.Upper.Value - apz2.Lower.Value; double width3 = apz3.Upper.Value - apz3.Lower.Value; Assert.Equal(width2, width1 * 2, precision: 10); Assert.Equal(width3, width1 * 3, precision: 10); } [Fact] public void FlatLine_ReturnsSameMiddle() { var apz = new Apz(10); for (int i = 0; i < 50; i++) { apz.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000)); } // Middle should converge to 100 Assert.Equal(100, apz.Last.Value, precision: 1); } [Fact] public void ZeroRange_ProducesZeroBandWidth() { var apz = new Apz(10); // Feed bars with no range (high = low = close) for (int i = 0; i < 50; i++) { apz.Update(new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1000)); } // Bands should converge to middle (zero width) Assert.Equal(apz.Last.Value, apz.Upper.Value, precision: 1); Assert.Equal(apz.Last.Value, apz.Lower.Value, precision: 1); } #endregion }