mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-25 22:08: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,66 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class PercentileIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void PercentileIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new PercentileIndicator();
|
||||
|
||||
Assert.Equal(14, indicator.Period);
|
||||
Assert.Equal(50.0, indicator.Percent);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("Percentile - Rolling Percentile", indicator.Name);
|
||||
Assert.False(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PercentileIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new PercentileIndicator { Period = 14 };
|
||||
|
||||
Assert.Equal(0, PercentileIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PercentileIndicator_Initialize_CreatesInternalPercentile()
|
||||
{
|
||||
var indicator = new PercentileIndicator { Period = 10, Percent = 25.0 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
Assert.Equal("Percentile", indicator.LinesSeries[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PercentileIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new PercentileIndicator { Period = 5, Percent = 75.0 };
|
||||
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 percentile = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Percentile of a trending series should be finite
|
||||
Assert.True(double.IsFinite(percentile));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class PercentileIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 2000, 1, 0)]
|
||||
public int Period { get; set; } = 14;
|
||||
|
||||
[InputParameter("Percentile (0-100)", sortIndex: 2, 0, 100, 0.1, 1)]
|
||||
public double Percent { get; set; } = 50.0;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Percentile _percentile = null!;
|
||||
private readonly LineSeries _series;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"Percentile {Period} ({Percent}%)";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/statistics/percentile/Percentile.Quantower.cs";
|
||||
|
||||
public PercentileIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = false;
|
||||
Name = "Percentile - Rolling Percentile";
|
||||
Description = "Value below which a given percentage of observations fall in a rolling window";
|
||||
|
||||
_series = new LineSeries(name: "Percentile", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_percentile = new Percentile(Period, Percent);
|
||||
_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 = _percentile.Update(input, args.IsNewBar());
|
||||
|
||||
_series.SetValue(result.Value, _percentile.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class PercentileTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ValidParameters_NoThrow()
|
||||
{
|
||||
var p = new Percentile(10, 25.0);
|
||||
Assert.Equal("Percentile(10,25)", p.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PeriodZero_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Percentile(0, 50.0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePercent_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Percentile(10, -1.0));
|
||||
Assert.Equal("percent", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PercentOver100_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Percentile(10, 101.0));
|
||||
Assert.Equal("percent", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentile50_MatchesMedian_OddPeriod()
|
||||
{
|
||||
// {1, 2, 3, 4, 5} → median = 3
|
||||
// rank = (50/100)*(5-1) = 2.0 → sorted[2] = 3
|
||||
var p = new Percentile(5, 50.0);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(3.0, p.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentile50_MatchesMedian_EvenPeriod()
|
||||
{
|
||||
// {1, 2, 3, 4} → rank = (50/100)*(4-1) = 1.5
|
||||
// sorted[1]=2, sorted[2]=3 → 2 + 0.5*(3-2) = 2.5
|
||||
var p = new Percentile(4, 50.0);
|
||||
for (int i = 1; i <= 4; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(2.5, p.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentile0_ReturnsMinimum()
|
||||
{
|
||||
var p = new Percentile(5, 0.0);
|
||||
p.Update(new TValue(DateTime.UtcNow, 10));
|
||||
p.Update(new TValue(DateTime.UtcNow, 20));
|
||||
p.Update(new TValue(DateTime.UtcNow, 5));
|
||||
p.Update(new TValue(DateTime.UtcNow, 30));
|
||||
p.Update(new TValue(DateTime.UtcNow, 15));
|
||||
Assert.Equal(5.0, p.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentile100_ReturnsMaximum()
|
||||
{
|
||||
var p = new Percentile(5, 100.0);
|
||||
p.Update(new TValue(DateTime.UtcNow, 10));
|
||||
p.Update(new TValue(DateTime.UtcNow, 20));
|
||||
p.Update(new TValue(DateTime.UtcNow, 5));
|
||||
p.Update(new TValue(DateTime.UtcNow, 30));
|
||||
p.Update(new TValue(DateTime.UtcNow, 15));
|
||||
Assert.Equal(30.0, p.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentile25_LinearInterpolation()
|
||||
{
|
||||
// {1, 2, 3, 4, 5} sorted → rank = (25/100)*(5-1) = 1.0 → sorted[1] = 2
|
||||
var p = new Percentile(5, 25.0);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(2.0, p.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentile75_LinearInterpolation()
|
||||
{
|
||||
// {1, 2, 3, 4, 5} sorted → rank = (75/100)*(5-1) = 3.0 → sorted[3] = 4
|
||||
var p = new Percentile(5, 75.0);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(4.0, p.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleValue_ReturnsItself()
|
||||
{
|
||||
var p = new Percentile(1, 50.0);
|
||||
p.Update(new TValue(DateTime.UtcNow, 42.0));
|
||||
Assert.Equal(42.0, p.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsAtPeriod()
|
||||
{
|
||||
var p = new Percentile(5, 50.0);
|
||||
for (int i = 1; i <= 4; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, i * 10));
|
||||
Assert.False(p.IsHot);
|
||||
}
|
||||
p.Update(new TValue(DateTime.UtcNow, 50));
|
||||
Assert.True(p.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_CorrectsBar()
|
||||
{
|
||||
var p = new Percentile(5, 50.0);
|
||||
// {1, 2, 3, 4, 5} → p50 = 3
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(3.0, p.Last.Value);
|
||||
|
||||
// Correct last bar to 1 → {1, 2, 3, 4, 1} sorted {1,1,2,3,4} → rank=2 → sorted[2]=2
|
||||
p.Update(new TValue(DateTime.UtcNow, 1), isNew: false);
|
||||
Assert.Equal(2.0, p.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BarCorrection_RestoreToOriginal()
|
||||
{
|
||||
var p = new Percentile(5, 50.0);
|
||||
// {10, 20, 30, 40, 50} → p50: rank=2 → 30
|
||||
p.Update(new TValue(DateTime.UtcNow, 10));
|
||||
p.Update(new TValue(DateTime.UtcNow, 20));
|
||||
p.Update(new TValue(DateTime.UtcNow, 30));
|
||||
p.Update(new TValue(DateTime.UtcNow, 40));
|
||||
p.Update(new TValue(DateTime.UtcNow, 50));
|
||||
double original = p.Last.Value;
|
||||
Assert.Equal(30.0, original);
|
||||
|
||||
// Correct to 5 → {10, 20, 30, 40, 5} sorted {5,10,20,30,40} → p50=20
|
||||
p.Update(new TValue(DateTime.UtcNow, 5), isNew: false);
|
||||
Assert.NotEqual(original, p.Last.Value);
|
||||
Assert.Equal(20.0, p.Last.Value);
|
||||
|
||||
// Correct back to 50
|
||||
var result = p.Update(new TValue(DateTime.UtcNow, 50), isNew: false);
|
||||
Assert.Equal(original, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValid()
|
||||
{
|
||||
var p = new Percentile(3, 50.0);
|
||||
p.Update(new TValue(DateTime.UtcNow, 10));
|
||||
p.Update(new TValue(DateTime.UtcNow, 20));
|
||||
p.Update(new TValue(DateTime.UtcNow, 30));
|
||||
|
||||
// NaN should substitute last valid (30) → buffer gets {20, 30, 30} after sliding
|
||||
p.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(p.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValid()
|
||||
{
|
||||
var p = new Percentile(3, 50.0);
|
||||
p.Update(new TValue(DateTime.UtcNow, 10));
|
||||
p.Update(new TValue(DateTime.UtcNow, 20));
|
||||
p.Update(new TValue(DateTime.UtcNow, 30));
|
||||
|
||||
p.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(p.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var p = new Percentile(5, 50.0);
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.True(p.IsHot);
|
||||
|
||||
p.Reset();
|
||||
Assert.False(p.IsHot);
|
||||
Assert.Equal(default, p.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 percent = 25.0;
|
||||
|
||||
// Streaming
|
||||
var indicator = new Percentile(period, percent);
|
||||
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 = Percentile.Batch(source, period, percent);
|
||||
|
||||
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 percent = 75.0;
|
||||
|
||||
// Streaming
|
||||
var indicator = new Percentile(period, percent);
|
||||
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];
|
||||
Percentile.Batch(source.Values, spanOutput.AsSpan(), period, percent);
|
||||
|
||||
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>(() =>
|
||||
Percentile.Batch(source.AsSpan(), output.AsSpan(), 5, 50.0));
|
||||
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>(() =>
|
||||
Percentile.Batch(source.AsSpan(), output.AsSpan(), 0, 50.0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_PercentOutOfRange_Throws()
|
||||
{
|
||||
var source = new double[] { 1, 2, 3 };
|
||||
var output = new double[3];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Percentile.Batch(source.AsSpan(), output.AsSpan(), 3, 101.0));
|
||||
Assert.Equal("percent", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_EmptyInput_NoException()
|
||||
{
|
||||
Span<double> source = [];
|
||||
Span<double> output = [];
|
||||
Percentile.Batch(source, output, 5, 50.0);
|
||||
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];
|
||||
Percentile.Batch(source.AsSpan(), output.AsSpan(), 50, 50.0);
|
||||
Assert.True(double.IsFinite(output[^1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chaining_PubEventFires()
|
||||
{
|
||||
var p = new Percentile(5, 50.0);
|
||||
int eventCount = 0;
|
||||
p.Pub += (object? _, in TValueEventArgs e) => eventCount++;
|
||||
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(10, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantValues_ReturnsConstant()
|
||||
{
|
||||
var p = new Percentile(5, 25.0);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, 42.0));
|
||||
}
|
||||
Assert.Equal(42.0, p.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlidingWindow_CorrectlyDropsOldest()
|
||||
{
|
||||
var p = new Percentile(3, 50.0);
|
||||
// {100} → 100
|
||||
p.Update(new TValue(DateTime.UtcNow, 100));
|
||||
// {100, 200} → rank=0.5 → 100 + 0.5*100 = 150
|
||||
p.Update(new TValue(DateTime.UtcNow, 200));
|
||||
// {100, 200, 300} → rank=1 → 200
|
||||
p.Update(new TValue(DateTime.UtcNow, 300));
|
||||
Assert.Equal(200.0, p.Last.Value);
|
||||
|
||||
// {200, 300, 400} → rank=1 → 300
|
||||
p.Update(new TValue(DateTime.UtcNow, 400));
|
||||
Assert.Equal(300.0, p.Last.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
namespace QuanTAlib.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Percentile validation tests — self-consistency and cross-indicator validation.
|
||||
/// Percentile(p=50) must match Median indicator exactly.
|
||||
/// </summary>
|
||||
public sealed class PercentileValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Percentile50_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;
|
||||
|
||||
// Percentile at 50%
|
||||
var percentile = new Percentile(period, 50.0);
|
||||
var pResults = 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]);
|
||||
pResults[i] = percentile.Update(tv).Value;
|
||||
mResults[i] = median.Update(tv).Value;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(mResults[i], pResults[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentile_BatchAndStreaming_Match()
|
||||
{
|
||||
double[] data = [10, 20, 15, 30, 25, 40, 35, 50, 45, 60, 55, 70, 65, 80, 75];
|
||||
int period = 5;
|
||||
double percent = 25.0;
|
||||
|
||||
// Streaming
|
||||
var p = new Percentile(period, percent);
|
||||
var streamingResults = new double[data.Length];
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
streamingResults[i] = p.Update(new TValue(DateTime.UtcNow, data[i])).Value;
|
||||
}
|
||||
|
||||
// Batch via spans
|
||||
var spanOutput = new double[data.Length];
|
||||
Percentile.Batch(data.AsSpan(), spanOutput.AsSpan(), period, percent);
|
||||
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], spanOutput[i], precision: 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentile_KnownValues()
|
||||
{
|
||||
// {10, 20, 30, 40, 50} sorted, p=25 → rank = 0.25*4 = 1.0 → sorted[1] = 20
|
||||
var p = new Percentile(5, 25.0);
|
||||
p.Update(new TValue(DateTime.UtcNow, 10));
|
||||
p.Update(new TValue(DateTime.UtcNow, 20));
|
||||
p.Update(new TValue(DateTime.UtcNow, 30));
|
||||
p.Update(new TValue(DateTime.UtcNow, 40));
|
||||
var result = p.Update(new TValue(DateTime.UtcNow, 50));
|
||||
|
||||
Assert.Equal(20.0, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentile_BoundaryValues()
|
||||
{
|
||||
// p=0 → minimum, p=100 → maximum
|
||||
var p0 = new Percentile(5, 0.0);
|
||||
var p100 = new Percentile(5, 100.0);
|
||||
|
||||
double[] data = { 30, 10, 50, 20, 40 };
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
var tv = new TValue(DateTime.UtcNow, data[i]);
|
||||
p0.Update(tv);
|
||||
p100.Update(tv);
|
||||
}
|
||||
|
||||
Assert.Equal(10.0, p0.Last.Value);
|
||||
Assert.Equal(50.0, p100.Last.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// PERCENTILE: Rolling Percentile
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Computes the value at a given percentile for a rolling window of data using
|
||||
/// linear interpolation (PERCENTILE.INC / Excel method).
|
||||
///
|
||||
/// Calculation:
|
||||
/// 1. Maintain a sorted window of the last 'Period' values.
|
||||
/// 2. Compute rank = (p / 100) * (n - 1).
|
||||
/// 3. Interpolate between floor and ceil indices.
|
||||
///
|
||||
/// Properties:
|
||||
/// - p=0 returns the minimum value in the window.
|
||||
/// - p=50 returns the median (equivalent to Median indicator).
|
||||
/// - p=100 returns the maximum value in the window.
|
||||
///
|
||||
/// Complexity:
|
||||
/// Update: O(N) due to sorted buffer maintenance (BinarySearch + Array.Copy).
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Percentile : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _percent;
|
||||
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 int _p_sortedCount;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new Percentile indicator.</summary>
|
||||
/// <param name="period">The size of the rolling window (must be >= 1).</param>
|
||||
/// <param name="percent">The percentile to compute (0-100).</param>
|
||||
public Percentile(int period, double percent = 50.0)
|
||||
{
|
||||
if (period < 1)
|
||||
{
|
||||
throw new ArgumentException("Period must be at least 1.", nameof(period));
|
||||
}
|
||||
if (percent < 0.0 || percent > 100.0)
|
||||
{
|
||||
throw new ArgumentException("Percent must be between 0 and 100.", nameof(percent));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_percent = percent;
|
||||
_buffer = new RingBuffer(period);
|
||||
_sortedBuffer = new double[period];
|
||||
_p_sortedBuffer = new double[period];
|
||||
Name = $"Percentile({period},{percent})";
|
||||
WarmupPeriod = period;
|
||||
_handler = Handle;
|
||||
}
|
||||
|
||||
public Percentile(ITValuePublisher source, int period, double percent = 50.0) : this(period, percent)
|
||||
{
|
||||
_source = source;
|
||||
source.Pub += _handler;
|
||||
}
|
||||
|
||||
public Percentile(TSeries source, int period, double percent = 50.0) : this(period, percent)
|
||||
{
|
||||
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
|
||||
_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
|
||||
_lastValidValue = _p_lastValidValue;
|
||||
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);
|
||||
}
|
||||
|
||||
// Re-apply NaN guard for corrected value
|
||||
if (double.IsFinite(input.Value))
|
||||
{
|
||||
_lastValidValue = input.Value;
|
||||
}
|
||||
}
|
||||
|
||||
int count = _buffer.Count;
|
||||
double result = ComputePercentile(_sortedBuffer, count, _percent);
|
||||
|
||||
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, _percent);
|
||||
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 percentile via linear interpolation on a sorted span (PERCENTILE.INC method).</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double ComputePercentile(double[] sorted, int count, double p)
|
||||
{
|
||||
if (count == 1)
|
||||
{
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
double rank = (p / 100.0) * (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 Percentile series from source.</summary>
|
||||
public static TSeries Batch(TSeries source, int period, double percent = 50.0)
|
||||
{
|
||||
var indicator = new Percentile(period, percent);
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>Computes Percentile in-place over a span.</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period, double percent = 50.0)
|
||||
{
|
||||
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 (percent < 0.0 || percent > 100.0)
|
||||
{
|
||||
throw new ArgumentException("Percent must be between 0 and 100.", nameof(percent));
|
||||
}
|
||||
|
||||
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] = ComputePercentileSpan(sortedBuf, count, percent);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedSorted);
|
||||
ArrayPool<double>.Shared.Return(rentedWindow);
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries Results, Percentile Indicator) Calculate(TSeries source, int period, double percent = 50.0)
|
||||
{
|
||||
var indicator = new Percentile(period, percent);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double ComputePercentileSpan(Span<double> sorted, int count, double p)
|
||||
{
|
||||
if (count == 1)
|
||||
{
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
double rank = (p / 100.0) * (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,109 @@
|
||||
# PERCENTILE: Rolling Percentile
|
||||
|
||||
> "There are three kinds of lies: lies, damned lies, and statistics." — Mark Twain.
|
||||
> But percentiles, at least, tell you exactly where you stand.
|
||||
|
||||
## Introduction
|
||||
|
||||
The Rolling Percentile computes the value below which a given percentage of observations fall within a sliding window. Unlike fixed percentile calculations over static datasets, the rolling variant maintains a sorted buffer that updates in O(N) per bar, providing real-time distributional context. When p=50, it reduces to the Median; when p=0 or p=100, it returns the window minimum or maximum respectively. The PERCENTILE.INC (inclusive) interpolation method matches Excel and PineScript conventions.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Percentile calculations date to Francis Galton's work on anthropometric data in the 1880s. The rolling variant emerged with computerized trading systems in the 1990s, where traders needed to know where the current price sits relative to its recent distribution. Multiple interpolation methods exist (nearest-rank, exclusive, inclusive); this implementation uses the inclusive linear interpolation method (C=1 in Hyndman and Fan's taxonomy, Method 7), which matches Excel's `PERCENTILE.INC` and PineScript's `percentile()`.
|
||||
|
||||
The distinction matters: Wolfram Alpha uses nearest-rank by default, producing integer-indexed results. Our linear interpolation smoothly transitions between adjacent sorted values, yielding fractional results that better serve continuous financial data.
|
||||
|
||||
## Architecture and Physics
|
||||
|
||||
### 1. Sorted Buffer Maintenance
|
||||
|
||||
The indicator maintains a `double[]` sorted buffer alongside a `RingBuffer` for the sliding window. Each update:
|
||||
|
||||
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 percentile via linear interpolation: O(1).
|
||||
|
||||
Total per-update cost: O(N) for the array shifts, dominated by the `Array.Copy` operations.
|
||||
|
||||
### 2. Linear Interpolation (PERCENTILE.INC)
|
||||
|
||||
For sorted values $x_0, x_1, \ldots, x_{n-1}$ and percentile $p \in [0, 100]$:
|
||||
|
||||
$$\text{rank} = \frac{p}{100} \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:
|
||||
- $p = 0$: returns $x_0$ (minimum)
|
||||
- $p = 100$: returns $x_{n-1}$ (maximum)
|
||||
- $n = 1$: returns the single value regardless of $p$
|
||||
|
||||
### 3. Bar Correction
|
||||
|
||||
State rollback uses `_p_sortedBuffer` backup arrays, identical to the Median and IQR pattern. When `isNew=false`, the sorted buffer is restored from the backup before applying the correction.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
The PERCENTILE.INC formula (Hyndman and Fan Method 7):
|
||||
|
||||
$$Q(p) = (1 - g) \cdot x_j + g \cdot x_{j+1}$$
|
||||
|
||||
where:
|
||||
- $j = \lfloor p \cdot (n-1) / 100 \rfloor$
|
||||
- $g = p \cdot (n-1) / 100 - j$ (fractional part)
|
||||
|
||||
This is equivalent to the FMA form used in implementation:
|
||||
|
||||
$$Q(p) = \text{FMA}(g, x_{j+1} - x_j, x_j)$$
|
||||
|
||||
## 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, PERCENTILE.INC interpolation |
|
||||
| Excel PERCENTILE.INC | ✔️ | Same Method 7 interpolation |
|
||||
| QuanTAlib Median (p=50) | ✔️ | Cross-validated, exact match |
|
||||
| Wolfram Alpha | ≠ | Uses nearest-rank (Method 1), different by design |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **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.
|
||||
|
||||
2. **Period=1 edge case.** A single value has a defined percentile (itself) for any p in [0, 100]. The implementation handles this correctly.
|
||||
|
||||
3. **Window not full.** Before reaching full period, the percentile is computed over the available values. This gives valid but potentially misleading results during warmup.
|
||||
|
||||
4. **Percent=50 vs Median.** For even-length windows, Percentile(p=50) uses linear interpolation which yields the average of two middle values — identical to Median. For odd-length windows, both return the middle value directly.
|
||||
|
||||
5. **NaN propagation.** NaN inputs are replaced with the last valid value. This prevents NaN from contaminating the sorted buffer and producing incorrect percentiles.
|
||||
|
||||
6. **Floating-point accumulation.** Since percentile 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