Files
QuanTAlib/lib/channels/uchannel/tests/Uchannel.Validation.Tests.cs
T
Miha Kralj 060649192f 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
2026-03-12 12:34:16 -07:00

440 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for UCHANNEL (Ehlers Ultimate Channel).
/// Since this is a proprietary Ehlers indicator (2024), no external library implementations exist.
/// These tests validate internal consistency, mathematical properties, and behavior characteristics.
/// </summary>
public class UchannelValidationTests
{
private const int DefaultStrPeriod = 20;
private const int DefaultCenterPeriod = 20;
private const double DefaultMultiplier = 1.0;
/// <summary>
/// Validates that streaming and batch calculations produce identical results.
/// </summary>
[Fact]
public void Uchannel_StreamingVsBatch_MatchWithinTolerance()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming
var streaming = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
var streamMiddle = new List<double>();
var streamUpper = new List<double>();
var streamLower = new List<double>();
foreach (var bar in bars)
{
streaming.Update(bar);
streamMiddle.Add(streaming.Middle.Value);
streamUpper.Add(streaming.Upper.Value);
streamLower.Add(streaming.Lower.Value);
}
// Batch
var (batchUpper, batchMiddle, batchLower, _) = Uchannel.Calculate(bars, DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
// Compare all values (skip first few for warmup)
for (int i = DefaultCenterPeriod; i < bars.Count; i++)
{
Assert.Equal(streamMiddle[i], batchMiddle[i].Value, precision: 10);
Assert.Equal(streamUpper[i], batchUpper[i].Value, precision: 10);
Assert.Equal(streamLower[i], batchLower[i].Value, precision: 10);
}
}
/// <summary>
/// Validates that span-based calculation matches streaming calculation.
/// </summary>
[Fact]
public void Uchannel_SpanVsStreaming_MatchWithinTolerance()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Streaming
var streaming = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
var streamMiddle = new List<double>();
foreach (var bar in bars)
{
streaming.Update(bar);
streamMiddle.Add(streaming.Middle.Value);
}
// Span
double[] highArr = bars.High.Values.ToArray();
double[] lowArr = bars.Low.Values.ToArray();
double[] closeArr = bars.Close.Values.ToArray();
double[] spanUpper = new double[highArr.Length];
double[] spanMiddle = new double[highArr.Length];
double[] spanLower = new double[highArr.Length];
Uchannel.Batch(highArr.AsSpan(), lowArr.AsSpan(), closeArr.AsSpan(),
spanUpper.AsSpan(), spanMiddle.AsSpan(), spanLower.AsSpan(),
DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
// Compare all values
for (int i = DefaultCenterPeriod; i < bars.Count; i++)
{
Assert.Equal(streamMiddle[i], spanMiddle[i], precision: 10);
}
}
/// <summary>
/// Validates USF (Ultrasmooth Filter) mathematical properties:
/// The middle line should lag less than a simple moving average.
/// </summary>
[Fact]
public void Uchannel_USF_HasLessLagThanSMA()
{
int period = 20;
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var uchannel = new Uchannel(period, period, 1.0);
var sma = new Sma(period);
double uchannelLagSum = 0;
double smaLagSum = 0;
foreach (var bar in bars)
{
uchannel.Update(bar);
sma.Update(new TValue(bar.Time, bar.Close));
if (uchannel.IsHot && sma.IsHot)
{
// Measure deviation from close (proxy for lag in trending market)
uchannelLagSum += Math.Abs(uchannel.Middle.Value - bar.Close);
smaLagSum += Math.Abs(sma.Last.Value - bar.Close);
}
}
// USF should have less overall deviation (implying less lag)
Assert.True(uchannelLagSum < smaLagSum,
$"USF lag sum ({uchannelLagSum:F4}) should be less than SMA lag sum ({smaLagSum:F4})");
}
/// <summary>
/// Validates that the channel bands are symmetric around the middle.
/// Upper - Middle should equal Middle - Lower.
/// </summary>
[Fact]
public void Uchannel_Bands_AreSymmetric()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
foreach (var bar in bars)
{
uchannel.Update(bar);
double upperDist = uchannel.Upper.Value - uchannel.Middle.Value;
double lowerDist = uchannel.Middle.Value - uchannel.Lower.Value;
Assert.Equal(upperDist, lowerDist, precision: 10);
}
}
/// <summary>
/// Validates that band width equals 2 × STR × multiplier.
/// </summary>
[Fact]
public void Uchannel_Width_Equals2xSTRxMultiplier()
{
double multiplier = 1.5;
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, multiplier);
foreach (var bar in bars)
{
uchannel.Update(bar);
double expectedWidth = 2 * uchannel.STR.Value * multiplier;
Assert.Equal(expectedWidth, uchannel.Width.Value, precision: 10);
}
}
/// <summary>
/// Validates that STR (Smoothed True Range) is always non-negative.
/// </summary>
[Fact]
public void Uchannel_STR_AlwaysNonNegative()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.2, seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
foreach (var bar in bars)
{
uchannel.Update(bar);
Assert.True(uchannel.STR.Value >= 0,
$"STR ({uchannel.STR.Value}) should always be non-negative");
}
}
/// <summary>
/// Validates that True Range calculation handles gaps correctly.
/// True Range should account for gap between prev close and current high/low.
/// </summary>
[Fact]
public void Uchannel_TrueRange_HandlesGapsCorrectly()
{
var uchannel = new Uchannel(3, 3, 1.0);
// Day 1: Normal bar
var bar1 = new TBar(DateTime.UtcNow, 102.0, 98.0, 100.0, 100.0, 1000);
uchannel.Update(bar1);
// TR = 102 - 98 = 4
// Day 2: Gap up (open above prev close)
var bar2 = new TBar(DateTime.UtcNow.AddDays(1), 115.0, 110.0, 112.0, 114.0, 1000);
uchannel.Update(bar2);
// True High = max(115, 100) = 115
// True Low = min(110, 100) = 100
// TR = 115 - 100 = 15
// Day 3: Gap down (open below prev close)
var bar3 = new TBar(DateTime.UtcNow.AddDays(2), 108.0, 90.0, 95.0, 92.0, 1000);
uchannel.Update(bar3);
// True High = max(108, 114) = 114
// True Low = min(90, 114) = 90
// TR = 114 - 90 = 24
// STR should reflect these larger TR values due to gaps
Assert.True(uchannel.STR.Value > 0);
}
/// <summary>
/// Validates that different STR and center periods work independently.
/// </summary>
[Fact]
public void Uchannel_DifferentPeriods_ProduceDifferentResults()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var uchannel1 = new Uchannel(10, 20, 1.0); // Short STR, long center
var uchannel2 = new Uchannel(20, 10, 1.0); // Long STR, short center
var uchannel3 = new Uchannel(15, 15, 1.0); // Equal periods
foreach (var bar in bars)
{
uchannel1.Update(bar);
uchannel2.Update(bar);
uchannel3.Update(bar);
}
// Middle lines should differ (different center periods)
Assert.NotEqual(uchannel1.Middle.Value, uchannel2.Middle.Value);
// STR should differ (different STR periods)
Assert.NotEqual(uchannel1.STR.Value, uchannel2.STR.Value);
}
/// <summary>
/// Validates that the multiplier scales the band width proportionally.
/// </summary>
[Fact]
public void Uchannel_Multiplier_ScalesBandWidthProportionally()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var uchannel1 = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, 1.0);
var uchannel2 = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, 2.0);
var uchannel3 = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, 0.5);
foreach (var bar in bars)
{
uchannel1.Update(bar);
uchannel2.Update(bar);
uchannel3.Update(bar);
}
// Width should scale proportionally with multiplier
Assert.Equal(uchannel1.Width.Value * 2, uchannel2.Width.Value, precision: 10);
Assert.Equal(uchannel1.Width.Value / 2, uchannel3.Width.Value, precision: 10);
// Middle should be the same (same center period)
Assert.Equal(uchannel1.Middle.Value, uchannel2.Middle.Value, precision: 10);
Assert.Equal(uchannel1.Middle.Value, uchannel3.Middle.Value, precision: 10);
}
/// <summary>
/// Validates that bar correction (isNew=false) works correctly.
/// </summary>
[Fact]
public void Uchannel_BarCorrection_WorksCorrectly()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
// Process all bars
foreach (var bar in bars)
{
uchannel.Update(bar);
}
double originalMiddle = uchannel.Middle.Value;
double originalUpper = uchannel.Upper.Value;
double originalSTR = uchannel.STR.Value;
// Simulate tick corrections
for (int tick = 0; tick < 20; tick++)
{
var correctionBar = new TBar(DateTime.UtcNow, 150.0 + tick, 140.0, 145.0, 148.0, 1000);
uchannel.Update(correctionBar, isNew: false);
}
// Restore with original last bar
uchannel.Update(bars[^1], isNew: false);
Assert.Equal(originalMiddle, uchannel.Middle.Value, precision: 10);
Assert.Equal(originalUpper, uchannel.Upper.Value, precision: 10);
Assert.Equal(originalSTR, uchannel.STR.Value, precision: 10);
}
/// <summary>
/// Validates that the indicator converges to stable values.
/// </summary>
[Fact]
public void Uchannel_ConvergesToStableValues()
{
var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
// Feed constant bars
for (int i = 0; i < 100; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 105.0, 95.0, 100.0, 100.0, 1000);
uchannel.Update(bar);
}
double middle50 = uchannel.Middle.Value;
// Feed more constant bars
for (int i = 0; i < 100; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(100 + i), 105.0, 95.0, 100.0, 100.0, 1000);
uchannel.Update(bar);
}
double middle100 = uchannel.Middle.Value;
// Should converge to close value (100.0)
Assert.True(Math.Abs(middle50 - 100.0) < 0.1);
Assert.True(Math.Abs(middle100 - 100.0) < 0.01);
}
/// <summary>
/// Validates that STR converges to the True Range value for constant volatility.
/// </summary>
[Fact]
public void Uchannel_STR_ConvergesToTrueRange()
{
var uchannel = new Uchannel(10, 10, 1.0);
// Feed bars with constant TR = 10 (high=105, low=95)
// TBar constructor: (time, open, high, low, close, volume)
for (int i = 0; i < 100; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100.0, 105.0, 95.0, 100.0, 1000);
uchannel.Update(bar);
}
// STR should converge to TR value (10.0)
Assert.True(Math.Abs(uchannel.STR.Value - 10.0) < 0.1,
$"STR ({uchannel.STR.Value}) should converge to TR (10.0)");
}
/// <summary>
/// Validates behavior with high volatility data.
/// </summary>
[Fact]
public void Uchannel_HighVolatility_ProducesWiderBands()
{
var gbmLow = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.05, seed: 42);
var gbmHigh = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.30, seed: 42);
var barsLow = gbmLow.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var barsHigh = gbmHigh.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var uchannelLow = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
var uchannelHigh = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
foreach (var bar in barsLow)
{
uchannelLow.Update(bar);
}
foreach (var bar in barsHigh)
{
uchannelHigh.Update(bar);
}
// High volatility should produce wider bands
Assert.True(uchannelHigh.Width.Value > uchannelLow.Width.Value,
$"High vol width ({uchannelHigh.Width.Value:F4}) should be > low vol width ({uchannelLow.Width.Value:F4})");
}
/// <summary>
/// Validates that the indicator handles edge case with period = 1.
/// </summary>
[Fact]
public void Uchannel_Period1_Works()
{
var uchannel = new Uchannel(1, 1, 1.0);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
uchannel.Update(bar);
Assert.True(double.IsFinite(uchannel.Middle.Value));
Assert.True(double.IsFinite(uchannel.Upper.Value));
Assert.True(double.IsFinite(uchannel.Lower.Value));
}
}
/// <summary>
/// Validates that reset properly clears all state.
/// </summary>
[Fact]
public void Uchannel_Reset_ClearsAllState()
{
var uchannel = new Uchannel(DefaultStrPeriod, DefaultCenterPeriod, DefaultMultiplier);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
// Process bars
foreach (var bar in bars)
{
uchannel.Update(bar);
}
double valueBefore = uchannel.Middle.Value;
// Reset
uchannel.Reset();
// Process same bars again
foreach (var bar in bars)
{
uchannel.Update(bar);
}
double valueAfter = uchannel.Middle.Value;
// Should produce same results
Assert.Equal(valueBefore, valueAfter, precision: 10);
}
}