mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-15 17:18:05 +00:00
more volatilty
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,49 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class UiIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 200, 1, 0)]
|
||||
public int Period { get; set; } = 14;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Ui _ui = null!;
|
||||
private readonly LineSeries _series;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"UI({Period})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/ui/Ui.Quantower.cs";
|
||||
|
||||
public UiIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "UI - Ulcer Index";
|
||||
Description = "Ulcer Index measures downside volatility by calculating the root mean square of percentage drawdowns from recent highs";
|
||||
|
||||
_series = new LineSeries(name: "UI", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_ui = new Ui(Period);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
TBar bar = this.GetInputBar(args);
|
||||
TValue result = _ui.Update(bar, isNew: args.IsNewBar());
|
||||
_series.SetValue(result.Value, _ui.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
// 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.Calculate(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;
|
||||
double[] source = new double[dataLen];
|
||||
double[] output = new double[dataLen];
|
||||
|
||||
// Fill with realistic data
|
||||
double price = 100.0;
|
||||
var rng = new Random(42);
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
double change = (rng.NextDouble() - 0.5) * 2; // -1% to +1%
|
||||
price *= (1 + change / 100);
|
||||
source[i] = price;
|
||||
}
|
||||
|
||||
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.Calculate(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,664 @@
|
||||
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.Calculate(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Ulcer Index (UI) Indicator
|
||||
// Measures downside volatility by tracking drawdowns from recent highs
|
||||
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// UI: Ulcer Index
|
||||
/// A volatility indicator that measures downside risk by calculating the
|
||||
/// root mean square of percentage drawdowns from recent highs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Calculation steps:</b>
|
||||
/// <list type="number">
|
||||
/// <item>Track highest close over period (rolling maximum)</item>
|
||||
/// <item>Calculate percent drawdown: ((close - highestClose) / highestClose) × 100</item>
|
||||
/// <item>Square the drawdown</item>
|
||||
/// <item>Average the squared drawdowns over the period</item>
|
||||
/// <item>Take square root: UI = √(avgSquaredDrawdown)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <b>Key characteristics:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item>Measures only downside volatility (unlike ATR which measures both directions)</item>
|
||||
/// <item>Zero when price is at period high (no drawdown)</item>
|
||||
/// <item>Higher values indicate deeper/longer drawdowns</item>
|
||||
/// <item>Useful for risk-adjusted performance metrics (Martin Ratio)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <b>Sources:</b>
|
||||
/// Peter G. Martin, Byron B. McCann (1989). "The Investor's Guide to Fidelity Funds."
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Ui : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly RingBuffer _closeBuffer;
|
||||
private readonly RingBuffer _squaredDrawdownBuffer;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double SumSquaredDrawdown,
|
||||
double LastValidClose,
|
||||
double LastUi,
|
||||
int Count
|
||||
);
|
||||
private State _s;
|
||||
private State _ps;
|
||||
|
||||
// Backup buffers for state rollback
|
||||
private readonly double[] _closeBackup;
|
||||
private readonly double[] _squaredDrawdownBackup;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the Ui class.
|
||||
/// </summary>
|
||||
/// <param name="period">The lookback period for calculating drawdowns (default 14).</param>
|
||||
/// <exception cref="ArgumentException">Thrown when period is less than 1.</exception>
|
||||
public Ui(int period = 14)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
_period = period;
|
||||
WarmupPeriod = period;
|
||||
Name = $"Ui({period})";
|
||||
_closeBuffer = new RingBuffer(period);
|
||||
_squaredDrawdownBuffer = new RingBuffer(period);
|
||||
_closeBackup = new double[period];
|
||||
_squaredDrawdownBackup = new double[period];
|
||||
_s = new State(0, 0, 0, 0);
|
||||
_ps = _s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the Ui class with a source.
|
||||
/// </summary>
|
||||
/// <param name="source">The data source for chaining.</param>
|
||||
/// <param name="period">The lookback period (default 14).</param>
|
||||
public Ui(ITValuePublisher source, int period = 14) : this(period)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
/// <summary>
|
||||
/// True if the indicator has enough data for valid results.
|
||||
/// </summary>
|
||||
public override bool IsHot => _s.Count >= WarmupPeriod;
|
||||
|
||||
/// <summary>
|
||||
/// The lookback period.
|
||||
/// </summary>
|
||||
public int Period => _period;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a TValue input.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
return UpdateCore(input.Time, input.Value, isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a new bar (uses close price).
|
||||
/// </summary>
|
||||
/// <param name="bar">The input bar.</param>
|
||||
/// <param name="isNew">Whether this is a new bar or an update.</param>
|
||||
/// <returns>The calculated Ulcer Index value.</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar bar, bool isNew = true)
|
||||
{
|
||||
return UpdateCore(bar.Time, bar.Close, isNew);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||
|
||||
Batch(source.Values, vSpan, _period);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
// Update internal state
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private TValue UpdateCore(long timeTicks, double close, bool isNew)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
// Backup buffers
|
||||
_closeBuffer.CopyTo(_closeBackup);
|
||||
_squaredDrawdownBuffer.CopyTo(_squaredDrawdownBackup);
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
// Restore buffers
|
||||
_closeBuffer.Clear();
|
||||
for (int i = 0; i < _closeBackup.Length && i < _ps.Count; i++)
|
||||
{
|
||||
_closeBuffer.Add(_closeBackup[i]);
|
||||
}
|
||||
_squaredDrawdownBuffer.Clear();
|
||||
for (int i = 0; i < _squaredDrawdownBackup.Length && i < _ps.Count; i++)
|
||||
{
|
||||
_squaredDrawdownBuffer.Add(_squaredDrawdownBackup[i]);
|
||||
}
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
// Handle non-finite values
|
||||
if (!double.IsFinite(close))
|
||||
{
|
||||
close = s.LastValidClose;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.LastValidClose = close;
|
||||
}
|
||||
|
||||
// Add close to buffer
|
||||
_closeBuffer.Add(close);
|
||||
|
||||
// Find highest close over period
|
||||
double highestClose = close;
|
||||
for (int i = 0; i < _closeBuffer.Count; i++)
|
||||
{
|
||||
if (_closeBuffer[i] > highestClose)
|
||||
{
|
||||
highestClose = _closeBuffer[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate percent drawdown
|
||||
double percentDrawdown = highestClose > 0 ? ((close - highestClose) / highestClose) * 100.0 : 0;
|
||||
double squaredDrawdown = percentDrawdown * percentDrawdown;
|
||||
|
||||
// Update running sum (remove oldest if buffer is full)
|
||||
double sumSquaredDrawdown = s.SumSquaredDrawdown;
|
||||
if (_squaredDrawdownBuffer.Count >= _period)
|
||||
{
|
||||
sumSquaredDrawdown -= _squaredDrawdownBuffer[0];
|
||||
}
|
||||
sumSquaredDrawdown += squaredDrawdown;
|
||||
|
||||
_squaredDrawdownBuffer.Add(squaredDrawdown);
|
||||
|
||||
// Calculate UI
|
||||
int count = Math.Min(_squaredDrawdownBuffer.Count, _period);
|
||||
double avgSquaredDrawdown = count > 0 ? sumSquaredDrawdown / count : 0;
|
||||
double ui = Math.Sqrt(avgSquaredDrawdown);
|
||||
|
||||
if (!double.IsFinite(ui) || ui < 0)
|
||||
{
|
||||
ui = s.LastUi;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.LastUi = ui;
|
||||
}
|
||||
|
||||
// Update state
|
||||
s.SumSquaredDrawdown = sumSquaredDrawdown;
|
||||
if (isNew)
|
||||
{
|
||||
s.Count++;
|
||||
}
|
||||
|
||||
_s = s;
|
||||
|
||||
Last = new TValue(timeTicks, ui);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Reset()
|
||||
{
|
||||
_closeBuffer.Clear();
|
||||
_squaredDrawdownBuffer.Clear();
|
||||
Array.Clear(_closeBackup);
|
||||
Array.Clear(_squaredDrawdownBackup);
|
||||
_s = new State(0, 0, 0, 0);
|
||||
_ps = _s;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Ulcer Index for a series (static).
|
||||
/// </summary>
|
||||
/// <param name="source">The source series.</param>
|
||||
/// <param name="period">The lookback period.</param>
|
||||
/// <returns>A TSeries containing the Ulcer Index values.</returns>
|
||||
public static TSeries Calculate(TSeries source, int period = 14)
|
||||
{
|
||||
var ui = new Ui(period);
|
||||
return ui.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculation using spans.
|
||||
/// </summary>
|
||||
/// <param name="source">Close prices.</param>
|
||||
/// <param name="output">Output Ulcer Index values.</param>
|
||||
/// <param name="period">The lookback period.</param>
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> source,
|
||||
Span<double> output,
|
||||
int period = 14)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
if (output.Length < source.Length)
|
||||
{
|
||||
throw new ArgumentException("Output span must be at least as long as source span", nameof(output));
|
||||
}
|
||||
|
||||
int len = source.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int StackallocThreshold = 256;
|
||||
|
||||
// Use ArrayPool for larger allocations
|
||||
double[]? closeRented = null;
|
||||
double[]? sqDrawdownRented = null;
|
||||
|
||||
if (period > StackallocThreshold)
|
||||
{
|
||||
closeRented = ArrayPool<double>.Shared.Rent(period);
|
||||
sqDrawdownRented = ArrayPool<double>.Shared.Rent(period);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
scoped Span<double> closeBuffer = period <= StackallocThreshold
|
||||
? stackalloc double[period]
|
||||
: closeRented.AsSpan(0, period);
|
||||
|
||||
scoped Span<double> sqDrawdownBuffer = period <= StackallocThreshold
|
||||
? stackalloc double[period]
|
||||
: sqDrawdownRented.AsSpan(0, period);
|
||||
|
||||
closeBuffer.Clear();
|
||||
sqDrawdownBuffer.Clear();
|
||||
|
||||
double lastValidClose = 0;
|
||||
double sumSquaredDrawdown = 0;
|
||||
int bufferCount = 0;
|
||||
int bufferIndex = 0;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double close = source[i];
|
||||
|
||||
// Handle non-finite values
|
||||
if (!double.IsFinite(close))
|
||||
{
|
||||
close = lastValidClose;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValidClose = close;
|
||||
}
|
||||
|
||||
// Add to circular buffer
|
||||
closeBuffer[bufferIndex] = close;
|
||||
|
||||
// Find highest close in buffer
|
||||
int currentCount = Math.Min(bufferCount + 1, period);
|
||||
double highestClose = close;
|
||||
for (int j = 0; j < currentCount; j++)
|
||||
{
|
||||
int idx = (bufferIndex - j + period) % period;
|
||||
if (closeBuffer[idx] > highestClose)
|
||||
{
|
||||
highestClose = closeBuffer[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate percent drawdown
|
||||
double percentDrawdown = highestClose > 0 ? ((close - highestClose) / highestClose) * 100.0 : 0;
|
||||
double squaredDrawdown = percentDrawdown * percentDrawdown;
|
||||
|
||||
// Update running sum
|
||||
if (bufferCount >= period)
|
||||
{
|
||||
sumSquaredDrawdown -= sqDrawdownBuffer[bufferIndex];
|
||||
}
|
||||
sumSquaredDrawdown += squaredDrawdown;
|
||||
sqDrawdownBuffer[bufferIndex] = squaredDrawdown;
|
||||
|
||||
// Calculate UI
|
||||
int count = Math.Min(bufferCount + 1, period);
|
||||
double avgSquaredDrawdown = count > 0 ? sumSquaredDrawdown / count : 0;
|
||||
double ui = Math.Sqrt(avgSquaredDrawdown);
|
||||
|
||||
if (!double.IsFinite(ui) || ui < 0)
|
||||
{
|
||||
ui = i > 0 ? output[i - 1] : 0;
|
||||
}
|
||||
|
||||
output[i] = ui;
|
||||
|
||||
// Advance buffer index
|
||||
bufferIndex = (bufferIndex + 1) % period;
|
||||
if (bufferCount < period)
|
||||
{
|
||||
bufferCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (closeRented != null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(closeRented);
|
||||
}
|
||||
if (sqDrawdownRented != null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(sqDrawdownRented);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
# UI: Ulcer Index
|
||||
|
||||
> "The ulcer-inducing anxiety of watching your portfolio decline—now quantified."
|
||||
|
||||
Ulcer Index (UI) is a downside volatility measure that quantifies the depth and duration of drawdowns from recent highs. Developed by Peter G. Martin in 1987, UI captures what most volatility measures miss: the pain of being underwater. Unlike standard deviation or ATR that treat upside and downside moves equally, UI measures only the decline from peaks—the psychological stress that keeps investors awake at night.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Peter G. Martin introduced the Ulcer Index in 1987, with the full methodology published in his 1989 book "The Investor's Guide to Fidelity Funds" co-authored with Byron McCann. The name comes from the stress-induced ulcers that investors might develop watching their portfolios decline.
|
||||
|
||||
Martin developed UI as a risk metric specifically for evaluating mutual fund performance. He recognized that traditional volatility measures (like standard deviation) penalize upside volatility equally with downside—but investors don't mind upside "volatility." The problem is drawdowns: how far below the recent high, and for how long.
|
||||
|
||||
The Ulcer Index became the denominator for the Martin Ratio (also called the Ulcer Performance Index or UPI), a risk-adjusted return measure analogous to the Sharpe Ratio but using UI instead of standard deviation:
|
||||
|
||||
$$
|
||||
\text{Martin Ratio} = \frac{R - R_f}{UI}
|
||||
$$
|
||||
|
||||
This makes UI particularly valuable for comparing investments: lower UI means less "ulcer-inducing" drawdowns.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. Rolling Maximum (Highest Close)
|
||||
|
||||
Track the highest closing price over the lookback period:
|
||||
|
||||
$$
|
||||
H_t = \max(C_{t}, C_{t-1}, \ldots, C_{t-n+1})
|
||||
$$
|
||||
|
||||
where:
|
||||
|
||||
- $C_t$ = Close price at time $t$
|
||||
- $n$ = Period (default 14)
|
||||
|
||||
### 2. Percent Drawdown
|
||||
|
||||
Calculate how far price has fallen from the rolling high:
|
||||
|
||||
$$
|
||||
D_t = \frac{C_t - H_t}{H_t} \times 100
|
||||
$$
|
||||
|
||||
Note: $D_t \leq 0$ always (price cannot exceed its own maximum).
|
||||
|
||||
For computation, we use the absolute percentage:
|
||||
|
||||
$$
|
||||
|D_t| = \left|\frac{C_t - H_t}{H_t}\right| \times 100
|
||||
$$
|
||||
|
||||
### 3. Squared Drawdown
|
||||
|
||||
Square the drawdown to penalize larger declines more heavily:
|
||||
|
||||
$$
|
||||
D_t^2 = \left(\frac{C_t - H_t}{H_t} \times 100\right)^2
|
||||
$$
|
||||
|
||||
### 4. Average Squared Drawdown
|
||||
|
||||
Calculate the mean of squared drawdowns over the period:
|
||||
|
||||
$$
|
||||
\overline{D^2} = \frac{1}{n}\sum_{i=0}^{n-1} D_{t-i}^2
|
||||
$$
|
||||
|
||||
### 5. Ulcer Index
|
||||
|
||||
Take the square root (RMS - root mean square):
|
||||
|
||||
$$
|
||||
UI_t = \sqrt{\overline{D^2}} = \sqrt{\frac{1}{n}\sum_{i=0}^{n-1} D_{t-i}^2}
|
||||
$$
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Why Squared Drawdowns?
|
||||
|
||||
The squaring serves two purposes:
|
||||
|
||||
1. **Eliminates sign**: All drawdowns become positive contributions
|
||||
2. **Penalizes large drawdowns**: A 20% drawdown contributes 400 to the sum; a 10% drawdown contributes only 100
|
||||
|
||||
This quadratic penalty means UI is highly sensitive to severe drawdowns—exactly what investors fear most.
|
||||
|
||||
### RMS Interpretation
|
||||
|
||||
The square root at the end returns UI to the same units as the input (percentage). UI can be interpreted as the "typical" percentage drawdown, weighted toward larger declines.
|
||||
|
||||
### Example Calculation
|
||||
|
||||
Consider a 5-period example:
|
||||
|
||||
| Day | Close | Rolling High | Drawdown (%) | Drawdown² |
|
||||
| :---: | :---: | :---: | :---: | :---: |
|
||||
| 1 | 100 | 100 | 0 | 0 |
|
||||
| 2 | 98 | 100 | -2 | 4 |
|
||||
| 3 | 95 | 100 | -5 | 25 |
|
||||
| 4 | 97 | 100 | -3 | 9 |
|
||||
| 5 | 99 | 100 | -1 | 1 |
|
||||
|
||||
$$
|
||||
UI = \sqrt{\frac{0 + 4 + 25 + 9 + 1}{5}} = \sqrt{7.8} \approx 2.79
|
||||
$$
|
||||
|
||||
### Properties
|
||||
|
||||
1. **Non-negativity**: $UI_t \geq 0$ always
|
||||
2. **Zero at peak**: When $C_t = H_t$, drawdown is 0
|
||||
3. **Units**: Percentage (same as input drawdown)
|
||||
4. **Asymmetric**: Only measures downside (drawdown), ignores upside
|
||||
5. **Trend-sensitive**: Prolonged declines accumulate higher UI
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, Scalar)
|
||||
|
||||
Per-bar operations:
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| MAX scan (period elements) | n | 1 | n |
|
||||
| SUB | 2 | 1 | 2 |
|
||||
| DIV | 2 | 15 | 30 |
|
||||
| MUL | 2 | 3 | 6 |
|
||||
| SQRT | 1 | 15 | 15 |
|
||||
| Ring buffer ops | 2 | 2 | 4 |
|
||||
| **Total** | — | — | **~57 + n cycles** |
|
||||
|
||||
The MAX scan dominates for larger periods. For period=14, approximately 71 cycles per bar.
|
||||
|
||||
### Batch Mode (512 values, SIMD/FMA)
|
||||
|
||||
| Operation | Scalar Ops | SIMD Ops (AVX2) | Speedup |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| Rolling max | Complex | Limited | ~2-4× |
|
||||
| Arithmetic | 4096 | 512 | 8× |
|
||||
| SQRT | 512 | 64 | 8× |
|
||||
|
||||
Rolling maximum has limited SIMD benefit due to sequential dependency, but arithmetic operations vectorize well.
|
||||
|
||||
### Memory Profile
|
||||
|
||||
- **Per instance:** ~144 bytes (state + two ring buffers of size period)
|
||||
- **Backup arrays:** 2 × period × 8 bytes (for bar correction)
|
||||
- **Period 14:** ~368 bytes per instance
|
||||
- **100 instances:** ~36 KB
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 10/10 | Exact calculation, matches reference |
|
||||
| **Timeliness** | 7/10 | Period-based lag |
|
||||
| **Smoothness** | 7/10 | RMS smoothing, but can change quickly |
|
||||
| **Interpretability** | 9/10 | Clear meaning: typical drawdown % |
|
||||
| **Risk Assessment** | 10/10 | Excellent downside risk measure |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **TA-Lib** | N/A | Not implemented |
|
||||
| **Skender** | ✅ | Matches calculation |
|
||||
| **Tulip** | N/A | Not implemented |
|
||||
| **OoplesFinance** | ✅ | Matches calculation |
|
||||
| **PineScript** | ✅ | Matches ui.pine reference |
|
||||
| **Manual** | ✅ | Validated against Martin's formula |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Warmup period**: UI requires a full period of data before producing valid results. During warmup, values represent partial-period calculations that may underestimate true UI.
|
||||
|
||||
2. **Zero interpretation**: UI=0 means price is at or above the period high—no drawdown. This doesn't mean low risk; the market might be at a blow-off top.
|
||||
|
||||
3. **Period selection**: Shorter periods (7-14) react quickly to recent drawdowns but may miss longer declines. Longer periods (21-50) capture extended bear markets but lag on recovery.
|
||||
|
||||
4. **Comparison across assets**: UI is percentage-based, so it's comparable across different-priced assets (unlike raw TR or ATR).
|
||||
|
||||
5. **Trend bias**: In strong uptrends, UI approaches zero (constantly at new highs). This might mask lurking risk when the trend eventually breaks.
|
||||
|
||||
6. **Not a timing indicator**: UI measures risk, not direction. High UI during a decline doesn't predict reversal—it just confirms you're underwater.
|
||||
|
||||
## Trading Applications
|
||||
|
||||
### Risk-Adjusted Performance (Martin Ratio)
|
||||
|
||||
Compare investments using the Martin Ratio:
|
||||
|
||||
$$
|
||||
\text{Martin Ratio} = \frac{\text{Annualized Return} - R_f}{UI}
|
||||
$$
|
||||
|
||||
Higher Martin Ratio = better risk-adjusted returns (more return per unit of "ulcer").
|
||||
|
||||
### Portfolio Selection
|
||||
|
||||
Filter investments by maximum acceptable UI:
|
||||
|
||||
```
|
||||
If UI > 15: Too volatile for conservative portfolios
|
||||
If UI < 5: Suitable for risk-averse investors
|
||||
```
|
||||
|
||||
### Position Sizing
|
||||
|
||||
Adjust position size based on UI:
|
||||
|
||||
```
|
||||
Position size = Base size × (Target UI / Actual UI)
|
||||
```
|
||||
|
||||
Higher UI assets get smaller allocations.
|
||||
|
||||
### Drawdown Monitoring
|
||||
|
||||
Track UI in real-time to monitor portfolio stress:
|
||||
|
||||
```
|
||||
If UI crosses above threshold: Consider hedging or reducing exposure
|
||||
If UI declining from high: Recovery underway
|
||||
```
|
||||
|
||||
### Strategy Evaluation
|
||||
|
||||
Compare trading strategies by UI:
|
||||
|
||||
```
|
||||
Strategy A: Return 15%, UI 8 → Martin Ratio = 1.88
|
||||
Strategy B: Return 12%, UI 4 → Martin Ratio = 3.00
|
||||
Strategy B is better risk-adjusted despite lower returns
|
||||
```
|
||||
|
||||
## Relationship to Other Indicators
|
||||
|
||||
| Indicator | Relationship to UI |
|
||||
| :--- | :--- |
|
||||
| **Standard Deviation** | UI measures only downside; StdDev measures both directions |
|
||||
| **ATR** | ATR is range-based; UI is drawdown-based |
|
||||
| **Maximum Drawdown** | MDD is the worst single drawdown; UI averages all drawdowns |
|
||||
| **Sharpe Ratio** | Uses StdDev; Martin Ratio uses UI |
|
||||
| **Sortino Ratio** | Uses downside deviation; similar philosophy to UI |
|
||||
| **Calmar Ratio** | Uses max drawdown; UI uses average drawdown |
|
||||
|
||||
## References
|
||||
|
||||
- Martin, P. G., & McCann, B. B. (1989). *The Investor's Guide to Fidelity Funds*. John Wiley & Sons.
|
||||
- Martin, P. G. (1987). "Ulcer Index, An Alternative Approach to the Measurement of Investment Risk & Risk-Adjusted Performance."
|
||||
- Kaufman, P. J. (2013). *Trading Systems and Methods* (5th ed.). Wiley.
|
||||
Reference in New Issue
Block a user