mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-22 12:38:06 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user