using OoplesFinance.StockIndicators; using OoplesFinance.StockIndicators.Models; using Skender.Stock.Indicators; namespace QuanTAlib.Validation; /// /// Validation tests for ZSCORE indicator. /// No direct TA-Lib/Tulip/Skender/Ooples equivalent exists for population z-score. /// Validates against manual computation and mathematical properties. /// public sealed class ZscoreValidationTests { [Fact] public void Zscore_ManualComputation_MatchesPineScript() { // PineScript formula: z = (x - mean) / sqrt(popVariance) // Data: {10, 20, 30, 40, 50}, period=5 // mean = 30, popVar = ((10-30)²+(20-30)²+(30-30)²+(40-30)²+(50-30)²)/5 = 1000/5 = 200 // sigma = sqrt(200) ≈ 14.1421 // z(50) = (50-30)/sqrt(200) = 20/14.1421 ≈ 1.4142 var z = new Zscore(5); double[] data = [10, 20, 30, 40, 50]; foreach (double d in data) { z.Update(new TValue(DateTime.UtcNow, d)); } double expected = 20.0 / Math.Sqrt(200.0); Assert.Equal(expected, z.Last.Value, 1e-9); } [Fact] public void Zscore_GBMData_BoundedRange() { // For GBM-generated data, z-scores should typically be within [-4, 4] int period = 20; var z = new Zscore(period); var rng = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42); for (int i = 0; i < 200; i++) { TBar bar = rng.Next(); z.Update(new TValue(bar.Time, bar.Close)); if (z.IsHot) { Assert.True(z.Last.Value > -10.0 && z.Last.Value < 10.0, $"Z-score {z.Last.Value} outside expected range at i={i}"); } } } [Fact] public void Zscore_ScalingInvariance_HoldsForLinearTransform() { // z(a*x + b) should equal z(x) for constant a > 0, any b int period = 10; var z1 = new Zscore(period); var z2 = new Zscore(period); var rng = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 88); for (int i = 0; i < 30; i++) { double val = rng.Next().Close; z1.Update(new TValue(DateTime.UtcNow, val)); z2.Update(new TValue(DateTime.UtcNow, val * 3.0 + 100.0)); // linear transform if (z1.IsHot && z2.IsHot) { Assert.Equal(z1.Last.Value, z2.Last.Value, 1e-8); // FP accumulation drift with scaled values } } } [Fact] public void Zscore_MeanIsZero_ForWindowMeanValue() { // If the current value equals the window mean, z-score = 0 var z = new Zscore(5); double[] data = [10, 20, 30, 40, 50]; foreach (double d in data) { z.Update(new TValue(DateTime.UtcNow, d)); } // Now add 30 (== current mean) _ = z.Update(new TValue(DateTime.UtcNow, 30.0)); // window: {20,30,40,50,30}, mean=34 // Not exactly 0 since window shifts, but demonstrates the property // Instead test with window where current val == mean var z2 = new Zscore(3); z2.Update(new TValue(DateTime.UtcNow, 10.0)); z2.Update(new TValue(DateTime.UtcNow, 20.0)); var r = z2.Update(new TValue(DateTime.UtcNow, 15.0)); // mean = 15, z(15) = 0 Assert.Equal(0.0, r.Value, 1e-9); } [Fact] public void Zscore_MatchesManualPopulationStddev() { // Verify zscore = (value - mean) / population_stddev int period = 10; var zs = new Zscore(period); var rng = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 99); var values = new List(); for (int i = 0; i < 20; i++) { double val = rng.Next().Close; values.Add(val); var tv = new TValue(DateTime.UtcNow, val); zs.Update(tv); if (zs.IsHot) { // Manual population z-score over the last 'period' values var window = values.Skip(values.Count - period).Take(period).ToArray(); double mean = window.Average(); double popVariance = window.Select(v => (v - mean) * (v - mean)).Average(); double popSigma = Math.Sqrt(popVariance); double expected = popSigma > 0 ? (val - mean) / popSigma : 0; Assert.Equal(expected, zs.Last.Value, 1e-9); } } } [Fact] public void Zscore_MatchesOoples_Structural() { var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42); var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1)); var ooplesData = bars.Select(b => new TickerData { Date = new DateTime(b.Time, DateTimeKind.Utc), Open = b.Open, High = b.High, Low = b.Low, Close = b.Close, Volume = b.Volume }).ToList(); var result = new StockData(ooplesData).CalculateFastZScore(); var values = result.CustomValuesList; int finiteCount = values.Count(v => double.IsFinite(v)); Assert.True(finiteCount > 100, $"Expected >100 finite values, got {finiteCount}"); } /// /// Structural validation using Skender GetStdDev as a related metric. /// Z-score = (value - mean) / stddev. Skender provides GetStdDev which computes /// the denominator of the z-score formula. We verify that QuanTAlib z-score /// is consistent with the relationship: z * stddev + mean ≈ value. /// Skender v2 does not have a direct GetZScore method. /// [Fact] public void Validate_Skender_StdDev_RelatedToZscore() { using var data = new QuanTAlib.Tests.ValidationTestData(); const int period = 20; // QuanTAlib Zscore (streaming) var zs = new Zscore(period); foreach (var tv in data.Data) { zs.Update(tv); } // Skender StdDev var sResult = data.SkenderQuotes.GetStdDev(period).ToList(); // Structural: Skender StdDev produces finite output int finiteCount = sResult.Count(r => r.StdDev is not null && double.IsFinite(r.StdDev.Value)); Assert.True(finiteCount > 100, $"Skender StdDev should produce >100 finite values, got {finiteCount}"); // QuanTAlib Zscore must be finite and bounded Assert.True(double.IsFinite(zs.Last.Value), "QuanTAlib Zscore last must be finite"); Assert.True(zs.Last.Value > -10 && zs.Last.Value < 10, $"Zscore {zs.Last.Value} outside expected [-10,10] range for GBM data"); } }