mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-19 19:18:05 +00:00
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:
@@ -0,0 +1,54 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class QuantileIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void QuantileIndicator_Constructor_DefaultValues()
|
||||
{
|
||||
var indicator = new QuantileIndicator();
|
||||
Assert.Equal(14, indicator.Period);
|
||||
Assert.Equal(0.5, indicator.QuantileLevel);
|
||||
Assert.False(indicator.SeparateWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuantileIndicator_MinHistoryDepths()
|
||||
{
|
||||
var indicator = new QuantileIndicator { Period = 20 };
|
||||
Assert.Equal(20, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuantileIndicator_Initialize_CreatesInternalQuantile()
|
||||
{
|
||||
var indicator = new QuantileIndicator { Period = 10, QuantileLevel = 0.75 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Equal("Quantile 10 (0.75)", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuantileIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new QuantileIndicator { Period = 5, QuantileLevel = 0.75 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data
|
||||
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);
|
||||
}
|
||||
|
||||
// Line series should have a value
|
||||
double quantile = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Quantile of a trending series should be finite
|
||||
Assert.True(double.IsFinite(quantile));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class QuantileIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 2000, 1, 0)]
|
||||
public int Period { get; set; } = 14;
|
||||
|
||||
[InputParameter("Quantile Level (0.0-1.0)", sortIndex: 2, 0.0, 1.0, 0.01, 2)]
|
||||
public double QuantileLevel { get; set; } = 0.5;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Quantile _quantile = null!;
|
||||
private readonly LineSeries _series;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"Quantile {Period} ({QuantileLevel})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/statistics/quantile/Quantile.Quantower.cs";
|
||||
|
||||
public QuantileIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = false;
|
||||
Name = "Quantile - Rolling Quantile";
|
||||
Description = "Fraction of observations that fall below a given value in a rolling window";
|
||||
|
||||
_series = new LineSeries(name: "Quantile", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_quantile = new Quantile(Period, QuantileLevel);
|
||||
_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 = _quantile.Update(input, args.IsNewBar());
|
||||
|
||||
_series.SetValue(result.Value, _quantile.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class QuantileTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ValidParameters_NoThrow()
|
||||
{
|
||||
var q = new Quantile(10, 0.25);
|
||||
Assert.Equal("Quantile(10,0.25)", q.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PeriodZero_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Quantile(0, 0.5));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeQuantile_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Quantile(10, -0.01));
|
||||
Assert.Equal("quantileLevel", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_QuantileOver1_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Quantile(10, 1.01));
|
||||
Assert.Equal("quantileLevel", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile50_MatchesMedian_OddPeriod()
|
||||
{
|
||||
// {1, 2, 3, 4, 5} → median = 3
|
||||
// rank = 0.5 * (5-1) = 2.0 → sorted[2] = 3
|
||||
var q = new Quantile(5, 0.5);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(3.0, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile50_MatchesMedian_EvenPeriod()
|
||||
{
|
||||
// {1, 2, 3, 4} → rank = 0.5 * (4-1) = 1.5
|
||||
// sorted[1]=2, sorted[2]=3 → 2 + 0.5*(3-2) = 2.5
|
||||
var q = new Quantile(4, 0.5);
|
||||
for (int i = 1; i <= 4; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(2.5, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile0_ReturnsMinimum()
|
||||
{
|
||||
var q = new Quantile(5, 0.0);
|
||||
q.Update(new TValue(DateTime.UtcNow, 10));
|
||||
q.Update(new TValue(DateTime.UtcNow, 20));
|
||||
q.Update(new TValue(DateTime.UtcNow, 5));
|
||||
q.Update(new TValue(DateTime.UtcNow, 30));
|
||||
q.Update(new TValue(DateTime.UtcNow, 15));
|
||||
Assert.Equal(5.0, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile1_ReturnsMaximum()
|
||||
{
|
||||
var q = new Quantile(5, 1.0);
|
||||
q.Update(new TValue(DateTime.UtcNow, 10));
|
||||
q.Update(new TValue(DateTime.UtcNow, 20));
|
||||
q.Update(new TValue(DateTime.UtcNow, 5));
|
||||
q.Update(new TValue(DateTime.UtcNow, 30));
|
||||
q.Update(new TValue(DateTime.UtcNow, 15));
|
||||
Assert.Equal(30.0, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile25_LinearInterpolation()
|
||||
{
|
||||
// {1, 2, 3, 4, 5} sorted → rank = 0.25 * (5-1) = 1.0 → sorted[1] = 2
|
||||
var q = new Quantile(5, 0.25);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(2.0, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile75_LinearInterpolation()
|
||||
{
|
||||
// {1, 2, 3, 4, 5} sorted → rank = 0.75 * (5-1) = 3.0 → sorted[3] = 4
|
||||
var q = new Quantile(5, 0.75);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(4.0, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleValue_ReturnsItself()
|
||||
{
|
||||
var q = new Quantile(1, 0.5);
|
||||
q.Update(new TValue(DateTime.UtcNow, 42.0));
|
||||
Assert.Equal(42.0, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsAtPeriod()
|
||||
{
|
||||
var q = new Quantile(5, 0.5);
|
||||
for (int i = 1; i <= 4; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, i * 10));
|
||||
Assert.False(q.IsHot);
|
||||
}
|
||||
q.Update(new TValue(DateTime.UtcNow, 50));
|
||||
Assert.True(q.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_CorrectsBar()
|
||||
{
|
||||
var q = new Quantile(5, 0.5);
|
||||
// {1, 2, 3, 4, 5} → q50 = 3
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(3.0, q.Last.Value);
|
||||
|
||||
// Correct last bar to 1 → {1, 2, 3, 4, 1} sorted {1,1,2,3,4} → rank=2 → sorted[2]=2
|
||||
q.Update(new TValue(DateTime.UtcNow, 1), isNew: false);
|
||||
Assert.Equal(2.0, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BarCorrection_RestoreToOriginal()
|
||||
{
|
||||
var q = new Quantile(5, 0.5);
|
||||
// {10, 20, 30, 40, 50} → q50: rank=2 → 30
|
||||
q.Update(new TValue(DateTime.UtcNow, 10));
|
||||
q.Update(new TValue(DateTime.UtcNow, 20));
|
||||
q.Update(new TValue(DateTime.UtcNow, 30));
|
||||
q.Update(new TValue(DateTime.UtcNow, 40));
|
||||
q.Update(new TValue(DateTime.UtcNow, 50));
|
||||
double original = q.Last.Value;
|
||||
Assert.Equal(30.0, original);
|
||||
|
||||
// Correct to 5 → {10, 20, 30, 40, 5} sorted {5,10,20,30,40} → q50=20
|
||||
q.Update(new TValue(DateTime.UtcNow, 5), isNew: false);
|
||||
Assert.NotEqual(original, q.Last.Value);
|
||||
Assert.Equal(20.0, q.Last.Value);
|
||||
|
||||
// Correct back to 50
|
||||
var result = q.Update(new TValue(DateTime.UtcNow, 50), isNew: false);
|
||||
Assert.Equal(original, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValid()
|
||||
{
|
||||
var q = new Quantile(3, 0.5);
|
||||
q.Update(new TValue(DateTime.UtcNow, 10));
|
||||
q.Update(new TValue(DateTime.UtcNow, 20));
|
||||
q.Update(new TValue(DateTime.UtcNow, 30));
|
||||
|
||||
// NaN should substitute last valid (30) → buffer gets {20, 30, 30} after sliding
|
||||
q.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(q.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValid()
|
||||
{
|
||||
var q = new Quantile(3, 0.5);
|
||||
q.Update(new TValue(DateTime.UtcNow, 10));
|
||||
q.Update(new TValue(DateTime.UtcNow, 20));
|
||||
q.Update(new TValue(DateTime.UtcNow, 30));
|
||||
|
||||
q.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(q.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var q = new Quantile(5, 0.5);
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.True(q.IsHot);
|
||||
|
||||
q.Reset();
|
||||
Assert.False(q.IsHot);
|
||||
Assert.Equal(default, q.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesStreaming()
|
||||
{
|
||||
var rng = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
source.Add(rng.Next());
|
||||
}
|
||||
int period = 14;
|
||||
double quantileLevel = 0.25;
|
||||
|
||||
// Streaming
|
||||
var indicator = new Quantile(period, quantileLevel);
|
||||
var streamingResults = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamingResults[i] = indicator.Update(new TValue(source.Times[i], source.Values[i])).Value;
|
||||
}
|
||||
|
||||
// Batch
|
||||
var batchSeries = Quantile.Batch(source, period, quantileLevel);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batchSeries.Values[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_MatchesStreaming()
|
||||
{
|
||||
var rng = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 241);
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
source.Add(rng.Next());
|
||||
}
|
||||
int period = 14;
|
||||
double quantileLevel = 0.75;
|
||||
|
||||
// Streaming
|
||||
var indicator = new Quantile(period, quantileLevel);
|
||||
var streamingResults = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamingResults[i] = indicator.Update(new TValue(source.Times[i], source.Values[i])).Value;
|
||||
}
|
||||
|
||||
// Span batch
|
||||
var spanOutput = new double[source.Count];
|
||||
Quantile.Batch(source.Values, spanOutput.AsSpan(), period, quantileLevel);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], spanOutput[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_LengthMismatch_Throws()
|
||||
{
|
||||
var source = new double[] { 1, 2, 3, 4, 5 };
|
||||
var output = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Quantile.Batch(source.AsSpan(), output.AsSpan(), 5, 0.5));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_PeriodZero_Throws()
|
||||
{
|
||||
var source = new double[] { 1, 2, 3 };
|
||||
var output = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Quantile.Batch(source.AsSpan(), output.AsSpan(), 0, 0.5));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_QuantileOutOfRange_Throws()
|
||||
{
|
||||
var source = new double[] { 1, 2, 3 };
|
||||
var output = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Quantile.Batch(source.AsSpan(), output.AsSpan(), 3, 1.01));
|
||||
Assert.Equal("quantileLevel", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_EmptyInput_NoException()
|
||||
{
|
||||
Span<double> source = [];
|
||||
Span<double> output = [];
|
||||
Quantile.Batch(source, output, 5, 0.5);
|
||||
Assert.Equal(0, output.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_LargeData_NoStackOverflow()
|
||||
{
|
||||
var rng = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 309);
|
||||
var source = new double[10_000];
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
source[i] = rng.Next().Close;
|
||||
}
|
||||
var output = new double[source.Length];
|
||||
Quantile.Batch(source.AsSpan(), output.AsSpan(), 50, 0.5);
|
||||
Assert.True(double.IsFinite(output[^1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chaining_PubEventFires()
|
||||
{
|
||||
var q = new Quantile(5, 0.5);
|
||||
int eventCount = 0;
|
||||
q.Pub += (object? _, in TValueEventArgs e) => eventCount++;
|
||||
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(10, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantValues_ReturnsConstant()
|
||||
{
|
||||
var q = new Quantile(5, 0.25);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, 42.0));
|
||||
}
|
||||
Assert.Equal(42.0, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlidingWindow_CorrectlyDropsOldest()
|
||||
{
|
||||
var q = new Quantile(3, 0.5);
|
||||
// {100} → 100
|
||||
q.Update(new TValue(DateTime.UtcNow, 100));
|
||||
// {100, 200} → rank=0.5 → 100 + 0.5*100 = 150
|
||||
q.Update(new TValue(DateTime.UtcNow, 200));
|
||||
// {100, 200, 300} → rank=1 → 200
|
||||
q.Update(new TValue(DateTime.UtcNow, 300));
|
||||
Assert.Equal(200.0, q.Last.Value);
|
||||
|
||||
// {200, 300, 400} → rank=1 → 300
|
||||
q.Update(new TValue(DateTime.UtcNow, 400));
|
||||
Assert.Equal(300.0, q.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FractionalInterpolation()
|
||||
{
|
||||
// {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} → q=0.33
|
||||
// rank = 0.33 * 9 = 2.97 → lo=2, hi=3
|
||||
// sorted[2]=3, sorted[3]=4 → 3 + 0.97*(4-3) = 3.97
|
||||
var q = new Quantile(10, 0.33);
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
q.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(3.97, q.Last.Value, precision: 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace QuanTAlib.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Quantile validation tests — cross-indicator validation against Percentile and Median.
|
||||
/// Quantile(q) must equal Percentile(q*100) for all q ∈ [0, 1].
|
||||
/// </summary>
|
||||
public sealed class QuantileValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Quantile50_Matches_MedianIndicator()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
source.Add(gbm.Next());
|
||||
}
|
||||
int period = 14;
|
||||
|
||||
// Quantile at q=0.5
|
||||
var quantile = new Quantile(period, 0.5);
|
||||
var qResults = new double[source.Count];
|
||||
|
||||
// Median
|
||||
var median = new Median(period);
|
||||
var mResults = new double[source.Count];
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var tv = new TValue(source.Times[i], source.Values[i]);
|
||||
qResults[i] = quantile.Update(tv).Value;
|
||||
mResults[i] = median.Update(tv).Value;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(mResults[i], qResults[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile_Matches_Percentile()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 456);
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
source.Add(gbm.Next());
|
||||
}
|
||||
int period = 14;
|
||||
|
||||
// Quantile at q=0.25
|
||||
var quantile = new Quantile(period, 0.25);
|
||||
var qResults = new double[source.Count];
|
||||
|
||||
// Percentile at p=25
|
||||
var percentile = new Percentile(period, 25.0);
|
||||
var pResults = new double[source.Count];
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
var tv = new TValue(source.Times[i], source.Values[i]);
|
||||
qResults[i] = quantile.Update(tv).Value;
|
||||
pResults[i] = percentile.Update(tv).Value;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(pResults[i], qResults[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile_BatchAndStreaming_Match()
|
||||
{
|
||||
double[] data = [10, 20, 15, 30, 25, 40, 35, 50, 45, 60, 55, 70, 65, 80, 75];
|
||||
int period = 5;
|
||||
double quantileLevel = 0.25;
|
||||
|
||||
// Streaming
|
||||
var q = new Quantile(period, quantileLevel);
|
||||
var streamingResults = new double[data.Length];
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
streamingResults[i] = q.Update(new TValue(DateTime.UtcNow, data[i])).Value;
|
||||
}
|
||||
|
||||
// Batch via spans
|
||||
var spanOutput = new double[data.Length];
|
||||
Quantile.Batch(data.AsSpan(), spanOutput.AsSpan(), period, quantileLevel);
|
||||
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], spanOutput[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile_KnownValues()
|
||||
{
|
||||
// {10, 20, 30, 40, 50} sorted, q=0.25 → rank = 0.25*4 = 1.0 → sorted[1] = 20
|
||||
var q = new Quantile(5, 0.25);
|
||||
q.Update(new TValue(DateTime.UtcNow, 10));
|
||||
q.Update(new TValue(DateTime.UtcNow, 20));
|
||||
q.Update(new TValue(DateTime.UtcNow, 30));
|
||||
q.Update(new TValue(DateTime.UtcNow, 40));
|
||||
var result = q.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
Assert.Equal(20.0, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quantile_BoundaryValues()
|
||||
{
|
||||
// q=0 → minimum, q=1 → maximum
|
||||
var q0 = new Quantile(5, 0.0);
|
||||
var q1 = new Quantile(5, 1.0);
|
||||
|
||||
double[] data = { 30, 10, 50, 20, 40 };
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
var tv = new TValue(DateTime.UtcNow, data[i]);
|
||||
q0.Update(tv);
|
||||
q1.Update(tv);
|
||||
}
|
||||
|
||||
Assert.Equal(10.0, q0.Last.Value);
|
||||
Assert.Equal(50.0, q1.Last.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// QUANTILE: Rolling Quantile
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Computes the value at a given quantile for a rolling window of data using
|
||||
/// linear interpolation (equivalent to PERCENTILE.INC with q ∈ [0, 1]).
|
||||
///
|
||||
/// Calculation:
|
||||
/// 1. Maintain a sorted window of the last 'Period' values.
|
||||
/// 2. Compute rank = q * (n - 1).
|
||||
/// 3. Interpolate between floor and ceil indices.
|
||||
///
|
||||
/// Properties:
|
||||
/// - q=0 returns the minimum value in the window.
|
||||
/// - q=0.5 returns the median (equivalent to Median indicator).
|
||||
/// - q=1 returns the maximum value in the window.
|
||||
///
|
||||
/// Complexity:
|
||||
/// Update: O(N) due to sorted buffer maintenance (BinarySearch + Array.Copy).
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Quantile : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _quantileLevel;
|
||||
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 double _p_lastValidValue;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new Quantile indicator.</summary>
|
||||
/// <param name="period">The size of the rolling window (must be >= 1).</param>
|
||||
/// <param name="quantileLevel">The quantile level to compute (0.0 to 1.0).</param>
|
||||
public Quantile(int period, double quantileLevel = 0.25)
|
||||
{
|
||||
if (period < 1)
|
||||
{
|
||||
throw new ArgumentException("Period must be at least 1.", nameof(period));
|
||||
}
|
||||
if (quantileLevel < 0.0 || quantileLevel > 1.0)
|
||||
{
|
||||
throw new ArgumentException("Quantile level must be between 0.0 and 1.0.", nameof(quantileLevel));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_quantileLevel = quantileLevel;
|
||||
_buffer = new RingBuffer(period);
|
||||
_sortedBuffer = new double[period];
|
||||
_p_sortedBuffer = new double[period];
|
||||
Name = $"Quantile({period},{quantileLevel})";
|
||||
WarmupPeriod = period;
|
||||
_handler = Handle;
|
||||
}
|
||||
|
||||
public Quantile(ITValuePublisher source, int period, double quantileLevel = 0.25) : this(period, quantileLevel)
|
||||
{
|
||||
_source = source;
|
||||
source.Pub += _handler;
|
||||
}
|
||||
|
||||
public Quantile(TSeries source, int period, double quantileLevel = 0.25) : this(period, quantileLevel)
|
||||
{
|
||||
Prime(source.Values);
|
||||
if (source.Count > 0)
|
||||
{
|
||||
Last = new TValue(source.LastTime, Last.Value);
|
||||
}
|
||||
_source = source;
|
||||
source.Pub += _handler;
|
||||
}
|
||||
|
||||
/// <summary>True when the buffer has reached full period length.</summary>
|
||||
public override bool IsHot => _buffer.IsFull;
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
if (source.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_buffer.Clear();
|
||||
Array.Clear(_sortedBuffer);
|
||||
Array.Clear(_p_sortedBuffer);
|
||||
_lastValidValue = 0;
|
||||
_p_lastValidValue = 0;
|
||||
|
||||
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]));
|
||||
}
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
double value = input.Value;
|
||||
|
||||
// NaN/Infinity guard — substitute last valid
|
||||
if (!double.IsFinite(value))
|
||||
{
|
||||
value = _lastValidValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_lastValidValue = _lastValidValue;
|
||||
}
|
||||
_lastValidValue = value;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
// Save sorted buffer state for rollback
|
||||
Array.Copy(_sortedBuffer, _p_sortedBuffer, _buffer.Count);
|
||||
|
||||
if (_buffer.IsFull)
|
||||
{
|
||||
double old = _buffer.Oldest;
|
||||
RemoveFromSorted(old);
|
||||
}
|
||||
_buffer.Add(value);
|
||||
AddToSorted(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Restore sorted buffer from backup before mutation
|
||||
_lastValidValue = _p_lastValidValue;
|
||||
int prevCount = _buffer.Count;
|
||||
if (prevCount > 0)
|
||||
{
|
||||
Array.Copy(_p_sortedBuffer, _sortedBuffer, prevCount);
|
||||
}
|
||||
|
||||
if (_buffer.Count > 0)
|
||||
{
|
||||
double current = _buffer.Newest;
|
||||
RemoveFromSorted(current);
|
||||
_buffer.UpdateNewest(value);
|
||||
AddToSorted(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
_buffer.Add(value);
|
||||
AddToSorted(value);
|
||||
}
|
||||
|
||||
// Re-apply NaN guard for corrected value
|
||||
if (double.IsFinite(input.Value))
|
||||
{
|
||||
_lastValidValue = input.Value;
|
||||
}
|
||||
}
|
||||
|
||||
int count = _buffer.Count;
|
||||
double result = ComputeQuantile(_sortedBuffer, count, _quantileLevel);
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
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, _quantileLevel);
|
||||
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);
|
||||
_lastValidValue = 0;
|
||||
_p_lastValidValue = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>Computes quantile via linear interpolation on a sorted array.</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double ComputeQuantile(double[] sorted, int count, double q)
|
||||
{
|
||||
if (count == 1)
|
||||
{
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
double rank = q * (count - 1);
|
||||
int lo = (int)rank;
|
||||
int hi = lo + 1;
|
||||
|
||||
if (hi >= count)
|
||||
{
|
||||
return sorted[count - 1];
|
||||
}
|
||||
|
||||
double frac = rank - lo;
|
||||
// skipcq: CS-R1140 — FMA for interpolation precision
|
||||
return Math.FusedMultiplyAdd(frac, sorted[hi] - sorted[lo], sorted[lo]);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a batch Quantile series from source.</summary>
|
||||
public static TSeries Batch(TSeries source, int period, double quantileLevel = 0.25)
|
||||
{
|
||||
var indicator = new Quantile(period, quantileLevel);
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>Computes Quantile in-place over a span.</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period, double quantileLevel = 0.25)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output must have the same length.", nameof(output));
|
||||
}
|
||||
if (period < 1)
|
||||
{
|
||||
throw new ArgumentException("Period must be at least 1.", nameof(period));
|
||||
}
|
||||
if (quantileLevel < 0.0 || quantileLevel > 1.0)
|
||||
{
|
||||
throw new ArgumentException("Quantile level must be between 0.0 and 1.0.", nameof(quantileLevel));
|
||||
}
|
||||
|
||||
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> sortedBuf = rentedSorted.AsSpan(0, period);
|
||||
Span<double> window = rentedWindow.AsSpan(0, period);
|
||||
sortedBuf.Clear();
|
||||
window.Clear();
|
||||
|
||||
int windowIdx = 0;
|
||||
int count = 0;
|
||||
|
||||
double lastValidValue = 0.0;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
|
||||
// NaN/Infinity guard
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = lastValidValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValidValue = val;
|
||||
}
|
||||
|
||||
if (count == period)
|
||||
{
|
||||
double old = window[windowIdx];
|
||||
int oldIndex = BinarySearchSpan(sortedBuf, count, old);
|
||||
if (oldIndex >= 0)
|
||||
{
|
||||
if (oldIndex < count - 1)
|
||||
{
|
||||
sortedBuf.Slice(oldIndex + 1, count - 1 - oldIndex).CopyTo(sortedBuf.Slice(oldIndex));
|
||||
}
|
||||
count--;
|
||||
}
|
||||
}
|
||||
|
||||
window[windowIdx] = val;
|
||||
windowIdx = (windowIdx + 1) % period;
|
||||
|
||||
int newIndex = BinarySearchSpan(sortedBuf, count, val);
|
||||
if (newIndex < 0)
|
||||
{
|
||||
newIndex = ~newIndex;
|
||||
}
|
||||
|
||||
if (newIndex < count)
|
||||
{
|
||||
sortedBuf.Slice(newIndex, count - newIndex).CopyTo(sortedBuf.Slice(newIndex + 1));
|
||||
}
|
||||
sortedBuf[newIndex] = val;
|
||||
count++;
|
||||
|
||||
output[i] = ComputeQuantileSpan(sortedBuf, count, quantileLevel);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedSorted);
|
||||
ArrayPool<double>.Shared.Return(rentedWindow);
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries Results, Quantile Indicator) Calculate(TSeries source, int period, double quantileLevel = 0.25)
|
||||
{
|
||||
var indicator = new Quantile(period, quantileLevel);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double ComputeQuantileSpan(Span<double> sorted, int count, double q)
|
||||
{
|
||||
if (count == 1)
|
||||
{
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
double rank = q * (count - 1);
|
||||
int lo = (int)rank;
|
||||
int hi = lo + 1;
|
||||
|
||||
if (hi >= count)
|
||||
{
|
||||
return sorted[count - 1];
|
||||
}
|
||||
|
||||
double frac = rank - lo;
|
||||
return Math.FusedMultiplyAdd(frac, sorted[hi] - sorted[lo], sorted[lo]);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# QUANTILE: Rolling Quantile
|
||||
|
||||
> "The quantile function is the inverse of the distribution function." — Every probability textbook ever written, and yet somehow it still surprises people.
|
||||
|
||||
## Introduction
|
||||
|
||||
The Rolling Quantile computes the value below which a given fraction of observations fall within a sliding window. It is mathematically identical to Percentile but uses the statistician's convention of q ∈ [0, 1] instead of the analyst's p ∈ [0, 100]. When q=0.5, it returns the median; q=0 gives the minimum; q=1 gives the maximum. The linear interpolation method matches Excel's PERCENTILE.INC and PineScript's `ta.percentile_linear_interpolation` conventions (Hyndman-Fan Method 7).
|
||||
|
||||
## Historical Context
|
||||
|
||||
Francis Galton introduced percentiles in 1885. The quantile formulation (0 to 1) gained dominance in mathematical statistics because it maps directly to cumulative distribution functions. In practice, the two are interchangeable: quantile q = percentile(100q). The choice between them is a matter of API convention, not mathematics. Trading platforms tend to use percentiles (0-100 range, more intuitive for non-statisticians); statistical libraries prefer quantiles (0-1 range, composable with CDFs and probability calculations).
|
||||
|
||||
Our implementation provides both: `Percentile` for the 0-100 convention, `Quantile` for the 0-1 convention. They share identical algorithms.
|
||||
|
||||
## Architecture and Physics
|
||||
|
||||
### 1. Sorted Buffer Maintenance
|
||||
|
||||
Each `Update` call:
|
||||
|
||||
1. **Remove** the oldest value from the sorted buffer (if window full): O(log N) search + O(N) shift.
|
||||
2. **Insert** the new value into sorted position: O(log N) search + O(N) shift.
|
||||
3. **Compute** the quantile via linear interpolation: O(1).
|
||||
|
||||
Total per-update cost: O(N) for the array shifts, dominated by the `Array.Copy` operations.
|
||||
|
||||
### 2. Linear Interpolation (Hyndman-Fan Method 7)
|
||||
|
||||
For sorted values $x_0, x_1, \ldots, x_{n-1}$ and quantile level $q \in [0, 1]$:
|
||||
|
||||
$$\text{rank} = q \cdot (n - 1)$$
|
||||
|
||||
$$\text{result} = x_{\lfloor r \rfloor} + (r - \lfloor r \rfloor) \cdot (x_{\lceil r \rceil} - x_{\lfloor r \rfloor})$$
|
||||
|
||||
where $r = \text{rank}$.
|
||||
|
||||
Boundary cases:
|
||||
|
||||
- $q = 0$: returns $x_0$ (minimum)
|
||||
- $q = 1$: returns $x_{n-1}$ (maximum)
|
||||
- $n = 1$: returns the single value regardless of $q$
|
||||
|
||||
### 3. Bar Correction
|
||||
|
||||
State rollback uses `_p_sortedBuffer` backup arrays, identical to the Percentile, Median, and IQR pattern. When `isNew=false`, the sorted buffer is restored from the backup before applying the correction.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
The quantile function $Q(q)$ for a discrete sample using Hyndman-Fan Method 7:
|
||||
|
||||
$$Q(q) = (1 - g) \cdot x_j + g \cdot x_{j+1}$$
|
||||
|
||||
where:
|
||||
|
||||
- $j = \lfloor q \cdot (n-1) \rfloor$
|
||||
- $g = q \cdot (n-1) - j$ (fractional part)
|
||||
|
||||
This is equivalent to the FMA form used in implementation:
|
||||
|
||||
$$Q(q) = \text{FMA}(g, x_{j+1} - x_j, x_j)$$
|
||||
|
||||
Relationship to Percentile: $Q(q) = P(100q)$ where $P$ is the percentile function.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Operation | Cost | Notes |
|
||||
|-----------|------|-------|
|
||||
| BinarySearch | O(log N) | `Array.BinarySearch` for insert/remove position |
|
||||
| Array.Copy (shift) | O(N) | Dominates update cost |
|
||||
| Interpolation | O(1) | Single FMA operation |
|
||||
| Bar correction | O(N) | `Array.Copy` for buffer backup/restore |
|
||||
| Memory | O(2N) | Sorted buffer + backup buffer |
|
||||
|
||||
| Quality | Score (1-10) |
|
||||
|---------|-------------|
|
||||
| Precision | 10 — exact within IEEE 754 double precision |
|
||||
| Latency | 7 — O(N) per update, fast for typical periods (5-50) |
|
||||
| Memory | 8 — two double arrays + RingBuffer |
|
||||
| Robustness | 9 — NaN/Infinity guarded, bar correction supported |
|
||||
| SIMD applicability | 2 — comparison-heavy algorithm not vectorizable |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Match | Notes |
|
||||
|---------|-------|-------|
|
||||
| PineScript | ✔️ | Source implementation, same linear interpolation |
|
||||
| Excel PERCENTILE.INC | ✔️ | Same Method 7 interpolation (q = p/100) |
|
||||
| QuanTAlib Percentile | ✔️ | Cross-validated, Quantile(q) == Percentile(q*100) |
|
||||
| QuanTAlib Median (q=0.5) | ✔️ | Cross-validated, exact match |
|
||||
| Wolfram Alpha | ≠ | Uses nearest-rank (Method 1), different by design |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Parameter range confusion.** Quantile uses q ∈ [0, 1], not [0, 100]. Passing 25 instead of 0.25 will throw `ArgumentException`. Use `Percentile` if you prefer the 0-100 range.
|
||||
|
||||
2. **Interpolation method confusion.** Wolfram Alpha, NumPy (`linear`), and Excel (`PERCENTILE.INC`) all use slightly different conventions. Our implementation matches Excel/PineScript (Method 7). Do not validate against Wolfram's nearest-rank results.
|
||||
|
||||
3. **Period=1 edge case.** A single value has a defined quantile (itself) for any q in [0, 1]. The implementation handles this correctly.
|
||||
|
||||
4. **Window not full.** Before reaching full period, the quantile is computed over the available values. This gives valid but potentially misleading results during warmup.
|
||||
|
||||
5. **q=0.5 vs Median.** For even-length windows, Quantile(q=0.5) uses linear interpolation which yields the average of two middle values — identical to Median. For odd-length windows, both return the middle value directly.
|
||||
|
||||
6. **Floating-point accumulation.** Since quantile uses direct sorted-buffer access (not running sums), there is no floating-point drift. The result is always computed fresh from the sorted values.
|
||||
|
||||
7. **Large periods.** For period > 256, the span batch implementation uses `ArrayPool` instead of `stackalloc` to avoid stack overflow in chained indicator scenarios.
|
||||
|
||||
## References
|
||||
|
||||
- Hyndman, R.J. and Fan, Y. (1996). "Sample Quantiles in Statistical Packages." *The American Statistician*, 50(4), 361-365.
|
||||
- Galton, F. (1885). "Some Results of the Anthropometric Laboratory." *Journal of the Anthropological Institute*, 14, 275-287.
|
||||
- Microsoft Excel Documentation: [PERCENTILE.INC function](https://support.microsoft.com/en-us/office/percentile-inc-function-680f9539-45eb-410b-9a5e-c1355e5fe2ed)
|
||||
- TradingView PineScript Reference: [ta.percentile_linear_interpolation](https://www.tradingview.com/pine-script-reference/v6/)
|
||||
Reference in New Issue
Block a user