Implement ZTEST: One-Sample t-Test Statistic with validation tests

- Added Ztest class to compute the one-sample t-statistic using sample standard deviation with Bessel correction.
- Implemented validation tests for Ztest to ensure accuracy against manual calculations and PineScript.
- Updated documentation for Ztest, detailing its mathematical foundation, performance profile, and common pitfalls.
- Adjusted NDepend badges to reflect changes in code metrics after implementation.
- Updated missing indicators report to reflect the completion of statistical indicators, including ZTEST.
This commit is contained in:
Miha Kralj
2026-02-16 16:54:36 -08:00
parent 09ffd31a40
commit b3a64f18fa
73 changed files with 13041 additions and 88 deletions
+130
View File
@@ -0,0 +1,130 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class JbIndicatorTests
{
[Fact]
public void JbIndicator_Constructor_SetsDefaults()
{
var indicator = new JbIndicator();
Assert.Equal(20, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("JB - Jarque-Bera Test", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(SourceType.Close, indicator.Source);
}
[Fact]
public void JbIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new JbIndicator { Period = 20 };
Assert.Equal(0, JbIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void JbIndicator_Initialize_CreatesInternalJb()
{
var indicator = new JbIndicator { Period = 10 };
indicator.Initialize();
Assert.Equal(4, indicator.LinesSeries.Count);
Assert.Equal("JB", indicator.LinesSeries[0].Name);
Assert.Equal("10%", indicator.LinesSeries[1].Name);
Assert.Equal("5%", indicator.LinesSeries[2].Name);
Assert.Equal("1%", indicator.LinesSeries[3].Name);
}
[Fact]
public void JbIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new JbIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double jb = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(jb));
}
[Fact]
public void JbIndicator_DifferentSourceTypes()
{
var indicator = new JbIndicator { Period = 5, Source = SourceType.Open };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double jb = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(jb));
}
[Fact]
public void JbIndicator_ShortName_IncludesPeriod()
{
var indicator = new JbIndicator { Period = 30 };
Assert.Equal("JB 30", indicator.ShortName);
}
[Fact]
public void JbIndicator_NewBar_UpdatesValue()
{
var indicator = new JbIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
_ = indicator.LinesSeries[0].GetValue(0);
indicator.HistoricalData.AddBar(now.AddMinutes(20), 200, 210, 190, 205);
var newArgs = new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(newArgs);
double valueAfter = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(valueAfter));
}
[Fact]
public void JbIndicator_CriticalValueLines_AreSet()
{
var indicator = new JbIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
// Critical value lines should be set
Assert.Equal(4.605, indicator.LinesSeries[1].GetValue(0), 3);
Assert.Equal(5.991, indicator.LinesSeries[2].GetValue(0), 3);
Assert.Equal(9.210, indicator.LinesSeries[3].GetValue(0), 3);
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class JbIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 3, 2000, 1, 0)]
public int Period { get; set; } = 20;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Jb _jb = null!;
private readonly LineSeries _series;
private readonly LineSeries _crit10;
private readonly LineSeries _crit05;
private readonly LineSeries _crit01;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"JB {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/statistics/jb/Jb.Quantower.cs";
public JbIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "JB - Jarque-Bera Test";
Description = "Normality test using skewness and kurtosis. Large values reject normality.";
_series = new LineSeries(name: "JB", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
_crit10 = new LineSeries(name: "10%", color: Color.Gray, width: 1, style: LineStyle.Dash);
_crit05 = new LineSeries(name: "5%", color: Color.Orange, width: 1, style: LineStyle.Dash);
_crit01 = new LineSeries(name: "1%", color: Color.Red, width: 1, style: LineStyle.Solid);
AddLineSeries(_series);
AddLineSeries(_crit10);
AddLineSeries(_crit05);
AddLineSeries(_crit01);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_jb = new Jb(Period);
_priceSelector = Source.GetPriceSelector();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var item = this.HistoricalData[this.Count - 1, SeekOriginHistory.Begin];
double value = _priceSelector(item);
var time = this.HistoricalData.Time();
var input = new TValue(time, value);
TValue result = _jb.Update(input, args.IsNewBar());
_series.SetValue(result.Value, _jb.IsHot, ShowColdValues);
_crit10.SetValue(4.605);
_crit05.SetValue(5.991);
_crit01.SetValue(9.210);
}
}
+492
View File
@@ -0,0 +1,492 @@
using Xunit;
namespace QuanTAlib.Tests;
// ═══════════════════════════════════════════════════════════════
// A) Constructor Validation
// ═══════════════════════════════════════════════════════════════
public class JbConstructorTests
{
[Fact]
public void Constructor_PeriodLessThan3_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Jb(2));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_PeriodZero_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Jb(0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_NegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Jb(-5));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_ValidPeriod_SetsName()
{
var jb = new Jb(20);
Assert.Equal("Jb(20)", jb.Name);
}
[Fact]
public void Constructor_ValidPeriod_SetsWarmupPeriod()
{
var jb = new Jb(20);
Assert.Equal(20, jb.WarmupPeriod);
}
[Fact]
public void Constructor_MinimumPeriod3_Works()
{
var jb = new Jb(3);
Assert.Equal("Jb(3)", jb.Name);
}
}
// ═══════════════════════════════════════════════════════════════
// B) Basic Calculation
// ═══════════════════════════════════════════════════════════════
public class JbBasicTests
{
[Fact]
public void Update_ReturnsTValue()
{
var jb = new Jb(5);
var result = jb.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.IsType<TValue>(result);
}
[Fact]
public void Update_LastAccessible()
{
var jb = new Jb(5);
jb.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(jb.Last.Value));
}
[Fact]
public void Update_ConstantSeries_JbIsZero()
{
// Constant series → skewness = 0, excess kurtosis = 0 → JB = 0
var jb = new Jb(10);
for (int i = 0; i < 20; i++)
{
jb.Update(new TValue(DateTime.UtcNow, 42.0));
}
Assert.Equal(0.0, jb.Last.Value, 10);
}
[Fact]
public void Update_SymmetricData_SkewnessZero_KurtosisNonZero()
{
// Symmetric data has skewness ≈ 0, but kurtosis may differ from normal
// For uniform-like data {1,2,3,...,n}, JB > 0 due to platykurtic shape
var jb = new Jb(20);
for (int i = 1; i <= 20; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i));
}
// Uniform distribution is platykurtic: excess kurtosis < 0, so JB > 0
Assert.True(jb.Last.Value >= 0.0);
}
[Fact]
public void Update_JbAlwaysNonNegative()
{
// JB = (n/6)(S² + EK²/4) is sum of squares → always >= 0
var jb = new Jb(20);
var rng = new GBM();
for (int i = 0; i < 100; i++)
{
var bar = rng.Next();
jb.Update(new TValue(bar.Time, bar.Close));
Assert.True(jb.Last.Value >= 0.0, $"JB was negative at bar {i}: {jb.Last.Value}");
}
}
[Fact]
public void Update_KnownNormalDistribution_SmallJb()
{
// Near-normal data should produce small JB values
// Using a simple linear series with period 50 as proxy
var jb = new Jb(50);
for (int i = 0; i < 100; i++)
{
// Triangular wave approximating normal shape
double val = 50.0 + Math.Sin(i * 0.1) * 10.0;
jb.Update(new TValue(DateTime.UtcNow, val));
}
Assert.True(double.IsFinite(jb.Last.Value));
}
}
// ═══════════════════════════════════════════════════════════════
// C) State + Bar Correction (critical)
// ═══════════════════════════════════════════════════════════════
public class JbStateCorrectionTests
{
[Fact]
public void IsNew_True_AdvancesState()
{
var jb = new Jb(5);
jb.Update(new TValue(DateTime.UtcNow, 10.0), isNew: true);
jb.Update(new TValue(DateTime.UtcNow, 20.0), isNew: true);
double afterTwo = jb.Last.Value;
jb.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
double afterThree = jb.Last.Value;
// Adding an outlier should change JB
Assert.NotEqual(afterTwo, afterThree);
}
[Fact]
public void IsNew_False_Rewrites()
{
var jb = new Jb(5);
for (int i = 1; i <= 5; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i * 10.0));
}
double before = jb.Last.Value;
// Correct last bar with same value
jb.Update(new TValue(DateTime.UtcNow, 50.0), isNew: false);
Assert.Equal(before, jb.Last.Value, 10);
}
[Fact]
public void IsNew_False_DifferentValue_ChangesResult()
{
var jb = new Jb(5);
double[] vals = [10, 20, 30, 40, 50];
for (int i = 0; i < vals.Length; i++)
{
jb.Update(new TValue(DateTime.UtcNow, vals[i]));
}
double before = jb.Last.Value;
// Correct last bar with very different value → changes skewness → changes JB
jb.Update(new TValue(DateTime.UtcNow, 200.0), isNew: false);
Assert.NotEqual(before, jb.Last.Value);
}
[Fact]
public void IterativeCorrections_RestoreState()
{
var jb = new Jb(5);
for (int i = 1; i <= 5; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i * 10.0));
}
double original = jb.Last.Value;
// Multiple corrections
jb.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
jb.Update(new TValue(DateTime.UtcNow, 50.0), isNew: false);
Assert.Equal(original, jb.Last.Value, 10);
}
[Fact]
public void Reset_ClearsState()
{
var jb = new Jb(5);
for (int i = 1; i <= 10; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i));
}
Assert.True(jb.IsHot);
jb.Reset();
Assert.False(jb.IsHot);
Assert.Equal(default, jb.Last);
}
}
// ═══════════════════════════════════════════════════════════════
// D) Warmup/Convergence
// ═══════════════════════════════════════════════════════════════
public class JbWarmupTests
{
[Fact]
public void IsHot_FlipsWhenBufferFull()
{
var jb = new Jb(5);
for (int i = 0; i < 4; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i + 1));
Assert.False(jb.IsHot);
}
jb.Update(new TValue(DateTime.UtcNow, 5));
Assert.True(jb.IsHot);
}
[Fact]
public void WarmupPeriod_EqualsToPeriod()
{
var jb = new Jb(20);
Assert.Equal(20, jb.WarmupPeriod);
}
[Fact]
public void SingleValue_JbIsZero()
{
var jb = new Jb(5);
jb.Update(new TValue(DateTime.UtcNow, 42.0));
Assert.Equal(0.0, jb.Last.Value, 10);
}
[Fact]
public void TwoValues_JbIsZero()
{
var jb = new Jb(5);
jb.Update(new TValue(DateTime.UtcNow, 10.0));
jb.Update(new TValue(DateTime.UtcNow, 20.0));
Assert.Equal(0.0, jb.Last.Value, 10);
}
}
// ═══════════════════════════════════════════════════════════════
// E) Robustness (critical)
// ═══════════════════════════════════════════════════════════════
public class JbRobustnessTests
{
[Fact]
public void NaN_UsesLastValid()
{
var jb = new Jb(5);
for (int i = 1; i <= 5; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i * 10.0));
}
jb.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(jb.Last.Value));
}
[Fact]
public void Infinity_UsesLastValid()
{
var jb = new Jb(5);
for (int i = 1; i <= 5; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i * 10.0));
}
jb.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(jb.Last.Value));
}
[Fact]
public void NegativeInfinity_UsesLastValid()
{
var jb = new Jb(5);
for (int i = 1; i <= 5; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i * 10.0));
}
jb.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(jb.Last.Value));
}
[Fact]
public void BatchNaN_NoPropagation()
{
var jb = new Jb(5);
for (int i = 0; i < 10; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i % 2 == 0 ? double.NaN : (double)(i * 10)));
}
Assert.True(double.IsFinite(jb.Last.Value));
}
}
// ═══════════════════════════════════════════════════════════════
// F) Consistency (critical)
// ═══════════════════════════════════════════════════════════════
public class JbConsistencyTests
{
private const double Tolerance = 1e-8;
[Fact]
public void BatchCalc_MatchesStreaming()
{
int period = 10;
int bars = 100;
var rng = new GBM();
var source = new TSeries();
for (int i = 0; i < bars; i++)
{
var bar = rng.Next();
source.Add(new TValue(bar.Time, bar.Close));
}
// Streaming
var streaming = new Jb(period);
var streamResults = new double[bars];
for (int i = 0; i < bars; i++)
{
streaming.Update(source[i]);
streamResults[i] = streaming.Last.Value;
}
// Batch
var batchSeries = Jb.Batch(source, period);
for (int i = period - 1; i < bars; i++)
{
Assert.Equal(streamResults[i], batchSeries[i].Value, Tolerance);
}
}
[Fact]
public void SpanCalc_MatchesStreaming()
{
int period = 10;
int bars = 100;
var rng = new GBM();
var source = new TSeries();
for (int i = 0; i < bars; i++)
{
var bar = rng.Next();
source.Add(new TValue(bar.Time, bar.Close));
}
// Streaming
var streaming = new Jb(period);
var streamResults = new double[bars];
for (int i = 0; i < bars; i++)
{
streaming.Update(source[i]);
streamResults[i] = streaming.Last.Value;
}
// Span
var spanOutput = new double[bars];
Jb.Batch(source.Values, spanOutput.AsSpan(), period);
for (int i = period - 1; i < bars; i++)
{
Assert.Equal(streamResults[i], spanOutput[i], Tolerance);
}
}
[Fact]
public void EventBased_MatchesStreaming()
{
int period = 10;
int bars = 50;
var rng = new GBM();
var source = new TSeries();
var eventJb = new Jb(source, period);
var manualJb = new Jb(period);
for (int i = 0; i < bars; i++)
{
var bar = rng.Next();
var tv = new TValue(bar.Time, bar.Close);
manualJb.Update(tv);
source.Add(tv);
}
Assert.Equal(manualJb.Last.Value, eventJb.Last.Value, Tolerance);
}
}
// ═══════════════════════════════════════════════════════════════
// G) Span API Tests
// ═══════════════════════════════════════════════════════════════
public class JbSpanTests
{
[Fact]
public void Span_MismatchedLengths_ThrowsArgumentException()
{
var source = new double[10];
var output = new double[5];
var ex = Assert.Throws<ArgumentException>(() =>
Jb.Batch(source.AsSpan(), output.AsSpan(), 5));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Span_InvalidPeriod_ThrowsArgumentException()
{
var source = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() =>
Jb.Batch(source.AsSpan(), output.AsSpan(), 2));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Span_EmptyInput_NoException()
{
var source = ReadOnlySpan<double>.Empty;
var output = Span<double>.Empty;
Jb.Batch(source, output, 5);
Assert.True(true); // S2699 — confirms no exception
}
[Fact]
public void Span_LargeData_NoStackOverflow()
{
int len = 10_000;
var source = new double[len];
var output = new double[len];
var rng = new GBM();
for (int i = 0; i < len; i++)
{
var bar = rng.Next();
source[i] = bar.Close;
}
Jb.Batch(source.AsSpan(), output.AsSpan(), 50);
Assert.True(double.IsFinite(output[len - 1]));
}
[Fact]
public void Span_HandlesNaN()
{
var source = new double[] { 10, 20, double.NaN, 40, 50, 60, 70, 80, 90, 100 };
var output = new double[10];
Jb.Batch(source.AsSpan(), output.AsSpan(), 5);
Assert.True(double.IsFinite(output[9]));
}
}
// ═══════════════════════════════════════════════════════════════
// H) Chainability
// ═══════════════════════════════════════════════════════════════
public class JbEventTests
{
[Fact]
public void Pub_Fires()
{
var jb = new Jb(5);
bool fired = false;
jb.Pub += (object? _, in TValueEventArgs _) => fired = true;
jb.Update(new TValue(DateTime.UtcNow, 42.0));
Assert.True(fired);
}
[Fact]
public void EventChaining_Works()
{
var source = new TSeries();
var jb = new Jb(source, 5);
source.Add(new TValue(DateTime.UtcNow, 10.0));
source.Add(new TValue(DateTime.UtcNow, 20.0));
source.Add(new TValue(DateTime.UtcNow, 30.0));
Assert.True(double.IsFinite(jb.Last.Value));
}
}
+204
View File
@@ -0,0 +1,204 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for JB — self-consistency and mathematical properties.
/// No external library implements rolling Jarque-Bera, so validation is based
/// on known mathematical properties and analytical results.
/// </summary>
public class JbValidationTests
{
[Fact]
public void ConstantSeries_JbIsZero()
{
var jb = new Jb(20);
for (int i = 0; i < 50; i++)
{
jb.Update(new TValue(DateTime.UtcNow, 100.0));
}
Assert.Equal(0.0, jb.Last.Value, 10);
}
[Fact]
public void SymmetricData_SkewnessTermIsZero()
{
// Symmetric data around mean → skewness ≈ 0
// JB should be driven entirely by excess kurtosis term
var jb = new Jb(11);
for (int i = -5; i <= 5; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i));
}
// For uniform-like data, excess kurtosis ≈ -1.2, so JB > 0
Assert.True(jb.Last.Value >= 0.0);
Assert.True(double.IsFinite(jb.Last.Value));
}
[Fact]
public void LinearSequence_KnownJb()
{
// Window of {1,...,20}: uniform distribution
// Population skewness ≈ 0, excess kurtosis ≈ -1.2
// JB = (20/6) * (S² + EK²/4) ≈ 1.212 (exact depends on FP rounding in moment sums)
var jb = new Jb(20);
for (int i = 1; i <= 20; i++)
{
jb.Update(new TValue(DateTime.UtcNow, i));
}
// Verify JB is in expected range for uniform-like data
Assert.True(jb.Last.Value > 1.0 && jb.Last.Value < 1.5,
$"JB for linear sequence {1..20} expected ~1.2, got {jb.Last.Value}");
}
[Fact]
public void SkewedData_LargerJb()
{
// Right-skewed data should produce larger JB than symmetric
var jbSymmetric = new Jb(10);
for (int i = -5; i <= 4; i++)
{
jbSymmetric.Update(new TValue(DateTime.UtcNow, i));
}
var jbSkewed = new Jb(10);
double[] skewed = [1, 1, 1, 2, 2, 3, 5, 10, 20, 100];
for (int i = 0; i < skewed.Length; i++)
{
jbSkewed.Update(new TValue(DateTime.UtcNow, skewed[i]));
}
Assert.True(jbSkewed.Last.Value > jbSymmetric.Last.Value,
$"Skewed JB ({jbSkewed.Last.Value}) should exceed symmetric JB ({jbSymmetric.Last.Value})");
}
[Fact]
public void Deterministic_SameInputSameOutput()
{
int period = 10;
var jb1 = new Jb(period);
var jb2 = new Jb(period);
var rng1 = new GBM(seed: 42);
var rng2 = new GBM(seed: 42);
for (int i = 0; i < 50; i++)
{
var bar1 = rng1.Next();
var bar2 = rng2.Next();
jb1.Update(new TValue(bar1.Time, bar1.Close));
jb2.Update(new TValue(bar2.Time, bar2.Close));
}
Assert.Equal(jb1.Last.Value, jb2.Last.Value, 1e-10);
}
[Fact]
public void BatchVsStreaming_Match()
{
int period = 10;
int bars = 100;
var rng = new GBM();
var source = new TSeries();
for (int i = 0; i < bars; i++)
{
var bar = rng.Next();
source.Add(new TValue(bar.Time, bar.Close));
}
var streaming = new Jb(period);
double lastStreaming = 0;
for (int i = 0; i < bars; i++)
{
streaming.Update(source[i]);
lastStreaming = streaming.Last.Value;
}
var batchSeries = Jb.Batch(source, period);
Assert.Equal(lastStreaming, batchSeries[bars - 1].Value, 1e-8);
}
[Fact]
public void SpanVsStreaming_Match()
{
int period = 10;
int bars = 100;
var rng = new GBM();
var source = new TSeries();
for (int i = 0; i < bars; i++)
{
var bar = rng.Next();
source.Add(new TValue(bar.Time, bar.Close));
}
var streaming = new Jb(period);
var streamResults = new double[bars];
for (int i = 0; i < bars; i++)
{
streaming.Update(source[i]);
streamResults[i] = streaming.Last.Value;
}
var spanOutput = new double[bars];
Jb.Batch(source.Values, spanOutput.AsSpan(), period);
for (int i = period - 1; i < bars; i++)
{
Assert.Equal(streamResults[i], spanOutput[i], 1e-8);
}
}
[Fact]
public void CalculateBridge_ReturnsIndicatorAndResults()
{
int period = 10;
var rng = new GBM();
var source = new TSeries();
for (int i = 0; i < 50; i++)
{
var bar = rng.Next();
source.Add(new TValue(bar.Time, bar.Close));
}
var (results, indicator) = Jb.Calculate(source, period);
Assert.Equal(50, results.Count);
Assert.True(indicator.IsHot);
}
[Fact]
public void JbNonNegative_ForAllInputs()
{
var jb = new Jb(20);
var rng = new GBM();
for (int i = 0; i < 200; i++)
{
var bar = rng.Next();
jb.Update(new TValue(bar.Time, bar.Close));
Assert.True(jb.Last.Value >= 0.0, $"JB negative at bar {i}");
}
}
[Fact]
public void OutlierIncreases_Jb()
{
// Adding outlier to normal-ish data should increase JB
var jb = new Jb(10);
for (int i = 1; i <= 9; i++)
{
jb.Update(new TValue(DateTime.UtcNow, 50.0 + i));
}
jb.Update(new TValue(DateTime.UtcNow, 55.0));
double normalJb = jb.Last.Value;
var jbOutlier = new Jb(10);
for (int i = 1; i <= 9; i++)
{
jbOutlier.Update(new TValue(DateTime.UtcNow, 50.0 + i));
}
jbOutlier.Update(new TValue(DateTime.UtcNow, 500.0));
double outlierJb = jbOutlier.Last.Value;
Assert.True(outlierJb > normalJb,
$"Outlier JB ({outlierJb}) should exceed normal JB ({normalJb})");
}
}
+697
View File
@@ -0,0 +1,697 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;
namespace QuanTAlib;
/// <summary>
/// JB: Jarque-Bera Test Statistic
/// </summary>
/// <remarks>
/// The Jarque-Bera test measures how far a distribution deviates from normality
/// by examining skewness and kurtosis. Under the null hypothesis of normality,
/// JB ~ χ²(2). Large values reject normality.
///
/// Formula:
/// JB = (n / 6) × (S² + EK² / 4)
/// where S = skewness = m₃ / m₂^(3/2)
/// EK = excess kurtosis = (m₄ / m₂²) 3
/// mₖ = k-th central moment = Σ(xᵢ x̄)ᵏ / n
///
/// O(1) streaming via running sums of x, x², x³, x⁴ with periodic resync
/// to limit floating-point drift.
///
/// Critical values (χ² with 2 df):
/// 10% → 4.605, 5% → 5.991, 1% → 9.210
///
/// IsHot:
/// Becomes true when the buffer reaches full period length.
/// </remarks>
[SkipLocalsInit]
public sealed class Jb : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private readonly TValuePublishedHandler _handler;
private readonly ITValuePublisher? _source;
private bool _disposed;
private double _sum;
private double _sumSq;
private double _sumCu;
private double _sumQu;
private double _p_sum;
private double _p_sumSq;
private double _p_sumCu;
private double _p_sumQu;
private double _lastValidValue;
private double _p_lastValidValue;
private int _updateCount;
private const int ResyncInterval = 1000;
private const double Epsilon = 1e-10;
public override bool IsHot => _buffer.IsFull;
/// <summary>Creates a new JB indicator with the specified period.</summary>
/// <param name="period">The lookback period (must be >= 3).</param>
public Jb(int period)
{
if (period < 3)
{
throw new ArgumentException("Period must be at least 3.", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Jb({period})";
WarmupPeriod = period;
_handler = Handle;
}
public Jb(ITValuePublisher source, int period) : this(period)
{
_source = source;
source.Pub += _handler;
}
public Jb(TSeries source, int period) : this(period)
{
_source = source;
source.Pub += _handler;
Prime(source.Values);
if (source.Count > 0)
{
Last = new TValue(source.LastTime, Last.Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs args) => Update(args.Value, args.IsNew);
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
if (source.Length == 0)
{
return;
}
_buffer.Clear();
_sum = 0;
_sumSq = 0;
_sumCu = 0;
_sumQu = 0;
_lastValidValue = 0;
_p_lastValidValue = 0;
_updateCount = 0;
int warmupLength = Math.Min(source.Length, WarmupPeriod);
int startIndex = source.Length - warmupLength;
for (int i = startIndex; i < source.Length; i++)
{
Update(new TValue(DateTime.MinValue, source[i]));
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
double value = input.Value;
// NaN/Infinity guard — substitute last valid
if (!double.IsFinite(value))
{
value = _lastValidValue;
}
else
{
if (isNew)
{
_p_lastValidValue = _lastValidValue;
}
_lastValidValue = value;
}
if (isNew)
{
// Save state for rollback
_p_sum = _sum;
_p_sumSq = _sumSq;
_p_sumCu = _sumCu;
_p_sumQu = _sumQu;
if (_buffer.IsFull)
{
double old = _buffer.Oldest;
double oldSq = old * old;
_sum -= old;
_sumSq -= oldSq;
_sumCu -= oldSq * old;
_sumQu -= oldSq * oldSq;
}
_buffer.Add(value);
double vSq = value * value;
_sum += value;
_sumSq += vSq;
_sumCu += vSq * value;
_sumQu += vSq * vSq;
_updateCount++;
if (_updateCount % ResyncInterval == 0)
{
Resync();
}
}
else
{
// Restore previous state
_lastValidValue = _p_lastValidValue;
_sum = _p_sum;
_sumSq = _p_sumSq;
_sumCu = _p_sumCu;
_sumQu = _p_sumQu;
if (_buffer.Count > 0)
{
_buffer.UpdateNewest(value);
Resync();
}
else
{
_buffer.Add(value);
double vSq = value * value;
_sum += value;
_sumSq += vSq;
_sumCu += vSq * value;
_sumQu += vSq * vSq;
}
// Re-apply NaN guard for corrected value
if (double.IsFinite(input.Value))
{
_lastValidValue = input.Value;
}
}
double jb = CalculateJbFromSums(_sum, _sumSq, _sumCu, _sumQu, _buffer.Count);
Last = new TValue(input.Time, jb);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Batch(source.Values, vSpan, _period);
source.Times.CopyTo(tSpan);
// Reset running state before priming
_buffer.Clear();
_sum = 0;
_sumSq = 0;
_sumCu = 0;
_sumQu = 0;
_lastValidValue = 0;
_p_lastValidValue = 0;
_updateCount = 0;
// Prime the state
int primeStart = Math.Max(0, len - _period);
for (int i = primeStart; i < len; i++)
{
Update(source[i]);
}
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
public static TSeries Batch(TSeries source, int period)
{
var jb = new Jb(period);
return jb.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length.", nameof(output));
}
if (period < 3)
{
throw new ArgumentException("Period must be at least 3.", nameof(period));
}
int len = source.Length;
if (len == 0)
{
return;
}
// Try SIMD path for large, clean datasets
const int SimdThreshold = 256;
if (len >= SimdThreshold && Avx2.IsSupported && !source.ContainsNonFinite())
{
CalculateAvx2Core(source, output, period);
return;
}
// Scalar path
CalculateScalarCore(source, output, period);
}
public static (TSeries Results, Jb Indicator) Calculate(TSeries source, int period)
{
var indicator = new Jb(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_buffer.Clear();
_sum = 0;
_sumSq = 0;
_sumCu = 0;
_sumQu = 0;
_p_sum = 0;
_p_sumSq = 0;
_p_sumCu = 0;
_p_sumQu = 0;
_lastValidValue = 0;
_p_lastValidValue = 0;
_updateCount = 0;
Last = default;
}
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing && _source != null)
{
_source.Pub -= _handler;
}
_disposed = true;
}
base.Dispose(disposing);
}
/////////////////////////////////////////////////////////////////////////////////////////////////
// Private helpers
/////////////////////////////////////////////////////////////////////////////////////////////////
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double CalculateJbFromSums(double sum, double sumSq, double sumCu, double sumQu, double n)
{
if (n < 3)
{
return 0;
}
double mean = sum / n;
double meanSq = mean * mean;
// m₂ = (Σx̲ - Σx²/n) / n
double m2Numerator = sumSq - (sum * sum) / n;
if (m2Numerator < Epsilon)
{
return 0;
}
double m2 = m2Numerator / n;
if (m2 <= Epsilon)
{
return 0;
}
// m₃ = (Σx³ - 3·mean·Σx² + 2·n·mean³) / n
double m3Numerator = sumCu - 3 * mean * sumSq + 2 * n * meanSq * mean;
double m3 = m3Numerator / n;
// m₄ = (Σx⁴ - 4·mean·Σx³ + 6·mean²·Σx² - 3·n·mean⁴) / n
double m4Numerator = sumQu - 4 * mean * sumCu + 6 * meanSq * sumSq - 3 * n * meanSq * meanSq;
double m4 = m4Numerator / n;
// Skewness = m₃ / m₂^(3/2)
double m2Sqrt = Math.Sqrt(m2);
double skewness = m3 / (m2 * m2Sqrt);
// Excess Kurtosis = (m₄ / m₂²) - 3
double excessKurtosis = (m4 / (m2 * m2)) - 3.0;
// JB = (n/6) × (S² + EK²/4)
// skipcq: CS-R1140 — FMA for precision in JB formula
return (n / 6.0) * Math.FusedMultiplyAdd(skewness, skewness, excessKurtosis * excessKurtosis / 4.0);
}
private void Resync()
{
double sum = 0, sumSq = 0, sumCu = 0, sumQu = 0;
var span = _buffer.GetSpan();
for (int i = 0; i < span.Length; i++)
{
double val = span[i];
double vSq = val * val;
sum += val;
sumSq += vSq;
sumCu += vSq * val;
sumQu += vSq * vSq;
}
_sum = sum;
_sumSq = sumSq;
_sumCu = sumCu;
_sumQu = sumQu;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateScalarCore(ReadOnlySpan<double> source, Span<double> output, int period)
{
int len = source.Length;
// Pre-process source: replace NaN/Infinity with lastValid so sliding-window
// subtraction always uses the identical substituted value used during warmup.
const int StackallocThreshold = 256;
double[]? rented = null;
scoped Span<double> sanitized;
if (len <= StackallocThreshold)
{
sanitized = stackalloc double[len];
}
else
{
rented = ArrayPool<double>.Shared.Rent(len);
sanitized = rented.AsSpan(0, len);
}
try
{
double lastValid = 0;
for (int j = 0; j < len; j++)
{
double val = source[j];
if (!double.IsFinite(val))
{
val = lastValid;
}
else
{
lastValid = val;
}
sanitized[j] = val;
}
double sum = 0, sumSq = 0, sumCu = 0, sumQu = 0;
int i = 0;
// Warmup phase
int warmupEnd = Math.Min(period, len);
for (; i < warmupEnd; i++)
{
double val = sanitized[i];
double vSq = val * val;
sum += val;
sumSq += vSq;
sumCu += vSq * val;
sumQu += vSq * vSq;
output[i] = CalculateJbFromSums(sum, sumSq, sumCu, sumQu, i + 1);
}
// Sliding window phase
int tickCount = period;
for (; i < len; i++)
{
double val = sanitized[i];
double oldVal = sanitized[i - period];
double vSq = val * val;
double oSq = oldVal * oldVal;
sum = sum - oldVal + val;
sumSq = sumSq - oSq + vSq;
sumCu = sumCu - (oSq * oldVal) + (vSq * val);
sumQu = sumQu - (oSq * oSq) + (vSq * vSq);
output[i] = CalculateJbFromSums(sum, sumSq, sumCu, sumQu, period);
tickCount++;
if (tickCount >= ResyncInterval)
{
tickCount = 0;
ResyncFromSanitized(sanitized, i, period, ref sum, ref sumSq, ref sumCu, ref sumQu);
}
}
}
finally
{
if (rented is not null)
{
ArrayPool<double>.Shared.Return(rented);
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ResyncFromSanitized(ReadOnlySpan<double> sanitized, int endIndex, int period,
ref double sum, ref double sumSq, ref double sumCu, ref double sumQu)
{
double s = 0, sSq = 0, sCu = 0, sQu = 0;
int startIdx = endIndex - period + 1;
for (int k = 0; k < period; k++)
{
double v = sanitized[startIdx + k];
double vSq = v * v;
s += v;
sSq += vSq;
sCu += vSq * v;
sQu += vSq * vSq;
}
sum = s;
sumSq = sSq;
sumCu = sCu;
sumQu = sQu;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WarmupJb(int period, ref double srcRef, ref double outRef,
out double sum, out double sumSq, out double sumCu, out double sumQu)
{
sum = 0; sumSq = 0; sumCu = 0; sumQu = 0;
for (int i = 0; i < period; i++)
{
double val = Unsafe.Add(ref srcRef, i);
double vSq = val * val;
sum += val;
sumSq += vSq;
sumCu += vSq * val;
sumQu += vSq * vSq;
Unsafe.Add(ref outRef, i) = CalculateJbFromSums(sum, sumSq, sumCu, sumQu, i + 1);
}
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
private static void CalculateAvx2Core(ReadOnlySpan<double> source, Span<double> output, int period)
{
int len = source.Length;
const int VectorWidth = 4;
ref double srcRef = ref MemoryMarshal.GetReference(source);
ref double outRef = ref MemoryMarshal.GetReference(output);
WarmupJb(period, ref srcRef, ref outRef, out double sum, out double sumSq, out double sumCu, out double sumQu);
if (len <= period)
{
return;
}
double invN = 1.0 / period;
double n = period;
var vInvN = Vector256.Create(invN);
var vN = Vector256.Create(n);
var vThree = Vector256.Create(3.0);
var vTwo = Vector256.Create(2.0);
var vFour = Vector256.Create(4.0);
var vSix = Vector256.Create(6.0);
var vEpsilon = Vector256.Create(Epsilon);
var vZero = Vector256<double>.Zero;
int simdEnd = period + ((len - period) / VectorWidth) * VectorWidth;
int tickCount = period;
for (int i = period; i < simdEnd; i += VectorWidth)
{
var vNew = Vector256.LoadUnsafe(ref Unsafe.Add(ref srcRef, i));
var vOld = Vector256.LoadUnsafe(ref Unsafe.Add(ref srcRef, i - period));
// Deltas for Sum
var vDelta = Avx.Subtract(vNew, vOld);
// Deltas for SumSq
var vNewSq = Avx.Multiply(vNew, vNew);
var vOldSq = Avx.Multiply(vOld, vOld);
var vDeltaSq = Avx.Subtract(vNewSq, vOldSq);
// Deltas for SumCu
var vNewCu = Avx.Multiply(vNewSq, vNew);
var vOldCu = Avx.Multiply(vOldSq, vOld);
var vDeltaCu = Avx.Subtract(vNewCu, vOldCu);
// Deltas for SumQu
var vNewQu = Avx.Multiply(vNewSq, vNewSq);
var vOldQu = Avx.Multiply(vOldSq, vOldSq);
var vDeltaQu = Avx.Subtract(vNewQu, vOldQu);
// Prefix sums for Sum
var vShift1 = Avx2.Permute4x64(vDelta.AsUInt64(), 0b_10_01_00_00).AsDouble(); // skipcq: CS-R1131
vShift1 = Avx.Blend(vZero, vShift1, 0b_1110);
var vP1 = Avx.Add(vDelta, vShift1);
var vShift2 = Avx2.Permute4x64(vP1.AsUInt64(), 0b_01_00_00_00).AsDouble(); // skipcq: CS-R1131
vShift2 = Avx.Blend(vZero, vShift2, 0b_1100);
var vSums = Avx.Add(Vector256.Create(sum), Avx.Add(vP1, vShift2));
// Prefix sums for SumSq
var vShiftSq1 = Avx2.Permute4x64(vDeltaSq.AsUInt64(), 0b_10_01_00_00).AsDouble(); // skipcq: CS-R1131
vShiftSq1 = Avx.Blend(vZero, vShiftSq1, 0b_1110);
var vP1Sq = Avx.Add(vDeltaSq, vShiftSq1);
var vShiftSq2 = Avx2.Permute4x64(vP1Sq.AsUInt64(), 0b_01_00_00_00).AsDouble(); // skipcq: CS-R1131
vShiftSq2 = Avx.Blend(vZero, vShiftSq2, 0b_1100);
var vSumSqs = Avx.Add(Vector256.Create(sumSq), Avx.Add(vP1Sq, vShiftSq2));
// Prefix sums for SumCu
var vShiftCu1 = Avx2.Permute4x64(vDeltaCu.AsUInt64(), 0b_10_01_00_00).AsDouble(); // skipcq: CS-R1131
vShiftCu1 = Avx.Blend(vZero, vShiftCu1, 0b_1110);
var vP1Cu = Avx.Add(vDeltaCu, vShiftCu1);
var vShiftCu2 = Avx2.Permute4x64(vP1Cu.AsUInt64(), 0b_01_00_00_00).AsDouble(); // skipcq: CS-R1131
vShiftCu2 = Avx.Blend(vZero, vShiftCu2, 0b_1100);
var vSumCus = Avx.Add(Vector256.Create(sumCu), Avx.Add(vP1Cu, vShiftCu2));
// Prefix sums for SumQu
var vShiftQu1 = Avx2.Permute4x64(vDeltaQu.AsUInt64(), 0b_10_01_00_00).AsDouble(); // skipcq: CS-R1131
vShiftQu1 = Avx.Blend(vZero, vShiftQu1, 0b_1110);
var vP1Qu = Avx.Add(vDeltaQu, vShiftQu1);
var vShiftQu2 = Avx2.Permute4x64(vP1Qu.AsUInt64(), 0b_01_00_00_00).AsDouble(); // skipcq: CS-R1131
vShiftQu2 = Avx.Blend(vZero, vShiftQu2, 0b_1100);
var vSumQus = Avx.Add(Vector256.Create(sumQu), Avx.Add(vP1Qu, vShiftQu2));
// Calculate JB for 4 lanes
var vMean = Avx.Multiply(vSums, vInvN);
var vMeanSq = Avx.Multiply(vMean, vMean);
var vMeanCu = Avx.Multiply(vMeanSq, vMean);
var vMeanQu = Avx.Multiply(vMeanSq, vMeanSq);
// m₂ = (SumSq Sum²/n) / n
var vSumSquared = Avx.Multiply(vSums, vSums);
var vM2Num = Fma.IsSupported
? Fma.MultiplyAddNegated(vSumSquared, vInvN, vSumSqs)
: Avx.Subtract(vSumSqs, Avx.Multiply(vSumSquared, vInvN));
vM2Num = Avx.Max(vZero, vM2Num);
var vM2 = Avx.Multiply(vM2Num, vInvN);
// m₃ = (SumCu 3·mean·SumSq + 2·n·mean³) / n
var vTerm3_2 = Avx.Multiply(vThree, Avx.Multiply(vMean, vSumSqs));
var vNMeanCu = Avx.Multiply(vN, vMeanCu);
var vM3Num = Fma.IsSupported
? Fma.MultiplyAdd(vTwo, vNMeanCu, Avx.Subtract(vSumCus, vTerm3_2))
: Avx.Add(Avx.Subtract(vSumCus, vTerm3_2), Avx.Multiply(vTwo, vNMeanCu));
var vM3 = Avx.Multiply(vM3Num, vInvN);
// m₄ = (SumQu 4·mean·SumCu + 6·mean²·SumSq 3·n·mean⁴) / n
var vTerm4_1 = Avx.Multiply(vFour, Avx.Multiply(vMean, vSumCus));
var vTerm4_2 = Avx.Multiply(vSix, Avx.Multiply(vMeanSq, vSumSqs));
var vTerm4_3 = Avx.Multiply(vThree, Avx.Multiply(vN, vMeanQu));
var vM4Num = Avx.Add(Avx.Subtract(Avx.Subtract(vSumQus, vTerm4_1), vTerm4_3), vTerm4_2);
var vM4 = Avx.Multiply(vM4Num, vInvN);
// Skewness = m₃ / (m₂ · √m₂)
var vM2Sqrt = Avx.Sqrt(vM2);
var vSkewDenom = Avx.Multiply(vM2, vM2Sqrt);
var vSkew = Avx.Divide(vM3, vSkewDenom);
// Excess Kurtosis = (m₄ / m₂²) 3
var vM2Sq = Avx.Multiply(vM2, vM2);
var vKurt = Avx.Subtract(Avx.Divide(vM4, vM2Sq), vThree);
// JB = (n/6) × (S² + EK²/4)
var vSkewSq = Avx.Multiply(vSkew, vSkew);
var vKurtSq = Avx.Multiply(vKurt, vKurt);
var vKurtTerm = Avx.Divide(vKurtSq, vFour);
var vJbInner = Avx.Add(vSkewSq, vKurtTerm);
var vNOver6 = Avx.Divide(vN, vSix);
var vJb = Avx.Multiply(vNOver6, vJbInner);
// Mask: zero out where m₂ is too small
var vMask = Avx.Compare(vM2, vEpsilon, FloatComparisonMode.OrderedGreaterThanNonSignaling);
vJb = Avx.BlendVariable(vZero, vJb, vMask);
// Clamp negative JB to zero (numerical noise)
vJb = Avx.Max(vZero, vJb);
vJb.StoreUnsafe(ref Unsafe.Add(ref outRef, i));
sum = vSums.GetElement(3);
sumSq = vSumSqs.GetElement(3);
sumCu = vSumCus.GetElement(3);
sumQu = vSumQus.GetElement(3);
tickCount += VectorWidth;
if (tickCount >= ResyncInterval)
{
tickCount = 0;
double s = 0, sSq = 0, sCu = 0, sQu = 0;
int startIdx = i + VectorWidth - period;
for (int k = 0; k < period; k++)
{
double v = Unsafe.Add(ref srcRef, startIdx + k);
double v2 = v * v;
s += v;
sSq += v2;
sCu += v2 * v;
sQu += v2 * v2;
}
sum = s;
sumSq = sSq;
sumCu = sCu;
sumQu = sQu;
}
}
// Scalar tail
for (int i = simdEnd; i < len; i++)
{
double val = Unsafe.Add(ref srcRef, i);
double oldVal = Unsafe.Add(ref srcRef, i - period);
double vSq = val * val;
double oSq = oldVal * oldVal;
sum = sum - oldVal + val;
sumSq = sumSq - oSq + vSq;
sumCu = sumCu - (oSq * oldVal) + (vSq * val);
sumQu = sumQu - (oSq * oSq) + (vSq * vSq);
Unsafe.Add(ref outRef, i) = CalculateJbFromSums(sum, sumSq, sumCu, sumQu, n);
}
}
}
+135
View File
@@ -0,0 +1,135 @@
# JB: Jarque-Bera Test
> "The assumption of normality is the most dangerous assumption in all of statistics." — George Box (paraphrased)
The Jarque-Bera test quantifies departure from normality by combining skewness and excess kurtosis into a single chi-squared statistic. A rolling JB value near zero means the window looks Gaussian. Values exceeding 5.991 (5% significance) reject normality. Financial returns almost always fail this test, which is precisely why the test matters.
## Historical Context
Carlos Jarque and Anil Bera published the test in 1980, building on earlier work by Bowman and Shenton (1975). The insight was elegant: under normality, skewness is zero and kurtosis is three, so any deviation from these values indicates non-Gaussianity. The test statistic combines both deviations into a single number that follows a chi-squared distribution with two degrees of freedom.
Most implementations compute JB on static samples. This rolling implementation maintains O(1) updates by tracking running sums of powers (x, x², x³, x⁴), matching the approach used in the companion Skew indicator but extended to the fourth moment.
## Architecture
### 1. Running Power Sums
Four accumulators track $\sum x_i$, $\sum x_i^2$, $\sum x_i^3$, $\sum x_i^4$ over a sliding window of size $n$. When a new value enters and the oldest exits, each accumulator updates via simple addition/subtraction. This yields O(1) complexity per update.
### 2. Central Moments from Power Sums
Central moments are computed from raw power sums without explicitly centering each value:
$$m_2 = \frac{\sum x_i^2 - \frac{(\sum x_i)^2}{n}}{n}$$
$$m_3 = \frac{\sum x_i^3 - 3\bar{x}\sum x_i^2 + 2n\bar{x}^3}{n}$$
$$m_4 = \frac{\sum x_i^4 - 4\bar{x}\sum x_i^3 + 6\bar{x}^2\sum x_i^2 - 3n\bar{x}^4}{n}$$
### 3. Periodic Resync
Floating-point drift accumulates in running sums. Every 1000 ticks, the accumulator is rebuilt from the buffer contents. This bounds error growth without degrading amortized complexity.
## Mathematical Foundation
### Skewness
$$S = \frac{m_3}{m_2^{3/2}}$$
### Excess Kurtosis
$$K = \frac{m_4}{m_2^2} - 3$$
### Jarque-Bera Statistic
$$JB = \frac{n}{6}\left(S^2 + \frac{K^2}{4}\right)$$
Under $H_0$ (normality), $JB \sim \chi^2(2)$.
### Critical Values
| Significance | Critical Value |
|:-------------|:---------------|
| 10% (0.10) | 4.605 |
| 5% (0.05) | 5.991 |
| 1% (0.01) | 9.210 |
### Parameter Mapping
| Parameter | PineScript | QuanTAlib |
|:----------|:-----------|:----------|
| Window | `length` | `period` |
| Min Value | 10 | 3 |
QuanTAlib allows period >= 3 (minimum for meaningful moments), though periods below 10 produce unstable estimates.
## Performance Profile
### Operation Count (Scalar, per bar)
| Operation | Count | Cycle Cost |
|:----------|:------|:-----------|
| ADD/SUB | 20 | 1 |
| MUL | 16 | 3 |
| DIV | 5 | 15 |
| SQRT | 1 | 15 |
| FMA | 1 | 4 |
### Batch Mode (SIMD/AVX2)
Vectorized path processes 4 bars per iteration using prefix-sum accumulators for all four power sums. Available when `Avx2.IsSupported` and input contains no NaN values.
| Metric | Scalar | AVX2 |
|:------------|:-------|:-------|
| Bars/cycle | 1 | ~3.2 |
| Throughput | 1x | ~3.2x |
### Quality Metrics
| Metric | Score | Notes |
|:------------|:------|:------|
| Accuracy | 8/10 | Running sums accumulate FP drift; resync every 1000 ticks |
| Timeliness | 9/10 | No lag beyond window fill |
| Sensitivity | 7/10 | Responds to both skewness and kurtosis changes |
| Robustness | 8/10 | NaN/Infinity guarded; non-negative by construction |
## Validation
No external library implements rolling Jarque-Bera with matching methodology. Validation relies on mathematical properties.
| Library | Status | Notes |
|:---------|:------:|:------|
| TA-Lib | - | Not implemented |
| Skender | - | Not implemented |
| Tulip | - | Not implemented |
| Ooples | - | Not implemented |
Self-validation:
- Constant series produces JB = 0
- Linear sequence {1..20} produces JB = 1.2 (analytical: uniform excess kurtosis = -6/5)
- Skewed data produces larger JB than symmetric data
- JB is always non-negative (sum of squares)
- Batch, streaming, span, and event modes produce identical results
## Common Pitfalls
1. **Small windows inflate JB.** With n < 10, moment estimates are noisy. The test's chi-squared approximation requires n >= 30 for reliable p-values. QuanTAlib allows n >= 3 for computation but interprets results cautiously below n = 20.
2. **JB tests population skewness, not sample.** This implementation uses population moments (dividing by n, not n-1), matching the original Jarque-Bera formulation and the PineScript reference. Sample-adjusted versions exist but produce different critical values.
3. **Zero variance data returns JB = 0.** When all values in the window are identical, m2 = 0 and the formula is undefined. The implementation returns 0, which correctly indicates no evidence against normality (a degenerate distribution is trivially "normal-shaped").
4. **Financial returns almost always reject normality.** Fat tails (positive excess kurtosis) are universal in financial data. A persistently high JB is normal for markets. The indicator is most useful for detecting *changes* in the degree of non-normality.
5. **FP drift in x⁴ accumulator.** The fourth power amplifies floating-point errors more than lower moments. The resync interval of 1000 ticks keeps drift bounded, but for very long-running streams (>100k ticks), consider shorter resync intervals.
6. **NaN handling substitutes last valid.** Non-finite inputs are replaced with the most recent finite value. This maintains continuity but can mask data quality issues. Monitor NaN frequency separately.
7. **Memory: 4 doubles of running state.** The O(1) update carries sum, sumSq, sumCu, sumQu plus previous-state copies for bar correction. Total state footprint is ~128 bytes excluding the RingBuffer.
## References
- Jarque, C. M.; Bera, A. K. (1980). "Efficient tests for normality, homoscedasticity and serial independence of regression residuals." *Economics Letters*, 6(3), 255-259.
- Bowman, K. O.; Shenton, L. R. (1975). "Omnibus test contours for departures from normality based on √b₁ and b₂." *Biometrika*, 62(2), 243-250.
- PineScript reference: `lib/statistics/jb/jb.pine`