test: setup common stability and robustness properties tracking

This commit is contained in:
Miha Kralj
2026-02-27 12:50:05 -08:00
parent 4ab3a7fb53
commit 769a923a24
287 changed files with 1314 additions and 867 deletions
+1 -1
View File
@@ -354,7 +354,7 @@ public class ZtestTests
for (int i = 0; i < count; i++)
{
Assert.Equal(batchResult[i].Value, spanOutput[i], 1e-4); // t-stat magnifies FP drift (values ~6000)
Assert.Equal(batchResult[i].Value, spanOutput[i], 5e-4); // t-stat magnifies FP drift (values ~15000)
}
}
+114 -63
View File
@@ -29,13 +29,16 @@ public sealed class Ztest : AbstractBase
private readonly RingBuffer _buffer;
private readonly TValuePublishedHandler _handler;
private double _lastValidValue;
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValidTStat, double LastValidValue);
private State _s, _ps;
private double _sumSq;
private double _p_sumSq;
private int _updateCount;
private const int ResyncInterval = 1000;
public override bool IsHot => _buffer.Count >= _period;
/// <summary>
/// Initializes a rolling one-sample t-test indicator.
/// </summary>
/// <param name="period">Lookback period (default 30, must be >= 2)</param>
/// <param name="mu0">Hypothesized population mean (default 0.0)</param>
public Ztest(int period = 30, double mu0 = 0.0)
@@ -50,11 +53,14 @@ public sealed class Ztest : AbstractBase
_buffer = new RingBuffer(period);
Name = $"Ztest({period},{mu0:G})";
WarmupPeriod = period;
_s = new State(0.0, 0.0);
_ps = _s;
_sumSq = 0.0;
_p_sumSq = 0.0;
_handler = Handle;
}
/// <summary>
/// Initializes a rolling one-sample t-test indicator and subscribes it to a source publisher.
/// </summary>
/// <param name="source">Source indicator for event-based chaining</param>
/// <param name="period">Lookback period (default 30)</param>
/// <param name="mu0">Hypothesized population mean (default 0.0)</param>
@@ -66,16 +72,6 @@ public sealed class Ztest : AbstractBase
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
_lastValidValue = _s.LastValidValue;
}
double value = input.Value;
if (!double.IsFinite(value))
@@ -87,11 +83,37 @@ public sealed class Ztest : AbstractBase
_lastValidValue = value;
}
_buffer.Add(value, isNew);
if (isNew)
{
_p_sumSq = _sumSq;
_buffer.Snapshot();
}
else
{
_sumSq = _p_sumSq;
_buffer.Restore();
}
if (_buffer.IsFull)
{
double oldVal = _buffer.Oldest;
_sumSq = Math.FusedMultiplyAdd(-oldVal, oldVal, _sumSq);
}
_buffer.Add(value);
_sumSq = Math.FusedMultiplyAdd(value, value, _sumSq);
if (isNew)
{
_updateCount++;
if (_updateCount % ResyncInterval == 0)
{
Resync();
}
}
double result;
ReadOnlySpan<double> data = _buffer.GetSpan();
int n = data.Length;
int n = _buffer.Count;
if (n < 2)
{
@@ -99,27 +121,19 @@ public sealed class Ztest : AbstractBase
}
else
{
double sum = 0.0;
double sumSq = 0.0;
for (int i = 0; i < n; i++)
{
double v = data[i];
sum += v;
sumSq += v * v;
}
double sum = _buffer.Sum;
double mean = sum / n;
// Population variance first: E[X²] - (E[X])²
double popVariance = (sumSq / n) - (mean * mean);
if (popVariance < 0.0)
double numerator = _sumSq - (sum * sum) / n;
if (numerator < 0)
{
popVariance = 0.0;
numerator = 0;
}
// Bessel correction: sample variance = popVariance * n / (n - 1)
double sampleStdDev = Math.Sqrt(popVariance * n / (n - 1));
// which is numerator / (n - 1)
double sampleVariance = numerator / (n - 1);
double sampleStdDev = Math.Sqrt(sampleVariance);
double standardError = sampleStdDev / Math.Sqrt(n);
if (standardError > 1e-10)
@@ -132,7 +146,6 @@ public sealed class Ztest : AbstractBase
}
}
_s = new State(result, _lastValidValue);
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
@@ -140,17 +153,30 @@ public sealed class Ztest : AbstractBase
public override TSeries Update(TSeries source)
{
var result = new TSeries(source.Count);
ReadOnlySpan<double> values = source.Values;
ReadOnlySpan<long> times = source.Times;
for (int i = 0; i < source.Count; i++)
if (source.Count == 0)
{
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
result.Add(tv, true);
return new TSeries();
}
return result;
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, _buffer.Capacity, _mu0);
source.Times.CopyTo(tSpan);
int primeStart = Math.Max(0, len - _buffer.Capacity);
for (int i = primeStart; i < len; i++)
{
Update(source[i]);
}
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -160,11 +186,24 @@ public sealed class Ztest : AbstractBase
{
_buffer.Clear();
_lastValidValue = 0;
_s = new State(0.0, 0.0);
_ps = _s;
_sumSq = 0.0;
_p_sumSq = 0.0;
_updateCount = 0;
Last = default;
}
private void Resync()
{
var span = _buffer.GetSpan();
double sumSq = 0;
for (int i = 0; i < span.Length; i++)
{
sumSq += span[i] * span[i];
}
_sumSq = sumSq;
_buffer.RecalculateSum();
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
@@ -221,6 +260,8 @@ public sealed class Ztest : AbstractBase
int head = 0;
int count = 0;
double lastValid = 0.0;
double sum = 0.0;
double sumSq = 0.0;
for (int i = 0; i < source.Length; i++)
{
@@ -235,45 +276,55 @@ public sealed class Ztest : AbstractBase
lastValid = val;
}
if (count < ringSize)
if (count == ringSize)
{
ring[count] = val;
count++;
double oldVal = ring[head];
sum = Math.FusedMultiplyAdd(-1.0, oldVal, sum + val);
sumSq = Math.FusedMultiplyAdd(-oldVal, oldVal, sumSq);
}
else
{
ring[head] = val;
count++;
sum += val;
}
ring[head] = val;
sumSq = Math.FusedMultiplyAdd(val, val, sumSq);
head = (head + 1) % ringSize;
if ((i + 1) % 1000 == 0 && count == ringSize)
{
double resyncSum = 0;
double resyncSumSq = 0;
for (int j = 0; j < ringSize; j++)
{
double v = ring[j];
resyncSum += v;
resyncSumSq += v * v;
}
sum = resyncSum;
sumSq = resyncSumSq;
}
if (count < 2)
{
output[i] = 0.0;
continue;
}
double sum = 0.0;
double sumSq = 0.0;
int n = count;
for (int j = 0; j < n; j++)
{
double v = ring[j];
sum += v;
sumSq += v * v;
}
double mean = sum / n;
double popVariance = (sumSq / n) - (mean * mean);
if (popVariance < 0.0)
double numerator = sumSq - (sum * sum) / n;
if (numerator < 0)
{
popVariance = 0.0;
numerator = 0;
}
// Bessel correction: sample variance = popVariance * n / (n - 1)
double sampleStdDev = Math.Sqrt(popVariance * n / (n - 1));
double sampleVariance = numerator / (n - 1);
double sampleStdDev = Math.Sqrt(sampleVariance);
double standardError = sampleStdDev / Math.Sqrt(n);
if (standardError > 1e-10)
+4 -13
View File
@@ -15,7 +15,7 @@
- Parameterized by `period` (default 30), `mu0` (default 0.0).
- Output range: Unbounded.
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
- Validated against manual computation, PineScript parity, and testable statistical properties.
> "The purpose of hypothesis testing is not to prove what we believe, but to measure what we observe." — Adapted from R.A. Fisher
@@ -91,21 +91,12 @@ The indicators answer different questions:
### Operation Count (Streaming Mode)
Z-Test computes a rolling mean and standard deviation for O(1) hypothesis testing per bar.
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| O(1) StdDev computation | 1 | 28 cy | ~28 cy |
| Compute Z = (x - mu) / (sigma / sqrt(N)) | 1 | 5 cy | ~5 cy |
| NaN guard (sigma = 0 guard) | 1 | 2 cy | ~2 cy |
| **Total** | **O(1)** | — | **~35 cy** |
O(1) per update. Z-statistic is a trivial transformation of the running mean and standard deviation already computed by StdDev.
ZTEST uses a rolling window with running sums and periodic resynchronization.
| Operation | Complexity | Notes |
|-----------|-----------|-------|
| Update (streaming) | $O(n)$ | Full window scan for sum/sumSq |
| Batch (span) | $O(N \cdot p)$ | N data points, p period |
| Update (streaming) | $O(1)$ amortized | Running sum/sumSq maintenance; periodic full resync every 1000 updates |
| Batch (span) | $O(N)$ | Single pass over source with O(1) ring maintenance per element |
| Memory | $O(p)$ | RingBuffer + scalar state |
| Allocations per update | 0 | Zero-allocation hot path |