// ZTEST: One-Sample t-Test Statistic // Computes t = (x̄ - μ₀) / (s / √n) using sample standard deviation (N-1 Bessel correction) // Formula: t = (mean - mu0) / standardError, where standardError = sampleStdDev / sqrt(n) using System.Buffers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// ZTEST: One-Sample t-Test — computes the t-statistic measuring how many /// standard errors the rolling sample mean deviates from a hypothesized mean μ₀. /// /// /// Key properties: /// - Uses sample standard deviation (N-1 denominator, Bessel correction) /// - Output is unbounded; values beyond ±2.04 (period=30) suggest 95% significance /// - When standard error is negligible (< 1e-10), returns 0.0 /// - Period must be >= 2 /// - Despite the name "ZTEST" (per PineScript convention), this computes a t-statistic /// /// Reference Pine Script implementation [SkipLocalsInit] public sealed class Ztest : AbstractBase { private readonly int _period; private readonly double _mu0; private readonly RingBuffer _buffer; private readonly TValuePublishedHandler _handler; private double _lastValidValue; private double _sumSq; private double _p_sumSq; private int _updateCount; private const int ResyncInterval = 1000; public override bool IsHot => _buffer.Count >= _period; /// /// Initializes a rolling one-sample t-test indicator. /// /// Lookback period (default 30, must be >= 2) /// Hypothesized population mean (default 0.0) public Ztest(int period = 30, double mu0 = 0.0) { if (period < 2) { throw new ArgumentException("Period must be >= 2 for t-test calculation.", nameof(period)); } _period = period; _mu0 = mu0; _buffer = new RingBuffer(period); Name = $"Ztest({period},{mu0:G})"; WarmupPeriod = period; _sumSq = 0.0; _p_sumSq = 0.0; _handler = Handle; } /// /// Initializes a rolling one-sample t-test indicator and subscribes it to a source publisher. /// /// Source indicator for event-based chaining /// Lookback period (default 30) /// Hypothesized population mean (default 0.0) public Ztest(ITValuePublisher source, int period = 30, double mu0 = 0.0) : this(period, mu0) { 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; _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; 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; } // Bessel correction: sample variance = 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) { result = (mean - _mu0) / standardError; } 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(len); var v = new List(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)] 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; _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 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 = 30, double mu0 = 0.0) { var indicator = new Ztest(period, mu0); return indicator.Update(source); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch(ReadOnlySpan source, Span output, int period = 30, double mu0 = 0.0) { 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 ring; if (ringSize <= StackallocThreshold) { ring = stackalloc double[ringSize]; } else { rented = ArrayPool.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; 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]; sum = Math.FusedMultiplyAdd(-1.0, oldVal, sum + val); sumSq = Math.FusedMultiplyAdd(-oldVal, oldVal, sumSq); } else { 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; } int n = count; double mean = sum / n; double numerator = sumSq - (sum * sum) / n; if (numerator < 0) { numerator = 0; } // Bessel correction: sample variance = popVariance * n / (n - 1) double sampleVariance = numerator / (n - 1); double sampleStdDev = Math.Sqrt(sampleVariance); double standardError = sampleStdDev / Math.Sqrt(n); if (standardError > 1e-10) { output[i] = (mean - mu0) / standardError; } else { output[i] = 0.0; } } } finally { if (rented != null) { ArrayPool.Shared.Return(rented); } } } public static (TSeries Results, Ztest Indicator) Calculate(TSeries source, int period = 30, double mu0 = 0.0) { var indicator = new Ztest(period, mu0); TSeries results = indicator.Update(source); return (results, indicator); } }