Files
QuanTAlib/lib/channels/bbands/Bbands.Tests.cs
T
Miha Kralj 653aafacd8 feat: Add Prime method to various indicators for initializing state with historical data
- Implemented Prime method in Vel, Ao, Apo, Frama, Adl, Adosc, Aobv, Cmf, Efi, Eom, Iii, Kvo, Mfi, Nvi, Obv, Pvd, Pvi, Pvo, Pvr, Pvt, Tvi, Twap, Va, Vf, Vo, Vroc, Vwad, Vwap, and Vwma classes.
- The Prime method resets the indicator state and processes the provided historical bar data to initialize the indicator.
- Added warmup period property to Adl and Wad classes to define the minimum number of data points required for validity.
- Updated benchmark tests to use Batch methods for performance evaluation.
2026-02-11 20:38:38 -08:00

769 lines
25 KiB
C#

using Xunit;
namespace QuanTAlib.Tests;
public class BbandsTests
{
[Fact]
public void Bbands_Constructor_ValidParameters()
{
// Arrange & Act
Bbands bbands = new(period: 20, multiplier: 2.0);
// Assert
Assert.NotNull(bbands);
Assert.Equal("Bbands(20,2.0)", bbands.Name);
Assert.Equal(20, bbands.WarmupPeriod);
Assert.False(bbands.IsHot);
}
[Fact]
public void Bbands_Constructor_InvalidPeriod_ThrowsArgumentOutOfRangeException()
{
// Arrange, Act & Assert
ArgumentOutOfRangeException exception = Assert.Throws<ArgumentOutOfRangeException>(
() => new Bbands(period: 1));
Assert.Equal("period", exception.ParamName);
}
[Fact]
public void Bbands_Constructor_InvalidMultiplier_ThrowsArgumentOutOfRangeException()
{
// Arrange, Act & Assert
ArgumentOutOfRangeException exception = Assert.Throws<ArgumentOutOfRangeException>(
() => new Bbands(period: 20, multiplier: 0.05));
Assert.Equal("multiplier", exception.ParamName);
}
[Fact]
public void Bbands_Update_ReturnsCorrectMiddleBand()
{
// Arrange
Bbands bbands = new(period: 5, multiplier: 2.0);
double[] prices = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
DateTime time = DateTime.UtcNow;
// Act
TValue result = default;
for (int i = 0; i < prices.Length; i++)
{
result = bbands.Update(new TValue(time.AddSeconds(i), prices[i]));
}
// Assert - Middle should be SMA(5) = (11+12+13+14+15)/5 = 13.0
Assert.Equal(13.0, result.Value, precision: 10);
Assert.True(bbands.IsHot);
}
[Fact]
public void Bbands_BandCalculations_CorrectValues()
{
// Arrange
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
double[] prices = [10.0, 12.0, 14.0];
// Act
for (int i = 0; i < prices.Length; i++)
{
bbands.Update(new TValue(time.AddSeconds(i), prices[i]));
}
// Assert
// SMA = (10+12+14)/3 = 12.0
Assert.Equal(12.0, bbands.Middle.Value, precision: 10);
// StdDev = sqrt(((10-12)^2 + (12-12)^2 + (14-12)^2) / 3) = sqrt(8/3) ≈ 1.6329931618554521
double expectedStdDev = Math.Sqrt(8.0 / 3.0);
double expectedUpper = 12.0 + (2.0 * expectedStdDev);
double expectedLower = 12.0 - (2.0 * expectedStdDev);
Assert.Equal(expectedUpper, bbands.Upper.Value, precision: 10);
Assert.Equal(expectedLower, bbands.Lower.Value, precision: 10);
Assert.Equal(expectedUpper - expectedLower, bbands.Width.Value, precision: 10);
}
[Fact]
public void Bbands_PercentB_CorrectCalculation()
{
// Arrange
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
double[] prices = [10.0, 12.0, 14.0, 13.0];
// Act
for (int i = 0; i < prices.Length; i++)
{
bbands.Update(new TValue(time.AddSeconds(i), prices[i]));
}
// Assert
// For the last value (13.0) with window [12.0, 14.0, 13.0]
// SMA = 13.0, StdDev = sqrt(2/3), Lower ≈ 11.367, Upper ≈ 14.633
// %B = (13.0 - Lower) / (Upper - Lower)
double percentB = bbands.PercentB.Value;
Assert.True(percentB >= 0.0 && percentB <= 1.0);
}
[Fact]
public void Bbands_IsNew_False_RollsBackCorrectly()
{
// Arrange
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
// Act
bbands.Update(new TValue(time, 10.0), isNew: true);
bbands.Update(new TValue(time.AddSeconds(1), 12.0), isNew: true);
bbands.Update(new TValue(time.AddSeconds(2), 14.0), isNew: true);
double middleBefore = bbands.Middle.Value;
bbands.Update(new TValue(time.AddSeconds(2), 15.0), isNew: false);
double middleAfter = bbands.Middle.Value;
// Assert - Value should change due to replacement
Assert.NotEqual(middleBefore, middleAfter);
}
[Fact]
public void Bbands_NaN_HandledGracefully()
{
// Arrange
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
// Act
bbands.Update(new TValue(time, 10.0), isNew: true);
bbands.Update(new TValue(time.AddSeconds(1), 12.0), isNew: true);
bbands.Update(new TValue(time.AddSeconds(2), double.NaN), isNew: true);
double afterNaN = bbands.Middle.Value;
// Assert - Should substitute last valid value
Assert.True(double.IsFinite(afterNaN));
}
[Fact]
public void Bbands_Reset_ClearsState()
{
// Arrange
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
bbands.Update(new TValue(time, 10.0));
bbands.Update(new TValue(time.AddSeconds(1), 12.0));
bbands.Update(new TValue(time.AddSeconds(2), 14.0));
// Act
bbands.Reset();
// Assert
Assert.False(bbands.IsHot);
}
[Fact]
public void Bbands_WarmupPeriod_IsHotTransition()
{
// Arrange
Bbands bbands = new(period: 5, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
// Act & Assert
for (int i = 0; i < 4; i++)
{
bbands.Update(new TValue(time.AddSeconds(i), 10.0 + i));
Assert.False(bbands.IsHot);
}
bbands.Update(new TValue(time.AddSeconds(4), 14.0));
Assert.True(bbands.IsHot);
}
[Fact]
public void Bbands_UpdateTSeries_ReturnsValidSeries()
{
// Arrange
int period = 5;
Bbands bbands = new(period, multiplier: 2.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
// Act
TSeries result = bbands.Update(source);
// Assert
Assert.Equal(source.Count, result.Count);
Assert.True(bbands.IsHot);
// Verify last values match streaming
Bbands streamingBbands = new(period, multiplier: 2.0);
for (int i = Math.Max(0, source.Count - period); i < source.Count; i++)
{
streamingBbands.Update(source[i], isNew: true);
}
Assert.Equal(streamingBbands.Middle.Value, result[^1].Value, precision: 10);
}
[Fact]
public void Bbands_StaticCalculate_ReturnsValidSeries()
{
// Arrange
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
// Act
TSeries result = Bbands.Batch(source, period: 5, multiplier: 2.0);
// Assert
Assert.Equal(source.Count, result.Count);
// Note: Static Calculate doesn't set IsHot on any instance, just returns the series
}
[Fact]
public void Bbands_SpanCalculate_MatchesTSeries()
{
// Arrange
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
int period = 20;
double multiplier = 2.0;
// Act - Copy to arrays before Span usage to avoid ref local issues
double[] sourceArray = source.Values.ToArray();
double[] middleArray = new double[source.Count];
double[] upperArray = new double[source.Count];
double[] lowerArray = new double[source.Count];
Bbands.Batch(sourceArray.AsSpan(), middleArray, upperArray, lowerArray, period, multiplier);
TSeries seriesResult = Bbands.Batch(source, period, multiplier);
// Assert - Compare last 10 values
for (int i = source.Count - 10; i < source.Count; i++)
{
Assert.Equal(seriesResult[i].Value, middleArray[i], precision: 10);
}
}
[Fact]
public void Bbands_SpanCalculate_InvalidLength_ThrowsArgumentException()
{
// Arrange
double[] sourceArr = new double[10];
double[] middleArr = new double[10];
double[] upperArr = new double[10];
double[] lowerArr = new double[9]; // Wrong length
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(
() => Bbands.Batch(sourceArr.AsSpan(), middleArr.AsSpan(), upperArr.AsSpan(), lowerArr.AsSpan()));
Assert.Equal("source", exception.ParamName);
}
[Fact]
public void Bbands_Chainability_WorksCorrectly()
{
// Arrange
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var pubSource = new TSeries();
Bbands bbands1 = new(pubSource, period: 5, multiplier: 2.0);
Bbands bbands2 = new(bbands1, period: 3, multiplier: 1.5);
// Act
bool eventFired = false;
bbands2.Pub += (object? sender, in TValueEventArgs args) => eventFired = true;
foreach (var item in source)
{
pubSource.Add(item);
}
// Assert
Assert.True(eventFired);
Assert.True(bbands2.IsHot);
}
[Fact]
public void Bbands_Consistency_StreamingVsBatchVsSpan()
{
// Arrange
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
int period = 20;
double multiplier = 2.0;
// Streaming
Bbands streamingBbands = new(period, multiplier);
foreach (var item in source)
{
streamingBbands.Update(item);
}
// Batch
TSeries batchResult = Bbands.Batch(source, period, multiplier);
// Span - Copy arrays before using to avoid ref local lambda issue
double[] sourceArray = source.Values.ToArray();
double[] middleArray = new double[source.Count];
double[] upperArray = new double[source.Count];
double[] lowerArray = new double[source.Count];
Bbands.Batch(sourceArray.AsSpan(), middleArray, upperArray, lowerArray, period, multiplier);
// Assert - Compare last 50 values (streaming only has last value)
Assert.Equal(batchResult[^1].Value, streamingBbands.Middle.Value, precision: 8);
Assert.Equal(middleArray[^1], streamingBbands.Middle.Value, precision: 8);
}
#region Default Constructor
[Fact]
public void Bbands_Constructor_DefaultParameters()
{
// Default: period=20, multiplier=2.0
Bbands bbands = new();
Assert.Equal("Bbands(20,2.0)", bbands.Name);
Assert.Equal(20, bbands.WarmupPeriod);
Assert.False(bbands.IsHot);
}
#endregion
#region Prime Tests
[Fact]
public void Bbands_Prime_SetsIndicatorToHot()
{
Bbands bbands = new(period: 5, multiplier: 2.0);
double[] data = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109];
Assert.False(bbands.IsHot);
bbands.Prime(data);
Assert.True(bbands.IsHot);
Assert.True(double.IsFinite(bbands.Middle.Value));
Assert.True(double.IsFinite(bbands.Upper.Value));
Assert.True(double.IsFinite(bbands.Lower.Value));
}
[Fact]
public void Bbands_Prime_EmptySpan_DoesNotThrow()
{
Bbands bbands = new(period: 5, multiplier: 2.0);
var ex = Record.Exception(() => bbands.Prime(ReadOnlySpan<double>.Empty));
Assert.Null(ex);
Assert.False(bbands.IsHot);
}
[Fact]
public void Bbands_Prime_WithStep_UsesCorrectSpacing()
{
Bbands bbands = new(period: 3, multiplier: 2.0);
double[] data = [100, 102, 104, 106, 108];
var step = TimeSpan.FromHours(1);
bbands.Prime(data, step);
Assert.True(bbands.IsHot);
Assert.True(double.IsFinite(bbands.Last.Value));
}
[Fact]
public void Bbands_Prime_ThenUpdate_ContinuesCorrectly()
{
Bbands bbands = new(period: 5, multiplier: 2.0);
double[] primeData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109];
bbands.Prime(primeData);
Assert.True(bbands.IsHot);
double middleAfterPrime = bbands.Middle.Value;
// Continue with streaming
bbands.Update(new TValue(DateTime.UtcNow, 120.0), isNew: true);
Assert.NotEqual(middleAfterPrime, bbands.Middle.Value);
Assert.True(double.IsFinite(bbands.Middle.Value));
Assert.True(double.IsFinite(bbands.Upper.Value));
Assert.True(double.IsFinite(bbands.Lower.Value));
Assert.True(bbands.Upper.Value > bbands.Middle.Value);
Assert.True(bbands.Lower.Value < bbands.Middle.Value);
}
[Fact]
public void Bbands_Prime_MatchesStreamingResults()
{
double[] data = [100, 102, 98, 105, 103, 107, 101, 99, 106, 104];
// Via Prime
Bbands primedBbands = new(period: 5, multiplier: 2.0);
primedBbands.Prime(data);
// Via streaming Update
Bbands streamBbands = new(period: 5, multiplier: 2.0);
DateTime startTime = DateTime.UtcNow;
for (int i = 0; i < data.Length; i++)
{
streamBbands.Update(new TValue(startTime + i * TimeSpan.FromSeconds(1), data[i]), isNew: true);
}
Assert.Equal(streamBbands.Middle.Value, primedBbands.Middle.Value, precision: 10);
Assert.Equal(streamBbands.Upper.Value, primedBbands.Upper.Value, precision: 10);
Assert.Equal(streamBbands.Lower.Value, primedBbands.Lower.Value, precision: 10);
}
#endregion
#region Calculate Tests
[Fact]
public void Bbands_Calculate_ReturnsResultsAndHotIndicator()
{
var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var (results, indicator) = Bbands.Calculate(source, period: 5, multiplier: 2.0);
// Check results
Assert.Equal(50, results.Count);
// Check indicator is hot and has valid state
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.Middle.Value));
Assert.True(double.IsFinite(indicator.Upper.Value));
Assert.True(double.IsFinite(indicator.Lower.Value));
// Verify indicator can continue streaming
indicator.Update(new TValue(DateTime.UtcNow, 110.0), isNew: true);
Assert.True(double.IsFinite(indicator.Middle.Value));
}
[Fact]
public void Bbands_Calculate_DefaultParameters()
{
var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(30, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
TSeries source = bars.Close;
var (results, indicator) = Bbands.Calculate(source);
Assert.Equal(30, results.Count);
Assert.Equal(20, indicator.WarmupPeriod);
Assert.True(indicator.IsHot); // 30 > 20
}
#endregion
#region Update(TSeries) Edge Cases
[Fact]
public void Bbands_UpdateTSeries_NullSource_ThrowsArgumentNullException()
{
Bbands bbands = new(period: 5, multiplier: 2.0);
Assert.Throws<ArgumentNullException>(() => bbands.Update((TSeries)null!));
}
#endregion
#region Infinity Handling
[Fact]
public void Bbands_Infinity_HandledGracefully()
{
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
bbands.Update(new TValue(time, 10.0), isNew: true);
bbands.Update(new TValue(time.AddSeconds(1), 12.0), isNew: true);
// PositiveInfinity should use last valid value
bbands.Update(new TValue(time.AddSeconds(2), double.PositiveInfinity), isNew: true);
Assert.True(double.IsFinite(bbands.Middle.Value));
Assert.True(double.IsFinite(bbands.Upper.Value));
Assert.True(double.IsFinite(bbands.Lower.Value));
// NegativeInfinity should also be handled
bbands.Update(new TValue(time.AddSeconds(3), double.NegativeInfinity), isNew: true);
Assert.True(double.IsFinite(bbands.Middle.Value));
}
#endregion
#region PercentB Edge Cases
[Fact]
public void Bbands_PercentB_ZeroWidth_ReturnsZero()
{
// When all values are the same, stddev = 0, width = 0, percentB should be 0
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
bbands.Update(new TValue(time, 100.0), isNew: true);
bbands.Update(new TValue(time.AddSeconds(1), 100.0), isNew: true);
bbands.Update(new TValue(time.AddSeconds(2), 100.0), isNew: true);
Assert.Equal(0.0, bbands.Width.Value, precision: 10);
Assert.Equal(0.0, bbands.PercentB.Value, precision: 10);
}
[Fact]
public void Bbands_PercentB_AtMiddle_IsFiftyPercent()
{
// When price equals the middle band, %B should be ≈ 0.5
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
bbands.Update(new TValue(time, 10.0), isNew: true);
bbands.Update(new TValue(time.AddSeconds(1), 12.0), isNew: true);
bbands.Update(new TValue(time.AddSeconds(2), 14.0), isNew: true);
// SMA = 12.0, feeding 12.0 next — it becomes middle of [12, 14, 12] = SMA ≈ 12.67
// Need to check the actual calculation rather than assume
// The point: when price = middle, %B = (price - lower) / (upper - lower)
// which would be 0.5 since middle is equidistant from upper and lower
bbands.Update(new TValue(time.AddSeconds(3), bbands.Middle.Value), isNew: true);
// After this update, the SMA shifts, but %B should be ≈ 0.5
Assert.True(bbands.PercentB.Value > 0.3 && bbands.PercentB.Value < 0.7);
}
#endregion
#region Reset State Tests
[Fact]
public void Bbands_Reset_ClearsAllProperties()
{
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
bbands.Update(new TValue(time, 10.0));
bbands.Update(new TValue(time.AddSeconds(1), 12.0));
bbands.Update(new TValue(time.AddSeconds(2), 14.0));
Assert.True(bbands.IsHot);
Assert.NotEqual(0, bbands.Middle.Value);
Assert.NotEqual(0, bbands.Upper.Value);
bbands.Reset();
Assert.False(bbands.IsHot);
Assert.Equal(0, bbands.Middle.Value);
Assert.Equal(0, bbands.Upper.Value);
Assert.Equal(0, bbands.Lower.Value);
Assert.Equal(0, bbands.Width.Value);
Assert.Equal(0, bbands.PercentB.Value);
}
[Fact]
public void Bbands_Reset_ThenReuse_ProducesSameResults()
{
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
double[] prices = [10.0, 12.0, 14.0];
// First pass
for (int i = 0; i < prices.Length; i++)
{
bbands.Update(new TValue(time.AddSeconds(i), prices[i]));
}
double firstMiddle = bbands.Middle.Value;
double firstUpper = bbands.Upper.Value;
double firstLower = bbands.Lower.Value;
// Reset and second pass with same data
bbands.Reset();
for (int i = 0; i < prices.Length; i++)
{
bbands.Update(new TValue(time.AddSeconds(i), prices[i]));
}
Assert.Equal(firstMiddle, bbands.Middle.Value, precision: 10);
Assert.Equal(firstUpper, bbands.Upper.Value, precision: 10);
Assert.Equal(firstLower, bbands.Lower.Value, precision: 10);
}
#endregion
#region Span Batch Edge Cases
[Fact]
public void Bbands_SpanBatch_EmptyArrays_DoesNotThrow()
{
double[] source = [];
double[] middle = [];
double[] upper = [];
double[] lower = [];
var ex = Record.Exception(() => Bbands.Batch(
source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan()));
Assert.Null(ex);
}
[Fact]
public void Bbands_SpanBatch_InvalidPeriod_ThrowsArgumentOutOfRangeException()
{
double[] source = new double[10];
double[] middle = new double[10];
double[] upper = new double[10];
double[] lower = new double[10];
Assert.Throws<ArgumentOutOfRangeException>(() =>
Bbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(),
period: 1, multiplier: 2.0));
}
[Fact]
public void Bbands_SpanBatch_InvalidMultiplier_ThrowsArgumentOutOfRangeException()
{
double[] source = new double[10];
double[] middle = new double[10];
double[] upper = new double[10];
double[] lower = new double[10];
Assert.Throws<ArgumentOutOfRangeException>(() =>
Bbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(),
period: 5, multiplier: 0.05));
}
[Fact]
public void Bbands_SpanBatch_ShorterThanPeriod_SetsNaN()
{
// Source shorter than period — all upper/lower should be NaN
double[] source = [100, 101, 102];
double[] middle = new double[3];
double[] upper = new double[3];
double[] lower = new double[3];
Bbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(),
period: 5, multiplier: 2.0);
// First (period-1) values should be NaN for upper/lower
for (int i = 0; i < 3; i++)
{
Assert.True(double.IsNaN(upper[i]));
Assert.True(double.IsNaN(lower[i]));
}
}
[Fact]
public void Bbands_SpanBatch_NaN_InWindow_EmitsNaN()
{
double[] source = [100, 101, double.NaN, 103, 104, 105, 106, 107, 108, 109];
double[] middle = new double[10];
double[] upper = new double[10];
double[] lower = new double[10];
Bbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(),
period: 5, multiplier: 2.0);
// Index 4 (first complete window [100,101,NaN,103,104]) contains NaN
// So upper/lower at index 4 should be NaN
Assert.True(double.IsNaN(upper[4]));
Assert.True(double.IsNaN(lower[4]));
// Once NaN exits the window, values should become finite again
// Window at index 7: [103, 104, 105, 106, 107] — all finite
Assert.True(double.IsFinite(upper[7]));
Assert.True(double.IsFinite(lower[7]));
}
#endregion
#region Band Relationship Tests
[Fact]
public void Bbands_UpperAlwaysAboveLower()
{
Bbands bbands = new(period: 5, multiplier: 2.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
DateTime time = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
bbands.Update(new TValue(time.AddMinutes(i), bar.Close), isNew: true);
if (bbands.IsHot)
{
Assert.True(bbands.Upper.Value >= bbands.Lower.Value,
$"Upper ({bbands.Upper.Value}) should be >= Lower ({bbands.Lower.Value}) at step {i}");
Assert.True(bbands.Width.Value >= 0,
$"Width ({bbands.Width.Value}) should be >= 0 at step {i}");
}
}
}
[Fact]
public void Bbands_MiddleIsBetweenBands()
{
Bbands bbands = new(period: 5, multiplier: 2.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
DateTime time = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
bbands.Update(new TValue(time.AddMinutes(i), bar.Close), isNew: true);
if (bbands.IsHot)
{
Assert.True(bbands.Middle.Value >= bbands.Lower.Value,
$"Middle ({bbands.Middle.Value}) should be >= Lower ({bbands.Lower.Value})");
Assert.True(bbands.Middle.Value <= bbands.Upper.Value,
$"Middle ({bbands.Middle.Value}) should be <= Upper ({bbands.Upper.Value})");
}
}
}
[Fact]
public void Bbands_MultiplierAffectsBandWidth()
{
DateTime time = DateTime.UtcNow;
double[] prices = [100, 102, 98, 105, 103, 107, 101, 99, 106, 104];
Bbands narrow = new(period: 5, multiplier: 1.0);
Bbands wide = new(period: 5, multiplier: 3.0);
for (int i = 0; i < prices.Length; i++)
{
narrow.Update(new TValue(time.AddSeconds(i), prices[i]), isNew: true);
wide.Update(new TValue(time.AddSeconds(i), prices[i]), isNew: true);
}
// Wider multiplier should produce wider bands
Assert.True(wide.Width.Value > narrow.Width.Value);
// Middle should be the same (same SMA)
Assert.Equal(narrow.Middle.Value, wide.Middle.Value, precision: 10);
}
#endregion
#region Last Property
[Fact]
public void Bbands_Last_EqualsMiddle()
{
Bbands bbands = new(period: 3, multiplier: 2.0);
DateTime time = DateTime.UtcNow;
bbands.Update(new TValue(time, 10.0));
bbands.Update(new TValue(time.AddSeconds(1), 12.0));
bbands.Update(new TValue(time.AddSeconds(2), 14.0));
// Last should be the Middle band value
Assert.Equal(bbands.Middle.Value, bbands.Last.Value, precision: 10);
Assert.Equal(bbands.Middle.Time, bbands.Last.Time);
}
#endregion
}