mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-15 17:18:05 +00:00
Add Standardize class for Z-Score normalization and update project files
- Implemented the Standardize class for calculating Z-Score normalization over a specified lookback period. - Updated NDepend badge SVG files to reflect new metrics. - Modified NDepend project files to reference the updated solution file name. - Removed outdated documentation files related to indicator proposals and channel documentation remediation. - Updated workspace configuration to point to the new solution file.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
using Xunit;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class StandardizeIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void StandardizeIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new StandardizeIndicator();
|
||||
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("STANDARDIZE - Z-Score Normalization", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_MinHistoryDepths_EqualsPeriod()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 30 };
|
||||
Assert.Equal(30, indicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_ShortName_IncludesPeriod()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 10 };
|
||||
Assert.Equal("STND(10)", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_Initialize_CreatesLineSeries()
|
||||
{
|
||||
var indicator = new StandardizeIndicator();
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
Assert.Equal("Z-Score", indicator.LinesSeries[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 10, 15, 5, 10);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
|
||||
// Single bar: not enough data for stdev, expect 0
|
||||
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add bars with varying close values: 2, 4, 6
|
||||
indicator.HistoricalData.AddBar(now, 2, 3, 1, 2);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 4, 5, 3, 4);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(2), 6, 7, 5, 6);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(3, indicator.LinesSeries[0].Count);
|
||||
// Last value should be finite (indicator is computing z-score)
|
||||
double lastValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(lastValue), $"Z-score should be finite, got {lastValue}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 10, 15, 5, 10);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_OutputIsFinite()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add various bars
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), i * 10, i * 10 + 5, i * 10 - 5, i * 10);
|
||||
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
// All z-score values should be finite
|
||||
for (int i = 0; i < indicator.LinesSeries[0].Count; i++)
|
||||
{
|
||||
double val = indicator.LinesSeries[0].GetValue(i);
|
||||
Assert.True(double.IsFinite(val), $"Value {val} at index {i} is not finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_DifferentSourceTypes_Work()
|
||||
{
|
||||
var sources = new[]
|
||||
{
|
||||
SourceType.Open,
|
||||
SourceType.High,
|
||||
SourceType.Low,
|
||||
SourceType.Close,
|
||||
SourceType.HL2,
|
||||
SourceType.HLC3,
|
||||
};
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Source = source, Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 10 + i, 20 + i, 5 + i, 15 + i);
|
||||
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
Assert.Equal(5, indicator.LinesSeries[0].Count);
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"Source {source}: value {val} is not finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
var periods = new[] { 2, 5, 14, 50, 100 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = period };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < period + 5; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), i, i + 1, i - 1, i);
|
||||
indicator.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
Assert.Equal(period + 5, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_MeanValue_ReturnsZero()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Create symmetric pattern around 50
|
||||
indicator.HistoricalData.AddBar(now, 30, 35, 25, 30);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 40, 45, 35, 40);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(2), 60, 65, 55, 60);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(3), 70, 75, 65, 70);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(4), 50, 55, 45, 50); // Mean
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
// Last value = 50 = mean of [30, 40, 60, 70, 50] = 250/5 = 50
|
||||
// Z-score should be 0
|
||||
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardizeIndicator_FlatData_ReturnsZero()
|
||||
{
|
||||
var indicator = new StandardizeIndicator { Period = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// All same close values
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 100);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 100, 105, 95, 100);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(2), 100, 105, 95, 100);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
// Flat data: stdev = 0, should return 0
|
||||
Assert.Equal(0.0, indicator.LinesSeries[0].GetValue(0), 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using static QuanTAlib.IndicatorExtensions;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// STANDARDIZE (Z-Score Normalization) Quantower indicator.
|
||||
/// Calculates the z-score of values over a lookback period using sample standard deviation.
|
||||
/// </summary>
|
||||
public class StandardizeIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Period", sortIndex: 0, minimum: 2, maximum: 1000, increment: 1)]
|
||||
public int Period { get; set; } = 20;
|
||||
|
||||
[InputParameter("Show Cold Values", sortIndex: 100)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Standardize? _standardize;
|
||||
private Func<IHistoryItem, double>? _selector;
|
||||
|
||||
public int MinHistoryDepths => Period;
|
||||
public override string ShortName => $"STND({Period})";
|
||||
|
||||
public StandardizeIndicator()
|
||||
{
|
||||
Name = "STANDARDIZE - Z-Score Normalization";
|
||||
Description = "Calculates the z-score of values over a lookback period using sample standard deviation";
|
||||
SeparateWindow = true;
|
||||
OnBackGround = true;
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_standardize = new Standardize(Period);
|
||||
_selector = Source.GetPriceSelector();
|
||||
|
||||
AddLineSeries(new LineSeries("Z-Score", Color.Yellow, 2, LineStyle.Solid));
|
||||
}
|
||||
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
if (_standardize == null || _selector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
double value = _selector(item);
|
||||
bool isNew = args.IsNewBar();
|
||||
|
||||
TValue input = new(item.TimeLeft, value);
|
||||
_standardize.Update(input, isNew);
|
||||
|
||||
bool isHot = _standardize.IsHot;
|
||||
|
||||
LinesSeries[0].SetValue(_standardize.Last.Value, isHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class StandardizeTests
|
||||
{
|
||||
private readonly GBM _gbm = new(100, 0.05, 0.2, seed: 42);
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Constructor_ValidPeriod_SetsProperties()
|
||||
{
|
||||
var standardize = new Standardize(20);
|
||||
|
||||
Assert.Equal("Standardize(20)", standardize.Name);
|
||||
Assert.Equal(20, standardize.WarmupPeriod);
|
||||
Assert.False(standardize.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Constructor_InvalidPeriod_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Standardize(1));
|
||||
Assert.Throws<ArgumentException>(() => new Standardize(0));
|
||||
Assert.Throws<ArgumentException>(() => new Standardize(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Constructor_Period2_IsMinimumValid()
|
||||
{
|
||||
var standardize = new Standardize(2);
|
||||
Assert.Equal("Standardize(2)", standardize.Name);
|
||||
Assert.Equal(2, standardize.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_BasicCalculation()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Feed values: 10, 20, 30, 40, 50
|
||||
// Mean = 30, Sample StdDev = sqrt(((10-30)^2 + (20-30)^2 + ... + (50-30)^2) / 4)
|
||||
// = sqrt((400 + 100 + 0 + 100 + 400) / 4) = sqrt(250) ≈ 15.811
|
||||
// Z-score of 50: (50 - 30) / 15.811 ≈ 1.265
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
double expectedStdDev = Math.Sqrt(250.0); // 15.811...
|
||||
double expectedZ = (50 - 30) / expectedStdDev; // ≈ 1.265
|
||||
|
||||
Assert.Equal(expectedZ, result.Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_MeanValueReturnsZero()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Values with known pattern
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
// Mean = (0 + 100 + 50 + 50 + 50) / 5 = 50
|
||||
// Value 50 = mean, so z-score = 0
|
||||
Assert.Equal(0.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_NegativeZScore()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Feed ascending values, then test below mean
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
|
||||
// Mean of [10, 20, 30, 40, 10] = 22
|
||||
// Value 10 < mean, so z-score should be negative
|
||||
Assert.True(result.Value < 0, "Z-score should be negative for below-mean value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_PositiveZScore()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Feed descending values, then test above mean
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
// Value 50 > mean, so z-score should be positive
|
||||
Assert.True(result.Value > 0, "Z-score should be positive for above-mean value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_FlatRange_ReturnsZero()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// All same values
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Flat data: stdev = 0, value = mean, so z-score = 0
|
||||
Assert.Equal(0.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_IsNew_False_RollsBack()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
var result1 = standardize.Update(new TValue(DateTime.UtcNow, 25), isNew: true);
|
||||
var result2 = standardize.Update(new TValue(DateTime.UtcNow, 75), isNew: false);
|
||||
|
||||
// Different values should give different z-scores
|
||||
Assert.NotEqual(result1.Value, result2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_NaN_UsesLastValid()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var valid = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
var nanResult = standardize.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
Assert.Equal(valid.Value, nanResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Update_Infinity_UsesLastValid()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var valid = standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
var infResult = standardize.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
|
||||
Assert.Equal(valid.Value, infResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_IsHot_BecomesTrue_AfterWarmup()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
standardize.Update(new TValue(DateTime.UtcNow, i * 10));
|
||||
Assert.False(standardize.IsHot);
|
||||
}
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
Assert.True(standardize.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Reset_ClearsState()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
standardize.Update(new TValue(DateTime.UtcNow, i * 10));
|
||||
}
|
||||
|
||||
Assert.True(standardize.IsHot);
|
||||
|
||||
standardize.Reset();
|
||||
|
||||
Assert.False(standardize.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OutputIsFinite()
|
||||
{
|
||||
var standardize = new Standardize(20);
|
||||
var series = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(result.Value),
|
||||
$"Standardize output {result.Value} should be finite");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OutputTypicallyInReasonableRange()
|
||||
{
|
||||
var standardize = new Standardize(20);
|
||||
var series = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
int extremeCount = 0;
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
// Most z-scores should be within ±4 for normal data
|
||||
if (Math.Abs(result.Value) > 4)
|
||||
{
|
||||
extremeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow up to 5% extreme values
|
||||
Assert.True(extremeCount < 25, $"Too many extreme z-scores: {extremeCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Chaining_WorksCorrectly()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var standardize = new Standardize(source, 10);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), i * 5));
|
||||
}
|
||||
|
||||
Assert.True(standardize.IsHot);
|
||||
// Last value in a linear sequence should have positive z-score
|
||||
Assert.True(standardize.Last.Value > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_StaticCalculate_TSeries_MatchesStreaming()
|
||||
{
|
||||
var series = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var tseries = new TSeries();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
tseries.Add(new TValue(bar.Time, bar.Close), true);
|
||||
}
|
||||
|
||||
// Static calculation
|
||||
var staticResult = Standardize.Calculate(tseries, 14);
|
||||
|
||||
// Streaming calculation
|
||||
var streamStandardize = new Standardize(14);
|
||||
var streamResult = new TSeries();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
streamResult.Add(streamStandardize.Update(new TValue(bar.Time, bar.Close)), true);
|
||||
}
|
||||
|
||||
// Compare last 50 values
|
||||
for (int i = 50; i < 100; i++)
|
||||
{
|
||||
Assert.Equal(staticResult[i].Value, streamResult[i].Value, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_StaticCalculate_Span_MatchesStreaming()
|
||||
{
|
||||
var series = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] values = series.Select(b => b.Close).ToArray();
|
||||
double[] output = new double[values.Length];
|
||||
|
||||
// Span calculation
|
||||
Standardize.Calculate(values, output, 14);
|
||||
|
||||
// Streaming calculation
|
||||
var standardize = new Standardize(14);
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, values[i]));
|
||||
Assert.Equal(output[i], result.Value, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_StaticCalculate_Span_ValidatesParameters()
|
||||
{
|
||||
double[] source = [1, 2, 3, 4, 5];
|
||||
double[] output = new double[5];
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Standardize.Calculate([], output));
|
||||
Assert.Throws<ArgumentException>(() => Standardize.Calculate(source, new double[3]));
|
||||
Assert.Throws<ArgumentException>(() => Standardize.Calculate(source, output, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_RollingWindow_AdaptsToNewData()
|
||||
{
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
// Feed: 0, 50, 100 -> window complete
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Mean = 50, value = 100, should be positive z-score
|
||||
Assert.True(standardize.Last.Value > 0);
|
||||
|
||||
// Now feed 0, window becomes [50, 100, 0]
|
||||
// Mean = 50, value = 0, should be negative z-score
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
Assert.True(result.Value < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_SampleStdDev_UsesN_Minus_1()
|
||||
{
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
// Values: 2, 4, 6
|
||||
// Mean = 4
|
||||
// Sum of squared deviations = (2-4)² + (4-4)² + (6-4)² = 4 + 0 + 4 = 8
|
||||
// Sample variance = 8 / (3-1) = 4
|
||||
// Sample StdDev = 2
|
||||
// Z-score of 6: (6 - 4) / 2 = 1
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 2));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 4));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 6));
|
||||
|
||||
Assert.Equal(1.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Symmetry_PositiveAndNegative()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Create symmetric distribution around 50
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 60));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 70));
|
||||
// Mean = 50, StdDev = sqrt(200)
|
||||
|
||||
// Now test symmetry
|
||||
standardize.Reset();
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 60));
|
||||
var zPositive = standardize.Update(new TValue(DateTime.UtcNow, 70)); // Above mean
|
||||
|
||||
standardize.Reset();
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 70));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 60));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
var zNegative = standardize.Update(new TValue(DateTime.UtcNow, 30)); // Below mean
|
||||
|
||||
// Symmetric: |z(70)| should equal |z(30)|
|
||||
Assert.Equal(Math.Abs(zPositive.Value), Math.Abs(zNegative.Value), 1e-10);
|
||||
Assert.True(zPositive.Value > 0, "Z-score for above-mean value should be positive");
|
||||
Assert.True(zNegative.Value < 0, "Z-score for below-mean value should be negative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_NegativeValues_WorksCorrectly()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Range from -100 to +100
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Mean = 0, so z-score of 100 should be positive and equal to z-score of 0
|
||||
// z = (100 - 0) / stdev
|
||||
Assert.True(standardize.Last.Value > 0);
|
||||
|
||||
// Test zero: should have z-score of 0
|
||||
standardize.Reset();
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
var zeroResult = standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
Assert.Equal(0.0, zeroResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Prime_WorksCorrectly()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
double[] primeData = [10, 20, 30, 40, 50];
|
||||
standardize.Prime(primeData);
|
||||
|
||||
Assert.True(standardize.IsHot);
|
||||
// After prime, should have valid z-score
|
||||
Assert.True(double.IsFinite(standardize.Last.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for Standardize indicator.
|
||||
/// Since Standardize is a basic mathematical transformation (z-score), validation focuses on
|
||||
/// mathematical properties rather than external library comparison.
|
||||
/// </summary>
|
||||
public class StandardizeValidationTests
|
||||
{
|
||||
private readonly GBM _gbm = new(100, 0.05, 0.2, seed: 42);
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OutputIsFinite_AllPeriods()
|
||||
{
|
||||
// Test across multiple periods and data sets
|
||||
int[] periods = { 5, 14, 50, 100 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var standardize = new Standardize(period);
|
||||
var series = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(result.Value),
|
||||
$"Period {period}: output {result.Value} is not finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_MeanValue_ReturnsZero()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Create data where all values equal the mean
|
||||
double[] values = [50, 50, 50, 50, 50];
|
||||
|
||||
foreach (var v in values)
|
||||
{
|
||||
standardize.Update(new TValue(DateTime.UtcNow, v));
|
||||
}
|
||||
|
||||
// Value = mean, stdev = 0, should return 0
|
||||
Assert.Equal(0.0, standardize.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OneStdDevAboveMean_ReturnsOne()
|
||||
{
|
||||
// For a known distribution, verify z-score calculation
|
||||
// Values: 2, 4, 6 -> Mean = 4, Sample StdDev = 2
|
||||
// Z-score of 6 = (6 - 4) / 2 = 1
|
||||
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 2));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 4));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 6));
|
||||
|
||||
Assert.Equal(1.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_OneStdDevBelowMean_ReturnsNegativeOne()
|
||||
{
|
||||
// Values: 6, 4, 2 -> Mean = 4, Sample StdDev = 2
|
||||
// Z-score of 2 = (2 - 4) / 2 = -1
|
||||
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 6));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 4));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 2));
|
||||
|
||||
Assert.Equal(-1.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_TwoStdDevsAboveMean_ReturnsTwo()
|
||||
{
|
||||
// Values: 0, 4, 8 -> Mean = 4, Sample StdDev = 4
|
||||
// Z-score of 12 = (12 - 4) / 4 = 2
|
||||
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 4));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 8));
|
||||
|
||||
// Now add 12 to the window
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 12));
|
||||
// Window is now [4, 8, 12], Mean = 8, StdDev = 4
|
||||
// Z-score = (12 - 8) / 4 = 1.0
|
||||
|
||||
Assert.Equal(1.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_ManualCalculation_Matches()
|
||||
{
|
||||
// Manual calculation test
|
||||
var standardize = new Standardize(4);
|
||||
|
||||
double[] values = [10, 20, 30, 40];
|
||||
|
||||
foreach (var v in values)
|
||||
{
|
||||
standardize.Update(new TValue(DateTime.UtcNow, v));
|
||||
}
|
||||
|
||||
// Mean = (10 + 20 + 30 + 40) / 4 = 25
|
||||
// Sum of squared deviations = (10-25)² + (20-25)² + (30-25)² + (40-25)²
|
||||
// = 225 + 25 + 25 + 225 = 500
|
||||
// Sample variance = 500 / 3 = 166.667
|
||||
// Sample StdDev = sqrt(166.667) ≈ 12.91
|
||||
// Z-score of 40 = (40 - 25) / 12.91 ≈ 1.162
|
||||
|
||||
double mean = 25.0;
|
||||
double sampleVariance = 500.0 / 3.0;
|
||||
double sampleStdDev = Math.Sqrt(sampleVariance);
|
||||
double expectedZ = (40.0 - mean) / sampleStdDev;
|
||||
|
||||
Assert.Equal(expectedZ, standardize.Last.Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Symmetry_OppositeSignsForSymmetricValues()
|
||||
{
|
||||
// For symmetric values around the mean, z-scores should be opposite
|
||||
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Window: -20, -10, 0, 10, 20 -> Mean = 0
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
var zFor20 = standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
|
||||
// Window: 20, 10, 0, -10, -20 -> Mean = 0
|
||||
standardize.Reset();
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -10));
|
||||
var zForMinus20 = standardize.Update(new TValue(DateTime.UtcNow, -20));
|
||||
|
||||
// |z(20)| should equal |z(-20)| and have opposite signs
|
||||
Assert.Equal(Math.Abs(zFor20.Value), Math.Abs(zForMinus20.Value), 1e-10);
|
||||
Assert.True(zFor20.Value > 0);
|
||||
Assert.True(zForMinus20.Value < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_RollingWindow_AdaptsToNewData()
|
||||
{
|
||||
var standardize = new Standardize(3);
|
||||
|
||||
// Initial window: 0, 50, 100
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Mean = 50, value = 100 is above mean
|
||||
Assert.True(standardize.Last.Value > 0);
|
||||
|
||||
// Add 0, window becomes [50, 100, 0]
|
||||
// Mean = 50, value = 0 is below mean
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
Assert.True(result.Value < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_NegativeValues_WorksCorrectly()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// All negative values
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -75));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, -25));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
|
||||
// 0 is above the mean of negative values
|
||||
Assert.True(result.Value > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_LargeValues_StillPrecise()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Use larger differences to avoid floating-point precision issues
|
||||
double baseVal = 1e6; // Smaller base, larger differences
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal - 200));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal - 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal + 100));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, baseVal + 200));
|
||||
|
||||
// Mean = baseVal, should still give reasonable z-score
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value > 0, $"Expected positive z-score for above-mean value, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_SmallDifferences_StillPrecise()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Very small differences
|
||||
double baseVal = 100.0;
|
||||
double epsilon = 1e-8;
|
||||
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal + epsilon));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal + 2 * epsilon));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, baseVal + 3 * epsilon));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, baseVal + 4 * epsilon));
|
||||
|
||||
// Should be finite and reasonable
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_StreamingVsBatch_Match()
|
||||
{
|
||||
var series = _gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] values = series.Select(b => b.Close).ToArray();
|
||||
|
||||
// Streaming
|
||||
var streamStandardize = new Standardize(14);
|
||||
double[] streamResults = new double[values.Length];
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
streamResults[i] = streamStandardize.Update(new TValue(DateTime.UtcNow, values[i])).Value;
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] batchResults = new double[values.Length];
|
||||
Standardize.Calculate(values, batchResults, 14);
|
||||
|
||||
// Compare all values
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
Assert.Equal(batchResults[i], streamResults[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_AllModes_Consistent()
|
||||
{
|
||||
var series = _gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
int period = 14;
|
||||
|
||||
// Mode 1: Streaming via Update(TValue)
|
||||
var standardize1 = new Standardize(period);
|
||||
var results1 = new List<double>();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
results1.Add(standardize1.Update(new TValue(bar.Time, bar.Close)).Value);
|
||||
}
|
||||
|
||||
// Mode 2: Batch via Update(TSeries)
|
||||
var tseries = new TSeries();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
tseries.Add(new TValue(bar.Time, bar.Close), true);
|
||||
}
|
||||
|
||||
var results2 = Standardize.Calculate(tseries, period);
|
||||
|
||||
// Mode 3: Static span Calculate
|
||||
double[] values = series.Select(b => b.Close).ToArray();
|
||||
double[] results3 = new double[values.Length];
|
||||
Standardize.Calculate(values, results3, period);
|
||||
|
||||
// Mode 4: Event-based chaining
|
||||
var source = new TSeries();
|
||||
var standardize4 = new Standardize(source, period);
|
||||
foreach (var bar in series)
|
||||
{
|
||||
source.Add(new TValue(bar.Time, bar.Close), true);
|
||||
}
|
||||
|
||||
double results4 = standardize4.Last.Value;
|
||||
|
||||
// Compare all modes (use last 50 values for stability)
|
||||
for (int i = 50; i < 100; i++)
|
||||
{
|
||||
Assert.Equal(results1[i], results2[i].Value, 1e-10);
|
||||
Assert.Equal(results1[i], results3[i], 1e-10);
|
||||
}
|
||||
// Verify Mode 4 matches last value from other modes
|
||||
Assert.Equal(results1[^1], results4, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_BarCorrection_WorksCorrectly()
|
||||
{
|
||||
var standardize = new Standardize(5);
|
||||
|
||||
// Build up buffer
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
// New bar
|
||||
var first = standardize.Update(new TValue(DateTime.UtcNow, 75), isNew: true);
|
||||
|
||||
// Correction (same bar, different value)
|
||||
var corrected = standardize.Update(new TValue(DateTime.UtcNow, 25), isNew: false);
|
||||
|
||||
// Values should be different
|
||||
Assert.NotEqual(first.Value, corrected.Value);
|
||||
|
||||
// Further correction should still work
|
||||
var corrected2 = standardize.Update(new TValue(DateTime.UtcNow, 50), isNew: false);
|
||||
Assert.NotEqual(corrected.Value, corrected2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_Period2_IsMinimum()
|
||||
{
|
||||
var standardize = new Standardize(2);
|
||||
|
||||
// With only 2 values, sample stdev is still meaningful
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 0));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
// Mean = 50, Sample StdDev = sqrt(((0-50)² + (100-50)²) / 1) = sqrt(5000) ≈ 70.71
|
||||
// Z-score of 100 = (100 - 50) / 70.71 ≈ 0.707
|
||||
double mean = 50.0;
|
||||
double sampleVariance = (2500.0 + 2500.0) / 1.0; // N-1 = 1
|
||||
double sampleStdDev = Math.Sqrt(sampleVariance);
|
||||
double expectedZ = (100.0 - mean) / sampleStdDev;
|
||||
|
||||
Assert.Equal(expectedZ, result.Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_VeryLargePeriod_StillWorks()
|
||||
{
|
||||
var standardize = new Standardize(1000);
|
||||
var series = _gbm.Fetch(1500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
Assert.True(standardize.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_ZScoreDistribution_ReasonableForFinancialData()
|
||||
{
|
||||
var standardize = new Standardize(50);
|
||||
var series = _gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var zScores = new List<double>();
|
||||
foreach (var bar in series)
|
||||
{
|
||||
var result = standardize.Update(new TValue(bar.Time, bar.Close));
|
||||
if (standardize.IsHot)
|
||||
{
|
||||
zScores.Add(result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// For financial data (GBM returns lognormal data), the 68% rule doesn't apply directly
|
||||
// However, most z-scores should still be within reasonable bounds (±3)
|
||||
int withinThreeStdDev = zScores.Count(z => Math.Abs(z) <= 3);
|
||||
double ratio = (double)withinThreeStdDev / zScores.Count;
|
||||
|
||||
// At least 90% should be within ±3 for any reasonable distribution
|
||||
Assert.True(ratio > 0.90,
|
||||
$"Expected >90% of z-scores within ±3, got {ratio * 100:F1}%");
|
||||
|
||||
// Verify z-scores are reasonably distributed (not all extreme)
|
||||
int moderate = zScores.Count(z => Math.Abs(z) <= 2);
|
||||
double moderateRatio = (double)moderate / zScores.Count;
|
||||
|
||||
Assert.True(moderateRatio > 0.70,
|
||||
$"Expected >70% of z-scores within ±2, got {moderateRatio * 100:F1}%");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardize_SampleVsPopulationStdDev_UsesSample()
|
||||
{
|
||||
// Verify Bessel's correction (N-1) is used, not N
|
||||
|
||||
var standardize = new Standardize(4);
|
||||
|
||||
// Values: 10, 20, 30, 40
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 10));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 20));
|
||||
standardize.Update(new TValue(DateTime.UtcNow, 30));
|
||||
var result = standardize.Update(new TValue(DateTime.UtcNow, 40));
|
||||
|
||||
// Mean = 25
|
||||
// Population variance = ((10-25)² + (20-25)² + (30-25)² + (40-25)²) / 4 = 500/4 = 125
|
||||
// Sample variance = 500 / 3 = 166.667
|
||||
|
||||
double mean = 25.0;
|
||||
double popStdDev = Math.Sqrt(125.0);
|
||||
double sampleStdDev = Math.Sqrt(500.0 / 3.0);
|
||||
|
||||
double zWithPopulation = (40.0 - mean) / popStdDev;
|
||||
double zWithSample = (40.0 - mean) / sampleStdDev;
|
||||
|
||||
// Result should match sample (N-1) calculation, NOT population (N)
|
||||
Assert.Equal(zWithSample, result.Value, 1e-10);
|
||||
Assert.NotEqual(zWithPopulation, result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// STANDARDIZE: Z-Score Normalization
|
||||
// Calculates the z-score (standard score) of values over a lookback period
|
||||
// Formula: z = (x - μ) / σ where σ uses sample standard deviation (N-1)
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// STANDARDIZE: Z-Score Normalization
|
||||
/// Calculates the z-score of values over a lookback period using sample standard deviation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Key properties:
|
||||
/// - Output is unbounded (can be any real number, typically -3 to +3 for normal data)
|
||||
/// - Uses sample standard deviation (Bessel's correction, N-1 denominator)
|
||||
/// - Requires period >= 2 for meaningful standard deviation calculation
|
||||
/// - When stdev is zero (flat data), returns 0 if value equals mean, NaN otherwise
|
||||
/// - Commonly used for anomaly detection and inter-series comparison
|
||||
///
|
||||
/// Formula: z = (x - mean) / sample_stdev
|
||||
/// where sample_stdev = sqrt(sum((x_i - mean)^2) / (N - 1))
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Standardize : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly RingBuffer _buffer;
|
||||
|
||||
// Welford's online algorithm state for numerical stability
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(double LastValidZScore, double Sum, double SumSq, int ValidCount);
|
||||
private State _state, _p_state;
|
||||
|
||||
public override bool IsHot => _buffer.Count >= _period;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new Standardize indicator with specified lookback period.
|
||||
/// </summary>
|
||||
/// <param name="period">Lookback period for z-score calculation (default 20, must be >= 2)</param>
|
||||
public Standardize(int period = 20)
|
||||
{
|
||||
if (period < 2)
|
||||
{
|
||||
throw new ArgumentException("Period must be >= 2 for sample standard deviation", nameof(period));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Standardize({period})";
|
||||
WarmupPeriod = period;
|
||||
_state = new State(0.0, 0.0, 0.0, 0);
|
||||
_p_state = _state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new Standardize indicator with source for event-based chaining.
|
||||
/// </summary>
|
||||
/// <param name="source">Source indicator for chaining</param>
|
||||
/// <param name="period">Lookback period (default 20)</param>
|
||||
public Standardize(ITValuePublisher source, int period = 20) : this(period)
|
||||
{
|
||||
source.Pub += HandleUpdate;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
}
|
||||
|
||||
double value = input.Value;
|
||||
double result;
|
||||
|
||||
if (double.IsFinite(value))
|
||||
{
|
||||
_buffer.Add(value, isNew);
|
||||
|
||||
// Compute mean and sample variance from buffer
|
||||
ReadOnlySpan<double> data = _buffer.GetSpan();
|
||||
int n = data.Length;
|
||||
|
||||
if (n < 2)
|
||||
{
|
||||
// Not enough data for sample stdev
|
||||
result = 0.0;
|
||||
_state = new State(result, value, value * value, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate sum and sum of squares
|
||||
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 mean = sum / n;
|
||||
|
||||
// Population variance: (sumSq / n) - mean^2
|
||||
// Sample variance: (sumSq - n * mean^2) / (n - 1) = n / (n-1) * popVar
|
||||
double popVariance = (sumSq / n) - (mean * mean);
|
||||
|
||||
// Numerical stability: clamp tiny negative values to zero
|
||||
if (popVariance < 1e-10)
|
||||
{
|
||||
popVariance = 0.0;
|
||||
}
|
||||
|
||||
double sampleVariance = popVariance * n / (n - 1);
|
||||
double stdev = Math.Sqrt(sampleVariance);
|
||||
|
||||
if (stdev > 1e-10)
|
||||
{
|
||||
result = (value - mean) / stdev;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stdev is essentially zero - all values are the same
|
||||
// Return 0 as neutral z-score
|
||||
result = 0.0;
|
||||
}
|
||||
|
||||
_state = new State(result, sum, sumSq, n);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Invalid input - return last valid z-score
|
||||
result = _state.LastValidZScore;
|
||||
}
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
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++)
|
||||
{
|
||||
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
|
||||
result.Add(tv, true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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 Calculate(TSeries source, int period = 20)
|
||||
{
|
||||
var indicator = new Standardize(period);
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Z-score normalization over a span of values.
|
||||
/// </summary>
|
||||
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int period = 20)
|
||||
{
|
||||
if (source.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Source cannot be empty", nameof(source));
|
||||
}
|
||||
|
||||
if (output.Length < source.Length)
|
||||
{
|
||||
throw new ArgumentException("Output length must be >= source length", nameof(output));
|
||||
}
|
||||
|
||||
if (period < 2)
|
||||
{
|
||||
throw new ArgumentException("Period must be >= 2", nameof(period));
|
||||
}
|
||||
|
||||
double lastValid = 0.0;
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
output[i] = lastValid;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine window bounds
|
||||
int start = Math.Max(0, i - period + 1);
|
||||
int n = 0;
|
||||
double sum = 0.0;
|
||||
double sumSq = 0.0;
|
||||
|
||||
// Calculate sum and count of finite values in window
|
||||
for (int j = start; j <= i; j++)
|
||||
{
|
||||
double v = source[j];
|
||||
if (double.IsFinite(v))
|
||||
{
|
||||
sum += v;
|
||||
sumSq += v * v;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
if (n < 2)
|
||||
{
|
||||
output[i] = 0.0;
|
||||
lastValid = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
double mean = sum / n;
|
||||
double popVariance = (sumSq / n) - (mean * mean);
|
||||
|
||||
if (popVariance < 1e-10)
|
||||
{
|
||||
popVariance = 0.0;
|
||||
}
|
||||
|
||||
double sampleVariance = popVariance * n / (n - 1);
|
||||
double stdev = Math.Sqrt(sampleVariance);
|
||||
|
||||
double result;
|
||||
if (stdev > 1e-10)
|
||||
{
|
||||
result = (val - mean) / stdev;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = 0.0;
|
||||
}
|
||||
|
||||
lastValid = result;
|
||||
output[i] = result;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_state = new State(0.0, 0.0, 0.0, 0);
|
||||
_p_state = _state;
|
||||
Last = default;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user