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,66 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class ModeIndicatorTests
{
[Fact]
public void ModeIndicator_Constructor_SetsDefaults()
{
var indicator = new ModeIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("Mode - Statistical Mode (Most Frequent Value)", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(SourceType.Close, indicator.Source);
}
[Fact]
public void ModeIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new ModeIndicator { Period = 14 };
Assert.Equal(0, ModeIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void ModeIndicator_Initialize_CreatesInternalMode()
{
var indicator = new ModeIndicator { Period = 10 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
Assert.Equal("Mode", indicator.LinesSeries[0].Name);
}
[Fact]
public void ModeIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new ModeIndicator { Period = 5 };
indicator.Initialize();
// Add historical data with repeating close prices to produce a mode
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
double close = 100 + (i % 3); // cycles 100, 101, 102, 100, 101, ...
indicator.HistoricalData.AddBar(now.AddMinutes(i), close, close + 5, close - 5, close);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double mode = indicator.LinesSeries[0].GetValue(0);
// Mode of cycling values should be finite
Assert.True(double.IsFinite(mode));
}
}
+60
View File
@@ -0,0 +1,60 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class ModeIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 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 Mode _mode = null!;
private readonly LineSeries _series;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"Mode {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/statistics/mode/Mode.Quantower.cs";
public ModeIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "Mode - Statistical Mode (Most Frequent Value)";
Description = "The most frequently occurring value in a rolling window";
_series = new LineSeries(name: "Mode", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_mode = new Mode(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 = _mode.Update(input, args.IsNewBar());
_series.SetValue(result.Value, _mode.IsHot, ShowColdValues);
}
}
+385
View File
@@ -0,0 +1,385 @@
namespace QuanTAlib.Tests;
public class ModeTests
{
[Fact]
public void Constructor_ValidatesPeriod()
{
Assert.Throws<ArgumentException>(() => new Mode(0));
Assert.Throws<ArgumentException>(() => new Mode(-1));
var mode = new Mode(1);
Assert.NotNull(mode);
}
[Fact]
public void Constructor_SetsName()
{
var mode = new Mode(14);
Assert.Equal("Mode(14)", mode.Name);
}
[Fact]
public void Constructor_SetsWarmupPeriod()
{
var mode = new Mode(10);
Assert.Equal(10, mode.WarmupPeriod);
}
[Fact]
public void Calc_ReturnsValue()
{
var mode = new Mode(5);
Assert.Equal(0, mode.Last.Value);
TValue result = mode.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(result.Value, mode.Last.Value);
}
[Fact]
public void SingleValue_ReturnsItself()
{
var mode = new Mode(5);
var result = mode.Update(new TValue(DateTime.UtcNow, 42));
// Single value is trivially the mode
Assert.Equal(42, result.Value);
}
[Fact]
public void AllDistinct_ReturnsNaN()
{
// {1, 2, 3, 4, 5} — all unique → NaN (no mode)
var mode = new Mode(5);
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 4));
var result = mode.Update(new TValue(DateTime.UtcNow, 5));
Assert.True(double.IsNaN(result.Value));
}
[Fact]
public void RepeatedValue_ReturnsMode()
{
// {1, 2, 2, 3, 4} → mode = 2
var mode = new Mode(5);
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 3));
var result = mode.Update(new TValue(DateTime.UtcNow, 4));
Assert.Equal(2, result.Value);
}
[Fact]
public void MultipleRepeated_ReturnsHighestFrequency()
{
// {1, 2, 2, 3, 3, 3, 4} with period=7 → mode = 3
var mode = new Mode(7);
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 3));
var result = mode.Update(new TValue(DateTime.UtcNow, 4));
Assert.Equal(3, result.Value);
}
[Fact]
public void AllSameValue_ReturnsValue()
{
// {5, 5, 5, 5, 5} → mode = 5
var mode = new Mode(5);
for (int i = 0; i < 5; i++)
{
mode.Update(new TValue(DateTime.UtcNow, 5));
}
Assert.Equal(5, mode.Last.Value);
}
[Fact]
public void SlidingWindow_DropsOldValues()
{
// Feed {1, 1, 1, 2, 3} → mode = 1
// Then feed 4 → window becomes {1, 1, 2, 3, 4} → mode = 1
// Then feed 4 → window becomes {1, 2, 3, 4, 4} → mode = 4
var mode = new Mode(5);
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 3));
Assert.Equal(1, mode.Last.Value);
mode.Update(new TValue(DateTime.UtcNow, 4));
Assert.Equal(1, mode.Last.Value); // Still 1 (1,1,2,3,4)
mode.Update(new TValue(DateTime.UtcNow, 4));
Assert.Equal(4, mode.Last.Value); // Now 4 (1,2,3,4,4)
}
[Fact]
public void IsHot_BecomesTrueWhenBufferFull()
{
var mode = new Mode(5);
Assert.False(mode.IsHot);
for (int i = 1; i <= 4; i++)
{
mode.Update(new TValue(DateTime.UtcNow, i * 10));
Assert.False(mode.IsHot);
}
mode.Update(new TValue(DateTime.UtcNow, 50));
Assert.True(mode.IsHot);
}
[Fact]
public void Update_HandlesUpdates_IsNewFalse()
{
var mode = new Mode(5);
// 1, 2, 3, 4
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 4));
// Add 5 (all distinct → NaN)
mode.Update(new TValue(DateTime.UtcNow, 5), isNew: true);
Assert.True(double.IsNaN(mode.Last.Value));
// Correct to 1 (window: 1,2,3,4,1 → mode = 1)
var result = mode.Update(new TValue(DateTime.UtcNow, 1), isNew: false);
Assert.Equal(1, result.Value);
}
[Fact]
public void BarCorrection_RestoreToOriginal()
{
var mode = new Mode(5);
// Feed {1, 2, 3, 4, 4} → mode = 4
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 4));
mode.Update(new TValue(DateTime.UtcNow, 4), isNew: true);
double original = mode.Last.Value;
Assert.Equal(4, original);
// Correct last bar to 1 → {1, 2, 3, 4, 1} sorted {1,1,2,3,4} → mode = 1
mode.Update(new TValue(DateTime.UtcNow, 1), isNew: false);
Assert.NotEqual(original, mode.Last.Value);
Assert.Equal(1, mode.Last.Value);
// Correct back to 4 → {1, 2, 3, 4, 4} → mode = 4
var result = mode.Update(new TValue(DateTime.UtcNow, 4), isNew: false);
Assert.Equal(original, result.Value);
}
[Fact]
public void Reset_ClearsState()
{
var mode = new Mode(5);
for (int i = 0; i < 5; i++)
{
mode.Update(new TValue(DateTime.UtcNow, i));
}
mode.Reset();
Assert.False(mode.IsHot);
}
[Fact]
public void AllModes_ProduceSameResult()
{
const int period = 5;
int count = 50;
// Create data with repeated values to ensure mode exists
double[] data = new double[count];
for (int i = 0; i < count; i++)
{
data[i] = Math.Round(i % 7.0); // Values 0-6 with repeats
}
var times = new List<long>(count);
var values = new List<double>(count);
for (int i = 0; i < count; i++)
{
times.Add(DateTime.UtcNow.Ticks + i);
values.Add(data[i]);
}
var series = new TSeries(times, values);
// 1. Batch Mode
var batchSeries = Mode.Batch(series, period);
// 2. Span Mode
var spanOutput = new double[count];
Mode.Batch(data.AsSpan(), spanOutput.AsSpan(), period);
// 3. Streaming Mode
var streamingInd = new Mode(period);
var streamingResults = new double[count];
for (int i = 0; i < count; i++)
{
streamingResults[i] = streamingInd.Update(series[i]).Value;
}
// Assert all modes produce identical results
for (int i = 0; i < count; i++)
{
if (double.IsNaN(batchSeries[i].Value))
{
Assert.True(double.IsNaN(spanOutput[i]), $"Span output at {i} should be NaN");
Assert.True(double.IsNaN(streamingResults[i]), $"Streaming output at {i} should be NaN");
}
else
{
Assert.Equal(batchSeries[i].Value, spanOutput[i], precision: 10);
Assert.Equal(batchSeries[i].Value, streamingResults[i], precision: 10);
}
}
}
[Fact]
public void SpanBatch_ValidatesInput()
{
double[] source = [1, 2, 3, 4, 5];
double[] output = new double[5];
double[] wrongSizeOutput = new double[3];
// Period must be > 0
Assert.Throws<ArgumentException>(() =>
Mode.Batch(source.AsSpan(), output.AsSpan(), 0));
// Output must be same length as source
Assert.Throws<ArgumentException>(() =>
Mode.Batch(source.AsSpan(), wrongSizeOutput.AsSpan(), 5));
}
[Fact]
public void SpanBatch_MatchesTSeriesBatch()
{
int count = 50;
double[] data = new double[count];
for (int i = 0; i < count; i++)
{
data[i] = Math.Round(i % 5.0);
}
var times = new List<long>(count);
var values = new List<double>(count);
for (int i = 0; i < count; i++)
{
times.Add(DateTime.UtcNow.Ticks + i);
values.Add(data[i]);
}
var series = new TSeries(times, values);
var tseriesResult = Mode.Batch(series, 5);
var output = new double[count];
Mode.Batch(data.AsSpan(), output.AsSpan(), 5);
for (int i = 0; i < count; i++)
{
if (double.IsNaN(tseriesResult[i].Value))
{
Assert.True(double.IsNaN(output[i]));
}
else
{
Assert.Equal(tseriesResult[i].Value, output[i], 1e-10);
}
}
}
[Fact]
public void Batch_Matches_Streaming()
{
double[] data = [1, 1, 2, 2, 2, 3, 3, 1, 1, 1];
int period = 5;
// Streaming
var mode = new Mode(period);
var streamingResults = new List<double>();
foreach (var val in data)
{
streamingResults.Add(mode.Update(new TValue(DateTime.UtcNow, val)).Value);
}
// Batch
var series = new TSeries(new List<long>(new long[data.Length]), new List<double>(data));
var batchResult = Mode.Batch(series, period);
for (int i = 0; i < data.Length; i++)
{
if (double.IsNaN(streamingResults[i]))
{
Assert.True(double.IsNaN(batchResult.Values[i]));
}
else
{
Assert.Equal(streamingResults[i], batchResult.Values[i], precision: 10);
}
}
}
[Fact]
public void Chaining_PubEventFires()
{
var source = new Mode(5);
var chained = new Mode(source, 5);
for (int i = 0; i < 5; i++)
{
source.Update(new TValue(DateTime.UtcNow, i));
}
// Chained indicator should have received updates via Pub event
Assert.True(double.IsFinite(chained.Last.Value) || double.IsNaN(chained.Last.Value));
}
[Fact]
public void Period_One_AlwaysReturnsInput()
{
var mode = new Mode(1);
for (int i = 0; i < 10; i++)
{
double val = i * 3.14;
var result = mode.Update(new TValue(DateTime.UtcNow, val));
Assert.Equal(val, result.Value);
}
}
[Fact]
public void BimodalData_ReturnsFirstMode()
{
// {1, 1, 2, 2, 3} — bimodal (1 and 2 both appear twice)
// Sorted: {1, 1, 2, 2, 3}
// Scan finds 1 first with freq=2, then 2 with freq=2 (not > maxFreq)
// Returns 1 (first encountered in sorted order)
var mode = new Mode(5);
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 2));
var result = mode.Update(new TValue(DateTime.UtcNow, 3));
// First mode in sorted order wins
Assert.Equal(1, result.Value);
}
}
@@ -0,0 +1,76 @@
namespace QuanTAlib.Validation;
/// <summary>
/// Mode validation tests — self-consistency only.
/// No external library provides rolling mode calculations.
/// </summary>
public sealed class ModeValidationTests
{
[Fact]
public void Mode_SelfConsistency_KnownValues()
{
// Test with known mode values
// {1, 2, 2, 3, 3, 3, 4, 4, 4, 4} → mode = 4 (appears 4 times)
var mode = new Mode(10);
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 4));
mode.Update(new TValue(DateTime.UtcNow, 4));
mode.Update(new TValue(DateTime.UtcNow, 4));
var result = mode.Update(new TValue(DateTime.UtcNow, 4));
Assert.Equal(4, result.Value);
}
[Fact]
public void Mode_BatchAndStreaming_Match()
{
// Use data with known repeated values
double[] data = [10, 20, 20, 30, 30, 30, 40, 20, 20, 20, 10, 10, 30, 30, 30];
int period = 5;
// Streaming
var mode = new Mode(period);
var streamingResults = new double[data.Length];
for (int i = 0; i < data.Length; i++)
{
streamingResults[i] = mode.Update(new TValue(DateTime.UtcNow, data[i])).Value;
}
// Batch via spans
var spanOutput = new double[data.Length];
Mode.Batch(data.AsSpan(), spanOutput.AsSpan(), period);
for (int i = 0; i < data.Length; i++)
{
if (double.IsNaN(streamingResults[i]))
{
Assert.True(double.IsNaN(spanOutput[i]), $"Index {i}: streaming=NaN but span={spanOutput[i]}");
}
else
{
Assert.Equal(streamingResults[i], spanOutput[i], precision: 10);
}
}
}
[Fact]
public void Mode_MatchesWolframAlpha()
{
// Wolfram Alpha: mode of {1, 2, 2, 3, 3, 3, 4} = {3}
var mode = new Mode(7);
mode.Update(new TValue(DateTime.UtcNow, 1));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 2));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 3));
mode.Update(new TValue(DateTime.UtcNow, 3));
var result = mode.Update(new TValue(DateTime.UtcNow, 4));
Assert.Equal(3, result.Value);
}
}
+466
View File
@@ -0,0 +1,466 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Mode: Rolling Statistical Mode
/// </summary>
/// <remarks>
/// The Mode is the most frequently occurring value in a dataset. It is the only measure
/// of central tendency that can be used with nominal (categorical) data.
///
/// Calculation:
/// 1. Maintain a sorted list of the last 'Period' values.
/// 2. Scan sorted list for the longest consecutive run of equal values.
/// 3. If no value appears more than once (and there are multiple distinct values), return NaN.
///
/// Complexity:
/// Update: O(N) due to maintaining sorted structure (BinarySearch + Array.Copy) + O(N) scan.
/// </remarks>
[SkipLocalsInit]
public sealed class Mode : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private readonly double[] _sortedBuffer;
private readonly double[] _p_sortedBuffer;
private readonly TValuePublishedHandler _handler;
private readonly ITValuePublisher? _source;
private double _lastValidValue;
private int _p_sortedCount;
private bool _disposed;
public override bool IsHot => _buffer.IsFull;
/// <summary>
/// Creates a Mode indicator with the specified period.
/// </summary>
/// <param name="period">The size of the rolling window (must be > 0).</param>
public Mode(int period)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
_sortedBuffer = new double[period];
_p_sortedBuffer = new double[period];
Name = $"Mode({period})";
WarmupPeriod = period;
_handler = Handle;
}
/// <summary>
/// Creates a chained Mode indicator.
/// </summary>
public Mode(ITValuePublisher source, int period) : this(period)
{
_source = source;
source.Pub += _handler;
}
/// <summary>
/// Creates a Mode indicator primed from a TSeries source.
/// </summary>
public Mode(TSeries source, int period) : this(period)
{
Prime(source.Values);
if (source.Count > 0)
{
Last = new TValue(source.LastTime, Last.Value);
}
_source = source;
source.Pub += _handler;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Handle(object? sender, in TValueEventArgs args) => Update(args.Value, args.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
// NaN/Infinity guard: substitute last valid value
double value = input.Value;
if (!double.IsFinite(value))
{
value = _lastValidValue;
}
else
{
_lastValidValue = value;
}
if (isNew)
{
// Save sorted buffer state for potential rollback
_p_sortedCount = _buffer.Count;
Array.Copy(_sortedBuffer, _p_sortedBuffer, _p_sortedCount);
if (_buffer.IsFull)
{
double old = _buffer.Oldest;
RemoveFromSorted(old);
}
_buffer.Add(value);
AddToSorted(value);
}
else
{
// Restore sorted buffer from backup using saved count
if (_p_sortedCount > 0)
{
Array.Copy(_p_sortedBuffer, _sortedBuffer, _p_sortedCount);
}
if (_buffer.Count > 0)
{
double current = _buffer.Newest;
RemoveFromSorted(current);
_buffer.UpdateNewest(value);
AddToSorted(value);
}
else
{
_buffer.Add(value);
AddToSorted(value);
}
}
double mode = FindModeFromSorted(_sortedBuffer, _buffer.Count);
Last = new TValue(input.Time, mode);
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);
Prime(source.Values);
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
public override void Reset()
{
_buffer.Clear();
Array.Clear(_sortedBuffer);
Array.Clear(_p_sortedBuffer);
Last = default;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
if (source.Length == 0)
{
return;
}
_buffer.Clear();
Array.Clear(_sortedBuffer);
int warmupLength = Math.Min(source.Length, WarmupPeriod);
int startIndex = source.Length - warmupLength;
for (int i = startIndex; i < source.Length; i++)
{
Update(new TValue(DateTime.MinValue, source[i]));
}
}
/// <summary>
/// Calculates Mode for the entire series using a new instance.
/// </summary>
public static TSeries Batch(TSeries source, int period)
{
var mode = new Mode(period);
return mode.Update(source);
}
/// <summary>
/// Calculates Mode in-place using spans.
/// </summary>
[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 <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
int len = source.Length;
if (len == 0)
{
return;
}
double[] rentedSorted = ArrayPool<double>.Shared.Rent(period);
double[] rentedWindow = ArrayPool<double>.Shared.Rent(period);
try
{
Span<double> sortedBuffer = rentedSorted.AsSpan(0, period);
Span<double> window = rentedWindow.AsSpan(0, period);
sortedBuffer.Clear();
window.Clear();
int windowIdx = 0;
int count = 0;
for (int i = 0; i < len; i++)
{
double val = source[i];
if (count == period)
{
double old = window[windowIdx];
int oldIndex = BinarySearchSpan(sortedBuffer, count, old);
if (oldIndex >= 0)
{
if (oldIndex < count - 1)
{
sortedBuffer.Slice(oldIndex + 1, count - 1 - oldIndex).CopyTo(sortedBuffer.Slice(oldIndex));
}
count--;
}
}
window[windowIdx] = val;
windowIdx = (windowIdx + 1) % period;
int newIndex = BinarySearchSpan(sortedBuffer, count, val);
if (newIndex < 0)
{
newIndex = ~newIndex;
}
if (newIndex < count)
{
sortedBuffer.Slice(newIndex, count - newIndex).CopyTo(sortedBuffer.Slice(newIndex + 1));
}
sortedBuffer[newIndex] = val;
count++;
output[i] = FindModeFromSortedSpan(sortedBuffer, count);
}
}
finally
{
ArrayPool<double>.Shared.Return(rentedSorted);
ArrayPool<double>.Shared.Return(rentedWindow);
}
}
public static (TSeries Results, Mode Indicator) Calculate(TSeries source, int period)
{
var indicator = new Mode(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
/// <summary>
/// Finds the mode from a sorted array by scanning for the longest consecutive run.
/// Returns NaN if no value appears more than once (and there are multiple distinct values).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double FindModeFromSorted(double[] sorted, int count)
{
if (count == 0)
{
return double.NaN;
}
if (count == 1)
{
return sorted[0];
}
double modeVal = sorted[0];
int maxFreq = 1;
int currentFreq = 1;
int distinctCount = 1;
for (int i = 1; i < count; i++)
{
if (sorted[i] == sorted[i - 1])
{
currentFreq++;
}
else
{
if (currentFreq > maxFreq)
{
maxFreq = currentFreq;
modeVal = sorted[i - 1];
}
currentFreq = 1;
distinctCount++;
}
}
// Check the last run
if (currentFreq > maxFreq)
{
maxFreq = currentFreq;
modeVal = sorted[count - 1];
}
// No mode if all values unique and more than 1 distinct value
if (maxFreq <= 1 && distinctCount > 1)
{
return double.NaN;
}
return modeVal;
}
/// <summary>
/// Span-based mode finding for batch path.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double FindModeFromSortedSpan(Span<double> sorted, int count)
{
if (count == 0)
{
return double.NaN;
}
if (count == 1)
{
return sorted[0];
}
double modeVal = sorted[0];
int maxFreq = 1;
int currentFreq = 1;
int distinctCount = 1;
for (int i = 1; i < count; i++)
{
if (sorted[i] == sorted[i - 1])
{
currentFreq++;
}
else
{
if (currentFreq > maxFreq)
{
maxFreq = currentFreq;
modeVal = sorted[i - 1];
}
currentFreq = 1;
distinctCount++;
}
}
if (currentFreq > maxFreq)
{
maxFreq = currentFreq;
modeVal = sorted[count - 1];
}
if (maxFreq <= 1 && distinctCount > 1)
{
return double.NaN;
}
return modeVal;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void AddToSorted(double value)
{
int validCount = _buffer.Count - 1;
int index = Array.BinarySearch(_sortedBuffer, 0, validCount, value);
if (index < 0)
{
index = ~index;
}
if (index < validCount)
{
Array.Copy(_sortedBuffer, index, _sortedBuffer, index + 1, validCount - index);
}
_sortedBuffer[index] = value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void RemoveFromSorted(double value)
{
int validCount = _buffer.Count;
int index = Array.BinarySearch(_sortedBuffer, 0, validCount, value);
if (index < 0)
{
return;
}
if (index < validCount - 1)
{
Array.Copy(_sortedBuffer, index + 1, _sortedBuffer, index, validCount - 1 - index);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int BinarySearchSpan(Span<double> span, int length, double value)
{
int lo = 0;
int hi = length - 1;
while (lo <= hi)
{
int mid = lo + ((hi - lo) >> 1);
int cmp = span[mid].CompareTo(value);
if (cmp == 0)
{
return mid;
}
if (cmp < 0)
{
lo = mid + 1;
}
else
{
hi = mid - 1;
}
}
return ~lo;
}
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing && _source != null)
{
_source.Pub -= _handler;
}
_disposed = true;
}
base.Dispose(disposing);
}
}
+111
View File
@@ -0,0 +1,111 @@
# MODE: Statistical Mode (Most Frequent Value)
> "The mode is the value that appears most frequently in a data set — the only measure of central tendency that tells you what's actually popular, not what's average."
## Introduction
The **Mode** is a rolling statistical indicator that identifies the most frequently occurring value within
a sliding window of recent observations. Unlike the mean and median, which find the center of a
distribution through arithmetic, the mode finds it through frequency counting. For financial data
this means identifying price levels where the market has spent the most time — a concept with direct
implications for support/resistance identification.
## Historical Context
The mode predates formal statistics. Early astronomers used it to identify the "true" value among
repeated measurements. In modern finance, the concept maps directly to volume profile analysis
(price-at-time histograms) and Point of Control (POC) calculations, though those typically bin
continuous data while this implementation uses exact value comparison matching the PineScript reference.
## Mathematical Foundation
Given a window of $n$ values $\{x_1, x_2, \ldots, x_n\}$:
$$\text{Mode} = \arg\max_{v} \sum_{i=1}^{n} \mathbf{1}(x_i = v)$$
Where $\mathbf{1}(x_i = v)$ is the indicator function returning 1 when $x_i = v$.
**Special cases:**
- Single value in window: returns that value
- All values distinct ($n > 1$): returns `NaN` (no mode exists)
- Multimodal (tie): returns the smallest mode (first in sorted order)
## Architecture
### Sorted Window Approach
The implementation maintains a sorted buffer using `BinarySearch` + `Array.Copy` for O(N) insert/remove.
After each update, a single linear scan of the sorted buffer identifies the longest consecutive run
of equal values. This is more efficient than a dictionary approach for small-to-medium periods because
it avoids hashing overhead and GC pressure from dictionary internals.
### State Management
| Component | Purpose |
|-----------|---------|
| `RingBuffer _buffer` | Circular buffer tracking insertion order (for sliding window eviction) |
| `double[] _sortedBuffer` | Values maintained in sorted order for O(N) mode finding |
| `double[] _p_sortedBuffer` | Snapshot for `isNew=false` bar correction rollback |
### Complexity
| Operation | Time | Space |
|-----------|------|-------|
| `Update` (streaming) | O(N) | O(N) |
| `Batch` (span) | O(M·N) | O(N) |
Where N = period, M = total data points.
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `period` | `int` | — | Rolling window size (must be > 0) |
## Usage
```csharp
// Streaming mode
var mode = new Mode(14);
TValue result = mode.Update(new TValue(DateTime.UtcNow, price));
// Batch mode
TSeries results = Mode.Batch(series, 14);
// Span mode (zero-allocation output)
Mode.Batch(sourceSpan, outputSpan, 14);
```
## Interpretation
| Condition | Meaning |
|-----------|---------|
| Mode = specific value | Market spent most time at this price level |
| Mode = NaN | All values unique — no dominant price level |
| Mode stable across windows | Strong support/resistance at that level |
| Mode shifting | Distribution center is moving |
## Common Pitfalls
1. **Continuous data produces NaN**: Floating-point prices with many decimals rarely repeat exactly. Mode is most useful for rounded/discretized data (e.g., tick prices, integer values).
2. **Bimodal ties**: When multiple values share the highest frequency, the smallest value wins (first in sorted order). This is deterministic but may not match all statistical software.
3. **Period = 1**: Always returns the input value (trivially the mode).
4. **NaN inputs**: NaN values are stored in the buffer. If a window contains NaN duplicates, NaN could become the mode — this matches the PineScript behavior.
5. **Performance**: O(N) per update due to sorted buffer maintenance. For very large periods (>1000), consider if mode is the right tool.
## Validation
Self-consistency validation only — no external library provides rolling mode.
Verified against Wolfram Alpha for static datasets.
| Test | Status |
|------|--------|
| Wolfram Alpha {1,2,2,3,3,3,4} | ✔️ mode = 3 |
| Batch == Streaming == Span | ✔️ |
| Bar correction (isNew=false) | ✔️ |
## References
- PineScript reference: `mode.pine` (exact value comparison, map-based counting)
- Wolfram MathWorld: [Statistical Mode](https://mathworld.wolfram.com/Mode.html)