mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-20 03:28:05 +00:00
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
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class UiIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void UiIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new UiIndicator();
|
||||
|
||||
Assert.Equal(14, indicator.Period);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("UI - Ulcer Index", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new UiIndicator { Period = 20 };
|
||||
Assert.Contains("UI", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new UiIndicator();
|
||||
|
||||
Assert.Equal(0, UiIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_Initialize_CreatesInternalUi()
|
||||
{
|
||||
var indicator = new UiIndicator();
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new UiIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data with declining prices (creates drawdown)
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double basePrice = 110 - (i * 0.5); // Declining from 110
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 1, basePrice - 1, basePrice, 1000);
|
||||
|
||||
// Process update for each bar to simulate history loading
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Line series should have a value
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val >= 0, "Ulcer Index should be non-negative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new UiIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double basePrice = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 2, basePrice - 2, basePrice + 1, 1000);
|
||||
}
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Add new bar with price drop
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(30), 100, 105, 95, 100, 1500);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
int[] periods = { 5, 10, 14, 20 };
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var indicator = new UiIndicator { Period = period };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
// Create some price movement with occasional drawdowns
|
||||
double basePrice = 100 + (i % 10) - 5;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 2, basePrice - 2, basePrice, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"Period {period} should produce finite value");
|
||||
Assert.True(val >= 0, $"Period {period} should produce non-negative value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_Period_CanBeChanged()
|
||||
{
|
||||
var indicator = new UiIndicator();
|
||||
Assert.Equal(14, indicator.Period);
|
||||
|
||||
indicator.Period = 20;
|
||||
Assert.Equal(20, indicator.Period);
|
||||
|
||||
indicator.Period = 10;
|
||||
Assert.Equal(10, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_ShowColdValues_CanBeToggled()
|
||||
{
|
||||
var indicator = new UiIndicator();
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = true;
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new UiIndicator();
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Ui.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_AtPeriodHigh_ProducesZero()
|
||||
{
|
||||
var indicator = new UiIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Constantly rising prices = always at new high = no drawdown
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 0.5, price - 0.5, price, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val < 0.5, "Price at period high should produce near-zero UI");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_Drawdown_ProducesPositiveValue()
|
||||
{
|
||||
var indicator = new UiIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Price rises then drops - creates drawdown
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double price = 100 + i * 2; // Rise to 118
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// Now drop the price
|
||||
for (int i = 10; i < 20; i++)
|
||||
{
|
||||
double price = 118 - (i - 10) * 3; // Drop from 118 to 88
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val > 0, "Drawdown should produce positive UI value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_DeeperDrawdown_ProducesHigherValue()
|
||||
{
|
||||
var indicator1 = new UiIndicator { Period = 10 };
|
||||
var indicator2 = new UiIndicator { Period = 10 };
|
||||
indicator1.Initialize();
|
||||
indicator2.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Indicator 1: small drawdown (5%)
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
indicator1.HistoricalData.AddBar(now.AddMinutes(i), price, price + 0.5, price - 0.5, price, 1000);
|
||||
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
for (int i = 10; i < 20; i++)
|
||||
{
|
||||
double price = 109 - (i - 10) * 0.5; // Small drop
|
||||
indicator1.HistoricalData.AddBar(now.AddMinutes(i), price, price + 0.5, price - 0.5, price, 1000);
|
||||
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// Indicator 2: large drawdown (20%)
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
indicator2.HistoricalData.AddBar(now.AddMinutes(i), price, price + 0.5, price - 0.5, price, 1000);
|
||||
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
for (int i = 10; i < 20; i++)
|
||||
{
|
||||
double price = 109 - (i - 10) * 2; // Large drop
|
||||
indicator2.HistoricalData.AddBar(now.AddMinutes(i), price, price + 0.5, price - 0.5, price, 1000);
|
||||
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double smallDrawdown = indicator1.LinesSeries[0].GetValue(0);
|
||||
double largeDrawdown = indicator2.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(smallDrawdown));
|
||||
Assert.True(double.IsFinite(largeDrawdown));
|
||||
Assert.True(largeDrawdown > smallDrawdown, "Deeper drawdown should produce higher UI value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_UsesClosePrice_NotHighLow()
|
||||
{
|
||||
// UI uses close price for both the rolling max and drawdown calculation
|
||||
var indicator = new UiIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Price with constant close but varying high/low
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
// Close is constant at 100, but high/low varies
|
||||
double highRange = 5 + (i % 5);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100 + highRange, 100 - highRange, 100, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(val));
|
||||
// Since close is always 100 (at period high), UI should be near zero
|
||||
Assert.True(val < 0.5, "Constant close should produce near-zero UI regardless of high/low range");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_ConstantPrice_ProducesZero()
|
||||
{
|
||||
var indicator = new UiIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Constant price - no drawdown possible
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100.01, 99.99, 100, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val < 0.1, "Constant price should produce near-zero UI");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiIndicator_RecoveryFromDrawdown_ReducesValue()
|
||||
{
|
||||
var indicator = new UiIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Initial rise to establish a high
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double price = 100 + i; // Rise to 109
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// Drawdown - price drops significantly
|
||||
for (int i = 10; i < 15; i++)
|
||||
{
|
||||
double price = 109 - (i - 10) * 4; // Drop from 109 to 89
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double duringDrawdown = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Full recovery - price rises ABOVE the old high (so no more drawdown)
|
||||
// Need at least 10 more bars of rising prices to fully replace the drawdown window
|
||||
for (int i = 15; i < 30; i++)
|
||||
{
|
||||
double price = 89 + (i - 15) * 3; // Rise from 89 to 134 (well past old high of 109)
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 1, price - 1, price, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double afterRecovery = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(duringDrawdown));
|
||||
Assert.True(double.IsFinite(afterRecovery));
|
||||
Assert.True(duringDrawdown > 0, "During drawdown, UI should be positive");
|
||||
// After 15 bars of rising prices past the old high, UI should be near zero or much lower
|
||||
Assert.True(afterRecovery < duringDrawdown, "Recovery from drawdown should reduce UI value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
// Ulcer Index (UI) Unit Tests
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class UiTests
|
||||
{
|
||||
private readonly GBM _gbm;
|
||||
private const double Tolerance = 1e-10;
|
||||
private const int DefaultPeriod = 14;
|
||||
|
||||
public UiTests()
|
||||
{
|
||||
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
}
|
||||
|
||||
private TSeries GenerateData(int count)
|
||||
{
|
||||
_gbm.Reset(DateTime.UtcNow.Ticks);
|
||||
var bars = _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
ts.Add(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
return ts;
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParameters_SetsCorrectValues()
|
||||
{
|
||||
var ui = new Ui();
|
||||
Assert.Equal("Ui(14)", ui.Name);
|
||||
Assert.Equal(14, ui.WarmupPeriod);
|
||||
Assert.Equal(14, ui.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomPeriod_SetsCorrectValues()
|
||||
{
|
||||
var ui = new Ui(period: 20);
|
||||
Assert.Equal("Ui(20)", ui.Name);
|
||||
Assert.Equal(20, ui.WarmupPeriod);
|
||||
Assert.Equal(20, ui.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroPeriod_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Ui(period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Ui(period: -5));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithSource_SubscribesToEvents()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var ui = new Ui(source, DefaultPeriod);
|
||||
source.Add(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.NotEqual(default, ui.Last);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basic Calculation Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_PriceAtHigh_ReturnsZero()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Rising prices - each close is the highest
|
||||
double[] prices = [100, 101, 102, 103, 104];
|
||||
TValue result = default;
|
||||
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
result = ui.Update(new TValue(time.AddSeconds(i), prices[i]));
|
||||
}
|
||||
|
||||
// When price is at period high, drawdown is zero → UI is zero
|
||||
Assert.Equal(0.0, result.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_PriceDrawdown_ReturnsPositiveValue()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Price rises then falls
|
||||
double[] prices = [100, 105, 110, 105, 100];
|
||||
TValue result = default;
|
||||
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
result = ui.Update(new TValue(time.AddSeconds(i), prices[i]));
|
||||
}
|
||||
|
||||
// There's a drawdown from 110, so UI should be positive
|
||||
Assert.True(result.Value > 0, $"UI should be positive during drawdown, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_CalculatesCorrectUlcerIndex()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Manual calculation:
|
||||
// Prices: 100, 102, 101, 103, 100
|
||||
// Highest: 100, 102, 102, 103, 103
|
||||
// %Drawdown: 0, 0, (101-102)/102*100=-0.98, 0, (100-103)/103*100=-2.91
|
||||
// SqDrawdown: 0, 0, 0.96, 0, 8.48
|
||||
// Sum = 9.44, Avg = 1.888, UI = sqrt(1.888) ≈ 1.374
|
||||
double[] prices = [100, 102, 101, 103, 100];
|
||||
TValue result = default;
|
||||
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
result = ui.Update(new TValue(time.AddSeconds(i), prices[i]));
|
||||
}
|
||||
|
||||
// Verify it's approximately correct (allowing for rounding)
|
||||
Assert.True(result.Value > 1.0 && result.Value < 2.0,
|
||||
$"Expected UI around 1.37, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsNonNegative()
|
||||
{
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
var data = GenerateData(100);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var result = ui.Update(data[i]);
|
||||
Assert.True(result.Value >= 0, $"UI should be non-negative, got {result.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_DeeperDrawdown_HigherUi()
|
||||
{
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Shallow drawdown
|
||||
var ui1 = new Ui(period: 5);
|
||||
double[] prices1 = [100, 105, 110, 108, 109];
|
||||
for (int i = 0; i < prices1.Length; i++)
|
||||
{
|
||||
ui1.Update(new TValue(time.AddSeconds(i), prices1[i]));
|
||||
}
|
||||
var shallow = ui1.Last.Value;
|
||||
|
||||
// Deep drawdown
|
||||
var ui2 = new Ui(period: 5);
|
||||
double[] prices2 = [100, 105, 110, 95, 90];
|
||||
for (int i = 0; i < prices2.Length; i++)
|
||||
{
|
||||
ui2.Update(new TValue(time.AddSeconds(i), prices2[i]));
|
||||
}
|
||||
var deep = ui2.Last.Value;
|
||||
|
||||
Assert.True(deep > shallow,
|
||||
$"Deeper drawdown should have higher UI: deep={deep}, shallow={shallow}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsHot and WarmupPeriod Tests
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BeforeWarmup_ReturnsFalse()
|
||||
{
|
||||
var ui = new Ui(period: 10);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i));
|
||||
Assert.False(ui.IsHot);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_AtWarmup_ReturnsTrue()
|
||||
{
|
||||
var ui = new Ui(period: 10);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i));
|
||||
}
|
||||
Assert.True(ui.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_EqualsPeriod()
|
||||
{
|
||||
var ui = new Ui(period: 20);
|
||||
Assert.Equal(20, ui.WarmupPeriod);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State and Bar Correction Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build up to warmup (prices: 100, 110, 105, 108, 103 have drawdowns from 110)
|
||||
double[] prices = [100, 110, 105, 108, 103];
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), prices[i]), isNew: true);
|
||||
}
|
||||
|
||||
// Add another bar (isNew=true) - state should advance
|
||||
var result = ui.Update(new TValue(time.AddSeconds(5), 95), isNew: true);
|
||||
|
||||
// With isNew=true, state should have advanced (count incremented)
|
||||
// The UI value should be different because we added a new price point
|
||||
// Note: UI can be 0 only if all prices are at new highs, but 95 < 110, so drawdown exists
|
||||
Assert.True(ui.IsHot, "Should be hot after warmup period");
|
||||
Assert.True(result.Value >= 0, "UI should be non-negative");
|
||||
// 95 is a significant drawdown from 110 (highest), UI should be > 0
|
||||
Assert.NotEqual(0.0, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_RollsBackState()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build up history
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i), isNew: true);
|
||||
}
|
||||
|
||||
// New bar
|
||||
var result1 = ui.Update(new TValue(time.AddSeconds(5), 90), isNew: true);
|
||||
|
||||
// Update same bar with different value - should rollback
|
||||
var result2 = ui.Update(new TValue(time.AddSeconds(5), 80), isNew: false);
|
||||
|
||||
// Different values should produce different results
|
||||
Assert.NotEqual(result1.Value, result2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IterativeCorrections_RestoreState()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build history
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i), isNew: true);
|
||||
}
|
||||
|
||||
// Start a new bar
|
||||
var newBarResult = ui.Update(new TValue(time.AddSeconds(5), 95), isNew: true);
|
||||
|
||||
// Multiple corrections
|
||||
_ = ui.Update(new TValue(time.AddSeconds(5), 90), isNew: false);
|
||||
_ = ui.Update(new TValue(time.AddSeconds(5), 85), isNew: false);
|
||||
var correction3 = ui.Update(new TValue(time.AddSeconds(5), 95), isNew: false);
|
||||
|
||||
// Going back to original value should restore original result
|
||||
Assert.Equal(newBarResult.Value, correction3.Value, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
var data = GenerateData(20);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
ui.Update(data[i]);
|
||||
}
|
||||
|
||||
Assert.True(ui.IsHot);
|
||||
|
||||
ui.Reset();
|
||||
|
||||
Assert.False(ui.IsHot);
|
||||
Assert.Equal(default, ui.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_AllowsReuseOfIndicator()
|
||||
{
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
var data = GenerateData(20);
|
||||
|
||||
// First run
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
ui.Update(data[i]);
|
||||
}
|
||||
var firstResult = ui.Last;
|
||||
|
||||
ui.Reset();
|
||||
|
||||
// Second run with same data
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
ui.Update(data[i]);
|
||||
}
|
||||
var secondResult = ui.Last;
|
||||
|
||||
Assert.Equal(firstResult.Value, secondResult.Value, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NaN and Infinity Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNInput_UsesLastValidValue()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
var nanResult = ui.Update(new TValue(time.AddSeconds(5), double.NaN));
|
||||
|
||||
Assert.True(double.IsFinite(nanResult.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_InfinityInput_UsesLastValidValue()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
var infResult = ui.Update(new TValue(time.AddSeconds(5), double.PositiveInfinity));
|
||||
|
||||
Assert.True(double.IsFinite(infResult.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_WithNaN_ProducesSafeOutput()
|
||||
{
|
||||
double[] source = [100, 102, double.NaN, 98, 101];
|
||||
double[] output = new double[5];
|
||||
|
||||
Ui.Batch(source, output, period: 5);
|
||||
|
||||
foreach (var val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mode Consistency Tests
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceConsistentResults()
|
||||
{
|
||||
const int dataLen = 100;
|
||||
var data = GenerateData(dataLen);
|
||||
|
||||
// Mode 1: Streaming
|
||||
var ui1 = new Ui(DefaultPeriod);
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
ui1.Update(data[i], isNew: true);
|
||||
}
|
||||
|
||||
// Mode 2: Batch via TSeries
|
||||
var batchResult = Ui.Batch(data, DefaultPeriod);
|
||||
|
||||
// Mode 3: Span-based
|
||||
double[] spanOutput = new double[dataLen];
|
||||
Ui.Batch(data.Values, spanOutput, DefaultPeriod);
|
||||
|
||||
// Compare last 50 values
|
||||
int compareStart = dataLen - 50;
|
||||
for (int i = compareStart; i < dataLen; i++)
|
||||
{
|
||||
double batch = batchResult[i].Value;
|
||||
double span = spanOutput[i];
|
||||
|
||||
// Batch and Span should match exactly
|
||||
Assert.Equal(batch, span, Tolerance);
|
||||
}
|
||||
|
||||
// Final values should match
|
||||
Assert.Equal(ui1.Last.Value, batchResult[dataLen - 1].Value, 1e-8);
|
||||
Assert.Equal(ui1.Last.Value, spanOutput[dataLen - 1], 1e-8);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Span API Tests
|
||||
|
||||
[Fact]
|
||||
public void Batch_ValidatesOutputLength()
|
||||
{
|
||||
double[] source = [100, 101, 102];
|
||||
double[] output = new double[2]; // Too short
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => Ui.Batch(source, output, period: 3));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ValidatesPeriod()
|
||||
{
|
||||
double[] source = [100, 101, 102];
|
||||
double[] output = new double[3];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => Ui.Batch(source, output, period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptyInput_ProducesNoOutput()
|
||||
{
|
||||
double[] source = [];
|
||||
double[] output = [];
|
||||
|
||||
Ui.Batch(source, output, period: 5);
|
||||
// Should not throw
|
||||
Assert.Empty(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_MatchesStreamingMode()
|
||||
{
|
||||
const int dataLen = 50;
|
||||
var data = GenerateData(dataLen);
|
||||
|
||||
// Streaming
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
ui.Update(data[i]);
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] batchOutput = new double[dataLen];
|
||||
Ui.Batch(data.Values, batchOutput, DefaultPeriod);
|
||||
|
||||
// Compare final value
|
||||
Assert.Equal(ui.Last.Value, batchOutput[dataLen - 1], 1e-8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_LargeDataset_NoStackOverflow()
|
||||
{
|
||||
const int dataLen = 10000;
|
||||
var bars = new GBM(seed: 42).Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] source = bars.CloseValues.ToArray();
|
||||
double[] output = new double[dataLen];
|
||||
|
||||
Ui.Batch(source, output, DefaultPeriod);
|
||||
|
||||
// Verify all outputs are valid
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]));
|
||||
Assert.True(output[i] >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_LargePeriod_UsesArrayPool()
|
||||
{
|
||||
const int dataLen = 500;
|
||||
const int largePeriod = 300; // > 256 threshold
|
||||
|
||||
double[] source = new double[dataLen];
|
||||
double[] output = new double[dataLen];
|
||||
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
source[i] = 100 + Math.Sin(i * 0.1) * 10;
|
||||
}
|
||||
|
||||
// Should not throw - uses ArrayPool for large period
|
||||
Ui.Batch(source, output, largePeriod);
|
||||
|
||||
// Verify outputs are valid
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Chainability Tests
|
||||
|
||||
[Fact]
|
||||
public void Pub_FiresOnUpdate()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
int eventCount = 0;
|
||||
|
||||
ui.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
|
||||
|
||||
var time = DateTime.UtcNow;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
Assert.Equal(5, eventCount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSeries Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_TSeries_ReturnsCorrectLength()
|
||||
{
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
var data = GenerateData(50);
|
||||
|
||||
var result = ui.Update(data);
|
||||
|
||||
Assert.Equal(50, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Static_TSeries_Works()
|
||||
{
|
||||
var data = GenerateData(50);
|
||||
|
||||
var result = Ui.Batch(data, DefaultPeriod);
|
||||
|
||||
Assert.Equal(50, result.Count);
|
||||
Assert.All(result.Values.ToArray(), v => Assert.True(v >= 0));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Prime Tests
|
||||
|
||||
[Fact]
|
||||
public void Prime_SetsInitialState()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
double[] warmupData = [100, 101, 102, 103, 104];
|
||||
|
||||
ui.Prime(warmupData);
|
||||
|
||||
Assert.True(ui.IsHot);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TBar Update Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_TBar_UsesClosePrice()
|
||||
{
|
||||
var ui1 = new Ui(period: 5);
|
||||
var ui2 = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Update with TBar
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var bar = new TBar(time.AddSeconds(i), 100 + i, 102 + i, 98 + i, 101 + i, 1000);
|
||||
ui1.Update(bar);
|
||||
ui2.Update(new TValue(time.AddSeconds(i), bar.Close));
|
||||
}
|
||||
|
||||
// Both should produce same result (using close price)
|
||||
Assert.Equal(ui1.Last.Value, ui2.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
using OoplesFinance.StockIndicators;
|
||||
using OoplesFinance.StockIndicators.Models;
|
||||
|
||||
namespace QuanTAlib.Test;
|
||||
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for UI (Ulcer Index).
|
||||
/// UI = √(avg(percentDrawdown²)) where percentDrawdown = ((close - highestClose) / highestClose) × 100
|
||||
/// </summary>
|
||||
public class UiValidationTests
|
||||
{
|
||||
private const int DefaultPeriod = 14;
|
||||
|
||||
private static TSeries GenerateTestData(int count = 100)
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var ts = new TSeries();
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
ts.Add(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
return ts;
|
||||
}
|
||||
|
||||
// === Mathematical Validation ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates the UI formula: √(avg(percentDrawdown²))
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_Formula_IsCorrect()
|
||||
{
|
||||
// Manual calculation for period=5 with known prices
|
||||
double[] prices = [100, 102, 101, 103, 100];
|
||||
double[] highests = [100, 102, 102, 103, 103];
|
||||
double[] percentDrawdowns = new double[5];
|
||||
double[] squaredDrawdowns = new double[5];
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
percentDrawdowns[i] = ((prices[i] - highests[i]) / highests[i]) * 100;
|
||||
squaredDrawdowns[i] = percentDrawdowns[i] * percentDrawdowns[i];
|
||||
}
|
||||
|
||||
double avgSquared = squaredDrawdowns.Average();
|
||||
double expected = Math.Sqrt(avgSquared);
|
||||
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
TValue result = default;
|
||||
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
result = ui.Update(new TValue(time.AddSeconds(i), prices[i]));
|
||||
}
|
||||
|
||||
Assert.Equal(expected, result.Value, 10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates UI is zero when price continuously rises (no drawdowns).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_RisingPrices_ReturnsZero()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Continuously rising prices
|
||||
double[] prices = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109];
|
||||
TValue result = default;
|
||||
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
result = ui.Update(new TValue(time.AddSeconds(i), prices[i]));
|
||||
}
|
||||
|
||||
// When price is always at new highs, there's no drawdown
|
||||
Assert.Equal(0.0, result.Value, 10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates UI increases with deeper drawdowns.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_DeeperDrawdown_HigherValue()
|
||||
{
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Shallow drawdown (5% from peak)
|
||||
var ui1 = new Ui(period: 5);
|
||||
double[] prices1 = [100, 105, 110, 110, 104.5]; // 5% drawdown from 110
|
||||
for (int i = 0; i < prices1.Length; i++)
|
||||
{
|
||||
ui1.Update(new TValue(time.AddSeconds(i), prices1[i]));
|
||||
}
|
||||
double shallow = ui1.Last.Value;
|
||||
|
||||
// Deep drawdown (20% from peak)
|
||||
var ui2 = new Ui(period: 5);
|
||||
double[] prices2 = [100, 105, 110, 110, 88]; // 20% drawdown from 110
|
||||
for (int i = 0; i < prices2.Length; i++)
|
||||
{
|
||||
ui2.Update(new TValue(time.AddSeconds(i), prices2[i]));
|
||||
}
|
||||
double deep = ui2.Last.Value;
|
||||
|
||||
Assert.True(deep > shallow,
|
||||
$"Deeper drawdown should have higher UI: deep={deep:F4}, shallow={shallow:F4}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates UI captures sustained drawdowns over multiple periods.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_SustainedDrawdown_CapturesCorrectly()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Price rises to 110, then stays at lower levels
|
||||
double[] prices = [100, 105, 110, 105, 100, 100, 100];
|
||||
TValue result = default;
|
||||
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
result = ui.Update(new TValue(time.AddSeconds(i), prices[i]));
|
||||
}
|
||||
|
||||
// UI should be positive (sustained drawdown from 110)
|
||||
Assert.True(result.Value > 0, $"UI should be positive for sustained drawdown, got {result.Value}");
|
||||
}
|
||||
|
||||
// === Streaming Validation ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates streaming calculation matches manual calculation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_StreamingMatchesManual()
|
||||
{
|
||||
int period = 5;
|
||||
var ui = new Ui(period);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
double[] prices = [100, 102, 98, 105, 100, 103, 97, 110, 105, 100];
|
||||
|
||||
// Track for manual calculation
|
||||
var closeBuffer = new List<double>();
|
||||
var sqDrawdownBuffer = new List<double>();
|
||||
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
var result = ui.Update(new TValue(time.AddSeconds(i), prices[i]));
|
||||
|
||||
// Manual calculation
|
||||
closeBuffer.Add(prices[i]);
|
||||
if (closeBuffer.Count > period)
|
||||
{
|
||||
closeBuffer.RemoveAt(0);
|
||||
}
|
||||
|
||||
double highest = closeBuffer.Max();
|
||||
double percentDrawdown = highest > 0 ? ((prices[i] - highest) / highest) * 100 : 0;
|
||||
double squaredDrawdown = percentDrawdown * percentDrawdown;
|
||||
|
||||
sqDrawdownBuffer.Add(squaredDrawdown);
|
||||
if (sqDrawdownBuffer.Count > period)
|
||||
{
|
||||
sqDrawdownBuffer.RemoveAt(0);
|
||||
}
|
||||
|
||||
double avgSq = sqDrawdownBuffer.Average();
|
||||
double expected = Math.Sqrt(avgSq);
|
||||
|
||||
Assert.Equal(expected, result.Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates batch calculation matches streaming.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_BatchMatchesStreaming()
|
||||
{
|
||||
var data = GenerateTestData(100);
|
||||
|
||||
// Streaming
|
||||
var streamingUi = new Ui(DefaultPeriod);
|
||||
var streamingResults = new double[data.Count];
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
streamingResults[i] = streamingUi.Update(data[i]).Value;
|
||||
}
|
||||
|
||||
// Batch
|
||||
var batchOutput = new double[data.Count];
|
||||
Ui.Batch(data.Values, batchOutput, DefaultPeriod);
|
||||
|
||||
// Compare all values
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batchOutput[i], 10);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates TSeries batch matches streaming.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_TSeriesBatchMatchesStreaming()
|
||||
{
|
||||
var data = GenerateTestData(100);
|
||||
|
||||
// Streaming
|
||||
var streamingUi = new Ui(DefaultPeriod);
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
streamingUi.Update(data[i]);
|
||||
}
|
||||
|
||||
// Batch via TSeries
|
||||
var batchResult = Ui.Batch(data, DefaultPeriod);
|
||||
|
||||
Assert.Equal(streamingUi.Last.Value, batchResult.Last.Value, 10);
|
||||
}
|
||||
|
||||
// === Property Validation ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates UI is always non-negative.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_Output_IsNonNegative()
|
||||
{
|
||||
var data = GenerateTestData(100);
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var result = ui.Update(data[i]);
|
||||
Assert.True(result.Value >= 0, $"UI should be non-negative at index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates UI output is always finite.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_Output_IsFinite()
|
||||
{
|
||||
var data = GenerateTestData(100);
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var result = ui.Update(data[i]);
|
||||
Assert.True(double.IsFinite(result.Value), $"UI should be finite at index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates UI is bounded (typically single digits for reasonable price movements).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_Output_IsReasonablyBounded()
|
||||
{
|
||||
var data = GenerateTestData(100);
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var result = ui.Update(data[i]);
|
||||
// UI is percentage-based; for normal markets, rarely exceeds 20
|
||||
Assert.True(result.Value < 50, $"UI seems too high at index {i}: {result.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
// === Edge Cases ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates handling of flat prices (no volatility).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_FlatPrices_ReturnsZero()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var result = ui.Update(new TValue(time.AddSeconds(i), 100.0));
|
||||
// Flat prices = no drawdown = UI is zero
|
||||
Assert.Equal(0.0, result.Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates handling of very small price movements.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_SmallMovements_HandledCorrectly()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
double price = 100.0 + Math.Sin(i * 0.1) * 0.001; // Tiny movements
|
||||
var result = ui.Update(new TValue(time.AddSeconds(i), price));
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates handling of very large price movements.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_LargeMovements_HandledCorrectly()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Large price swings
|
||||
double[] prices = [100, 200, 50, 150, 75, 250, 100];
|
||||
|
||||
for (int i = 0; i < prices.Length; i++)
|
||||
{
|
||||
var result = ui.Update(new TValue(time.AddSeconds(i), prices[i]));
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates bar correction works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_BarCorrection_WorksCorrectly()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Feed initial data
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i), isNew: true);
|
||||
}
|
||||
|
||||
// Add new bar
|
||||
ui.Update(new TValue(time.AddSeconds(5), 95), isNew: true);
|
||||
double afterNew = ui.Last.Value;
|
||||
|
||||
// Correct with different value (much larger drawdown)
|
||||
ui.Update(new TValue(time.AddSeconds(5), 80), isNew: false);
|
||||
double afterCorrection = ui.Last.Value;
|
||||
|
||||
// Restore original
|
||||
ui.Update(new TValue(time.AddSeconds(5), 95), isNew: false);
|
||||
double afterRestore = ui.Last.Value;
|
||||
|
||||
Assert.NotEqual(afterNew, afterCorrection);
|
||||
Assert.Equal(afterNew, afterRestore, 10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates iterative corrections converge.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_IterativeCorrections_Converge()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Feed data
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i), isNew: true);
|
||||
}
|
||||
|
||||
// Multiple corrections on same bar
|
||||
for (int j = 0; j < 5; j++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(4), 100 + j * 2), isNew: false);
|
||||
}
|
||||
|
||||
// Final correction back to original
|
||||
ui.Update(new TValue(time.AddSeconds(4), 104), isNew: false);
|
||||
double afterCorrections = ui.Last.Value;
|
||||
|
||||
// Fresh calculation
|
||||
var uiFresh = new Ui(period: 5);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
uiFresh.Update(new TValue(time.AddSeconds(i), 100 + i), isNew: true);
|
||||
}
|
||||
double freshValue = uiFresh.Last.Value;
|
||||
|
||||
Assert.Equal(freshValue, afterCorrections, 10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates Reset clears state completely.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_Reset_ClearsState()
|
||||
{
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
var data = GenerateTestData(30);
|
||||
|
||||
// Feed data
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
ui.Update(data[i]);
|
||||
}
|
||||
|
||||
// Reset
|
||||
ui.Reset();
|
||||
|
||||
// State should be cleared
|
||||
Assert.False(ui.IsHot);
|
||||
Assert.Equal(default, ui.Last);
|
||||
|
||||
// Feed data again
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
ui.Update(data[i]);
|
||||
}
|
||||
|
||||
// Fresh indicator
|
||||
var uiFresh = new Ui(DefaultPeriod);
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
uiFresh.Update(data[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(uiFresh.Last.Value, ui.Last.Value, 10);
|
||||
}
|
||||
|
||||
// === Consistency Tests ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates stability over repeated runs with same seed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_Stability_ConsistentOverRepeatedRuns()
|
||||
{
|
||||
var results = new List<double>();
|
||||
|
||||
for (int run = 0; run < 3; run++)
|
||||
{
|
||||
var data = GenerateTestData(100);
|
||||
var ui = new Ui(DefaultPeriod);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
ui.Update(data[i]);
|
||||
}
|
||||
results.Add(ui.Last.Value);
|
||||
}
|
||||
|
||||
Assert.Equal(results[0], results[1], 15);
|
||||
Assert.Equal(results[1], results[2], 15);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates UI responds to volatility regime changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_RespondsToVolatilityChange()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
var lowVolResults = new List<double>();
|
||||
var highVolResults = new List<double>();
|
||||
|
||||
// Low volatility regime (small drawdowns)
|
||||
double price;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
price = 100 + (i * 0.1); // Gentle uptrend with tiny corrections
|
||||
lowVolResults.Add(ui.Update(new TValue(time.AddSeconds(i), price)).Value);
|
||||
}
|
||||
|
||||
// High volatility regime (large drawdowns)
|
||||
for (int i = 10; i < 20; i++)
|
||||
{
|
||||
// Sawtooth pattern with big drops
|
||||
price = i % 2 == 0 ? 110 : 90;
|
||||
highVolResults.Add(ui.Update(new TValue(time.AddSeconds(i), price)).Value);
|
||||
}
|
||||
|
||||
double avgHighVol = highVolResults.Skip(2).Average(); // Skip transition period
|
||||
|
||||
// High vol UI should be significantly higher due to larger drawdowns
|
||||
Assert.True(avgHighVol > 5, $"High vol UI ({avgHighVol:F4}) should show significant stress");
|
||||
}
|
||||
|
||||
// === WarmupPeriod Validation ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates WarmupPeriod equals period.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_WarmupPeriod_EqualsPeriod()
|
||||
{
|
||||
var ui = new Ui(period: 20);
|
||||
Assert.Equal(20, ui.WarmupPeriod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates IsHot is true after period bars.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_IsHot_AfterPeriod()
|
||||
{
|
||||
int period = 10;
|
||||
var ui = new Ui(period);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < period - 1; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i));
|
||||
Assert.False(ui.IsHot);
|
||||
}
|
||||
|
||||
ui.Update(new TValue(time.AddSeconds(period - 1), 100 + period - 1));
|
||||
Assert.True(ui.IsHot);
|
||||
}
|
||||
|
||||
// === NaN/Infinity Handling ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates NaN input uses last valid value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_NaNInput_UsesLastValid()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
var result = ui.Update(new TValue(time.AddSeconds(5), double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates Infinity input uses last valid value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_InfinityInput_UsesLastValid()
|
||||
{
|
||||
var ui = new Ui(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ui.Update(new TValue(time.AddSeconds(i), 100 + i));
|
||||
}
|
||||
|
||||
var result = ui.Update(new TValue(time.AddSeconds(5), double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates batch handles NaN values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_BatchNaN_HandledCorrectly()
|
||||
{
|
||||
var source = new double[] { 100, 102, double.NaN, 98, 101 };
|
||||
var output = new double[5];
|
||||
|
||||
Ui.Batch(source, output, period: 5);
|
||||
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]), $"Output at index {i} should be finite");
|
||||
Assert.True(output[i] >= 0, $"Output at index {i} should be non-negative");
|
||||
}
|
||||
}
|
||||
|
||||
// === Period Sensitivity ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates longer period produces smoother results.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_LongerPeriod_SmootherResults()
|
||||
{
|
||||
var data = GenerateTestData(100);
|
||||
|
||||
var uiShort = new Ui(period: 5);
|
||||
var uiLong = new Ui(period: 20);
|
||||
|
||||
var shortResults = new List<double>();
|
||||
var longResults = new List<double>();
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
shortResults.Add(uiShort.Update(data[i]).Value);
|
||||
longResults.Add(uiLong.Update(data[i]).Value);
|
||||
}
|
||||
|
||||
// Calculate variance of changes (smoothness measure)
|
||||
double shortVariance = CalculateChangeVariance(shortResults.Skip(20).ToList());
|
||||
double longVariance = CalculateChangeVariance(longResults.Skip(20).ToList());
|
||||
|
||||
// Longer period should be smoother (lower variance of changes)
|
||||
Assert.True(longVariance < shortVariance,
|
||||
$"Longer period should be smoother: short variance={shortVariance:F6}, long variance={longVariance:F6}");
|
||||
}
|
||||
|
||||
private static double CalculateChangeVariance(List<double> values)
|
||||
{
|
||||
if (values.Count < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var changes = new List<double>();
|
||||
for (int i = 1; i < values.Count; i++)
|
||||
{
|
||||
changes.Add(values[i] - values[i - 1]);
|
||||
}
|
||||
|
||||
double mean = changes.Average();
|
||||
double variance = changes.Select(c => (c - mean) * (c - mean)).Average();
|
||||
return variance;
|
||||
}
|
||||
|
||||
// === Known Value Test ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates UI against manually calculated known values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ui_KnownValues_MatchExpected()
|
||||
{
|
||||
var ui = new Ui(period: 3);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Period 3, prices: 100, 105, 100
|
||||
// Highest: 100, 105, 105
|
||||
// %Drawdown: 0, 0, (100-105)/105*100 = -4.762
|
||||
// SqDrawdown: 0, 0, 22.677
|
||||
// AvgSq = 22.677/3 = 7.559
|
||||
// UI = sqrt(7.559) = 2.749
|
||||
|
||||
ui.Update(new TValue(time.AddSeconds(0), 100));
|
||||
ui.Update(new TValue(time.AddSeconds(1), 105));
|
||||
var result = ui.Update(new TValue(time.AddSeconds(2), 100));
|
||||
|
||||
double expected = Math.Sqrt(22.6757369614512 / 3.0);
|
||||
Assert.Equal(expected, result.Value, 5);
|
||||
}
|
||||
|
||||
// === External Library Validation ===
|
||||
// NOTE: Skender.Stock.Indicators uses a different Ulcer Index algorithm variant:
|
||||
// Skender: For each bar j in the period window, highestClose = max(closes from window_start to j)
|
||||
// Each bar gets its own "growing" highest reference within the evaluation window.
|
||||
// QuanTAlib: highestClose = max(closes over the entire rolling period window)
|
||||
// Both are valid implementations of the Ulcer Index concept, but produce different values.
|
||||
// No external validation test is added for UI due to this algorithmic difference.
|
||||
|
||||
[Fact]
|
||||
public void Ui_MatchesOoples_Structural()
|
||||
{
|
||||
// CalculateUlcerIndex — structural test (different highest-close window variant)
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var ooplesData = bars.Select(b => new TickerData
|
||||
{
|
||||
Date = new DateTime(b.Time, DateTimeKind.Utc),
|
||||
Open = b.Open,
|
||||
High = b.High,
|
||||
Low = b.Low,
|
||||
Close = b.Close,
|
||||
Volume = b.Volume
|
||||
}).ToList();
|
||||
|
||||
var result = new StockData(ooplesData).CalculateUlcerIndex();
|
||||
var values = result.CustomValuesList;
|
||||
|
||||
int finiteCount = values.Count(v => double.IsFinite(v));
|
||||
Assert.True(finiteCount > 100, $"Expected >100 finite Ooples UI values, got {finiteCount}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user