Implement ZTEST: One-Sample t-Test Statistic with validation tests

- Added Ztest class to compute the one-sample t-statistic using sample standard deviation with Bessel correction.
- Implemented validation tests for Ztest to ensure accuracy against manual calculations and PineScript.
- Updated documentation for Ztest, detailing its mathematical foundation, performance profile, and common pitfalls.
- Adjusted NDepend badges to reflect changes in code metrics after implementation.
- Updated missing indicators report to reflect the completion of statistical indicators, including ZTEST.
This commit is contained in:
Miha Kralj
2026-02-16 16:54:36 -08:00
parent 09ffd31a40
commit b3a64f18fa
73 changed files with 13041 additions and 88 deletions
@@ -0,0 +1,108 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class TheilIndicatorTests
{
[Fact]
public void TheilIndicator_Constructor_SetsDefaults()
{
var indicator = new TheilIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("Theil - Theil T Index", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(SourceType.Close, indicator.Source);
}
[Fact]
public void TheilIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new TheilIndicator { Period = 14 };
Assert.Equal(0, TheilIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void TheilIndicator_ShortName_IncludesPeriod()
{
var indicator = new TheilIndicator { Period = 20 };
Assert.Equal("Theil 20", indicator.ShortName);
}
[Fact]
public void TheilIndicator_Initialize_CreatesInternalTheil()
{
var indicator = new TheilIndicator { Period = 10 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
Assert.Equal("Theil", indicator.LinesSeries[0].Name);
}
[Fact]
public void TheilIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new TheilIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double theil = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(theil));
Assert.True(theil >= -1e-10, $"Expected non-negative Theil, got {theil}");
}
[Fact]
public void TheilIndicator_NewBar_UpdatesValue()
{
var indicator = new TheilIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
_ = indicator.LinesSeries[0].GetValue(0);
indicator.HistoricalData.AddBar(now.AddMinutes(10), 200, 210, 190, 205);
var newArgs = new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(newArgs);
double valueAfter = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(valueAfter));
}
[Fact]
public void TheilIndicator_DifferentSourceTypes()
{
var indicator = new TheilIndicator { Period = 5, Source = SourceType.Open };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double theil = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(theil));
}
}
+60
View File
@@ -0,0 +1,60 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class TheilIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 2, 2000, 1, 0)]
public int Period { get; set; } = 14;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Theil _theil = null!;
private readonly LineSeries _series;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"Theil {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/statistics/theil/Theil.Quantower.cs";
public TheilIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "Theil - Theil T Index";
Description = "Measures inequality/concentration of values using generalized entropy";
_series = new LineSeries(name: "Theil", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_theil = new Theil(Period);
_priceSelector = Source.GetPriceSelector();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var item = this.HistoricalData[this.Count - 1, SeekOriginHistory.Begin];
double value = _priceSelector(item);
var time = this.HistoricalData.Time();
var input = new TValue(time, value);
TValue result = _theil.Update(input, args.IsNewBar());
_series.SetValue(result.Value, _theil.IsHot, ShowColdValues);
}
}
+373
View File
@@ -0,0 +1,373 @@
namespace QuanTAlib.Tests;
public class TheilTests
{
[Fact]
public void Constructor_DefaultPeriod_SetsName()
{
var t = new Theil(14);
Assert.Equal("Theil(14)", t.Name);
Assert.Equal(14, t.WarmupPeriod);
}
[Fact]
public void Constructor_PeriodLessThan2_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Theil(1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_PeriodZero_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Theil(0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void EqualValues_ReturnsZero()
{
var t = new Theil(5);
for (int i = 0; i < 5; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 10.0));
}
Assert.Equal(0.0, t.Last.Value, 1e-10);
}
[Fact]
public void UnequalValues_ReturnsPositive()
{
var t = new Theil(5);
double[] vals = [1, 2, 3, 4, 5];
for (int i = 0; i < 5; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), vals[i]));
}
Assert.True(t.Last.Value > 0);
}
[Fact]
public void HighInequality_LargerTheil()
{
// Uniform values → low Theil; highly skewed → high Theil
var tLow = new Theil(4);
double[] uniform = [10, 10, 10, 10];
for (int i = 0; i < 4; i++)
{
tLow.Update(new TValue(DateTime.UtcNow.AddSeconds(i), uniform[i]));
}
var tHigh = new Theil(4);
double[] skewed = [1, 1, 1, 100];
for (int i = 0; i < 4; i++)
{
tHigh.Update(new TValue(DateTime.UtcNow.AddSeconds(i), skewed[i]));
}
Assert.True(tHigh.Last.Value > tLow.Last.Value);
}
[Fact]
public void KnownValues_ManualComputation()
{
// x = [1, 2, 3], mean = 2
// ratios: 0.5, 1.0, 1.5
// contributions: 0.5*ln(0.5) + 1.0*ln(1.0) + 1.5*ln(1.5)
// = 0.5*(-0.6931) + 0 + 1.5*(0.4055)
// = -0.3466 + 0 + 0.6082 = 0.2616
// T = 0.2616 / 3 = 0.08720
var t = new Theil(3);
t.Update(new TValue(DateTime.UtcNow, 1.0));
t.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 2.0));
t.Update(new TValue(DateTime.UtcNow.AddSeconds(2), 3.0));
double expected = ((0.5 * Math.Log(0.5)) + (1.0 * Math.Log(1.0)) + (1.5 * Math.Log(1.5))) / 3.0;
Assert.Equal(expected, t.Last.Value, 1e-10);
}
[Fact]
public void ScaleInvariance_SameTheil()
{
// Multiplying all values by a constant should not change Theil
var t1 = new Theil(4);
var t2 = new Theil(4);
double[] vals = [1, 2, 3, 4];
for (int i = 0; i < 4; i++)
{
t1.Update(new TValue(DateTime.UtcNow.AddSeconds(i), vals[i]));
t2.Update(new TValue(DateTime.UtcNow.AddSeconds(i), vals[i] * 100.0));
}
Assert.Equal(t1.Last.Value, t2.Last.Value, 1e-10);
}
[Fact]
public void IsHot_BecomesTrue_WhenBufferFull()
{
var t = new Theil(3);
Assert.False(t.IsHot);
t.Update(new TValue(DateTime.UtcNow, 10.0));
Assert.False(t.IsHot);
t.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 20.0));
Assert.False(t.IsHot);
t.Update(new TValue(DateTime.UtcNow.AddSeconds(2), 30.0));
Assert.True(t.IsHot);
}
[Fact]
public void IsNewFalse_CorrectsBars()
{
var t = new Theil(5);
for (int i = 1; i <= 5; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), (double)i * 10));
}
double original = t.Last.Value;
// Correct with very different value
t.Update(new TValue(DateTime.UtcNow.AddSeconds(5), 1000.0), isNew: false);
double corrected = t.Last.Value;
Assert.NotEqual(original, corrected);
// Correct back
t.Update(new TValue(DateTime.UtcNow.AddSeconds(5), 50.0), isNew: false);
double restored = t.Last.Value;
Assert.Equal(original, restored, 1e-10);
}
[Fact]
public void NaN_SubstitutesLastValid()
{
var t = new Theil(3);
t.Update(new TValue(DateTime.UtcNow, 10.0));
t.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 20.0));
// Feed NaN — should use last valid value (20.0)
t.Update(new TValue(DateTime.UtcNow.AddSeconds(2), double.NaN));
// The result should still be finite
Assert.True(double.IsFinite(t.Last.Value));
}
[Fact]
public void Infinity_SubstitutesLastValid()
{
var t = new Theil(3);
t.Update(new TValue(DateTime.UtcNow, 10.0));
t.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 20.0));
t.Update(new TValue(DateTime.UtcNow.AddSeconds(2), double.PositiveInfinity));
Assert.True(double.IsFinite(t.Last.Value));
}
[Fact]
public void NegativeValues_SubstitutesLastValid()
{
var t = new Theil(3);
t.Update(new TValue(DateTime.UtcNow, 10.0));
t.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 20.0));
t.Update(new TValue(DateTime.UtcNow.AddSeconds(2), -5.0));
Assert.True(double.IsFinite(t.Last.Value));
}
[Fact]
public void Reset_ClearsState()
{
var t = new Theil(3);
for (int i = 1; i <= 3; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), (double)i * 10));
}
Assert.True(t.IsHot);
t.Reset();
Assert.False(t.IsHot);
Assert.Equal(default, t.Last);
}
[Fact]
public void BatchTSeries_MatchesStreaming()
{
int period = 5;
int dataLen = 50;
var gbm = new GBM(100, 0.05, 0.2, seed: 42);
var series = new TSeries();
for (int i = 0; i < dataLen; i++)
{
var bar = gbm.Next();
series.Add(new TValue(bar.Time, bar.Close));
}
// Batch
var batchResult = Theil.Batch(series, period);
// Streaming
var streaming = new Theil(period);
var streamResult = new TSeries();
for (int i = 0; i < dataLen; i++)
{
streamResult.Add(streaming.Update(series[i]));
}
Assert.Equal(batchResult.Count, streamResult.Count);
for (int i = 0; i < batchResult.Count; i++)
{
Assert.Equal(batchResult[i].Value, streamResult[i].Value, 1e-10);
}
}
[Fact]
public void BatchSpan_MatchesStreaming()
{
int period = 5;
int dataLen = 50;
var gbm = new GBM(100, 0.05, 0.2, seed: 42);
double[] values = new double[dataLen];
for (int i = 0; i < dataLen; i++)
{
values[i] = gbm.Next().Close;
}
double[] spanOut = new double[dataLen];
Theil.Batch(values.AsSpan(), spanOut.AsSpan(), period);
var streaming = new Theil(period);
for (int i = 0; i < dataLen; i++)
{
streaming.Update(new TValue(DateTime.UtcNow.AddSeconds(i), values[i]));
Assert.Equal(spanOut[i], streaming.Last.Value, 1e-10);
}
}
[Fact]
public void BatchSpan_LengthMismatch_Throws()
{
double[] src = new double[10];
double[] output = new double[5];
var ex = Assert.Throws<ArgumentException>(() => Theil.Batch(src.AsSpan(), output.AsSpan(), 3));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void BatchSpan_InvalidPeriod_Throws()
{
double[] src = new double[10];
double[] output = new double[10];
var ex = Assert.Throws<ArgumentException>(() => Theil.Batch(src.AsSpan(), output.AsSpan(), 1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void BatchSpan_NaN_HandledSafely()
{
double[] src = [10, 20, double.NaN, 30, 40];
double[] output = new double[5];
Theil.Batch(src.AsSpan(), output.AsSpan(), 3);
for (int i = 0; i < 5; i++)
{
Assert.True(double.IsFinite(output[i]));
}
}
[Fact]
public void EventChaining_Fires()
{
var t = new Theil(3);
int eventCount = 0;
t.Pub += (object? _, in TValueEventArgs _) => eventCount++;
for (int i = 1; i <= 5; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), (double)i * 10));
}
Assert.Equal(5, eventCount);
}
[Fact]
public void Calculate_ReturnsResultsAndIndicator()
{
var series = new TSeries();
for (int i = 1; i <= 10; i++)
{
series.Add(new TValue(DateTime.UtcNow.AddSeconds(i), (double)i * 10));
}
var (results, indicator) = Theil.Calculate(series, 5);
Assert.Equal(10, results.Count);
Assert.True(indicator.IsHot);
}
[Fact]
public void Prime_SetsState()
{
var t = new Theil(3);
double[] data = [10, 20, 30, 40, 50];
t.Prime(data);
Assert.True(t.IsHot);
Assert.True(double.IsFinite(t.Last.Value));
}
[Fact]
public void SingleValue_ReturnsZero()
{
var t = new Theil(5);
t.Update(new TValue(DateTime.UtcNow, 42.0));
// Only 1 value → should be 0 (can't compute inequality from 1 value)
Assert.Equal(0.0, t.Last.Value, 1e-10);
}
[Fact]
public void TwoEqualValues_ReturnsZero()
{
var t = new Theil(2);
t.Update(new TValue(DateTime.UtcNow, 50.0));
t.Update(new TValue(DateTime.UtcNow.AddSeconds(1), 50.0));
Assert.Equal(0.0, t.Last.Value, 1e-10);
}
[Fact]
public void SlidingWindow_DropsOldValues()
{
var t = new Theil(3);
// Fill: [10, 10, 10] → T=0
for (int i = 0; i < 3; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 10.0));
}
Assert.Equal(0.0, t.Last.Value, 1e-10);
// Add unequal: [10, 10, 100] → T > 0
t.Update(new TValue(DateTime.UtcNow.AddSeconds(3), 100.0));
Assert.True(t.Last.Value > 0);
}
[Fact]
public void LargePeriod_WorksWithArrayPool()
{
int period = 300;
var t = new Theil(period);
for (int i = 0; i < period; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
Assert.True(t.IsHot);
Assert.True(double.IsFinite(t.Last.Value));
}
[Fact]
public void LargePeriod_SpanBatch_Works()
{
int period = 300;
int len = 500;
double[] src = new double[len];
double[] output = new double[len];
for (int i = 0; i < len; i++)
{
src[i] = 100.0 + i;
}
Theil.Batch(src.AsSpan(), output.AsSpan(), period);
Assert.True(double.IsFinite(output[len - 1]));
}
}
@@ -0,0 +1,148 @@
namespace QuanTAlib.Validation;
public sealed class TheilValidationTests
{
[Fact]
public void EqualValues_PerfectEquality_ReturnsZero()
{
// When all values are identical, Theil T must be exactly 0
var t = new Theil(10);
for (int i = 0; i < 10; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 42.0));
}
Assert.Equal(0.0, t.Last.Value, 1e-12);
}
[Fact]
public void ScaleInvariance_Property()
{
// T(c*x) = T(x) for any positive constant c
int period = 10;
var gbm = new GBM(100, 0.05, 0.2, seed: 123);
double[] prices = new double[period];
for (int i = 0; i < period; i++)
{
prices[i] = gbm.Next().Close;
}
var t1 = new Theil(period);
var t2 = new Theil(period);
for (int i = 0; i < period; i++)
{
t1.Update(new TValue(DateTime.UtcNow.AddSeconds(i), prices[i]));
t2.Update(new TValue(DateTime.UtcNow.AddSeconds(i), prices[i] * 1000.0));
}
Assert.Equal(t1.Last.Value, t2.Last.Value, 1e-10);
}
[Fact]
public void NonNegativity_Property()
{
// Theil T Index is always >= 0
var gbm = new GBM(100, 0.05, 0.2, seed: 456);
var t = new Theil(20);
for (int i = 0; i < 100; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), gbm.Next().Close));
if (t.IsHot)
{
Assert.True(t.Last.Value >= -1e-12, $"Theil should be non-negative, got {t.Last.Value}");
}
}
}
[Fact]
public void StreamingMatchesBatch()
{
int period = 10;
int dataLen = 50;
var gbm = new GBM(100, 0.05, 0.2, seed: 789);
var series = new TSeries();
for (int i = 0; i < dataLen; i++)
{
series.Add(new TValue(DateTime.UtcNow.AddSeconds(i), gbm.Next().Close));
}
// Batch
var batchResult = Theil.Batch(series, period);
// Streaming
var streaming = new Theil(period);
for (int i = 0; i < dataLen; i++)
{
streaming.Update(series[i]);
Assert.Equal(batchResult[i].Value, streaming.Last.Value, 1e-10);
}
}
[Fact]
public void SpanMatchesStreaming()
{
int period = 10;
int dataLen = 50;
var gbm = new GBM(100, 0.05, 0.2, seed: 101);
double[] values = new double[dataLen];
for (int i = 0; i < dataLen; i++)
{
values[i] = gbm.Next().Close;
}
double[] spanOut = new double[dataLen];
Theil.Batch(values.AsSpan(), spanOut.AsSpan(), period);
var streaming = new Theil(period);
for (int i = 0; i < dataLen; i++)
{
streaming.Update(new TValue(DateTime.UtcNow.AddSeconds(i), values[i]));
Assert.Equal(spanOut[i], streaming.Last.Value, 1e-10);
}
}
[Fact]
public void HigherInequality_ProducesHigherTheil()
{
// A more concentrated distribution should produce a higher Theil T
var tUniform = new Theil(5);
double[] uniform = [10, 11, 12, 13, 14]; // roughly equal
for (int i = 0; i < 5; i++)
{
tUniform.Update(new TValue(DateTime.UtcNow.AddSeconds(i), uniform[i]));
}
var tConcentrated = new Theil(5);
double[] concentrated = [1, 1, 1, 1, 100]; // highly unequal
for (int i = 0; i < 5; i++)
{
tConcentrated.Update(new TValue(DateTime.UtcNow.AddSeconds(i), concentrated[i]));
}
Assert.True(tConcentrated.Last.Value > tUniform.Last.Value);
}
[Fact]
public void ManualComputation_FourValues()
{
// x = [2, 4, 6, 8], mean = 5
// ratios: 0.4, 0.8, 1.2, 1.6
// T = (1/4)[0.4*ln(0.4) + 0.8*ln(0.8) + 1.2*ln(1.2) + 1.6*ln(1.6)]
double mean = 5.0;
double[] x = [2, 4, 6, 8];
double theilSum = 0;
for (int i = 0; i < 4; i++)
{
double ratio = x[i] / mean;
theilSum += ratio * Math.Log(ratio);
}
double expected = theilSum / 4.0;
var t = new Theil(4);
for (int i = 0; i < 4; i++)
{
t.Update(new TValue(DateTime.UtcNow.AddSeconds(i), x[i]));
}
Assert.Equal(expected, t.Last.Value, 1e-10);
}
}
+292
View File
@@ -0,0 +1,292 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Theil: Theil's T Index (generalized entropy measure of inequality)
/// </summary>
/// <remarks>
/// Measures the inequality or concentration of values within a sliding window.
/// Based on information theory, the Theil T Index quantifies how far a distribution
/// deviates from perfect equality. Values must be positive.
///
/// Calculation:
/// T = (1/n) × Σ (xᵢ/μ) × ln(xᵢ/μ)
///
/// where μ = mean of all values in the window, n = count of valid positive values.
///
/// Properties:
/// - T = 0 indicates perfect equality (all values identical)
/// - Higher T indicates greater inequality/concentration
/// - Decomposable: total inequality = between-group + within-group
/// - Scale-invariant: multiplying all values by a constant doesn't change T
/// </remarks>
[SkipLocalsInit]
public sealed class Theil : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private double _lastValidValue;
private readonly TValuePublishedHandler _handler;
public override bool IsHot => _buffer.IsFull;
/// <summary>
/// Creates a new Theil T Index indicator.
/// </summary>
/// <param name="period">The lookback period (must be >= 2).</param>
public Theil(int period)
{
if (period < 2)
{
throw new ArgumentException("Period must be greater than or equal to 2", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Theil({period})";
WarmupPeriod = period;
_handler = Handle;
}
/// <summary>
/// Creates a chaining constructor that subscribes to a source indicator.
/// </summary>
public Theil(ITValuePublisher src, int period) : this(period)
{
src.Pub += _handler;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
double value = input.Value;
// NaN/Infinity guard: substitute last valid value
if (!double.IsFinite(value) || value <= 0)
{
value = _lastValidValue;
}
else
{
_lastValidValue = value;
}
if (isNew)
{
_buffer.Add(value);
}
else
{
_buffer.UpdateNewest(value);
}
double theil = ComputeTheil(_buffer.GetSpan());
Last = new TValue(input.Time, theil);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
if (source.Count == 0)
{
return [];
}
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);
// Reset running state before priming
_buffer.Clear();
_lastValidValue = 0;
// Prime the state
int primeStart = Math.Max(0, len - _period);
for (int i = primeStart; i < len; i++)
{
Update(source[i]);
}
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs args) => Update(args.Value, args.IsNew);
public override void Reset()
{
_buffer.Clear();
_lastValidValue = 0;
Last = default;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
DateTime ts = DateTime.MinValue;
foreach (double value in source)
{
Update(new TValue(ts, value));
if (step.HasValue)
{
ts = ts.Add(step.Value);
}
}
}
public static TSeries Batch(TSeries source, int period)
{
var theil = new Theil(period);
return theil.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (period < 2)
{
throw new ArgumentException("Period must be greater than or equal to 2", nameof(period));
}
int len = source.Length;
if (len == 0)
{
return;
}
CalculateScalarCore(source, output, period);
}
public static (TSeries Results, Theil Indicator) Calculate(TSeries source, int period)
{
var indicator = new Theil(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateScalarCore(ReadOnlySpan<double> source, Span<double> output, int period)
{
int len = source.Length;
const int StackallocThreshold = 256;
double[]? rentedWindow = null;
scoped Span<double> windowBuf;
if (period <= StackallocThreshold)
{
windowBuf = stackalloc double[period];
}
else
{
rentedWindow = ArrayPool<double>.Shared.Rent(period);
windowBuf = rentedWindow.AsSpan(0, period);
}
try
{
// Persistent lastValidValue across all iterations — matches streaming Update behavior
double lastValidValue = 0;
for (int i = 0; i < len; i++)
{
int windowStart = Math.Max(0, i - period + 1);
int windowLen = i - windowStart + 1;
// Copy window values with NaN/non-positive substitution using persistent lastValidValue
double windowLastValid = lastValidValue;
for (int j = 0; j < windowLen; j++)
{
double wv = source[windowStart + j];
if (!double.IsFinite(wv) || wv <= 0)
{
wv = windowLastValid;
}
else
{
windowLastValid = wv;
}
windowBuf[j] = wv;
}
// Update the persistent value with the last valid seen in this window
if (windowLastValid > 0)
{
lastValidValue = windowLastValid;
}
output[i] = ComputeTheil(windowBuf[..windowLen]);
}
}
finally
{
if (rentedWindow is not null)
{
ArrayPool<double>.Shared.Return(rentedWindow);
}
}
}
/// <summary>
/// Computes Theil's T Index from a span of positive values.
/// T = (1/n) × Σ (xᵢ/μ) × ln(xᵢ/μ)
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeTheil(ReadOnlySpan<double> values)
{
int n = values.Length;
if (n < 2)
{
return 0;
}
// Compute mean of positive values
double sum = 0;
int validCount = 0;
for (int i = 0; i < n; i++)
{
double v = values[i];
if (v > 0)
{
sum += v;
validCount++;
}
}
if (validCount == 0 || sum <= 0)
{
return double.NaN;
}
double mean = sum / validCount;
double invMean = 1.0 / mean;
// Compute Theil T: (1/n) × Σ (xᵢ/μ) × ln(xᵢ/μ)
double theilSum = 0;
for (int i = 0; i < n; i++)
{
double v = values[i];
if (v > 0)
{
double ratio = v * invMean;
theilSum += ratio * Math.Log(ratio);
}
}
return theilSum / validCount;
}
}
+123
View File
@@ -0,0 +1,123 @@
# THEIL: Theil's T Index
> "The only useful measure of inequality is one that tells you how much redistribution would make everyone equally well off." — Henri Theil
## Introduction
The Theil T Index is an information-theoretic measure of inequality (or concentration) within a distribution of positive values. Originally developed for income inequality analysis, it quantifies how far a set of values deviates from perfect equality. In financial contexts, it measures the concentration of returns or price magnitudes within a sliding window, producing values ranging from 0 (perfect equality, all values identical) upward with no fixed upper bound. The Theil T Index belongs to the family of generalized entropy indices and is notable for its decomposability property: total inequality can be additively decomposed into between-group and within-group components.
## Historical Context
Henri Theil introduced the T Index in 1967 in his work "Economics and Information Theory," borrowing Shannon's entropy framework to measure economic inequality. Where Shannon entropy measures information content, Theil's adaptation measures the "information content" of observing a particular share of total resources relative to equal shares. The measure gained prominence alongside the Gini coefficient and Atkinson index as a standard tool in welfare economics.
For financial markets, the Theil T Index serves as a concentration detector. A window of prices with roughly equal magnitudes yields T near 0; a window dominated by one extreme value (a spike or crash) produces high T. This makes it useful for detecting regime changes, volatility clustering, and abnormal price behavior that other measures (like standard deviation) may underweight due to squaring.
The key advantage over Gini: decomposability. The key advantage over variance-based measures: scale invariance. Multiplying all prices by a constant leaves T unchanged, measuring only the relative distribution structure.
## Architecture and Physics
### 1. Core Algorithm
The implementation uses a sliding window (RingBuffer) of size `period`. On each update:
1. Add the new value to the buffer (substituting last-valid for NaN/Infinity/non-positive)
2. Compute the mean of all valid positive values in the buffer
3. For each value, compute the ratio $r_i = x_i / \mu$ and accumulate $r_i \cdot \ln(r_i)$
4. Divide the sum by the count of valid values
### 2. Complexity
- **Update:** O(n) per tick where n = period (must scan buffer for mean, then for Theil sum)
- **Memory:** O(period) for the RingBuffer
- No O(1) streaming shortcut exists because the mean changes with every update, invalidating cached ratio computations
### 3. Value Domain
- **Input:** Positive values only (prices, volumes). Non-positive values and NaN/Infinity are replaced with last-valid substitution.
- **Output:** T >= 0. T = 0 for perfect equality. No fixed upper bound; maximum depends on window size and value distribution.
### 4. NaN/Infinity Handling
Non-finite or non-positive inputs are replaced with the last valid positive value. If no valid value has been seen, the value defaults to 0 (which is filtered out in the Theil computation).
## Mathematical Foundation
The Theil T Index (also called GE(1), generalized entropy with parameter 1) is defined as:
$$T = \frac{1}{n} \sum_{i=1}^{n} \frac{x_i}{\mu} \ln\left(\frac{x_i}{\mu}\right)$$
where $\mu = \frac{1}{n}\sum_{i=1}^{n} x_i$ is the arithmetic mean.
### Properties
- **Non-negativity:** $T \geq 0$ always (Jensen's inequality applied to the convex function $f(r) = r \ln r$)
- **Scale invariance:** $T(cx_1, cx_2, \ldots, cx_n) = T(x_1, x_2, \ldots, x_n)$ for any $c > 0$
- **Perfect equality:** $T = 0$ if and only if all $x_i$ are equal
- **Decomposability:** For groups $G_k$ with means $\mu_k$ and sizes $n_k$:
$$T_{total} = T_{between} + \sum_k \frac{n_k}{n} \cdot \frac{\mu_k}{\mu} \cdot T_k$$
### Relationship to Other Measures
| Measure | Sensitivity | Scale Invariant | Decomposable |
|---------|-------------|-----------------|--------------|
| Theil T (GE(1)) | Upper tail | Yes | Yes |
| Theil L (GE(0)) | Lower tail | Yes | Yes |
| Gini | Middle | Yes | No |
| Variance | All | No | Yes |
| Shannon Entropy | Histogram-based | No | N/A |
## Performance Profile
| Operation | Complexity | Notes |
|-----------|------------|-------|
| Update (streaming) | O(n) | Two passes: mean then Theil sum |
| Batch (span) | O(n*m) | n = data length, m = period |
| Memory | O(period) | RingBuffer |
| SIMD potential | Limited | Sequential dependency on mean |
### Quality Metrics
| Metric | Score (1-10) |
|--------|-------------|
| Lag | 8 - Window-based, inherent period/2 lag |
| Noise sensitivity | 7 - Stable; log dampens outlier impact |
| Responsiveness | 6 - Full window recomputation each tick |
| Scale independence | 10 - Perfect scale invariance by construction |
| Mathematical rigor | 10 - Well-established information-theoretic foundation |
## Validation
No external TA library implements Theil T Index directly. Validation relies on mathematical properties.
| Property | Method | Status |
|----------|--------|--------|
| Equal values → T=0 | Unit test | Verified |
| Scale invariance | Multiply by constant, compare | Verified |
| Non-negativity | GBM random walk, 100 bars | Verified |
| Known values (manual) | Hand computation vs output | Verified |
| Streaming == Batch == Span | Three-way consistency | Verified |
| Higher inequality → higher T | Uniform vs skewed distribution | Verified |
## Common Pitfalls
1. **Non-positive values:** The Theil T Index requires strictly positive inputs. Zero or negative values produce undefined logarithms. The implementation substitutes last-valid values, but feeding predominantly non-positive data yields meaningless results.
2. **Confusing T and L:** Theil's T (GE(1)) is sensitive to the upper tail; Theil's L (GE(0), mean log deviation) is sensitive to the lower tail. This implementation computes T only.
3. **Interpreting magnitude:** Unlike Gini (bounded [0,1]), Theil T has no fixed upper bound. Values must be interpreted relative to the data's own history, not against absolute thresholds.
4. **Small windows:** With period=2, only two values are compared. The index becomes highly volatile and loses statistical meaning. Recommend period >= 10 for meaningful results.
5. **All-equal series:** Returns exactly 0. This is correct behavior, not a bug. A constant price series has zero inequality by definition.
6. **Log(1) = 0 effect:** When a value equals the mean exactly, its contribution to T is zero (ratio=1, ln(1)=0). This is mathematically correct but means the index is insensitive to values near the mean.
7. **Comparison with Shannon Entropy:** Shannon entropy measures histogram-based randomness; Theil T measures value-based concentration. They answer different questions about the same data.
## References
- Theil, H. (1967). *Economics and Information Theory*. North-Holland Publishing Company.
- Cowell, F.A. (2011). *Measuring Inequality*. Oxford University Press. 3rd edition.
- Conceicao, P., & Ferreira, P. (2000). "The Young Person's Guide to the Theil Index." UTIP Working Paper No. 14.
- Shorrocks, A.F. (1980). "The Class of Additively Decomposable Inequality Measures." *Econometrica*, 48(3), 613-625.