mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 04:58:08 +00:00
Update SVG badges and missing indicators report
- Updated class count in classes.svg from 938 to 1078. - Adjusted comments percentage in comments.svg from 33.06 to 33.02. - Revised average cyclomatic complexity in complexity.svg from 2.19 to 2.12. - Increased source files count in files.svg from 1099 to 1275. - Updated lines of code in loc.svg from 114549 to 129859. - Increased methods count in methods.svg from 12035 to 14066. - Updated public types count in public-api.svg from 1086 to 1225. - Revised missing indicators report with updated counts and categories, reflecting recent implementations and planned additions.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class IqrIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void IqrIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new IqrIndicator();
|
||||
|
||||
Assert.Equal(20, indicator.Period);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("IQR - Interquartile Range", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IqrIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new IqrIndicator { Period = 20 };
|
||||
|
||||
Assert.Equal(0, IqrIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IqrIndicator_Initialize_CreatesInternalIqr()
|
||||
{
|
||||
var indicator = new IqrIndicator { Period = 10 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
Assert.Equal("IQR", indicator.LinesSeries[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IqrIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new IqrIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double iqr = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(iqr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IqrIndicator_DifferentSourceTypes()
|
||||
{
|
||||
var indicator = new IqrIndicator { Period = 5, Source = SourceType.Open };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double iqr = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(iqr));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IqrIndicator_ShortName_IncludesPeriod()
|
||||
{
|
||||
var indicator = new IqrIndicator { Period = 30 };
|
||||
Assert.Equal("IQR 30", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IqrIndicator_NewBar_UpdatesValue()
|
||||
{
|
||||
var indicator = new IqrIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
_ = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Add a new bar with a very different value
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(20), 200, 210, 190, 205);
|
||||
var newArgs = new UpdateArgs(UpdateReason.NewBar);
|
||||
indicator.ProcessUpdate(newArgs);
|
||||
|
||||
double valueAfter = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(valueAfter));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class IqrIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 2, 2000, 1, 0)]
|
||||
public int Period { get; set; } = 20;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Iqr _iqr = null!;
|
||||
private readonly LineSeries _series;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"IQR {Period}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/statistics/iqr/Iqr.Quantower.cs";
|
||||
|
||||
public IqrIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "IQR - Interquartile Range";
|
||||
Description = "Measures spread of the middle 50% of data (Q3 - Q1)";
|
||||
|
||||
_series = new LineSeries(name: "IQR", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_iqr = new Iqr(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 = _iqr.Update(input, args.IsNewBar());
|
||||
|
||||
_series.SetValue(result.Value, _iqr.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// A) Constructor Validation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class IqrConstructorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_PeriodLessThan2_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Iqr(1));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PeriodZero_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Iqr(0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Iqr(-5));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidPeriod_SetsName()
|
||||
{
|
||||
var iqr = new Iqr(20);
|
||||
Assert.Equal("Iqr(20)", iqr.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidPeriod_SetsWarmupPeriod()
|
||||
{
|
||||
var iqr = new Iqr(20);
|
||||
Assert.Equal(20, iqr.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MinimumPeriod2_Works()
|
||||
{
|
||||
var iqr = new Iqr(2);
|
||||
Assert.Equal("Iqr(2)", iqr.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// B) Basic Calculation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class IqrBasicTests
|
||||
{
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
var result = iqr.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.IsType<TValue>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_LastAccessible()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.Equal(0.0, iqr.Last.Value, 0); // single value → IQR = 0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantSeries_IqrIsZero()
|
||||
{
|
||||
var iqr = new Iqr(10);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 42.0));
|
||||
}
|
||||
Assert.Equal(0.0, iqr.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_KnownValues_CorrectIqr()
|
||||
{
|
||||
// Window: {1, 2, 3, 4, 5} → sorted: [1,2,3,4,5]
|
||||
// Q1: rank = 0.25*4 = 1.0 → value[1] = 2.0
|
||||
// Q3: rank = 0.75*4 = 3.0 → value[3] = 4.0
|
||||
// IQR = 4.0 - 2.0 = 2.0
|
||||
var iqr = new Iqr(5);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(2.0, iqr.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_KnownValues_Interpolation()
|
||||
{
|
||||
// Window: {1, 2, 3, 4} → sorted: [1,2,3,4]
|
||||
// Q1: rank = 0.25*3 = 0.75 → 1 + 0.75*(2-1) = 1.75
|
||||
// Q3: rank = 0.75*3 = 2.25 → 3 + 0.25*(4-3) = 3.25
|
||||
// IQR = 3.25 - 1.75 = 1.5
|
||||
var iqr = new Iqr(4);
|
||||
for (int i = 1; i <= 4; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(1.5, iqr.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TwoValues_CorrectIqr()
|
||||
{
|
||||
// Window: {10, 20} → sorted: [10,20]
|
||||
// Q1: rank = 0.25*1 = 0.25 → 10 + 0.25*(20-10) = 12.5
|
||||
// Q3: rank = 0.75*1 = 0.75 → 10 + 0.75*(20-10) = 17.5
|
||||
// IQR = 17.5 - 12.5 = 5.0
|
||||
var iqr = new Iqr(2);
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 10.0));
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 20.0));
|
||||
Assert.Equal(5.0, iqr.Last.Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// C) State + Bar Correction (critical)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class IqrStateCorrectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsNew_True_AdvancesState()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 10.0), isNew: true);
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 20.0), isNew: true);
|
||||
double afterTwo = iqr.Last.Value;
|
||||
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 30.0), isNew: true);
|
||||
double afterThree = iqr.Last.Value;
|
||||
|
||||
// Three values should produce different IQR than two
|
||||
Assert.NotEqual(afterTwo, afterThree);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_Rewrites()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
double before = iqr.Last.Value;
|
||||
|
||||
// Correct last bar with same value
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 5.0), isNew: false);
|
||||
Assert.Equal(before, iqr.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_DifferentValue_ChangesResult()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
// Feed values where Q1/Q3 region includes the last bar
|
||||
double[] vals = [10, 20, 30, 40, 50];
|
||||
for (int i = 0; i < vals.Length; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, vals[i]));
|
||||
}
|
||||
double before = iqr.Last.Value; // sorted [10,20,30,40,50] → Q1=20, Q3=40, IQR=20
|
||||
|
||||
// Correct last bar (50) with value that shifts Q3 → should change IQR
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 25.0), isNew: false);
|
||||
// sorted [10,20,25,30,40] → Q1=15 or 20, Q3=35 or 30, IQR differs
|
||||
Assert.NotEqual(before, iqr.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreState()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
double original = iqr.Last.Value;
|
||||
|
||||
// Multiple corrections
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 99.0), isNew: false);
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 5.0), isNew: false);
|
||||
Assert.Equal(original, iqr.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.True(iqr.IsHot);
|
||||
|
||||
iqr.Reset();
|
||||
Assert.False(iqr.IsHot);
|
||||
Assert.Equal(default, iqr.Last);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// D) Warmup/Convergence
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class IqrWarmupTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsHot_FlipsWhenBufferFull()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
Assert.False(iqr.IsHot);
|
||||
}
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 4));
|
||||
Assert.True(iqr.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_EqualsToPeriod()
|
||||
{
|
||||
var iqr = new Iqr(20);
|
||||
Assert.Equal(20, iqr.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleValue_IqrIsZero()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 42.0));
|
||||
Assert.Equal(0.0, iqr.Last.Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// E) Robustness (critical)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class IqrRobustnessTests
|
||||
{
|
||||
[Fact]
|
||||
public void NaN_UsesLastValid()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
_ = iqr.Last.Value;
|
||||
|
||||
// Feed NaN — should substitute last valid, IQR remains stable
|
||||
iqr.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(iqr.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_UsesLastValid()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
|
||||
iqr.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(iqr.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeInfinity_UsesLastValid()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
|
||||
iqr.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
|
||||
Assert.True(double.IsFinite(iqr.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchNaN_NoPropagation()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i % 2 == 0 ? double.NaN : (double)i));
|
||||
}
|
||||
Assert.True(double.IsFinite(iqr.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IqrAlwaysNonNegative()
|
||||
{
|
||||
var iqr = new Iqr(10);
|
||||
var rng = new GBM();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var bar = rng.Next();
|
||||
iqr.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(iqr.Last.Value >= 0.0, $"IQR was negative at bar {i}: {iqr.Last.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// F) Consistency (critical)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class IqrConsistencyTests
|
||||
{
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesStreaming()
|
||||
{
|
||||
int period = 10;
|
||||
int bars = 100;
|
||||
var rng = new GBM();
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < bars; i++)
|
||||
{
|
||||
var bar = rng.Next();
|
||||
source.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Streaming
|
||||
var streaming = new Iqr(period);
|
||||
var streamResults = new double[bars];
|
||||
for (int i = 0; i < bars; i++)
|
||||
{
|
||||
streaming.Update(source[i]);
|
||||
streamResults[i] = streaming.Last.Value;
|
||||
}
|
||||
|
||||
// Batch
|
||||
var batchSeries = Iqr.Batch(source, period);
|
||||
|
||||
for (int i = period - 1; i < bars; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], batchSeries[i].Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanCalc_MatchesStreaming()
|
||||
{
|
||||
int period = 10;
|
||||
int bars = 100;
|
||||
var rng = new GBM();
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < bars; i++)
|
||||
{
|
||||
var bar = rng.Next();
|
||||
source.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Streaming
|
||||
var streaming = new Iqr(period);
|
||||
var streamResults = new double[bars];
|
||||
for (int i = 0; i < bars; i++)
|
||||
{
|
||||
streaming.Update(source[i]);
|
||||
streamResults[i] = streaming.Last.Value;
|
||||
}
|
||||
|
||||
// Span
|
||||
var spanOutput = new double[bars];
|
||||
Iqr.Batch(source.Values, spanOutput.AsSpan(), period);
|
||||
|
||||
for (int i = period - 1; i < bars; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], spanOutput[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventBased_MatchesStreaming()
|
||||
{
|
||||
int period = 10;
|
||||
int bars = 50;
|
||||
var rng = new GBM();
|
||||
var source = new TSeries();
|
||||
|
||||
// Event-based: subscribe to source
|
||||
var eventIqr = new Iqr(source, period);
|
||||
|
||||
// Streaming manual
|
||||
var manualIqr = new Iqr(period);
|
||||
|
||||
for (int i = 0; i < bars; i++)
|
||||
{
|
||||
var bar = rng.Next();
|
||||
var tv = new TValue(bar.Time, bar.Close);
|
||||
manualIqr.Update(tv);
|
||||
source.Add(tv);
|
||||
}
|
||||
|
||||
Assert.Equal(manualIqr.Last.Value, eventIqr.Last.Value, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// G) Span API Tests
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class IqrSpanTests
|
||||
{
|
||||
[Fact]
|
||||
public void Span_MismatchedLengths_ThrowsArgumentException()
|
||||
{
|
||||
var source = new double[10];
|
||||
var output = new double[5];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Iqr.Batch(source.AsSpan(), output.AsSpan(), 5));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Span_InvalidPeriod_ThrowsArgumentException()
|
||||
{
|
||||
var source = new double[10];
|
||||
var output = new double[10];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Iqr.Batch(source.AsSpan(), output.AsSpan(), 1));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Span_EmptyInput_NoException()
|
||||
{
|
||||
var source = ReadOnlySpan<double>.Empty;
|
||||
var output = Span<double>.Empty;
|
||||
Iqr.Batch(source, output, 5);
|
||||
Assert.True(true); // S2699 — confirms no exception was thrown
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Span_LargeData_NoStackOverflow()
|
||||
{
|
||||
int len = 10_000;
|
||||
var source = new double[len];
|
||||
var output = new double[len];
|
||||
var rng = new GBM();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var bar = rng.Next();
|
||||
source[i] = bar.Close;
|
||||
}
|
||||
Iqr.Batch(source.AsSpan(), output.AsSpan(), 50);
|
||||
Assert.True(double.IsFinite(output[len - 1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Span_HandlesNaN()
|
||||
{
|
||||
var source = new double[] { 1, 2, double.NaN, 4, 5, 6, 7, 8, 9, 10 };
|
||||
var output = new double[10];
|
||||
Iqr.Batch(source.AsSpan(), output.AsSpan(), 5);
|
||||
// NaN is stored as-is in span batch (no last-valid substitution in static batch)
|
||||
// but output should still be finite for most values
|
||||
Assert.True(double.IsFinite(output[9]));
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// H) Chainability
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class IqrEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pub_Fires()
|
||||
{
|
||||
var iqr = new Iqr(5);
|
||||
bool fired = false;
|
||||
iqr.Pub += (object? _, in TValueEventArgs _) => fired = true;
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 42.0));
|
||||
Assert.True(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventChaining_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var iqr = new Iqr(source, 5);
|
||||
|
||||
source.Add(new TValue(DateTime.UtcNow, 10.0));
|
||||
source.Add(new TValue(DateTime.UtcNow, 20.0));
|
||||
source.Add(new TValue(DateTime.UtcNow, 30.0));
|
||||
|
||||
Assert.True(double.IsFinite(iqr.Last.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for IQR — self-consistency and mathematical properties.
|
||||
/// No external library implements rolling IQR with linear interpolation,
|
||||
/// so validation is based on known mathematical properties.
|
||||
/// </summary>
|
||||
public class IqrValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstantSeries_IqrIsZero()
|
||||
{
|
||||
var iqr = new Iqr(20);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
}
|
||||
Assert.Equal(0.0, iqr.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinearSequence_KnownIqr()
|
||||
{
|
||||
// Window of {1,2,3,...,20} → sorted [1..20]
|
||||
// Q1: rank = 0.25*19 = 4.75 → 5 + 0.75*(6-5) = 5.75
|
||||
// Q3: rank = 0.75*19 = 14.25 → 15 + 0.25*(16-15) = 15.25
|
||||
// IQR = 15.25 - 5.75 = 9.5
|
||||
var iqr = new Iqr(20);
|
||||
for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(9.5, iqr.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SymmetricDistribution_IqrSymmetric()
|
||||
{
|
||||
// Values: {-5,-4,-3,-2,-1,0,1,2,3,4,5} → sorted [-5..5], n=11
|
||||
// Q1: rank = 0.25*10 = 2.5 → -3 + 0.5*(-2-(-3)) = -2.5
|
||||
// Q3: rank = 0.75*10 = 7.5 → 3 + 0.5*(4-3) = 2.5 (wait, index 7=2, index 8=3)
|
||||
// Actually: sorted = [-5,-4,-3,-2,-1,0,1,2,3,4,5]
|
||||
// index: 0 1 2 3 4 5 6 7 8 9 10
|
||||
// Q1: rank=2.5 → sorted[2] + 0.5*(sorted[3]-sorted[2]) = -3 + 0.5*1 = -2.5
|
||||
// Q3: rank=7.5 → sorted[7] + 0.5*(sorted[8]-sorted[7]) = 2 + 0.5*1 = 2.5
|
||||
// IQR = 2.5 - (-2.5) = 5.0
|
||||
var iqr = new Iqr(11);
|
||||
for (int i = -5; i <= 5; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, i));
|
||||
}
|
||||
Assert.Equal(5.0, iqr.Last.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_SameInputSameOutput()
|
||||
{
|
||||
int period = 10;
|
||||
var iqr1 = new Iqr(period);
|
||||
var iqr2 = new Iqr(period);
|
||||
|
||||
var rng1 = new GBM(seed: 42);
|
||||
var rng2 = new GBM(seed: 42);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var bar1 = rng1.Next();
|
||||
var bar2 = rng2.Next();
|
||||
iqr1.Update(new TValue(bar1.Time, bar1.Close));
|
||||
iqr2.Update(new TValue(bar2.Time, bar2.Close));
|
||||
}
|
||||
|
||||
Assert.Equal(iqr1.Last.Value, iqr2.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchVsStreaming_Match()
|
||||
{
|
||||
int period = 10;
|
||||
int bars = 100;
|
||||
var rng = new GBM();
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < bars; i++)
|
||||
{
|
||||
var bar = rng.Next();
|
||||
source.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Streaming
|
||||
var streaming = new Iqr(period);
|
||||
double lastStreaming = 0;
|
||||
for (int i = 0; i < bars; i++)
|
||||
{
|
||||
streaming.Update(source[i]);
|
||||
lastStreaming = streaming.Last.Value;
|
||||
}
|
||||
|
||||
// Batch
|
||||
var batchSeries = Iqr.Batch(source, period);
|
||||
Assert.Equal(lastStreaming, batchSeries[bars - 1].Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanVsStreaming_Match()
|
||||
{
|
||||
int period = 10;
|
||||
int bars = 100;
|
||||
var rng = new GBM();
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < bars; i++)
|
||||
{
|
||||
var bar = rng.Next();
|
||||
source.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// Streaming
|
||||
var streaming = new Iqr(period);
|
||||
var streamResults = new double[bars];
|
||||
for (int i = 0; i < bars; i++)
|
||||
{
|
||||
streaming.Update(source[i]);
|
||||
streamResults[i] = streaming.Last.Value;
|
||||
}
|
||||
|
||||
// Span
|
||||
var spanOutput = new double[bars];
|
||||
Iqr.Batch(source.Values, spanOutput.AsSpan(), period);
|
||||
|
||||
for (int i = period - 1; i < bars; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], spanOutput[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateBridge_ReturnsIndicatorAndResults()
|
||||
{
|
||||
int period = 10;
|
||||
var rng = new GBM();
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var bar = rng.Next();
|
||||
source.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var (results, indicator) = Iqr.Calculate(source, period);
|
||||
Assert.Equal(50, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IqrNonNegative_ForAllInputs()
|
||||
{
|
||||
var iqr = new Iqr(20);
|
||||
var rng = new GBM();
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var bar = rng.Next();
|
||||
iqr.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(iqr.Last.Value >= 0.0, $"IQR negative at bar {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutlierResistance_IqrLessThanRange()
|
||||
{
|
||||
// IQR should always be <= full range for any window
|
||||
var iqr = new Iqr(10);
|
||||
var values = new double[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 100 };
|
||||
double min = double.MaxValue, max = double.MinValue;
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
iqr.Update(new TValue(DateTime.UtcNow, values[i]));
|
||||
if (values[i] < min) { min = values[i]; }
|
||||
if (values[i] > max) { max = values[i]; }
|
||||
}
|
||||
double range = max - min;
|
||||
Assert.True(iqr.Last.Value <= range, $"IQR ({iqr.Last.Value}) > range ({range})");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// IQR: Interquartile Range
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Interquartile Range measures the spread of the middle 50% of data in a rolling window.
|
||||
/// It equals Q3 (75th percentile) minus Q1 (25th percentile), providing a robust measure
|
||||
/// of statistical dispersion that is resistant to outliers.
|
||||
///
|
||||
/// Calculation:
|
||||
/// 1. Maintain a sorted window of the last 'Period' values.
|
||||
/// 2. Compute Q1 (25th percentile) and Q3 (75th percentile) via linear interpolation.
|
||||
/// 3. IQR = Q3 - Q1.
|
||||
///
|
||||
/// Percentile interpolation (matching Pine/Excel PERCENTILE.INC):
|
||||
/// rank = (p / 100) * (n - 1)
|
||||
/// result = value[floor(rank)] + frac(rank) * (value[ceil(rank)] - value[floor(rank)])
|
||||
///
|
||||
/// Complexity:
|
||||
/// Update: O(N) due to sorted buffer maintenance (BinarySearch + Array.Copy).
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Iqr : 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 double _p_lastValidValue;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new IQR indicator with the specified period.</summary>
|
||||
/// <param name="period">The size of the rolling window (must be >= 2).</param>
|
||||
public Iqr(int period)
|
||||
{
|
||||
if (period < 2)
|
||||
{
|
||||
throw new ArgumentException("Period must be at least 2.", nameof(period));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_buffer = new RingBuffer(period);
|
||||
_sortedBuffer = new double[period];
|
||||
_p_sortedBuffer = new double[period];
|
||||
Name = $"Iqr({period})";
|
||||
WarmupPeriod = period;
|
||||
_handler = Handle;
|
||||
}
|
||||
|
||||
public Iqr(ITValuePublisher source, int period) : this(period)
|
||||
{
|
||||
_source = source;
|
||||
source.Pub += _handler;
|
||||
}
|
||||
|
||||
public Iqr(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;
|
||||
}
|
||||
|
||||
/// <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 iqr;
|
||||
|
||||
if (count < 2)
|
||||
{
|
||||
iqr = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
double q1 = ComputePercentile(_sortedBuffer, count, 25.0);
|
||||
double q3 = ComputePercentile(_sortedBuffer, count, 75.0);
|
||||
iqr = q3 - q1;
|
||||
}
|
||||
|
||||
Last = new TValue(input.Time, iqr);
|
||||
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);
|
||||
_lastValidValue = 0;
|
||||
_p_lastValidValue = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>Computes percentile via linear interpolation on a sorted span.</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 IQR series from source.</summary>
|
||||
public static TSeries Batch(TSeries source, int period)
|
||||
{
|
||||
var iqr = new Iqr(period);
|
||||
return iqr.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>Computes IQR in-place over a span.</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 < 2)
|
||||
{
|
||||
throw new ArgumentException("Period must be at least 2.", 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> 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 — substitute last valid value
|
||||
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++;
|
||||
|
||||
if (count < 2)
|
||||
{
|
||||
output[i] = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
double q1 = ComputePercentileSpan(sortedBuf, count, 25.0);
|
||||
double q3 = ComputePercentileSpan(sortedBuf, count, 75.0);
|
||||
output[i] = q3 - q1;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedSorted);
|
||||
ArrayPool<double>.Shared.Return(rentedWindow);
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries Results, Iqr Indicator) Calculate(TSeries source, int period)
|
||||
{
|
||||
var indicator = new Iqr(period);
|
||||
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,140 @@
|
||||
# IQR: Interquartile Range
|
||||
|
||||
> "The median is the most important statistic, and the interquartile range is the second most important." — John Tukey
|
||||
|
||||
## Introduction
|
||||
|
||||
The Interquartile Range measures the spread of the middle 50% of a sorted dataset within a rolling window. By subtracting the 25th percentile (Q1) from the 75th percentile (Q3), IQR provides a robust dispersion metric that ignores outliers in both tails. Unlike standard deviation, which squares deviations and amplifies extremes, IQR tells you how wide the "typical" price band actually is.
|
||||
|
||||
## Historical Context
|
||||
|
||||
John Tukey formalized the IQR in the 1970s as part of his Exploratory Data Analysis (EDA) framework. The concept traces back to Francis Galton's work on percentiles in the 1880s. In finance, IQR serves as a building block for outlier detection (the "1.5 IQR rule" for Tukey fences), robust volatility estimation, and distribution shape analysis. Most implementations of rolling IQR require sorting, making it O(N log N) per bar with a naive approach; this implementation uses incremental sorted buffer maintenance at O(N) per update.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Sorted Buffer Maintenance
|
||||
|
||||
The indicator maintains a sorted array alongside a ring buffer. On each new bar:
|
||||
|
||||
1. Remove the oldest value from the sorted array via binary search + shift.
|
||||
2. Insert the new value at the correct sorted position via binary search + shift.
|
||||
3. Compute Q1 and Q3 from the sorted array via linear interpolation.
|
||||
|
||||
This avoids a full sort on every update, reducing complexity from O(N log N) to O(N) per bar.
|
||||
|
||||
### 2. Percentile Interpolation
|
||||
|
||||
Percentile is computed using the PERCENTILE.INC method (matching Excel and PineScript):
|
||||
|
||||
$$\text{rank} = \frac{p}{100} \times (n - 1)$$
|
||||
|
||||
$$\text{percentile} = x_{\lfloor r \rfloor} + (r - \lfloor r \rfloor) \times (x_{\lceil r \rceil} - x_{\lfloor r \rfloor})$$
|
||||
|
||||
where $p$ is the desired percentile (25 or 75), $n$ is the window size, and $x_i$ is the $i$-th value in the sorted window.
|
||||
|
||||
### 3. Bar Correction
|
||||
|
||||
The sorted buffer state is backed up before each new bar. On `isNew=false`, the backup is restored, the old newest value is removed, and the corrected value is inserted. This supports Quantower's bar-correction protocol.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### IQR Definition
|
||||
|
||||
$$\text{IQR} = Q_3 - Q_1$$
|
||||
|
||||
where:
|
||||
|
||||
- $Q_1 = P_{25}$ (25th percentile)
|
||||
- $Q_3 = P_{75}$ (75th percentile)
|
||||
|
||||
### Properties
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Range | $[0, \infty)$ |
|
||||
| Constant series | 0 |
|
||||
| Symmetric distribution | $Q_3 - Q_1$ equals twice the median absolute deviation from median |
|
||||
| Outlier resistance | Breakdown point = 25% (ignores up to 25% contamination per tail) |
|
||||
|
||||
### Relationship to Other Measures
|
||||
|
||||
- **Standard Deviation**: For normal data, $\text{IQR} \approx 1.35 \times \sigma$
|
||||
- **Median Absolute Deviation**: $\text{MAD} \approx \text{IQR} / 1.349$ for normal data
|
||||
- **Tukey Fences**: Outlier bounds at $Q_1 - 1.5 \times \text{IQR}$ and $Q_3 + 1.5 \times \text{IQR}$
|
||||
|
||||
## Usage
|
||||
|
||||
```csharp
|
||||
// Streaming
|
||||
var iqr = new Iqr(period: 20);
|
||||
TValue result = iqr.Update(new TValue(time, price));
|
||||
|
||||
// Batch
|
||||
TSeries results = Iqr.Batch(source, period: 20);
|
||||
|
||||
// Span (zero-allocation batch path)
|
||||
Iqr.Batch(sourceSpan, outputSpan, period: 20);
|
||||
|
||||
// Event-driven chaining
|
||||
var iqr = new Iqr(sourceIndicator, period: 20);
|
||||
|
||||
// Calculate bridge (returns results + indicator for continued streaming)
|
||||
var (results, indicator) = Iqr.Calculate(source, period: 20);
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
|
||||
| IQR Behavior | Market Signal |
|
||||
|-------------|---------------|
|
||||
| Rising IQR | Increasing price dispersion; volatility expansion |
|
||||
| Falling IQR | Decreasing price dispersion; volatility contraction |
|
||||
| IQR near zero | Price clustering; potential breakout setup |
|
||||
| IQR spike | Sudden distribution widening; possible regime change |
|
||||
|
||||
### Outlier Detection
|
||||
|
||||
Values outside $[Q_1 - 1.5 \times \text{IQR},\ Q_3 + 1.5 \times \text{IQR}]$ are statistical outliers. This can flag unusual price moves without sensitivity to extreme values that distort standard deviation.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Update complexity | O(N) per bar (binary search + array shift) |
|
||||
| Memory | O(N) — sorted buffer + ring buffer + backup |
|
||||
| SIMD potential | None (sorting is inherently sequential) |
|
||||
| Warmup period | Equal to period |
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Criterion | Score (1-10) |
|
||||
|-----------|:---:|
|
||||
| Outlier resistance | 9 |
|
||||
| Computational efficiency | 6 |
|
||||
| Interpretability | 9 |
|
||||
| Parameter sensitivity | 3 |
|
||||
| Lag | 5 |
|
||||
|
||||
## Validation
|
||||
|
||||
No external library implements a streaming rolling IQR with linear interpolation percentiles. Validation is based on:
|
||||
|
||||
- **Known-value tests**: Hand-computed Q1, Q3 for small windows
|
||||
- **Constant series**: IQR = 0
|
||||
- **Linear sequence**: Exact IQR computable analytically
|
||||
- **Batch vs streaming consistency**: All four API modes produce identical results
|
||||
- **Non-negativity**: IQR >= 0 for all inputs
|
||||
- **IQR <= range**: Always bounded by full window range
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Period too small** (< 5): Percentile interpolation becomes unstable with very few points. Minimum period is 2, but practical use requires >= 10.
|
||||
2. **Confusing IQR with standard deviation**: IQR measures range of middle 50%; it is not a drop-in replacement for standard deviation in formulas expecting variance-based measures.
|
||||
3. **Ignoring the window effect**: As the window slides, old outliers dropping out can cause sudden IQR changes that look like false signals.
|
||||
4. **Not accounting for NaN**: This implementation substitutes last-valid values for NaN/Infinity in streaming mode, but the static span batch does not (NaN propagates as-is in sorted position).
|
||||
5. **Over-relying on the 1.5 IQR rule**: The Tukey fence is calibrated for roughly normal distributions. Heavily skewed financial data may need adjusted multipliers.
|
||||
|
||||
## References
|
||||
|
||||
- Tukey, J.W. (1977). *Exploratory Data Analysis*. Addison-Wesley.
|
||||
- Galton, F. (1885). "Statistics by Intercomparison." *Philosophical Magazine*.
|
||||
- Frigge, M., Hoaglin, D.C., Iglewicz, B. (1989). "Some Implementations of the Boxplot." *The American Statistician*, 43(1), 50-54.
|
||||
Reference in New Issue
Block a user