mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-07-31 19:07:42 +00:00
060649192f
- 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
615 lines
16 KiB
C#
615 lines
16 KiB
C#
// 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
|
|
}
|