mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-12 23:58:04 +00:00
- 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
465 lines
16 KiB
C#
465 lines
16 KiB
C#
using System;
|
|
using QuanTAlib;
|
|
using Xunit;
|
|
|
|
namespace QuanTAlib.Tests;
|
|
|
|
public class SdchannelTests
|
|
{
|
|
[Fact]
|
|
public void Sdchannel_Constructor_ValidatesInput()
|
|
{
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new Sdchannel(1));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new Sdchannel(0));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new Sdchannel(-5));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new Sdchannel(10, 0.0));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new Sdchannel(10, -1.0));
|
|
|
|
var s = new Sdchannel(10, 2.0);
|
|
Assert.Equal(10, s.WarmupPeriod);
|
|
Assert.Contains("Sdchannel", s.Name, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_InitialState_Defaults()
|
|
{
|
|
var s = new Sdchannel(5);
|
|
|
|
Assert.Equal(0, s.Last.Value);
|
|
Assert.Equal(0, s.Upper.Value);
|
|
Assert.Equal(0, s.Lower.Value);
|
|
Assert.False(s.IsHot);
|
|
Assert.Equal(0, s.Slope);
|
|
Assert.Equal(0, s.StdDev);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_FirstValue_AllBandsEqual()
|
|
{
|
|
var s = new Sdchannel(10, 2.0);
|
|
|
|
var result = s.Update(new TValue(DateTime.UtcNow, 100));
|
|
|
|
// First value: regression = input, stdDev = 0, bands equal
|
|
Assert.Equal(100.0, result.Value, 1e-10);
|
|
Assert.Equal(100.0, s.Upper.Value, 1e-10);
|
|
Assert.Equal(100.0, s.Lower.Value, 1e-10);
|
|
Assert.Equal(0.0, s.Slope, 1e-10);
|
|
Assert.Equal(0.0, s.StdDev, 1e-10);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_TwoValues_LinearFit()
|
|
{
|
|
var s = new Sdchannel(10, 2.0);
|
|
|
|
s.Update(new TValue(DateTime.UtcNow, 100));
|
|
var result = s.Update(new TValue(DateTime.UtcNow, 110));
|
|
|
|
// Two points: y=100 at x=0, y=110 at x=1
|
|
// Regression line: y = 100 + 10*x
|
|
// At x=1: regression = 110
|
|
// Both points lie exactly on line, so stdDev = 0
|
|
Assert.Equal(110.0, result.Value, 1e-10);
|
|
Assert.Equal(10.0, s.Slope, 1e-10);
|
|
Assert.Equal(0.0, s.StdDev, 1e-10);
|
|
Assert.Equal(110.0, s.Upper.Value, 1e-10);
|
|
Assert.Equal(110.0, s.Lower.Value, 1e-10);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_ThreeValues_WithResiduals()
|
|
{
|
|
var s = new Sdchannel(10, 1.0);
|
|
|
|
// Points: (0,100), (1,120), (2,110)
|
|
// Sum x = 0+1+2 = 3, Sum x² = 0+1+4 = 5
|
|
// Sum y = 330, Sum xy = 0*100 + 1*120 + 2*110 = 340
|
|
// n=3, denom = 3*5 - 3*3 = 6
|
|
// slope = (3*340 - 3*330) / 6 = (1020 - 990) / 6 = 5
|
|
// intercept = (330 - 5*3) / 3 = 315/3 = 105
|
|
// regression at x=2: 105 + 5*2 = 115
|
|
s.Update(new TValue(DateTime.UtcNow, 100));
|
|
s.Update(new TValue(DateTime.UtcNow, 120));
|
|
var result = s.Update(new TValue(DateTime.UtcNow, 110));
|
|
|
|
Assert.Equal(115.0, result.Value, 1e-10);
|
|
Assert.Equal(5.0, s.Slope, 1e-10);
|
|
|
|
// Residuals: 100-105=-5, 120-110=10, 110-115=-5
|
|
// Sum residuals² = 25+100+25 = 150
|
|
// StdDev = sqrt(150/3) = sqrt(50) ≈ 7.07
|
|
double expectedStdDev = Math.Sqrt(50);
|
|
Assert.Equal(expectedStdDev, s.StdDev, 1e-10);
|
|
|
|
// Bands at ±1 stdDev
|
|
Assert.Equal(115.0 + expectedStdDev, s.Upper.Value, 1e-10);
|
|
Assert.Equal(115.0 - expectedStdDev, s.Lower.Value, 1e-10);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_BandWidth_ProportionalToMultiplier()
|
|
{
|
|
var s1 = new Sdchannel(10, 1.0);
|
|
var s2 = new Sdchannel(10, 2.0);
|
|
var s3 = new Sdchannel(10, 3.0);
|
|
|
|
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
|
|
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
var bar = gbm.Next(isNew: true);
|
|
var tv = new TValue(bar.Time, bar.Close);
|
|
s1.Update(tv);
|
|
s2.Update(tv);
|
|
s3.Update(tv);
|
|
}
|
|
|
|
double width1 = s1.Upper.Value - s1.Lower.Value;
|
|
double width2 = s2.Upper.Value - s2.Lower.Value;
|
|
double width3 = s3.Upper.Value - s3.Lower.Value;
|
|
|
|
// Width should scale with multiplier (width = 2 * multiplier * stdDev)
|
|
Assert.Equal(width2, width1 * 2, 1e-9);
|
|
Assert.Equal(width3, width1 * 3, 1e-9);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_BandOrder_Correct()
|
|
{
|
|
var s = new Sdchannel(10, 2.0);
|
|
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
|
|
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
var bar = gbm.Next(isNew: true);
|
|
s.Update(new TValue(bar.Time, bar.Close));
|
|
|
|
// After warmup with real data, bands should separate
|
|
if (i > 3 && s.StdDev > 0)
|
|
{
|
|
Assert.True(s.Upper.Value >= s.Last.Value, $"Upper >= Middle at bar {i}");
|
|
Assert.True(s.Lower.Value <= s.Last.Value, $"Lower <= Middle at bar {i}");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_BandSymmetry_AroundRegression()
|
|
{
|
|
var s = new Sdchannel(10, 2.0);
|
|
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
|
|
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
var bar = gbm.Next(isNew: true);
|
|
s.Update(new TValue(bar.Time, bar.Close));
|
|
|
|
// Bands should be symmetric around middle
|
|
double upperDist = s.Upper.Value - s.Last.Value;
|
|
double lowerDist = s.Last.Value - s.Lower.Value;
|
|
Assert.Equal(upperDist, lowerDist, 1e-10);
|
|
|
|
// Distance should be exactly multiplier * stdDev
|
|
double expectedDist = 2.0 * s.StdDev;
|
|
Assert.Equal(expectedDist, upperDist, 1e-10);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_ConstantValues_ZeroStdDev()
|
|
{
|
|
var s = new Sdchannel(5, 2.0);
|
|
|
|
for (int i = 0; i < 20; i++)
|
|
{
|
|
s.Update(new TValue(DateTime.UtcNow, 100));
|
|
}
|
|
|
|
// All same values on regression line -> no residuals
|
|
Assert.Equal(100.0, s.Last.Value, 1e-10);
|
|
Assert.Equal(0.0, s.Slope, 1e-10);
|
|
Assert.Equal(0.0, s.StdDev, 1e-10);
|
|
Assert.Equal(100.0, s.Upper.Value, 1e-10);
|
|
Assert.Equal(100.0, s.Lower.Value, 1e-10);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_LinearTrend_ZeroStdDev()
|
|
{
|
|
var s = new Sdchannel(5, 2.0);
|
|
|
|
// Perfect linear trend: 100, 102, 104, 106, 108
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
s.Update(new TValue(DateTime.UtcNow, 100 + i * 2));
|
|
}
|
|
|
|
// All points lie exactly on regression line
|
|
Assert.Equal(108.0, s.Last.Value, 1e-10);
|
|
Assert.Equal(2.0, s.Slope, 1e-10);
|
|
Assert.Equal(0.0, s.StdDev, 1e-10);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_IsHot_TurnsTrueAfterWarmup()
|
|
{
|
|
var s = new Sdchannel(5, 2.0);
|
|
|
|
for (int i = 0; i < 4; i++)
|
|
{
|
|
s.Update(new TValue(DateTime.UtcNow, 100 + i));
|
|
Assert.False(s.IsHot);
|
|
}
|
|
|
|
s.Update(new TValue(DateTime.UtcNow, 200));
|
|
Assert.True(s.IsHot);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_IsNewFalse_RebuildsState()
|
|
{
|
|
var s = new Sdchannel(10, 2.0);
|
|
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 7);
|
|
|
|
TValue remembered = default;
|
|
for (int i = 0; i < 30; i++)
|
|
{
|
|
var bar = gbm.Next(isNew: true);
|
|
remembered = new TValue(bar.Time, bar.Close);
|
|
s.Update(remembered, isNew: true);
|
|
}
|
|
|
|
double mid = s.Last.Value;
|
|
double up = s.Upper.Value;
|
|
double lo = s.Lower.Value;
|
|
double slope = s.Slope;
|
|
double stdDev = s.StdDev;
|
|
|
|
// Apply corrections
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
var bar = gbm.Next(isNew: false);
|
|
s.Update(new TValue(bar.Time, bar.Close), isNew: false);
|
|
}
|
|
|
|
// Restore with remembered value
|
|
s.Update(remembered, isNew: false);
|
|
|
|
Assert.Equal(mid, s.Last.Value, 1e-6);
|
|
Assert.Equal(up, s.Upper.Value, 1e-6);
|
|
Assert.Equal(lo, s.Lower.Value, 1e-6);
|
|
Assert.Equal(slope, s.Slope, 1e-6);
|
|
Assert.Equal(stdDev, s.StdDev, 1e-6);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_NaN_UsesLastValid()
|
|
{
|
|
var s = new Sdchannel(10, 2.0);
|
|
|
|
s.Update(new TValue(DateTime.UtcNow, 100));
|
|
s.Update(new TValue(DateTime.UtcNow, 105));
|
|
|
|
var result = s.Update(new TValue(DateTime.UtcNow, double.NaN));
|
|
Assert.True(double.IsFinite(result.Value));
|
|
Assert.True(double.IsFinite(s.Upper.Value));
|
|
Assert.True(double.IsFinite(s.Lower.Value));
|
|
|
|
var result2 = s.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
|
Assert.True(double.IsFinite(result2.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_BarCorrection_UpdatesLastValid()
|
|
{
|
|
// Verifies that bar correction (isNew:false) with a finite value updates LastValid,
|
|
// so subsequent NaN/Inf inputs use the corrected value, not the pre-correction value.
|
|
var s = new Sdchannel(5, 2.0);
|
|
var now = DateTime.UtcNow;
|
|
|
|
// Feed initial values
|
|
s.Update(new TValue(now, 100));
|
|
s.Update(new TValue(now, 110));
|
|
s.Update(new TValue(now, 120));
|
|
|
|
// Last bar: 130 (LastValid should be 130)
|
|
s.Update(new TValue(now, 130));
|
|
|
|
// Correct the last bar with isNew:false to 140 (should update LastValid to 140)
|
|
s.Update(new TValue(now, 140), isNew: false);
|
|
|
|
// Now send NaN - it should use LastValid=140, not the old 130
|
|
var resultWithNaN = s.Update(new TValue(now, double.NaN));
|
|
|
|
// The regression should include 100, 110, 120, 140 (the corrected value)
|
|
// If bug existed, it would use 130 instead
|
|
Assert.True(double.IsFinite(resultWithNaN.Value));
|
|
|
|
// Verify by checking the buffer contains the corrected value
|
|
// The regression endpoint should reflect using 140 not 130
|
|
// For 4 values [100, 110, 120, 140]:
|
|
// sumX = 0+1+2+3 = 6, sumX² = 14, n=4
|
|
// sumY = 470, sumXY = 0*100 + 1*110 + 2*120 + 3*140 = 770
|
|
// denom = 4*14 - 36 = 20
|
|
// slope = (4*770 - 6*470) / 20 = (3080 - 2820) / 20 = 13
|
|
// intercept = (470 - 13*6) / 4 = (470 - 78) / 4 = 98
|
|
// regression at x=3: 98 + 13*3 = 137
|
|
Assert.Equal(137.0, resultWithNaN.Value, 1e-9);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_Reset_Clears()
|
|
{
|
|
var s = new Sdchannel(10, 2.0);
|
|
s.Update(new TValue(DateTime.UtcNow, 100));
|
|
s.Update(new TValue(DateTime.UtcNow, 110));
|
|
s.Update(new TValue(DateTime.UtcNow, 120));
|
|
|
|
s.Reset();
|
|
|
|
Assert.Equal(0, s.Last.Value);
|
|
Assert.Equal(0, s.Upper.Value);
|
|
Assert.Equal(0, s.Lower.Value);
|
|
Assert.Equal(0, s.Slope);
|
|
Assert.Equal(0, s.StdDev);
|
|
Assert.False(s.IsHot);
|
|
|
|
s.Update(new TValue(DateTime.UtcNow, 50));
|
|
Assert.NotEqual(0, s.Last.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_BatchVsStreaming_Match()
|
|
{
|
|
var sStream = new Sdchannel(20, 2.0);
|
|
var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.15, seed: 42);
|
|
var series = new TSeries();
|
|
|
|
for (int i = 0; i < 200; i++)
|
|
{
|
|
var bar = gbm.Next(isNew: true);
|
|
series.Add(new TValue(bar.Time, bar.Close));
|
|
sStream.Update(series.Last, isNew: true);
|
|
}
|
|
|
|
double expectedMid = sStream.Last.Value;
|
|
double expectedUp = sStream.Upper.Value;
|
|
double expectedLo = sStream.Lower.Value;
|
|
|
|
var (midBatch, upBatch, loBatch) = Sdchannel.Batch(series, 20, 2.0);
|
|
|
|
Assert.Equal(expectedMid, midBatch.Last.Value, 1e-9);
|
|
Assert.Equal(expectedUp, upBatch.Last.Value, 1e-9);
|
|
Assert.Equal(expectedLo, loBatch.Last.Value, 1e-9);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_SpanBatch_Validates()
|
|
{
|
|
double[] source = [100, 105, 110];
|
|
double[] middle = new double[3];
|
|
double[] upper = new double[3];
|
|
double[] lower = new double[3];
|
|
double[] smallOut = new double[1];
|
|
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => Sdchannel.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 1));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => Sdchannel.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 0));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => Sdchannel.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 10, 0.0));
|
|
Assert.Throws<ArgumentException>(() => Sdchannel.Batch(source.AsSpan(), smallOut.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_SpanBatch_ComputesCorrectly()
|
|
{
|
|
double[] source = [100, 110, 100, 110, 100];
|
|
double[] middle = new double[5];
|
|
double[] upper = new double[5];
|
|
double[] lower = new double[5];
|
|
|
|
Sdchannel.Batch(source.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 3, 2.0);
|
|
|
|
// First value: regression = 100, stdDev = 0
|
|
Assert.Equal(100.0, middle[0], 1e-10);
|
|
Assert.Equal(100.0, upper[0], 1e-10);
|
|
Assert.Equal(100.0, lower[0], 1e-10);
|
|
|
|
// When stdDev > 0, bands should be symmetric around middle
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
double upperDist = upper[i] - middle[i];
|
|
double lowerDist = middle[i] - lower[i];
|
|
Assert.Equal(upperDist, lowerDist, 1e-10);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_Calculate_ReturnsIndicatorAndResults()
|
|
{
|
|
var series = new TSeries();
|
|
series.Add(new TValue(DateTime.UtcNow, 100));
|
|
series.Add(new TValue(DateTime.UtcNow, 105));
|
|
series.Add(new TValue(DateTime.UtcNow, 102));
|
|
|
|
var ((mid, up, lo), ind) = Sdchannel.Calculate(series, 2);
|
|
|
|
Assert.True(double.IsFinite(mid.Last.Value));
|
|
Assert.True(double.IsFinite(up.Last.Value));
|
|
Assert.True(double.IsFinite(lo.Last.Value));
|
|
|
|
// Continue streaming
|
|
ind.Update(new TValue(DateTime.UtcNow, 108));
|
|
Assert.True(double.IsFinite(ind.Last.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_Event_Publishes()
|
|
{
|
|
var src = new TSeries();
|
|
var s = new Sdchannel(src, 2);
|
|
bool fired = false;
|
|
s.Pub += (object? sender, in TValueEventArgs args) => fired = true;
|
|
|
|
src.Add(new TValue(DateTime.UtcNow, 100));
|
|
Assert.True(fired);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_LongSeriesStability()
|
|
{
|
|
var s = new Sdchannel(20, 2.0);
|
|
var gbm = new GBM(startPrice: 100, mu: 0.001, sigma: 0.02, seed: 123);
|
|
|
|
for (int i = 0; i < 10000; i++)
|
|
{
|
|
var bar = gbm.Next(isNew: true);
|
|
s.Update(new TValue(bar.Time, bar.Close));
|
|
|
|
Assert.True(double.IsFinite(s.Last.Value), $"Middle finite at {i}");
|
|
Assert.True(double.IsFinite(s.Upper.Value), $"Upper finite at {i}");
|
|
Assert.True(double.IsFinite(s.Lower.Value), $"Lower finite at {i}");
|
|
Assert.True(double.IsFinite(s.Slope), $"Slope finite at {i}");
|
|
Assert.True(double.IsFinite(s.StdDev), $"StdDev finite at {i}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Sdchannel_SlidingWindow_PeriodRespected()
|
|
{
|
|
var s = new Sdchannel(3, 2.0);
|
|
|
|
// Feed 5 values: 100, 200, 300, 400, 500
|
|
s.Update(new TValue(DateTime.UtcNow, 100));
|
|
s.Update(new TValue(DateTime.UtcNow, 200));
|
|
s.Update(new TValue(DateTime.UtcNow, 300));
|
|
s.Update(new TValue(DateTime.UtcNow, 400));
|
|
s.Update(new TValue(DateTime.UtcNow, 500));
|
|
|
|
// Window should now contain: 300, 400, 500
|
|
// Perfect linear trend with slope = 100
|
|
Assert.Equal(500.0, s.Last.Value, 1e-10);
|
|
Assert.Equal(100.0, s.Slope, 1e-10);
|
|
Assert.Equal(0.0, s.StdDev, 1e-10);
|
|
}
|
|
}
|