Files
QuanTAlib/lib/statistics/zscore/Zscore.cs
T
Miha Kralj 67ad6f0cba v0.8.7: Replace periodic ResyncInterval with Kahan compensated summation
Comprehensive refactor across all indicators replacing the periodic
ResyncInterval-based drift correction (every 1000 ticks recalculate
from scratch) with Kahan compensated summation for running sums.

Key changes:
- Remove ResyncInterval constants and TickCount fields from all State records
- Add Kahan compensation fields (SumComp, SumSqComp, etc.) to State records
- Replace naive sum += val - removed with Kahan delta pattern
- Remove Resync()/RecalculateSum() methods that did O(N) recalculation
- Update batch/SIMD paths to use Kahan compensation instead of resync loops
- IIR filters (EMA, REMA, RGMA) simplified: inherently self-correcting
- Version bump to 0.8.7
- Build system: README version stamping via Directory.Build.props
- Minor doc/test tolerance adjustments for new numerical characteristics

Affected modules: channels, core, cycles, dynamics, errors, momentum,
oscillators, statistics, trends_FIR, trends_IIR, volatility, volume
2026-03-13 22:01:31 -07:00

355 lines
9.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ZSCORE: Z-Score (Population Standard Score) — also known as STANDARDIZE
// Calculates z = (x - μ) / σ using population standard deviation (N denominator)
// Formula: z = (x - mean) / sqrt(Σ(xi - mean)² / N)
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// ZSCORE: Z-Score (also known as STANDARDIZE) — measures how many population
/// standard deviations a value lies from the rolling mean over a lookback window.
/// Uses Kahan compensated summation for numerical stability of the running sum-of-squares,
/// eliminating the need for periodic resynchronization.
/// </summary>
/// <remarks>
/// Key properties:
/// - Uses population standard deviation (N denominator, no Bessel correction)
/// - Output is unbounded (typically -3 to +3 for normally distributed data)
/// - When σ = 0 (constant data), returns 0.0
/// - Period must be >= 2
/// </remarks>
/// <seealso href="zscore.pine">Reference Pine Script implementation</seealso>
[SkipLocalsInit]
public sealed class Zscore : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private readonly TValuePublishedHandler _handler;
private double _lastValidValue;
private double _sumSq;
private double _p_sumSq;
private double _sumSqComp; // Kahan compensation for _sumSq
private double _p_sumSqComp;
public override bool IsHot => _buffer.Count >= _period;
/// <summary>
/// Initializes a rolling z-score indicator.
/// </summary>
/// <param name="period">Lookback period (default 14, must be >= 2)</param>
public Zscore(int period = 14)
{
if (period < 2)
{
throw new ArgumentException("Period must be >= 2 for standard deviation calculation.", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Zscore({period})";
WarmupPeriod = period;
_sumSq = 0.0;
_p_sumSq = 0.0;
_sumSqComp = 0.0;
_p_sumSqComp = 0.0;
_handler = Handle;
}
/// <summary>
/// Initializes a rolling z-score 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 14)</param>
public Zscore(ITValuePublisher source, int period = 14) : this(period)
{
source.Pub += _handler;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
double value = input.Value;
if (!double.IsFinite(value))
{
value = _lastValidValue;
}
else
{
_lastValidValue = value;
}
if (isNew)
{
_p_sumSq = _sumSq;
_p_sumSqComp = _sumSqComp;
_buffer.Snapshot();
}
else
{
_sumSq = _p_sumSq;
_sumSqComp = _p_sumSqComp;
_buffer.Restore();
}
if (_buffer.IsFull)
{
double oldVal = _buffer.Oldest;
// Kahan subtract old²
double y = -(oldVal * oldVal) - _sumSqComp;
double t = _sumSq + y;
_sumSqComp = (t - _sumSq) - y;
_sumSq = t;
}
_buffer.Add(value);
// Kahan add new²
{
double y = (value * value) - _sumSqComp;
double t = _sumSq + y;
_sumSqComp = (t - _sumSq) - y;
_sumSq = t;
}
double result;
int n = _buffer.Count;
if (n < 2)
{
result = 0.0;
}
else
{
double sum = _buffer.Sum;
double mean = sum / n;
double numerator = _sumSq - (sum * sum) / n;
if (numerator < 0)
{
numerator = 0;
}
double popVariance = numerator / n;
double stdDev = Math.Sqrt(popVariance);
if (stdDev > 1e-10)
{
result = (value - mean) / stdDev;
}
else
{
result = 0.0;
}
}
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return new TSeries();
}
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);
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)]
private void Handle(object? sender, in TValueEventArgs args) => Update(args.Value, args.IsNew);
public override void Reset()
{
_buffer.Clear();
_lastValidValue = 0;
_sumSq = 0.0;
_p_sumSq = 0.0;
_sumSqComp = 0.0;
_p_sumSqComp = 0.0;
Last = default;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
DateTime time = DateTime.UtcNow - (interval * source.Length);
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(time, source[i]), true);
time += interval;
}
}
public static TSeries Batch(TSeries source, int period = 14)
{
var indicator = new Zscore(period);
return indicator.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 14)
{
if (source.Length == 0)
{
throw new ArgumentException("Source span must not be empty.", nameof(source));
}
if (output.Length < source.Length)
{
throw new ArgumentException("Output span must be at least as long as source.", nameof(output));
}
if (period < 2)
{
throw new ArgumentException("Period must be >= 2.", nameof(period));
}
const int StackallocThreshold = 256;
double[]? rented = null;
int ringSize = period;
scoped Span<double> ring;
if (ringSize <= StackallocThreshold)
{
ring = stackalloc double[ringSize];
}
else
{
rented = ArrayPool<double>.Shared.Rent(ringSize);
ring = rented.AsSpan(0, ringSize);
}
try
{
int head = 0;
int count = 0;
double lastValid = 0.0;
double sum = 0.0;
double sumSq = 0.0;
double sumComp = 0.0; // Kahan compensation for sum
double sumSqComp = 0.0; // Kahan compensation for sumSq
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
if (!double.IsFinite(val))
{
val = lastValid;
}
else
{
lastValid = val;
}
if (count == ringSize)
{
double oldVal = ring[head];
// Kahan subtract old from sum
double ys = -oldVal - sumComp;
double ts = sum + ys;
sumComp = (ts - sum) - ys;
sum = ts;
// Kahan subtract old² from sumSq
double ysq = -(oldVal * oldVal) - sumSqComp;
double tsq = sumSq + ysq;
sumSqComp = (tsq - sumSq) - ysq;
sumSq = tsq;
}
else
{
count++;
}
ring[head] = val;
// Kahan add val to sum
{
double ys = val - sumComp;
double ts = sum + ys;
sumComp = (ts - sum) - ys;
sum = ts;
}
// Kahan add val² to sumSq
{
double ysq = (val * val) - sumSqComp;
double tsq = sumSq + ysq;
sumSqComp = (tsq - sumSq) - ysq;
sumSq = tsq;
}
head = (head + 1) % ringSize;
if (count < 2)
{
output[i] = 0.0;
continue;
}
int n = count;
double mean = sum / n;
double numerator = sumSq - (sum * sum) / n;
if (numerator < 0)
{
numerator = 0;
}
double popVariance = numerator / n;
double stdDev = Math.Sqrt(popVariance);
if (stdDev > 1e-10)
{
output[i] = (val - mean) / stdDev;
}
else
{
output[i] = 0.0;
}
}
}
finally
{
if (rented != null)
{
ArrayPool<double>.Shared.Return(rented);
}
}
}
public static (TSeries Results, Zscore Indicator) Calculate(TSeries source, int period = 14)
{
var indicator = new Zscore(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
}