using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
namespace QuanTAlib.Test;
using Xunit;
///
/// Validation tests for UI (Ulcer Index).
/// UI = √(avg(percentDrawdown²)) where percentDrawdown = ((close - highestClose) / highestClose) × 100
///
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 ===
///
/// Validates the UI formula: √(avg(percentDrawdown²))
///
[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);
}
///
/// Validates UI is zero when price continuously rises (no drawdowns).
///
[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);
}
///
/// Validates UI increases with deeper drawdowns.
///
[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}");
}
///
/// Validates UI captures sustained drawdowns over multiple periods.
///
[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 ===
///
/// Validates streaming calculation matches manual calculation.
///
[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();
var sqDrawdownBuffer = new List();
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);
}
}
///
/// Validates batch calculation matches streaming.
///
[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);
}
}
///
/// Validates TSeries batch matches streaming.
///
[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 ===
///
/// Validates UI is always non-negative.
///
[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}");
}
}
///
/// Validates UI output is always finite.
///
[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}");
}
}
///
/// Validates UI is bounded (typically single digits for reasonable price movements).
///
[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 ===
///
/// Validates handling of flat prices (no volatility).
///
[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);
}
}
///
/// Validates handling of very small price movements.
///
[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);
}
}
///
/// Validates handling of very large price movements.
///
[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);
}
}
///
/// Validates bar correction works correctly.
///
[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);
}
///
/// Validates iterative corrections converge.
///
[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);
}
///
/// Validates Reset clears state completely.
///
[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 ===
///
/// Validates stability over repeated runs with same seed.
///
[Fact]
public void Ui_Stability_ConsistentOverRepeatedRuns()
{
var results = new List();
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);
}
///
/// Validates UI responds to volatility regime changes.
///
[Fact]
public void Ui_RespondsToVolatilityChange()
{
var ui = new Ui(period: 5);
var time = DateTime.UtcNow;
var lowVolResults = new List();
var highVolResults = new List();
// 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 ===
///
/// Validates WarmupPeriod equals period.
///
[Fact]
public void Ui_WarmupPeriod_EqualsPeriod()
{
var ui = new Ui(period: 20);
Assert.Equal(20, ui.WarmupPeriod);
}
///
/// Validates IsHot is true after period bars.
///
[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 ===
///
/// Validates NaN input uses last valid value.
///
[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));
}
///
/// Validates Infinity input uses last valid value.
///
[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));
}
///
/// Validates batch handles NaN values.
///
[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 ===
///
/// Validates longer period produces smoother results.
///
[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();
var longResults = new List();
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 values)
{
if (values.Count < 2)
{
return 0;
}
var changes = new List();
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 ===
///
/// Validates UI against manually calculated known values.
///
[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}");
}
}