mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-07-28 01:37:43 +00:00
384 lines
11 KiB
C#
384 lines
11 KiB
C#
using Xunit;
|
|
|
|
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.Calculate(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.Calculate(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
|
|
} |