mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-12 15:48:05 +00:00
- 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.
769 lines
25 KiB
C#
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
|
|
}
|