mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-13 08:08:05 +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
440 lines
16 KiB
C#
440 lines
16 KiB
C#
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);
|
||
}
|
||
}
|