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:
Miha Kralj
2026-03-12 12:34:16 -07:00
parent 8937b0c0fa
commit 060649192f
1149 changed files with 1780 additions and 3316 deletions
@@ -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");
}
}
+614
View File
@@ -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}");
}
}