mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 21:18:04 +00:00
docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files
- Remove 'C# Implementation Considerations' sections from 34 indicator .md files - Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.) - Move test files into tests/ subdirectories for consistent project structure - Add trader-focused bullet points to indicator documentation
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Quantower.Tests;
|
||||
|
||||
public class DspIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void DspIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new DspIndicator();
|
||||
|
||||
Assert.Equal(40, indicator.Period);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("DSP - Ehlers Detrended Synthetic Price", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new DspIndicator();
|
||||
|
||||
Assert.Equal(0, DspIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_ShortName_IncludesPeriod()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
|
||||
Assert.True(indicator.ShortName.Contains("DSP", StringComparison.Ordinal));
|
||||
Assert.True(indicator.ShortName.Contains("20", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_Initialize_CreatesInternalDsp()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 40 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (DSP + Zero line)
|
||||
Assert.Equal(2, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
// Process update
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
|
||||
// Line series should have a value
|
||||
Assert.Equal(1, indicator.LinesSeries[0].Count);
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Should not throw an exception
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
// Assert that the indicator still exists (method completed without exception)
|
||||
Assert.NotNull(indicator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
double[] closes = { 100, 102, 105, 103, 107, 110, 108, 112, 115, 113 };
|
||||
|
||||
foreach (var close in closes)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now, close, close + 2, close - 2, close);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
now = now.AddMinutes(1);
|
||||
}
|
||||
|
||||
// All values should be finite
|
||||
for (int i = 0; i < closes.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(closes.Length - 1 - i)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_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 DspIndicator { Period = 20, Source = source };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
|
||||
$"Source {source} should produce finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_Period_CanBeChanged()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 40 };
|
||||
|
||||
Assert.Equal(40, indicator.Period);
|
||||
|
||||
indicator.Period = 20;
|
||||
Assert.Equal(20, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_Source_CanBeChanged()
|
||||
{
|
||||
var indicator = new DspIndicator { Source = SourceType.Close };
|
||||
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
|
||||
indicator.Source = SourceType.Open;
|
||||
Assert.Equal(SourceType.Open, indicator.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_ShowColdValues_CanBeChanged()
|
||||
{
|
||||
var indicator = new DspIndicator { ShowColdValues = true };
|
||||
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_ShortName_UpdatesWhenPeriodChanges()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 40 };
|
||||
string initialName = indicator.ShortName;
|
||||
|
||||
Assert.True(initialName.Contains("40", StringComparison.Ordinal));
|
||||
|
||||
indicator.Period = 20;
|
||||
string updatedName = indicator.ShortName;
|
||||
|
||||
Assert.True(updatedName.Contains("20", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_ProcessUpdate_IgnoresNonBarUpdates()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
// Process historical bar first
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Process other update reasons - should not throw
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
// Assert that the indicator still exists (method completed without exception)
|
||||
Assert.NotNull(indicator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_LineSeries_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 40 };
|
||||
indicator.Initialize();
|
||||
|
||||
var lineSeries = indicator.LinesSeries[0];
|
||||
|
||||
Assert.Equal("DSP", lineSeries.Name);
|
||||
Assert.Equal(2, lineSeries.Width);
|
||||
Assert.Equal(LineStyle.Solid, lineSeries.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_ZeroLine_HasCorrectProperties()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 40 };
|
||||
indicator.Initialize();
|
||||
|
||||
var zeroLine = indicator.LinesSeries[1];
|
||||
|
||||
Assert.Equal("Zero", zeroLine.Name);
|
||||
Assert.Equal(1, zeroLine.Width);
|
||||
Assert.Equal(LineStyle.Dash, zeroLine.Style);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
var periods = new[] { 8, 20, 40, 80 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var indicator = new DspIndicator { Period = period };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add enough bars to fill the buffer
|
||||
for (int i = 0; i < period + 10; i++)
|
||||
{
|
||||
double close = 100 + (i % 10);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), close, close + 2, close - 2, close);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// Last value should be finite
|
||||
double dspValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(dspValue), $"Period {period} should produce finite value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_ConstantPrice_ProducesZeroDsp()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add constant price bars - need enough for EMAs to converge
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// DSP should be approximately zero for constant price after convergence
|
||||
// Tolerance allows for floating-point rounding in EMA bias correction
|
||||
double dspValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(Math.Abs(dspValue) < 0.01, $"Constant price should produce near-zero DSP, got {dspValue}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_Uptrend_ProducesPositiveDsp()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add uptrending price bars
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// DSP should be positive for uptrend (fast EMA > slow EMA)
|
||||
double dspValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(dspValue > 0, $"Uptrend should produce positive DSP, got {dspValue}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_Downtrend_ProducesNegativeDsp()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Add downtrending price bars
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double price = 200 - i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// DSP should be negative for downtrend (fast EMA < slow EMA)
|
||||
double dspValue = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(dspValue < 0, $"Downtrend should produce negative DSP, got {dspValue}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DspIndicator_OscillatesAroundZero_ForSineWave()
|
||||
{
|
||||
var indicator = new DspIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var values = new List<double>();
|
||||
|
||||
// Generate sine wave price pattern
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(i * 0.1);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
values.Add(indicator.LinesSeries[0].GetValue(0));
|
||||
}
|
||||
|
||||
// Should have both positive and negative values
|
||||
int positiveCount = values.Count(v => v > 0);
|
||||
int negativeCount = values.Count(v => v < 0);
|
||||
|
||||
Assert.True(positiveCount > 0, "Should have positive DSP values");
|
||||
Assert.True(negativeCount > 0, "Should have negative DSP values");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class DspTests
|
||||
{
|
||||
private const double Tolerance = 1e-9;
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidPeriod_SetsProperties()
|
||||
{
|
||||
var dsp = new Dsp(40);
|
||||
|
||||
Assert.Equal("Dsp(40)", dsp.Name);
|
||||
Assert.False(dsp.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MinimumPeriod_Works()
|
||||
{
|
||||
var dsp = new Dsp(4);
|
||||
|
||||
Assert.Equal("Dsp(4)", dsp.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(3)]
|
||||
public void Constructor_InvalidPeriod_ThrowsArgumentOutOfRange(int period)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Dsp(period));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullSource_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new Dsp(null!, 40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidSource_Subscribes()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var dsp = new Dsp(source, 40);
|
||||
|
||||
source.Add(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.NotEqual(default, dsp.Last);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basic Calculation Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsValidTValue()
|
||||
{
|
||||
var dsp = new Dsp(40);
|
||||
var result = dsp.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AfterWarmup_IsHotTrue()
|
||||
{
|
||||
var dsp = new Dsp(8); // Small period for faster warmup
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
dsp.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
Assert.True(dsp.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantSeries_DspIsZero()
|
||||
{
|
||||
// For a constant series, both EMAs converge to the same value
|
||||
// so DSP = fast - slow = 0
|
||||
var dsp = new Dsp(40);
|
||||
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
|
||||
}
|
||||
|
||||
Assert.Equal(0.0, dsp.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Uptrend_DspPositive()
|
||||
{
|
||||
// Fast EMA reacts more quickly to rising prices, so DSP > 0
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100.0 + i * 1.0;
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
|
||||
Assert.True(dsp.Last.Value > 0, $"Uptrend should produce positive DSP, got {dsp.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Downtrend_DspNegative()
|
||||
{
|
||||
// Fast EMA reacts more quickly to falling prices, so DSP < 0
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 200.0 - i * 1.0;
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
|
||||
Assert.True(dsp.Last.Value < 0, $"Downtrend should produce negative DSP, got {dsp.Last.Value}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bar Correction Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
dsp.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
var first = dsp.Last.Value;
|
||||
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 110.0), isNew: true);
|
||||
var second = dsp.Last.Value;
|
||||
|
||||
// Values should be different after processing different prices
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_ReplacesCurrentBar()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
dsp.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 110.0), isNew: true);
|
||||
var beforeCorrection = dsp.Last.Value;
|
||||
|
||||
// Correct the bar with a different value
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 90.0), isNew: false);
|
||||
var afterCorrection = dsp.Last.Value;
|
||||
|
||||
Assert.NotEqual(beforeCorrection, afterCorrection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_MultipleCorrections_RestoresToSnapshot()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
// Build some history
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), isNew: true);
|
||||
}
|
||||
|
||||
// Add a new bar
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(30), 150.0), isNew: true);
|
||||
var originalValue = dsp.Last.Value;
|
||||
|
||||
// Correct multiple times
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(30), 160.0), isNew: false);
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(30), 140.0), isNew: false);
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(30), 150.0), isNew: false);
|
||||
var restoredValue = dsp.Last.Value;
|
||||
|
||||
Assert.Equal(originalValue, restoredValue, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.True(dsp.IsHot);
|
||||
|
||||
dsp.Reset();
|
||||
|
||||
Assert.False(dsp.IsHot);
|
||||
Assert.Equal(default, dsp.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_AllowsReuse()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
// First run
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
|
||||
}
|
||||
var firstResult = dsp.Last.Value;
|
||||
|
||||
dsp.Reset();
|
||||
|
||||
// Second run with same data
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
|
||||
}
|
||||
var secondResult = dsp.Last.Value;
|
||||
|
||||
Assert.Equal(firstResult, secondResult, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NaN/Infinity Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_NaN_UsesLastValidValue()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
dsp.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.NaN));
|
||||
var afterNaN = dsp.Last.Value;
|
||||
|
||||
Assert.True(double.IsFinite(afterNaN));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Infinity_UsesLastValidValue()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
dsp.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.PositiveInfinity));
|
||||
|
||||
Assert.True(double.IsFinite(dsp.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NegativeInfinity_UsesLastValidValue()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
dsp.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(1), double.NegativeInfinity));
|
||||
|
||||
Assert.True(double.IsFinite(dsp.Last.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consistency Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(42)]
|
||||
[InlineData(123)]
|
||||
[InlineData(999)]
|
||||
public void Update_StreamingMatchesBatch(int seed)
|
||||
{
|
||||
const int period = 40;
|
||||
const int dataLen = 100;
|
||||
|
||||
var gbm = new GBM(seed: seed);
|
||||
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Streaming
|
||||
var streaming = new Dsp(period);
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
streaming.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Batch via TSeries
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var batch = Dsp.Batch(tSeries, period);
|
||||
|
||||
// Compare last values
|
||||
Assert.Equal(batch[^1].Value, streaming.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_MatchesStreaming()
|
||||
{
|
||||
const int period = 20;
|
||||
const int dataLen = 200;
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Streaming
|
||||
var streaming = new Dsp(period);
|
||||
var streamingResults = new double[dataLen];
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
streaming.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
streamingResults[i] = streaming.Last.Value;
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] source = new double[dataLen];
|
||||
double[] batchResults = new double[dataLen];
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
source[i] = bars[i].Close;
|
||||
}
|
||||
|
||||
Dsp.Batch(source, batchResults, period);
|
||||
|
||||
// Compare all values
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batchResults[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Span API Tests
|
||||
|
||||
[Fact]
|
||||
public void Batch_ValidatesLengthMismatch()
|
||||
{
|
||||
double[] source = new double[100];
|
||||
double[] output = new double[50];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => Dsp.Batch(source, output, 20));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ValidatesPeriod()
|
||||
{
|
||||
double[] source = new double[100];
|
||||
double[] output = new double[100];
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Dsp.Batch(source, output, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptyArrays_NoException()
|
||||
{
|
||||
double[] source = [];
|
||||
double[] output = [];
|
||||
|
||||
var ex = Record.Exception(() => Dsp.Batch(source, output, 20));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_HandlesNaN()
|
||||
{
|
||||
double[] source = { 100, 101, double.NaN, 103, 104 };
|
||||
double[] output = new double[5];
|
||||
|
||||
Dsp.Batch(source, output, 4);
|
||||
|
||||
foreach (double v in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(v));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Chaining Tests
|
||||
|
||||
[Fact]
|
||||
public void Chaining_PropagatesUpdates()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var dsp = new Dsp(source, 20);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.True(dsp.IsHot);
|
||||
Assert.True(double.IsFinite(dsp.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chaining_MultipleIndicators()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var dsp1 = new Dsp(source, 20);
|
||||
var dsp2 = new Dsp(source, 40);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.1) * 10));
|
||||
}
|
||||
|
||||
// Both should have values
|
||||
Assert.True(double.IsFinite(dsp1.Last.Value));
|
||||
Assert.True(double.IsFinite(dsp2.Last.Value));
|
||||
|
||||
// Different periods should produce different results
|
||||
Assert.NotEqual(dsp1.Last.Value, dsp2.Last.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Period Behavior Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(4)]
|
||||
[InlineData(20)]
|
||||
[InlineData(40)]
|
||||
[InlineData(100)]
|
||||
public void Update_DifferentPeriods_ProducesValidResults(int period)
|
||||
{
|
||||
var dsp = new Dsp(period);
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
dsp.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
Assert.True(dsp.IsHot);
|
||||
Assert.True(double.IsFinite(dsp.Last.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
using Xunit;
|
||||
|
||||
using OoplesFinance.StockIndicators;
|
||||
using OoplesFinance.StockIndicators.Models;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for DSP (Detrended Synthetic Price).
|
||||
/// DSP is Ehlers' indicator not commonly implemented in trading libraries
|
||||
/// (TA-Lib, Skender, Tulip), so validation is done against mathematical properties
|
||||
/// and known theoretical results based on the original PineScript implementation.
|
||||
/// </summary>
|
||||
public class DspValidationTests
|
||||
{
|
||||
private const double Tolerance = 1e-9;
|
||||
|
||||
#region Mathematical Property Validation
|
||||
|
||||
[Fact]
|
||||
public void Validation_ConstantSeries_DspConvergesToZero()
|
||||
{
|
||||
// For constant input, both EMAs converge to the same value
|
||||
// DSP = fast_ema - slow_ema = constant - constant = 0
|
||||
var dsp = new Dsp(40);
|
||||
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
|
||||
}
|
||||
|
||||
Assert.Equal(0.0, dsp.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_OscillatesAroundZero()
|
||||
{
|
||||
// DSP should oscillate around zero over time
|
||||
var dsp = new Dsp(40);
|
||||
var values = new List<double>();
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
dsp.Update(new TValue(bar.Time, bar.Close));
|
||||
if (dsp.IsHot)
|
||||
{
|
||||
values.Add(dsp.Last.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have both positive and negative values
|
||||
int positiveCount = values.Count(v => v > 0);
|
||||
int negativeCount = values.Count(v => v < 0);
|
||||
|
||||
Assert.True(positiveCount > 0, "Should have positive DSP values");
|
||||
Assert.True(negativeCount > 0, "Should have negative DSP values");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_ZeroCrossings_IndicateMomentumShifts()
|
||||
{
|
||||
// DSP should cross zero when momentum shifts
|
||||
var dsp = new Dsp(20);
|
||||
var values = new List<double>();
|
||||
|
||||
// Generate sine wave to simulate price oscillation
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
double price = 100.0 + 10.0 * Math.Sin(i * 0.1);
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
if (dsp.IsHot)
|
||||
{
|
||||
values.Add(dsp.Last.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Count zero crossings
|
||||
int crossings = 0;
|
||||
for (int i = 1; i < values.Count; i++)
|
||||
{
|
||||
if (values[i - 1] * values[i] < 0)
|
||||
{
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
|
||||
// Should have multiple zero crossings for oscillating price
|
||||
Assert.True(crossings >= 3, $"Should have multiple zero crossings, got {crossings}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PineScript Formula Verification
|
||||
|
||||
[Fact]
|
||||
public void Validation_PeriodCalculation_QuarterAndHalfCycle()
|
||||
{
|
||||
// Verify period calculations match PineScript
|
||||
// For period = 40:
|
||||
// fast_period = max(2, round(40/4)) = max(2, 10) = 10
|
||||
// slow_period = max(3, round(40/2)) = max(3, 20) = 20
|
||||
|
||||
const int period = 40;
|
||||
int expectedFast = Math.Max(2, (int)Math.Round(period / 4.0));
|
||||
int expectedSlow = Math.Max(3, (int)Math.Round(period / 2.0));
|
||||
|
||||
Assert.Equal(10, expectedFast);
|
||||
Assert.Equal(20, expectedSlow);
|
||||
|
||||
// The indicator should use these periods internally
|
||||
var dsp = new Dsp(period);
|
||||
Assert.True(dsp.Name.Contains("40", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_SmallPeriod_MinimumPeriodClamping()
|
||||
{
|
||||
// For period = 4:
|
||||
// fast_period = max(2, round(4/4)) = max(2, 1) = 2
|
||||
// slow_period = max(3, round(4/2)) = max(3, 2) = 3
|
||||
|
||||
const int period = 4;
|
||||
int expectedFast = Math.Max(2, (int)Math.Round(period / 4.0));
|
||||
int expectedSlow = Math.Max(3, (int)Math.Round(period / 2.0));
|
||||
|
||||
Assert.Equal(2, expectedFast);
|
||||
Assert.Equal(3, expectedSlow);
|
||||
|
||||
// Indicator should still work with minimum period
|
||||
var dsp = new Dsp(period);
|
||||
dsp.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.True(double.IsFinite(dsp.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_EmaFormula_CorrectAlpha()
|
||||
{
|
||||
// alpha = 2 / (period + 1)
|
||||
// For fast_period = 10: alpha_fast = 2/11 ≈ 0.1818
|
||||
// For slow_period = 20: alpha_slow = 2/21 ≈ 0.0952
|
||||
|
||||
const int period = 40;
|
||||
int fastPeriod = Math.Max(2, (int)Math.Round(period / 4.0));
|
||||
int slowPeriod = Math.Max(3, (int)Math.Round(period / 2.0));
|
||||
|
||||
double alphaFast = 2.0 / (fastPeriod + 1);
|
||||
double alphaSlow = 2.0 / (slowPeriod + 1);
|
||||
|
||||
Assert.Equal(2.0 / 11.0, alphaFast, 1e-10);
|
||||
Assert.Equal(2.0 / 21.0, alphaSlow, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_DspSign_MatchesPriceDirection()
|
||||
{
|
||||
// Rising prices -> fast EMA > slow EMA -> DSP > 0
|
||||
// Falling prices -> fast EMA < slow EMA -> DSP < 0
|
||||
|
||||
var dspUp = new Dsp(20);
|
||||
var dspDown = new Dsp(20);
|
||||
|
||||
// Uptrend
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
dspUp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
// Downtrend
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
dspDown.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 200.0 - i));
|
||||
}
|
||||
|
||||
Assert.True(dspUp.Last.Value > 0, $"Uptrend DSP should be positive, got {dspUp.Last.Value}");
|
||||
Assert.True(dspDown.Last.Value < 0, $"Downtrend DSP should be negative, got {dspDown.Last.Value}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Streaming vs Batch Consistency
|
||||
|
||||
[Theory]
|
||||
[InlineData(42)]
|
||||
[InlineData(123)]
|
||||
[InlineData(999)]
|
||||
public void Validation_StreamingMatchesBatch(int seed)
|
||||
{
|
||||
const int period = 40;
|
||||
const int dataLen = 100;
|
||||
|
||||
var gbm = new GBM(seed: seed);
|
||||
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Streaming
|
||||
var streaming = new Dsp(period);
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
streaming.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Batch via TSeries
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var batch = Dsp.Batch(tSeries, period);
|
||||
|
||||
// Compare last values
|
||||
Assert.Equal(batch[^1].Value, streaming.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_SpanMatchesTSeries()
|
||||
{
|
||||
const int period = 20;
|
||||
const int dataLen = 200;
|
||||
|
||||
var gbm = new GBM(seed: 77);
|
||||
var bars = gbm.Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// TSeries approach
|
||||
var tSeries = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
tSeries.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var tSeriesResult = Dsp.Batch(tSeries, period);
|
||||
|
||||
// Span approach
|
||||
double[] source = new double[dataLen];
|
||||
double[] spanResult = new double[dataLen];
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
source[i] = bars[i].Close;
|
||||
}
|
||||
|
||||
Dsp.Batch(source, spanResult, period);
|
||||
|
||||
// Compare all values
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
Assert.Equal(tSeriesResult[i].Value, spanResult[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Different Period Sizes
|
||||
|
||||
[Theory]
|
||||
[InlineData(4)]
|
||||
[InlineData(20)]
|
||||
[InlineData(40)]
|
||||
[InlineData(80)]
|
||||
public void Validation_DifferentPeriods_ConsistentResults(int period)
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var dsp = new Dsp(period);
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
dsp.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
Assert.True(dsp.IsHot);
|
||||
Assert.True(double.IsFinite(dsp.Last.Value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(8)]
|
||||
[InlineData(20)]
|
||||
[InlineData(40)]
|
||||
public void Validation_LongerPeriod_SmallerMagnitude(int period)
|
||||
{
|
||||
// Longer period EMAs are closer together, resulting in smaller DSP magnitude
|
||||
var dsp = new Dsp(period);
|
||||
var magnitudes = new List<double>();
|
||||
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
dsp.Update(new TValue(bar.Time, bar.Close));
|
||||
if (dsp.IsHot)
|
||||
{
|
||||
magnitudes.Add(Math.Abs(dsp.Last.Value));
|
||||
}
|
||||
}
|
||||
|
||||
double avgMagnitude = magnitudes.Average();
|
||||
Assert.True(avgMagnitude > 0, "Should have non-zero average magnitude");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void Validation_VerySmallPrices_HandledCorrectly()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 0.0001 + i * 0.00001;
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
|
||||
Assert.True(dsp.IsHot);
|
||||
Assert.True(double.IsFinite(dsp.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_VeryLargePrices_HandledCorrectly()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 1e10 + i * 1e8;
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
|
||||
Assert.True(dsp.IsHot);
|
||||
Assert.True(double.IsFinite(dsp.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_HighVolatility_StableResults()
|
||||
{
|
||||
var dsp = new Dsp(20);
|
||||
|
||||
var gbm = new GBM(seed: 42, sigma: 0.5); // High volatility
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
dsp.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(dsp.Last.Value), "DSP should remain finite under high volatility");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Detrending Property
|
||||
|
||||
[Fact]
|
||||
public void Validation_Detrending_RemovesTrend()
|
||||
{
|
||||
// DSP should remove the trend component
|
||||
// For a strong trend, DSP should still oscillate around zero
|
||||
var dsp = new Dsp(20);
|
||||
var values = new List<double>();
|
||||
|
||||
// Strong uptrend with some noise
|
||||
for (int i = 0; i < 300; i++)
|
||||
{
|
||||
double trend = 100.0 + i * 0.5;
|
||||
double noise = Math.Sin(i * 0.3) * 2.0;
|
||||
double price = trend + noise;
|
||||
dsp.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
if (dsp.IsHot)
|
||||
{
|
||||
values.Add(dsp.Last.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Mean should be close to some value (biased positive due to trend)
|
||||
double mean = values.Average();
|
||||
|
||||
// But should still have oscillations (standard deviation > 0)
|
||||
double variance = values.Sum(v => Math.Pow(v - mean, 2)) / values.Count;
|
||||
double stdDev = Math.Sqrt(variance);
|
||||
|
||||
Assert.True(stdDev > 0, "DSP should have variance indicating oscillation");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
[Fact]
|
||||
public void Dsp_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).CalculateDetrendedSyntheticPrice();
|
||||
var values = result.CustomValuesList;
|
||||
int finiteCount = values.Count(v => double.IsFinite(v));
|
||||
Assert.True(finiteCount > 100, $"Expected >100 finite values, got {finiteCount}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user