namespace QuanTAlib.Tests;
///
/// 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.
///
public class UchannelValidationTests
{
private const int DefaultStrPeriod = 20;
private const int DefaultCenterPeriod = 20;
private const double DefaultMultiplier = 1.0;
///
/// Validates that streaming and batch calculations produce identical results.
///
[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();
var streamUpper = new List();
var streamLower = new List();
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);
}
}
///
/// Validates that span-based calculation matches streaming calculation.
///
[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();
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);
}
}
///
/// Validates USF (Ultrasmooth Filter) mathematical properties:
/// The middle line should lag less than a simple moving average.
///
[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})");
}
///
/// Validates that the channel bands are symmetric around the middle.
/// Upper - Middle should equal Middle - Lower.
///
[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);
}
}
///
/// Validates that band width equals 2 × STR × multiplier.
///
[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);
}
}
///
/// Validates that STR (Smoothed True Range) is always non-negative.
///
[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");
}
}
///
/// Validates that True Range calculation handles gaps correctly.
/// True Range should account for gap between prev close and current high/low.
///
[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);
}
///
/// Validates that different STR and center periods work independently.
///
[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);
}
///
/// Validates that the multiplier scales the band width proportionally.
///
[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);
}
///
/// Validates that bar correction (isNew=false) works correctly.
///
[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);
}
///
/// Validates that the indicator converges to stable values.
///
[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);
}
///
/// Validates that STR converges to the True Range value for constant volatility.
///
[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)");
}
///
/// Validates behavior with high volatility data.
///
[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})");
}
///
/// Validates that the indicator handles edge case with period = 1.
///
[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));
}
}
///
/// Validates that reset properly clears all state.
///
[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);
}
}