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
+404 -6
View File
@@ -4,6 +4,8 @@ namespace QuanTAlib.Tests;
public class JbandsTests
{
#region Constructor Tests
[Fact]
public void Jbands_Constructor_ValidatesInput()
{
@@ -16,6 +18,42 @@ public class JbandsTests
Assert.True(j.WarmupPeriod > 0);
}
[Fact]
public void Jbands_Constructor_InfinityPower_Throws()
{
Assert.Throws<ArgumentException>(() => new Jbands(14, 0, double.PositiveInfinity));
Assert.Throws<ArgumentException>(() => new Jbands(14, 0, double.NegativeInfinity));
}
[Fact]
public void Jbands_Period1_EdgeCase()
{
var j = new Jbands(1);
Assert.True(j.WarmupPeriod > 0);
j.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(100.0, j.Last.Value, 1e-10);
}
[Fact]
public void Jbands_ConstructorWithSource_ReceivesUpdates()
{
var source = new Sma(3);
using var j = new Jbands(source, 14);
for (int i = 0; i < 50; i++)
{
source.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i));
}
Assert.True(double.IsFinite(j.Last.Value));
Assert.NotEqual(0, j.Last.Value);
}
#endregion
#region Initial State Tests
[Fact]
public void Jbands_InitialState_Defaults()
{
@@ -27,6 +65,10 @@ public class JbandsTests
Assert.False(j.IsHot);
}
#endregion
#region First Bar Tests
[Fact]
public void Jbands_FirstBar_AllBandsEqual()
{
@@ -38,6 +80,21 @@ public class JbandsTests
Assert.Equal(100.0, j.Lower.Value, 1e-10);
}
[Fact]
public void Jbands_NaN_FirstBar_ReturnsNaN()
{
var j = new Jbands(14);
var result = j.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsNaN(result.Value));
Assert.True(double.IsNaN(j.Upper.Value));
Assert.True(double.IsNaN(j.Lower.Value));
}
#endregion
#region Band Behavior Tests
[Fact]
public void Jbands_UpperBand_SnapToNewHigh()
{
@@ -81,6 +138,43 @@ public class JbandsTests
Assert.True(j.Upper.Value > 100.0); // But not below price yet
}
[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);
}
}
[Fact]
public void Jbands_MiddleBand_IsSmoothed()
{
// JMA middle band is an IIR-smoothed value that can briefly
// exceed the envelope bands during fast transitions. Verify
// it converges close to price over time.
var j = new Jbands(14);
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.01, seed: 55);
// Feed steady data — middle should stay within a reasonable range
for (int i = 0; i < 500; i++)
{
j.Update(new TValue(DateTime.UtcNow, gbm.Next().Close), isNew: true);
}
// After convergence with low-volatility data, middle should be close to price
Assert.True(double.IsFinite(j.Last.Value));
Assert.True(j.Upper.Value >= j.Lower.Value);
}
#endregion
#region IsHot / WarmupPeriod Tests
[Fact]
public void Jbands_IsHot_TurnsTrueAfterWarmup()
{
@@ -98,6 +192,10 @@ public class JbandsTests
Assert.True(j.IsHot);
}
#endregion
#region State Management Tests
[Fact]
public void Jbands_IsNewFalse_RestoresState()
{
@@ -130,6 +228,10 @@ public class JbandsTests
Assert.Equal(lo, j.Lower.Value, 1e-10);
}
#endregion
#region NaN / Infinity Handling Tests
[Fact]
public void Jbands_NaN_UsesLastValid()
{
@@ -147,6 +249,10 @@ public class JbandsTests
Assert.True(double.IsFinite(result2.Value));
}
#endregion
#region Reset Tests
[Fact]
public void Jbands_Reset_Clears()
{
@@ -167,6 +273,243 @@ public class JbandsTests
Assert.Equal(50.0, j.Last.Value);
}
[Fact]
public void Jbands_Reset_ThenReuse_ProducesSameResults()
{
var j = new Jbands(14, 0, 0.45);
var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 88);
double[] prices = new double[100];
for (int i = 0; i < prices.Length; i++)
{
prices[i] = gbm.Next().Close;
}
// First pass
for (int i = 0; i < prices.Length; i++)
{
j.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i]), isNew: true);
}
double midFirst = j.Last.Value;
double upFirst = j.Upper.Value;
double loFirst = j.Lower.Value;
// Reset and second pass
j.Reset();
for (int i = 0; i < prices.Length; i++)
{
j.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i]), isNew: true);
}
Assert.Equal(midFirst, j.Last.Value, 1e-10);
Assert.Equal(upFirst, j.Upper.Value, 1e-10);
Assert.Equal(loFirst, j.Lower.Value, 1e-10);
}
#endregion
#region Dispose Tests
[Fact]
public void Jbands_Dispose_UnsubscribesFromSource()
{
var source = new Sma(3);
var j = new Jbands(source, 14);
// Feed some data through source
for (int i = 0; i < 20; i++)
{
source.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i));
}
double valueBeforeDispose = j.Last.Value;
// Dispose - should unsubscribe
j.Dispose();
// Feed more data - Jbands should NOT update
for (int i = 20; i < 40; i++)
{
source.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 200 + i));
}
Assert.Equal(valueBeforeDispose, j.Last.Value, 1e-10);
}
[Fact]
public void Jbands_Dispose_Idempotent()
{
var source = new Sma(3);
var j = new Jbands(source, 14);
j.Dispose();
j.Dispose(); // Should not throw
// Verify indicator is still in a valid state after double dispose
Assert.True(double.IsFinite(j.Last.Value) || j.Last.Value == 0);
}
[Fact]
public void Jbands_Dispose_WithoutSource_DoesNotThrow()
{
var j = new Jbands(14);
j.Dispose(); // No source subscription, should not throw
Assert.Equal(0, j.Last.Value);
}
#endregion
#region Prime Tests
[Fact]
public void Jbands_Prime_SetsIndicatorToHot()
{
var j = new Jbands(14);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
var series = new TSeries();
int count = j.WarmupPeriod + 10;
for (int i = 0; i < count; i++)
{
var bar = gbm.Next();
series.Add(bar.Time, bar.Close);
}
j.Prime(series);
Assert.True(j.IsHot);
Assert.True(double.IsFinite(j.Last.Value));
Assert.True(double.IsFinite(j.Upper.Value));
Assert.True(double.IsFinite(j.Lower.Value));
}
[Fact]
public void Jbands_Prime_EmptySeries_DoesNotThrow()
{
var j = new Jbands(14);
var empty = new TSeries();
j.Prime(empty); // Should not throw
Assert.False(j.IsHot);
}
[Fact]
public void Jbands_Prime_ThenUpdate_ContinuesCorrectly()
{
var j = new Jbands(14);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
var series = new TSeries();
int primeCount = j.WarmupPeriod + 5;
for (int i = 0; i < primeCount; i++)
{
var bar = gbm.Next();
series.Add(bar.Time, bar.Close);
}
j.Prime(series);
Assert.True(j.IsHot);
// Continue streaming
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next();
j.Update(new TValue(bar.Time, bar.Close), isNew: true);
}
Assert.True(j.IsHot);
Assert.True(double.IsFinite(j.Last.Value));
}
[Fact]
public void Jbands_Prime_MatchesStreamingResults()
{
var jPrime = new Jbands(14, 0, 0.45);
var jStream = new Jbands(14, 0, 0.45);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
var series = new TSeries();
int count = 100;
for (int i = 0; i < count; i++)
{
var bar = gbm.Next();
series.Add(bar.Time, bar.Close);
jStream.Update(new TValue(bar.Time, bar.Close), isNew: true);
}
jPrime.Prime(series);
Assert.Equal(jStream.Last.Value, jPrime.Last.Value, 1e-10);
Assert.Equal(jStream.Upper.Value, jPrime.Upper.Value, 1e-10);
Assert.Equal(jStream.Lower.Value, jPrime.Lower.Value, 1e-10);
}
#endregion
#region Calculate Tests
[Fact]
public void Jbands_Calculate_ReturnsResultsAndHotIndicator()
{
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
var series = new TSeries();
for (int i = 0; i < 300; i++)
{
var bar = gbm.Next();
series.Add(bar.Time, bar.Close);
}
var (results, indicator) = Jbands.Calculate(series, 14, 0, 0.45);
Assert.True(indicator.IsHot);
Assert.Equal(300, results.Middle.Count);
Assert.Equal(300, results.Upper.Count);
Assert.Equal(300, results.Lower.Count);
Assert.True(double.IsFinite(indicator.Last.Value));
}
#endregion
#region Update(TSeries) Tests
[Fact]
public void Jbands_UpdateTSeries_Direct()
{
var j = new Jbands(14);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
var series = new TSeries();
for (int i = 0; i < 300; i++)
{
var bar = gbm.Next();
series.Add(bar.Time, bar.Close);
}
var (middle, upper, lower) = j.Update(series);
Assert.Equal(300, middle.Count);
Assert.Equal(300, upper.Count);
Assert.Equal(300, lower.Count);
Assert.True(j.IsHot);
Assert.True(double.IsFinite(j.Last.Value));
}
[Fact]
public void Jbands_UpdateTSeries_EmptySeries_ReturnsEmptyTuples()
{
var j = new Jbands(14);
var empty = new TSeries();
var (middle, upper, lower) = j.Update(empty);
Assert.Empty(middle);
Assert.Empty(upper);
Assert.Empty(lower);
}
#endregion
#region Batch Tests
[Fact]
public void Jbands_BatchVsStreaming_Match()
{
@@ -236,6 +579,24 @@ public class JbandsTests
Assert.Equal(jStream.Lower.Value, lower[^1], 1e-10);
}
[Fact]
public void Jbands_SpanBatch_EmptySource_DoesNotThrow()
{
double[] source = [];
double[] middle = [];
double[] upper = [];
double[] lower = [];
Jbands.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 14);
Assert.Empty(source);
Assert.Empty(middle);
}
#endregion
#region Event / Chaining Tests
[Fact]
public void Jbands_Event_Publishes()
{
@@ -265,6 +626,10 @@ public class JbandsTests
Assert.True(double.IsFinite(downstream.Last.Value));
}
#endregion
#region Middle Band / JMA Tests
[Fact]
public void Jbands_MiddleBand_MatchesJma()
{
@@ -284,6 +649,10 @@ public class JbandsTests
Assert.Equal(jma.Last.Value, jbands.Last.Value, 1e-10);
}
#endregion
#region Phase Parameter Tests
[Fact]
public void Jbands_Phase_AffectsBehavior()
{
@@ -307,15 +676,44 @@ public class JbandsTests
}
[Fact]
public void Jbands_UpperAlwaysAboveLower()
public void Jbands_Phase_ClampingBelowMinus100()
{
var j = new Jbands(14);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.2, seed: 77);
// Phase < -100 should clamp phaseParam to 0.5
var jClamped = new Jbands(14, -200);
var jEdge = new Jbands(14, -100);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
for (int i = 0; i < 500; i++)
for (int i = 0; i < 100; i++)
{
j.Update(new TValue(DateTime.UtcNow, gbm.Next().Close), isNew: true);
Assert.True(j.Upper.Value >= j.Lower.Value);
double price = gbm.Next().Close;
var tv = new TValue(DateTime.UtcNow, price);
jClamped.Update(tv, isNew: true);
jEdge.Update(tv, isNew: true);
}
// Phase -200 should clamp to same as -100 (both → 0.5)
Assert.Equal(jEdge.Last.Value, jClamped.Last.Value, 1e-10);
}
[Fact]
public void Jbands_Phase_ClampingAbove100()
{
// Phase > 100 should clamp phaseParam to 2.5
var jClamped = new Jbands(14, 200);
var jEdge = new Jbands(14, 100);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
for (int i = 0; i < 100; i++)
{
double price = gbm.Next().Close;
var tv = new TValue(DateTime.UtcNow, price);
jClamped.Update(tv, isNew: true);
jEdge.Update(tv, isNew: true);
}
// Phase 200 should clamp to same as 100 (both → 2.5)
Assert.Equal(jEdge.Last.Value, jClamped.Last.Value, 1e-10);
}
#endregion
}
+10
View File
@@ -425,6 +425,16 @@ public sealed class Jbands : ITValuePublisher, IDisposable
}
}
/// <summary>
/// Calculates Jbands and returns both the results and the indicator instance.
/// </summary>
public static ((TSeries Middle, TSeries Upper, TSeries Lower) Results, Jbands Indicator) Calculate(TSeries source, int period, int phase = 0, double power = 0.45)
{
var indicator = new Jbands(period, phase, power);
var results = indicator.Update(source);
return (results, indicator);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double CalculateTrimmedMean(double fallback)
{