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.
This commit is contained in:
Miha Kralj
2026-02-11 20:38:38 -08:00
parent 75c6a9f135
commit 653aafacd8
71 changed files with 10527 additions and 242 deletions
+447
View File
@@ -318,4 +318,451 @@ public class BbandsTests
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
}