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:
Miha Kralj
2026-02-15 21:59:03 -08:00
parent dfeb23bf3d
commit 09ffd31a40
32 changed files with 4607 additions and 158 deletions
+2 -2
View File
@@ -20,9 +20,9 @@
"refs",
"search",
"explore",
"diag",
"__unlock_csharp_analysis__",
"understand",
"__unlock_csharp_analysis__"
"diag"
],
"disabled": false
}
+20 -17
View File
@@ -6,6 +6,7 @@
![GitHub last commit](https://img.shields.io/github/last-commit/mihakralj/QuanTAlib)
[![Nuget](https://img.shields.io/nuget/dt/QuanTAlib?style=flat-square)](https://www.nuget.org/packages/QuanTAlib/)
[![.NET](https://img.shields.io/badge/.NET-8.0%20|%2010.0-blue?style=flat-square)](https://dotnet.microsoft.com/en-us/download/dotnet)
[![Indicators](https://img.shields.io/badge/Indicators-265_|_387-blue?style=flat-square)](lib/_index.md)
Static code analysis provided by [ndepend](https://www.ndepend.com/):
[![Files](ndepend/badges/files.svg)](ndepend/ndependout/ndependreport.html)
@@ -31,23 +32,25 @@ TA libraries face a fundamental choice: accept approximations for simplicity OR
## Indicators
| Category | What It Measures | Representative Indicators |
| -------- | ---------------- | ------------------------- |
| [**Trends (FIR)**](lib/trends_FIR/_index.md) | Finite Impulse Response moving averages | SMA, WMA, HMA, ALMA, TRIMA, LSMA, EPMA |
| [**Trends (IIR)**](lib/trends_IIR/_index.md) | Infinite Impulse Response moving averages | EMA, DEMA, TEMA, T3, JMA, KAMA, VIDYA |
| [**Filters**](lib/filters/_index.md) | Signal processing and noise reduction filters | Bessel, Butterworth, Gaussian, Savitzky-Golay, Ehlers Super Smoother |
| [**Oscillators**](lib/oscillators/_index.md) | Indicators that fluctuate around a center line | RSI, MACD, Stochastic, AO, APO, CCI, Ultimate Oscillator |
| [**Dynamics**](lib/dynamics/_index.md) | Trend strength and direction indicators | ADX, Aroon, SuperTrend, Vortex, Chop, Ichimoku |
| [**Momentum**](lib/momentum/_index.md) | Speed and magnitude of price changes | Momentum, ROC, Velocity, RSX, Qstick, KDJ |
| [**Volatility**](lib/volatility/_index.md) | Size and variability of price movements | ATR, Bollinger Band Width, Historical Volatility, True Range |
| [**Volume**](lib/volume/_index.md) | Trading activity and price-volume relationships | OBV, VWAP, MFI, ADL, CMF, TVI, Force Index |
| [**Statistics**](lib/statistics/_index.md) | Statistical measures and tests | Correlation, Variance, StdDev, Skewness, Kurtosis, Z-Score |
| [**Channels**](lib/channels/_index.md) | Price boundaries and range definitions | Bollinger Bands, Keltner Channels, Donchian Channels |
| [**Cycles**](lib/cycles/_index.md) | Cycle analysis and signal processing | Hilbert Transform, Homodyne, Phasor, Ehlers Sine Wave |
| [**Reversals**](lib/reversals/_index.md) | Pattern recognition and reversal detection | Pivot Points, Fractals, Swings, Pivot Components |
| [**Forecasts**](lib/forecasts/_index.md) | Predictive indicators and projections | Time Series Forecast, AFIRMA, Chande Forecast Oscillator |
| [**Errors**](lib/errors/_index.md) | Error metrics and loss functions | RMSE, MAE, MAPE, SMAPE, MASE, R-Squared |
| [**Numerics**](lib/numerics/_index.md) | Mathematical transformations | Log, Exp, Sqrt, Tanh, ReLU, Sigmoid |
| Category | Count | What It Measures | Representative Indicators |
| -------- | :---: | ---------------- | ------------------------- |
| [**Trends (FIR)**](lib/trends_FIR/_index.md) | 17 | Finite Impulse Response moving averages | SMA, WMA, HMA, ALMA, TRIMA, LSMA, EPMA |
| [**Trends (IIR)**](lib/trends_IIR/_index.md) | 23 | Infinite Impulse Response moving averages | EMA, DEMA, TEMA, T3, JMA, KAMA, VIDYA |
| [**Filters**](lib/filters/_index.md) | 18 | Signal processing and noise reduction filters | Bessel, Butterworth, Gaussian, Savitzky-Golay, Ehlers Super Smoother |
| [**Oscillators**](lib/oscillators/_index.md) | 19 | Indicators that fluctuate around a center line | RSI, MACD, Stochastic, AO, APO, CCI, Ultimate Oscillator |
| [**Dynamics**](lib/dynamics/_index.md) | 18 | Trend strength and direction indicators | ADX, Aroon, SuperTrend, Vortex, Chop, Ichimoku |
| [**Momentum**](lib/momentum/_index.md) | 16 | Speed and magnitude of price changes | Momentum, ROC, Velocity, RSX, Qstick, KDJ |
| [**Volatility**](lib/volatility/_index.md) | 26 | Size and variability of price movements | ATR, Bollinger Band Width, Historical Volatility, True Range |
| [**Volume**](lib/volume/_index.md) | 26 | Trading activity and price-volume relationships | OBV, VWAP, MFI, ADL, CMF, TVI, Force Index |
| [**Statistics**](lib/statistics/_index.md) | 30 | Statistical measures and tests | Correlation, Variance, StdDev, Skewness, Kurtosis, Z-Score |
| [**Channels**](lib/channels/_index.md) | 23 | Price boundaries and range definitions | Bollinger Bands, Keltner Channels, Donchian Channels |
| [**Cycles**](lib/cycles/_index.md) | 14 | Cycle analysis and signal processing | Hilbert Transform, Homodyne, Phasor, Ehlers Sine Wave |
| [**Reversals**](lib/reversals/_index.md) | 12 | Pattern recognition and reversal detection | Pivot Points, Fractals, Swings, Pivot Components |
| [**Forecasts**](lib/forecasts/_index.md) | 1 | Predictive indicators and projections | Time Series Forecast, AFIRMA, Chande Forecast Oscillator |
| [**Errors**](lib/errors/_index.md) | 26 | Error metrics and loss functions | RMSE, MAE, MAPE, SMAPE, MASE, R-Squared |
| [**Numerics**](lib/numerics/_index.md) | 15 | Mathematical transformations | Log, Exp, Sqrt, Tanh, ReLU, Sigmoid |
**[Browse all 284 indicators →](lib/_index.md)**
## Quick Start
+3
View File
@@ -214,6 +214,9 @@ Mathematical and statistical computations on price series.
| [**COVARIANCE**](../lib/statistics/covariance/Covariance.md) | Covariance | Joint variability |
| [**ENTROPY**](../lib/statistics/entropy/Entropy.md) | Shannon Entropy | Normalized information entropy via histogram binning |
| [**GEOMEAN**](../lib/statistics/geomean/Geomean.md) | Geometric Mean | Rolling geometric mean via log-sum approach |
| [**HARMEAN**](../lib/statistics/harmean/Harmean.md) | Harmonic Mean | Rolling harmonic mean via reciprocal-sum approach |
| [**HURST**](../lib/statistics/hurst/Hurst.md) | Hurst Exponent | Long-range dependence via Rescaled Range (R/S) analysis |
| [**IQR**](../lib/statistics/iqr/Iqr.md) | Interquartile Range | Robust dispersion measure (Q3 - Q1) |
| [**GRANGER**](../lib/statistics/granger/Granger.md) | Granger Causality | F-statistic testing if X helps predict Y |
| [**LINREG**](../lib/statistics/linreg/LinReg.md) | Linear Regression | Best-fit line |
| [**MEDIAN**](../lib/statistics/median/Median.md) | Rolling Median | 50th percentile |
+6 -3
View File
@@ -122,12 +122,12 @@ No external reference exists. Implementation verified through unit tests, edge c
| **Garman-Klass Volatility** | [Gkv](../lib/volatility/gkv/Gkv.md) | - | - | - | - |
| **Gaussian Filter** | [Gauss](../lib/filters/gauss/Gauss.md) | - | - | - | ❔ |
| **Gaussian-Weighted MA** | Gwma | - | - | - | - |
| **Geometric Mean** | Geomean | - | - | - | - |
| **Granger Causality Test** | Granger | - | - | - | - |
| **Geometric Mean** | [Geomean](../lib/statistics/geomean/Geomean.md) | - | - | - | - |
| **Harmonic Mean** | [Harmean](../lib/statistics/harmean/Harmean.md) | - | - | - | - |
| **Granger Causality Test** | [Granger](../lib/statistics/granger/Granger.md) | - | - | - | - |
| **Hamming Window MA** | Hamma | - | - | - | ❔ |
| **Hann FIR Filter** | [Hann](../lib/filters/hann/Hann.md) | - | - | - | - |
| **Hanning Window MA** | Hanma | - | - | - | ❔ |
| **Harmonic Mean** | Harmean | - | - | - | - |
| **High-Low Volatility (Parkinson)** | [Hlv](../lib/volatility/hlv/Hlv.md) | - | - | - | - |
| **Highest value** | [Highest](../lib/numerics/highest/Highest.md) | ✔️ | ✔️ | - | - |
| **Hilbert Transform Dominant Cycle Period** | [HtDcPeriod](../lib/cycles/ht_dcperiod/ht_dcperiod.md) | ✔️ | - | - | - |
@@ -315,6 +315,9 @@ No external reference exists. Implementation verified through unit tests, edge c
| **Covariance** | [Covariance](../lib/statistics/covariance/Covariance.md) | - | - | - | - |
| **Entropy (Shannon)** | [Entropy](../lib/statistics/entropy/Entropy.md) | - | - | - | - |
| **Geometric Mean** | [Geomean](../lib/statistics/geomean/Geomean.md) | ✔️ | - | - | - |
| **Harmonic Mean** | [Harmean](../lib/statistics/harmean/Harmean.md) | ✔️ | - | - | - |
| **Hurst Exponent** | [Hurst](../lib/statistics/hurst/Hurst.md) | - | - | - | - |
| **Interquartile Range** | [Iqr](../lib/statistics/iqr/Iqr.md) | - | - | - | - |
| **Granger Causality** | [Granger](../lib/statistics/granger/Granger.md) | - | - | - | - |
| **Median (Statistical)** | [Median](../lib/statistics/median/Median.md) | ✔️ | - | - | - |
| **Skewness** | [Skew](../lib/statistics/skew/Skew.md) | ✔️ | - | - | - |
+14 -14
View File
@@ -7,19 +7,19 @@
| [Trends (FIR)](trends_FIR/_index.md) | 17 | Finite Impulse Response moving averages |
| [Trends (IIR)](trends_IIR/_index.md) | 23 | Infinite Impulse Response moving averages |
| [Filters](filters/_index.md) | 18 | Signal processing filters |
| [Oscillators](oscillators/_index.md) | 18 | Indicators that fluctuate around a center line |
| [Dynamics](dynamics/_index.md) | 16 | Trend strength and direction indicators |
| [Momentum](momentum/_index.md) | 17 | Momentum-based indicators |
| [Oscillators](oscillators/_index.md) | 19 | Indicators that fluctuate around a center line |
| [Dynamics](dynamics/_index.md) | 18 | Trend strength and direction indicators |
| [Momentum](momentum/_index.md) | 16 | Momentum-based indicators |
| [Volatility](volatility/_index.md) | 26 | Volatility estimators and indicators |
| [Volume](volume/_index.md) | 26 | Volume-based indicators |
| [Statistics](statistics/_index.md) | 31 | Statistical measures and tests |
| [Channels](channels/_index.md) | 22 | Price channels and bands |
| [Cycles](cycles/_index.md) | 15 | Cycle analysis and signal processing |
| [Reversals](reversals/_index.md) | 9 | Pattern recognition and reversal detection |
| [Statistics](statistics/_index.md) | 30 | Statistical measures and tests |
| [Channels](channels/_index.md) | 23 | Price channels and bands |
| [Cycles](cycles/_index.md) | 14 | Cycle analysis and signal processing |
| [Reversals](reversals/_index.md) | 12 | Pattern recognition and reversal detection |
| [Forecasts](forecasts/_index.md) | 1 | Predictive indicators |
| [Errors](errors/_index.md) | 26 | Error metrics and loss functions |
| [Numerics](numerics/_index.md) | 15 | Mathematical transformations |
| **Total** | **278** | |
| **Total** | **284** | |
## All Indicators
@@ -42,7 +42,7 @@
| [AO](oscillators/ao/Ao.md) | Awesome Oscillator | Oscillators |
| [AOBV](volume/aobv/Aobv.md) | Archer On-Balance Volume | Volume |
| [APCHANNEL](channels/apchannel/Apchannel.md) | Andrews' Pitchfork | Channels |
| [APO](momentum/apo/Apo.md) | Absolute Price Oscillator | Momentum |
| [APO](oscillators/apo/Apo.md) | Absolute Price Oscillator | Oscillators |
| [APZ](channels/apz/Apz.md) | Adaptive Price Zone | Channels |
| [AROON](dynamics/aroon/Aroon.md) | Aroon | Dynamics |
| [AROONOSC](dynamics/aroonosc/Aroonosc.md) | Aroon Oscillator | Dynamics |
@@ -74,12 +74,12 @@
| [CFB](momentum/cfb/Cfb.md) | Composite Fractal Behavior | Momentum |
| [CFO](oscillators/cfo/Cfo.md) | Chande Forecast Oscillator | Oscillators |
| [CG](cycles/cg/Cg.md) | Center of Gravity | Cycles |
| CHANDELIER | Chandelier Exit | Reversals |
| [CHANDELIER](reversals/chandelier/Chandelier.md) | Chandelier Exit | Reversals |
| [CHANGE](numerics/change/Change.md) | Percentage Change | Numerics |
| [CHEBY1](filters/cheby1/Cheby1.md) | Chebyshev Type I | Filters |
| [CHEBY2](filters/cheby2/Cheby2.md) | Chebyshev Type II | Filters |
| [CHOP](dynamics/chop/Chop.md) | Choppiness Index | Dynamics |
| CKSTOP | Chande Kroll Stop | Reversals |
| [CKSTOP](reversals/ckstop/Ckstop.md) | Chande Kroll Stop | Reversals |
| [CMA](statistics/cma/Cma.md) | Cumulative Moving Average | Statistics |
| [CMF](volume/cmf/Cmf.md) | Chaikin Money Flow | Volume |
| [CMO](momentum/cmo/Cmo.md) | Chande Momentum Oscillator | Momentum |
@@ -154,7 +154,7 @@
| IFFT | Inverse Fast Fourier Transform | Numerics |
| [III](volume/iii/Iii.md) | Intraday Intensity Index | Volume |
| [IMI](dynamics/imi/Imi.md) | Intraday Momentum Index | Dynamics |
| IMPULSE | Elder Impulse System | Dynamics |
| [IMPULSE](dynamics/impulse/Impulse.md) | Elder Impulse System | Dynamics |
| [INERTIA](oscillators/inertia/Inertia.md) | Inertia | Oscillators |
| [IQR](statistics/iqr/Iqr.md) | Interquartile Range | Statistics |
| [JB](statistics/jb/Jb.md) | Jarque-Bera Test | Statistics |
@@ -217,7 +217,7 @@
| [PCHANNEL](channels/pchannel/Pchannel.md) | Price Channel | Channels |
| [PERCENTILE](statistics/percentile/Percentile.md) | Percentile | Statistics |
| [PGO](oscillators/pgo/Pgo.md) | Pretty Good Oscillator | Oscillators |
| [PHASOR](cycles/phasor/Phasor.md) | Phasor Analysis | Cycles |
| PHASOR | Phasor Analysis | Cycles |
| [PIVOT](reversals/pivot/Pivot.md) | Pivot Points (Classic) | Reversals |
| [PIVOTCAM](reversals/pivotcam/Pivotcam.md) | Camarilla Pivot Points | Reversals |
| [PIVOTDEM](reversals/pivotdem/Pivotdem.md) | DeMark Pivot Points | Reversals |
@@ -299,7 +299,7 @@
| [TR](volatility/tr/Tr.md) | True Range | Volatility |
| [TRIMA](trends_FIR/trima/Trima.md) | Triangular MA | Trends (FIR) |
| [TRIX](oscillators/trix/Trix.md) | Triple Exponential Average | Oscillators |
| TSF | Time Series Forecast | Statistics |
| TSF | Time Series Forecast | Trends (FIR) |
| [TSI](momentum/tsi/Tsi.md) | True Strength Index | Momentum |
| [TTM_LRC](channels/ttm_lrc/TtmLrc.md) | TTM Linear Regression Channel | Channels |
| [TTM_SCALPER](reversals/ttm_scalper/TtmScalper.md) | TTM Scalper Alert | Reversals |
+3 -3
View File
@@ -16,9 +16,9 @@ Statistical tools applied to price and returns. These indicators quantify relati
| [ENTROPY](entropy/Entropy.md) | Shannon Entropy | Measures uncertainty/randomness. Higher entropy = less predictable. |
| [GEOMEAN](geomean/Geomean.md) | Geometric Mean | nth root of product. Use for growth rates and ratios. |
| [GRANGER](granger/Granger.md) | Granger Causality | Tests if one series helps predict another. Not true causality. |
| HARMEAN | Harmonic Mean | Reciprocal of arithmetic mean of reciprocals. For rates/ratios. |
| HURST | Hurst Exponent | Long-term memory. H>0.5: trending. H<0.5: mean-reverting. |
| IQR | Interquartile Range | P75 - P25. Robust dispersion measure. |
| [HARMEAN](harmean/Harmean.md) | Harmonic Mean | Reciprocal of arithmetic mean of reciprocals. For rates/ratios. |
| [HURST](hurst/Hurst.md) | Hurst Exponent | Long-term memory. H>0.5: trending. H<0.5: mean-reverting. |
| [IQR](iqr/Iqr.md) | Interquartile Range | P75 - P25. Robust dispersion measure. |
| JB | Jarque-Bera Test | Normality test using skewness and kurtosis. |
| KENDALL | Kendall Rank Correlation | Ordinal association. Robust to outliers. |
| KURTOSIS | Kurtosis | Tail heaviness. High kurtosis = fat tails = more extreme events. |
@@ -0,0 +1,122 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class HarmeanIndicatorTests
{
[Fact]
public void HarmeanIndicator_Constructor_SetsDefaults()
{
var indicator = new HarmeanIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("HARMEAN - Harmonic Mean", indicator.Name);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(SourceType.Close, indicator.Source);
}
[Fact]
public void HarmeanIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new HarmeanIndicator { Period = 14 };
Assert.Equal(0, HarmeanIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void HarmeanIndicator_Initialize_CreatesInternalHarmean()
{
var indicator = new HarmeanIndicator { Period = 10 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
Assert.Equal("Harmean", indicator.LinesSeries[0].Name);
}
[Fact]
public void HarmeanIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new HarmeanIndicator { Period = 5 };
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 harmean = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(harmean));
Assert.True(harmean > 0, $"Harmonic mean should be positive, got {harmean}");
}
[Fact]
public void HarmeanIndicator_DifferentSourceTypes()
{
var indicator = new HarmeanIndicator { Period = 5, Source = SourceType.Open };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double harmean = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(harmean));
Assert.True(harmean > 0);
}
[Fact]
public void HarmeanIndicator_ShortName_IncludesPeriod()
{
var indicator = new HarmeanIndicator { Period = 20 };
Assert.Equal("Harmean 20", indicator.ShortName);
}
[Fact]
public void HarmeanIndicator_NewBar_UpdatesValue()
{
var indicator = new HarmeanIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add enough bars to warm up
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
_ = indicator.LinesSeries[0].GetValue(0);
// Add a new bar with a very different value
indicator.HistoricalData.AddBar(now.AddMinutes(10), 200, 210, 190, 205);
var newArgs = new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(newArgs);
double valueAfter = indicator.LinesSeries[0].GetValue(0);
// Value should change after adding a significantly different bar
Assert.True(double.IsFinite(valueAfter));
Assert.True(valueAfter > 0);
}
}
@@ -0,0 +1,60 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class HarmeanIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 2, 2000, 1, 0)]
public int Period { get; set; } = 14;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Harmean _harmean = null!;
private readonly LineSeries _series;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"Harmean {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/statistics/harmean/Harmean.Quantower.cs";
public HarmeanIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "HARMEAN - Harmonic Mean";
Description = "Rolling harmonic mean of price data using reciprocal-sum approach";
_series = new LineSeries(name: "Harmean", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_harmean = new Harmean(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 = _harmean.Update(input, args.IsNewBar());
_series.SetValue(result.Value, _harmean.IsHot, ShowColdValues);
}
}
+494
View File
@@ -0,0 +1,494 @@
namespace QuanTAlib.Tests;
// ═══════════════════════════════════════════════════════════════════════════════
// A) Constructor validation
// ═══════════════════════════════════════════════════════════════════════════════
public sealed class HarmeanConstructorTests
{
[Fact]
public void Constructor_ValidPeriod_SetsName()
{
var h = new Harmean(14);
Assert.Equal("Harmean(14)", h.Name);
}
[Fact]
public void Constructor_ValidPeriod_SetsWarmupPeriod()
{
var h = new Harmean(20);
Assert.Equal(20, h.WarmupPeriod);
}
[Fact]
public void Constructor_ZeroPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Harmean(0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_NegativePeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Harmean(-5));
Assert.Equal("period", ex.ParamName);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// B) Basic calculation
// ═══════════════════════════════════════════════════════════════════════════════
public sealed class HarmeanBasicTests
{
[Fact]
public void Update_ReturnsTValue()
{
var h = new Harmean(5);
TValue result = h.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.IsType<TValue>(result);
}
[Fact]
public void Last_IsAccessible()
{
var h = new Harmean(5);
h.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(100.0, h.Last.Value, 10);
}
[Fact]
public void IsHot_IsAccessible()
{
var h = new Harmean(5);
Assert.False(h.IsHot);
}
[Fact]
public void Name_IsAccessible()
{
var h = new Harmean(14);
Assert.Equal("Harmean(14)", h.Name);
}
[Fact]
public void KnownValues_HarmeanOf2_8()
{
// HM(2, 8) = 2 / (1/2 + 1/8) = 2 / (5/8) = 16/5 = 3.2
var h = new Harmean(2);
h.Update(new TValue(DateTime.UtcNow, 2.0));
h.Update(new TValue(DateTime.UtcNow, 8.0));
Assert.Equal(16.0 / 5.0, h.Last.Value, 10);
}
[Fact]
public void KnownValues_HarmeanOf2_4_8()
{
// HM(2, 4, 8) = 3 / (1/2 + 1/4 + 1/8) = 3 / (7/8) = 24/7 ≈ 3.4286
var h = new Harmean(3);
h.Update(new TValue(DateTime.UtcNow, 2.0));
h.Update(new TValue(DateTime.UtcNow, 4.0));
h.Update(new TValue(DateTime.UtcNow, 8.0));
Assert.Equal(24.0 / 7.0, h.Last.Value, 10);
}
[Fact]
public void KnownValues_AllEqual()
{
// HM of identical values = that value
var h = new Harmean(5);
for (int i = 0; i < 5; i++)
{
h.Update(new TValue(DateTime.UtcNow, 42.0));
}
Assert.Equal(42.0, h.Last.Value, 10);
}
[Fact]
public void HarmeanAlwaysLessOrEqualGeometricMean()
{
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
var h = new Harmean(20);
var g = new Geomean(20);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
var tv = new TValue(bar.Time, bar.Close);
h.Update(tv);
g.Update(tv);
if (h.IsHot && g.IsHot)
{
Assert.True(h.Last.Value <= g.Last.Value + 1e-10,
$"HM {h.Last.Value} > GM {g.Last.Value} at bar {i}");
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// C) State + bar correction
// ═══════════════════════════════════════════════════════════════════════════════
public sealed class HarmeanStateCorrectionTests
{
[Fact]
public void IsNewTrue_AdvancesState()
{
var h = new Harmean(5);
h.Update(new TValue(DateTime.UtcNow, 10.0), isNew: true);
double v1 = h.Last.Value;
h.Update(new TValue(DateTime.UtcNow, 20.0), isNew: true);
double v2 = h.Last.Value;
Assert.NotEqual(v1, v2);
}
[Fact]
public void IsNewFalse_RewritesLastBar()
{
var h = new Harmean(5);
h.Update(new TValue(DateTime.UtcNow, 10.0), isNew: true);
h.Update(new TValue(DateTime.UtcNow, 20.0), isNew: true);
double v1 = h.Last.Value;
h.Update(new TValue(DateTime.UtcNow, 30.0), isNew: false);
double v2 = h.Last.Value;
Assert.NotEqual(v1, v2);
}
[Fact]
public void IterativeCorrections_RestoreToOriginal()
{
var h = new Harmean(10);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close));
}
// Push a new bar
var newBar = gbm.Next(isNew: true);
var newTv = new TValue(newBar.Time, newBar.Close);
h.Update(newTv);
double original = h.Last.Value;
// Overwrite 5 times
for (int c = 0; c < 5; c++)
{
h.Update(new TValue(DateTime.UtcNow, 100.0 + c), isNew: false);
}
// Rewrite back to original value
h.Update(newTv, isNew: false);
Assert.Equal(original, h.Last.Value, 8);
}
[Fact]
public void Reset_ClearsState()
{
var h = new Harmean(5);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(h.IsHot);
h.Reset();
Assert.False(h.IsHot);
Assert.Equal(default, h.Last);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// D) Warmup / convergence
// ═══════════════════════════════════════════════════════════════════════════════
public sealed class HarmeanWarmupTests
{
[Fact]
public void IsHot_FlipsWhenBufferFull()
{
int period = 10;
var h = new Harmean(period);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < period - 1; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close));
Assert.False(h.IsHot, $"Should not be hot at bar {i}");
}
var lastBar = gbm.Next(isNew: true);
h.Update(new TValue(lastBar.Time, lastBar.Close));
Assert.True(h.IsHot);
}
[Fact]
public void WarmupPeriod_MatchesConstructor()
{
var h = new Harmean(14);
Assert.Equal(14, h.WarmupPeriod);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// E) Robustness
// ═══════════════════════════════════════════════════════════════════════════════
public sealed class HarmeanRobustnessTests
{
[Fact]
public void NaN_UsesLastValid()
{
var h = new Harmean(5);
for (int i = 0; i < 5; i++)
{
h.Update(new TValue(DateTime.UtcNow, 10.0));
}
double before = h.Last.Value;
h.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.Equal(before, h.Last.Value, 10);
}
[Fact]
public void Infinity_UsesLastValid()
{
var h = new Harmean(5);
for (int i = 0; i < 5; i++)
{
h.Update(new TValue(DateTime.UtcNow, 10.0));
}
double before = h.Last.Value;
h.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.Equal(before, h.Last.Value, 10);
}
[Fact]
public void NegativeValue_UsesLastValid()
{
var h = new Harmean(5);
for (int i = 0; i < 5; i++)
{
h.Update(new TValue(DateTime.UtcNow, 10.0));
}
double before = h.Last.Value;
h.Update(new TValue(DateTime.UtcNow, -5.0));
Assert.Equal(before, h.Last.Value, 10);
}
[Fact]
public void ZeroValue_UsesLastValid()
{
var h = new Harmean(5);
for (int i = 0; i < 5; i++)
{
h.Update(new TValue(DateTime.UtcNow, 10.0));
}
double before = h.Last.Value;
h.Update(new TValue(DateTime.UtcNow, 0.0));
Assert.Equal(before, h.Last.Value, 10);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// F) Consistency (batch == streaming == span == eventing)
// ═══════════════════════════════════════════════════════════════════════════════
public sealed class HarmeanConsistencyTests
{
[Fact]
public void BatchCalc_MatchesStreaming()
{
int period = 14;
int dataLen = 200;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
var times = new List<long>(dataLen);
var values = new List<double>(dataLen);
for (int i = 0; i < dataLen; i++)
{
var bar = gbm.Next(isNew: true);
times.Add(bar.Time);
values.Add(bar.Close);
}
var series = new TSeries(times, values);
// Streaming
var hStream = new Harmean(period);
for (int i = 0; i < series.Count; i++)
{
hStream.Update(series[i]);
}
// Batch
var batchResult = Harmean.Batch(series, period);
Assert.Equal(hStream.Last.Value, batchResult[^1].Value, 8);
}
[Fact]
public void SpanCalc_MatchesTSeries()
{
int period = 14;
int dataLen = 200;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
var times = new List<long>(dataLen);
var values = new List<double>(dataLen);
for (int i = 0; i < dataLen; i++)
{
var bar = gbm.Next(isNew: true);
times.Add(bar.Time);
values.Add(bar.Close);
}
var series = new TSeries(times, values);
var batchResult = Harmean.Batch(series, period);
var src = series.Values;
Span<double> output = new double[dataLen];
Harmean.Batch(src, output, period);
for (int i = 0; i < dataLen; i++)
{
Assert.Equal(batchResult[i].Value, output[i], 8);
}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// G) Span API tests
// ═══════════════════════════════════════════════════════════════════════════════
public sealed class HarmeanSpanTests
{
[Fact]
public void Batch_MismatchedLengths_Throws()
{
var src = new double[] { 1, 2, 3 };
var output = new double[5];
var ex = Assert.Throws<ArgumentException>(() =>
Harmean.Batch(src, output, 2));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_ZeroPeriod_Throws()
{
var src = new double[] { 1, 2, 3 };
var output = new double[3];
var ex = Assert.Throws<ArgumentException>(() =>
Harmean.Batch(src, output, 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Batch_NaN_HandledGracefully()
{
var src = new double[] { 10, 20, double.NaN, 30, 40 };
var output = new double[5];
Harmean.Batch(src, output, 3);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"output[{i}] is not finite: {output[i]}");
}
}
[Fact]
public void Batch_LargeData_NoStackOverflow()
{
int len = 10_000;
var src = new double[len];
var output = new double[len];
for (int i = 0; i < len; i++)
{
src[i] = 100.0 + (i % 50);
}
Harmean.Batch(src, output, 300);
Assert.True(double.IsFinite(output[^1]));
}
[Fact]
public void Batch_KnownValues()
{
// HM(2) = 2, HM(2,8) = 16/5 = 3.2, HM(8,4) = 2/(1/8+1/4) = 2/(3/8) = 16/3, HM(4,16) = 2/(1/4+1/16) = 2/(5/16) = 32/5
var src = new double[] { 2, 8, 4, 16 };
var output = new double[4];
Harmean.Batch(src, output, 2);
Assert.Equal(2.0, output[0], 10); // only 1 value → HM = 2
Assert.Equal(16.0 / 5.0, output[1], 10); // HM(2,8) = 3.2
Assert.Equal(16.0 / 3.0, output[2], 10); // HM(8,4) = 16/3
Assert.Equal(32.0 / 5.0, output[3], 10); // HM(4,16) = 6.4
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// H) Chainability / Events
// ═══════════════════════════════════════════════════════════════════════════════
public sealed class HarmeanEventTests
{
[Fact]
public void Pub_FiresOnUpdate()
{
var h = new Harmean(5);
int fireCount = 0;
h.Pub += (object? sender, in TValueEventArgs args) => { fireCount++; };
h.Update(new TValue(DateTime.UtcNow, 10.0));
Assert.Equal(1, fireCount);
}
[Fact]
public void EventChaining_Works()
{
var source = new TSeries();
var h1 = new Harmean(source, 5);
int fireCount = 0;
h1.Pub += (object? sender, in TValueEventArgs args) => { fireCount++; };
for (int i = 0; i < 10; i++)
{
source.Add(new TValue(DateTime.UtcNow, 10.0 + i));
}
Assert.Equal(10, fireCount);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// I) Calculate() returns hot indicator
// ═══════════════════════════════════════════════════════════════════════════════
public sealed class HarmeanCalculateTests
{
[Fact]
public void Calculate_ReturnsHotIndicator()
{
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
var times = new List<long>(50);
var values = new List<double>(50);
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
times.Add(bar.Time);
values.Add(bar.Close);
}
var series = new TSeries(times, values);
var (results, indicator) = Harmean.Calculate(series, 14);
Assert.True(indicator.IsHot);
Assert.Equal(50, results.Count);
}
}
@@ -0,0 +1,182 @@
namespace QuanTAlib.Tests;
/// <summary>
/// Harmean Validation Tests - Self-consistency validation.
/// No external TA library implements rolling harmonic mean, so we validate
/// against mathematical properties and internal consistency.
/// </summary>
public sealed class HarmeanValidationTests
{
private static TSeries CreateGbmSeries(int count = 500, int seed = 42)
{
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: seed);
var times = new List<long>(count);
var values = new List<double>(count);
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
times.Add(bar.Time);
values.Add(bar.Close);
}
return new TSeries(times, values);
}
[Fact]
public void ConstantInput_ReturnsConstant()
{
// HM of identical values = that value
var h = new Harmean(20);
for (int i = 0; i < 50; i++)
{
h.Update(new TValue(DateTime.UtcNow, 42.0));
}
Assert.Equal(42.0, h.Last.Value, 10);
}
[Fact]
public void HarmeanLeqGeometricMean()
{
// HM-GM inequality: HM ≤ GM for all positive values
var series = CreateGbmSeries();
int period = 20;
var h = new Harmean(period);
var g = new Geomean(period);
for (int i = 0; i < series.Count; i++)
{
h.Update(series[i]);
g.Update(series[i]);
if (h.IsHot && g.IsHot)
{
Assert.True(h.Last.Value <= g.Last.Value + 1e-10,
$"HM-GM violated at bar {i}: HM={h.Last.Value}, GM={g.Last.Value}");
}
}
}
[Fact]
public void HarmeanLeqArithmeticMean()
{
// HM ≤ AM for all positive values
var series = CreateGbmSeries();
int period = 20;
var h = new Harmean(period);
var sma = new Sma(period);
for (int i = 0; i < series.Count; i++)
{
h.Update(series[i]);
sma.Update(series[i]);
if (h.IsHot)
{
Assert.True(h.Last.Value <= sma.Last.Value + 1e-10,
$"HM-AM violated at bar {i}: HM={h.Last.Value}, AM={sma.Last.Value}");
}
}
}
[Fact]
public void BatchAndStreaming_Match()
{
var series = CreateGbmSeries();
int period = 14;
// Streaming
var hStream = new Harmean(period);
var streamResults = new double[series.Count];
for (int i = 0; i < series.Count; i++)
{
hStream.Update(series[i]);
streamResults[i] = hStream.Last.Value;
}
// Batch
var batchResult = Harmean.Batch(series, period);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(streamResults[i], batchResult[i].Value, 8);
}
}
[Fact]
public void OutputIsPositive()
{
var series = CreateGbmSeries();
var h = new Harmean(14);
for (int i = 0; i < series.Count; i++)
{
h.Update(series[i]);
Assert.True(h.Last.Value > 0, $"Output not positive at bar {i}: {h.Last.Value}");
}
}
[Fact]
public void Calculate_ReturnsCorrectResults()
{
var series = CreateGbmSeries(100);
var (results, indicator) = Harmean.Calculate(series, 14);
Assert.True(indicator.IsHot);
Assert.Equal(100, results.Count);
Assert.True(double.IsFinite(results[^1].Value));
}
[Fact]
public void NearConstant_NearConstant()
{
// Values very close together → HM ≈ AM ≈ the value
var h = new Harmean(10);
for (int i = 0; i < 20; i++)
{
h.Update(new TValue(DateTime.UtcNow, 100.0 + i * 0.001));
}
Assert.True(Math.Abs(h.Last.Value - 100.01) < 0.1,
$"Expected near 100.01, got {h.Last.Value}");
}
[Fact]
public void SpanBatch_MatchesTSeriesBatch()
{
var series = CreateGbmSeries(200);
int period = 14;
var batchResult = Harmean.Batch(series, period);
var src = series.Values;
Span<double> output = new double[200];
Harmean.Batch(src, output, period);
for (int i = 0; i < 200; i++)
{
Assert.Equal(batchResult[i].Value, output[i], 8);
}
}
[Fact]
public void MeanInequality_HM_LE_GM_LE_AM()
{
// Full mean inequality chain: HM ≤ GM ≤ AM
var series = CreateGbmSeries(200);
int period = 14;
var h = new Harmean(period);
var g = new Geomean(period);
var sma = new Sma(period);
for (int i = 0; i < series.Count; i++)
{
h.Update(series[i]);
g.Update(series[i]);
sma.Update(series[i]);
if (h.IsHot && g.IsHot)
{
double hm = h.Last.Value;
double gm = g.Last.Value;
double am = sma.Last.Value;
Assert.True(hm <= gm + 1e-10,
$"HM > GM at bar {i}: HM={hm}, GM={gm}");
Assert.True(gm <= am + 1e-10,
$"GM > AM at bar {i}: GM={gm}, AM={am}");
}
}
}
}
+406
View File
@@ -0,0 +1,406 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// HARMEAN: Harmonic Mean over a rolling window
/// </summary>
/// <remarks>
/// Harmonic Mean is the reciprocal of the arithmetic mean of reciprocals:
/// HM = n / Σ(1/xᵢ). It penalizes extreme values more strongly than the
/// arithmetic or geometric mean, making it useful for averaging rates,
/// ratios, and price/earnings multiples.
///
/// The running sum of reciprocals enables O(1) updates: add 1/new, subtract 1/old.
/// Kahan-Babuška summation prevents floating-point drift in the reciprocal accumulator.
/// Periodic resync (every 1000 ticks) guards against long-running drift.
///
/// Non-positive values are replaced with the last valid positive value, since
/// 1/x is undefined for x = 0 and negative reciprocals break the mean.
/// For price series (always positive), this substitution is rarely triggered.
///
/// Key Features:
/// - O(1) time complexity per update via running sum of reciprocals
/// - Kahan-Babuška compensated summation for numerical stability
/// - Periodic resync every 1000 ticks to limit FP drift
/// - NaN/Infinity/non-positive substitution with last valid value
///
/// IsHot:
/// Becomes true when the buffer is full (period samples processed).
/// </remarks>
[SkipLocalsInit]
public sealed class Harmean : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private readonly TValuePublishedHandler _handler;
private readonly ITValuePublisher? _source;
private bool _disposed;
[StructLayout(LayoutKind.Auto)]
private record struct State
{
public double SumReciprocal;
public double C; // Kahan primary compensation
public double Cc; // Kahan secondary compensation (Babuška)
public double LastValidValue;
public int TickCount;
}
private State _s;
private State _ps;
private const int ResyncInterval = 1000;
public Harmean(int period)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Harmean({period})";
WarmupPeriod = period;
_handler = Handle;
}
public Harmean(ITValuePublisher source, int period) : this(period)
{
_source = source;
source.Pub += _handler;
}
public Harmean(TSeries source, int period) : this(period)
{
_source = source;
source.Pub += _handler;
Prime(source.Values);
if (source.Count > 0)
{
Last = new TValue(source.LastTime, Last.Value);
}
_ps = _s;
}
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/////////////////////////////////////////////////////////////////////////////////////////////////
// Mode B: Streaming (Stateful)
/////////////////////////////////////////////////////////////////////////////////////////////////
public override bool IsHot => _buffer.IsFull;
/////////////////////////////////////////////////////////////////////////////////////////////////
// Kahan-Babuška Core Operations (reciprocal domain)
/////////////////////////////////////////////////////////////////////////////////////////////////
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void KahanAdd(double x)
{
double y = x - _s.C;
double t = _s.SumReciprocal + y;
_s.C = (t - _s.SumReciprocal) - y;
_s.SumReciprocal = t;
double z = _s.C - _s.Cc;
double tt = _s.SumReciprocal + z;
_s.Cc = (tt - _s.SumReciprocal) - z;
_s.SumReciprocal = tt;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void KahanSubtract(double x)
{
KahanAdd(-x);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void RecalculateSumReciprocal()
{
_s.SumReciprocal = 0;
_s.C = 0;
_s.Cc = 0;
var span = _buffer.GetSpan();
for (int i = 0; i < span.Length; i++)
{
KahanAdd(1.0 / span[i]);
}
}
/////////////////////////////////////////////////////////////////////////////////////////////////
// Mode C: Priming (The Bridge)
/////////////////////////////////////////////////////////////////////////////////////////////////
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
if (source.Length == 0)
{
return;
}
_buffer.Clear();
_s = default;
_ps = default;
int warmupLength = Math.Min(source.Length, WarmupPeriod);
int startIndex = source.Length - warmupLength;
// Seed LastValidValue from prior context
_s.LastValidValue = double.NaN;
for (int i = startIndex - 1; i >= 0; i--)
{
if (double.IsFinite(source[i]) && source[i] > 0)
{
_s.LastValidValue = source[i];
break;
}
}
if (double.IsNaN(_s.LastValidValue))
{
for (int i = startIndex; i < source.Length; i++)
{
if (double.IsFinite(source[i]) && source[i] > 0)
{
_s.LastValidValue = source[i];
break;
}
}
}
for (int i = startIndex; i < source.Length; i++)
{
double val = GetValidValue(source[i]);
_buffer.Add(val);
KahanAdd(1.0 / val);
}
double result = (_buffer.Count > 0 && _s.SumReciprocal > 1e-300)
? _buffer.Count / _s.SumReciprocal
: double.NaN;
Last = new TValue(DateTime.MinValue, result);
_ps = _s;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private double GetValidValue(double input)
{
if (double.IsFinite(input) && input > 0)
{
_s.LastValidValue = input;
return input;
}
return _s.LastValidValue;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
double val = GetValidValue(input.Value);
double reciprocal = 1.0 / val;
if (_buffer.Count == _buffer.Capacity)
{
KahanSubtract(1.0 / _buffer.Oldest);
}
_buffer.Add(val);
KahanAdd(reciprocal);
_s.TickCount++;
if (_buffer.IsFull && _s.TickCount >= ResyncInterval)
{
_s.TickCount = 0;
RecalculateSumReciprocal();
}
}
else
{
_s = _ps;
_buffer.Snapshot();
_buffer.Restore();
double val = GetValidValue(input.Value);
if (_buffer.Count > 0)
{
_buffer.UpdateNewest(val);
RecalculateSumReciprocal();
}
else
{
_buffer.Add(val);
KahanAdd(1.0 / val);
}
}
double result = (_buffer.Count > 0 && _s.SumReciprocal > 1e-300)
? _buffer.Count / _s.SumReciprocal
: double.NaN;
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);
source.Times.CopyTo(tSpan);
Prime(source.Values);
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
/////////////////////////////////////////////////////////////////////////////////////////////////
// Mode A: Batch (Stateless)
/////////////////////////////////////////////////////////////////////////////////////////////////
public static TSeries Batch(TSeries source, int period)
{
var h = new Harmean(period);
return h.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
int len = source.Length;
if (len == 0)
{
return;
}
// Use simple sliding-window reciprocal sum for batch
double sumReciprocal = 0;
double lastValid = double.NaN;
int count = 0;
// Seed lastValid
for (int k = 0; k < len; k++)
{
if (double.IsFinite(source[k]) && source[k] > 0)
{
lastValid = source[k];
break;
}
}
const int StackallocThreshold = 256;
double[]? rented = null;
scoped Span<double> ring;
if (period <= StackallocThreshold)
{
ring = stackalloc double[period];
}
else
{
rented = ArrayPool<double>.Shared.Rent(period);
ring = rented.AsSpan(0, period);
}
try
{
int head = 0;
ring.Fill(0);
for (int i = 0; i < len; i++)
{
double val = source[i];
if (double.IsFinite(val) && val > 0)
{
lastValid = val;
}
else
{
val = lastValid;
}
double reciprocal = 1.0 / val;
if (count == period)
{
sumReciprocal -= ring[head];
}
else
{
count++;
}
ring[head] = reciprocal;
sumReciprocal += reciprocal;
head = (head + 1) % period;
output[i] = (sumReciprocal > 1e-300) ? count / sumReciprocal : double.NaN;
}
}
finally
{
if (rented != null)
{
ArrayPool<double>.Shared.Return(rented);
}
}
}
public static (TSeries Results, Harmean Indicator) Calculate(TSeries source, int period)
{
var h = new Harmean(period);
TSeries results = h.Update(source);
return (results, h);
}
public override void Reset()
{
_buffer.Clear();
_s = default;
_ps = default;
Last = default;
}
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing && _source != null)
{
_source.Pub -= _handler;
}
_disposed = true;
}
base.Dispose(disposing);
}
}
+101
View File
@@ -0,0 +1,101 @@
# HARMEAN: Harmonic Mean
> "The harmonic mean is never greater than the geometric mean, which is never greater than the arithmetic mean." - The Mean Inequality, a mathematical fact older than calculus
The Harmonic Mean computes the reciprocal of the arithmetic mean of reciprocals over a sliding window. It is the correct average for quantities defined in terms of rates or ratios (speed, P/E ratios, yield). For financial time series, the harmonic mean gives the largest discount to outliers, making it the most conservative of the three Pythagorean means.
## Historical Context
The harmonic mean appears in Archimedes' work on means (ca. 225 BCE) and was one of the three "Pythagorean means" studied by ancient Greek mathematicians alongside the arithmetic and geometric means. The name "harmonic" comes from its connection to musical intervals: the harmonic mean of two string lengths produces a note that is harmonically related to both. In modern finance, the harmonic mean surfaces when averaging price-to-earnings ratios across a portfolio (where arithmetic averaging systematically overstates the aggregate P/E), when computing average cost basis for dollar-cost averaging, and when combining rates that apply to equal fixed quantities.
Consider dollar-cost averaging: investing $1000/month into a stock at prices $50, $100, and $200 buys 20, 10, and 5 shares respectively. The average cost per share is $3000/35 = $85.71, which is the harmonic mean of {50, 100, 200}. The arithmetic mean ($116.67) would overstate your cost basis by 36%.
## Architecture and Physics
`Harmean` extends `AbstractBase` for single-value input streaming. Instead of recomputing reciprocals across the entire window each tick, it maintains a running sum of reciprocals using Kahan-Babuska compensated summation.
### Design Decisions
1. **Reciprocal-sum approach**: Maintains $\sum 1/x_i$ as a running accumulator. The harmonic mean is simply $n / \sum(1/x_i)$. This enables O(1) updates: add $1/x_{\text{new}}$, subtract $1/x_{\text{old}}$.
2. **O(1) streaming updates**: Uses a `RingBuffer` to track which raw values are in the window. When computing the reciprocal of an exiting value, it reads from the buffer rather than storing reciprocals separately. This avoids double-inversion numerical error.
3. **Periodic resync**: Every 1000 ticks, the running reciprocal sum is recomputed from scratch to bound floating-point drift. Sequential add/subtract cycles accumulate error proportional to the number of updates without this safeguard.
4. **Non-positive value handling**: Values $\leq 0$ produce undefined or negative reciprocals that break the mean. The indicator substitutes the last valid positive value, matching the PineScript reference behavior.
5. **No SIMD in Update**: The streaming path is inherently sequential (running compensated sum with state). The static `Batch(Span)` method uses a scalar circular buffer for maximum throughput.
## Mathematical Foundation
For $n$ positive values $x_1, x_2, \ldots, x_n$, the harmonic mean is:
$$ H = \frac{n}{\sum_{i=1}^{n} \frac{1}{x_i}} $$
Equivalently:
$$ \frac{1}{H} = \frac{1}{n} \sum_{i=1}^{n} \frac{1}{x_i} $$
The harmonic mean is the reciprocal of the arithmetic mean of reciprocals.
### Mean Inequality (HM-GM-AM)
For positive real numbers, the three Pythagorean means satisfy:
$$ H \leq G \leq A $$
where $H$ is the harmonic mean, $G$ is the geometric mean, and $A$ is the arithmetic mean. Equality holds if and only if all values are identical. This property is validated in the test suite.
### Kahan-Babuska Compensation
The running reciprocal-sum uses second-order compensation:
$$
\begin{aligned}
y &= \frac{1}{x_{\text{new}}} - c \\
t &= S + y \\
c &= (t - S) - y \\
S &= t
\end{aligned}
$$
This bounds the accumulated error to $O(\varepsilon)$ rather than $O(n\varepsilon)$ for naive summation, where $\varepsilon$ is machine epsilon.
## Performance Profile
| Metric | Score | Notes |
| :--- | :--- | :--- |
| **Throughput** | ~5ns/bar | O(1) reciprocal-add/subtract per update. |
| **Allocations** | 0 | RingBuffer pre-allocated; no heap allocation in Update. |
| **Complexity** | O(1) streaming | Amortized O(1) with periodic O(period) resync every 1000 ticks. |
| **Accuracy** | 9/10 | Kahan-Babuska compensation + periodic resync. |
## Validation
Self-validated against mathematical properties and Wolfram Alpha known values.
| Property | Status | Notes |
| :--- | :--- | :--- |
| **Known values** | ✅ | harmean({2, 8}) = 16/5 = 3.2; harmean({2, 4, 8}) = 24/7 ≈ 3.4286. |
| **Constant series** | ✅ | Returns the constant value exactly. |
| **HM ≤ GM ≤ AM** | ✅ | Validated across 500 GBM bars, period 20. |
| **Batch == Streaming** | ✅ | All modes produce identical results within 1e-8. |
| **Near-constant** | ✅ | Very low variance input converges to the value. |
## Common Pitfalls
1. **Non-positive inputs**: The harmonic mean is undefined for zero or negative values. The indicator substitutes the last valid positive value, but feeding predominantly non-positive data produces meaningless results.
2. **Extreme outliers**: The harmonic mean is heavily influenced by small values (since their reciprocals are large). A single near-zero value can drag the harmonic mean close to zero even if all other values are large.
3. **Sparse data**: With fewer values than the period, the harmonic mean uses whatever count is available. It becomes "hot" only when the full window is populated.
4. **Floating-point drift**: Without periodic resync, long-running streams accumulate reciprocal-sum errors. The 1000-tick resync interval bounds this drift.
5. **Comparison with arithmetic mean**: The harmonic mean is always less than or equal to the arithmetic mean. When they diverge significantly, the data has high variance in its reciprocals. This divergence itself can be a useful volatility signal.
## References
- Bullen, P.S. "Handbook of Means and Their Inequalities." Kluwer Academic Publishers, 2003.
- Ferger, W.F. "The Nature and Use of the Harmonic Mean." Journal of the American Statistical Association, 1931.
- PineScript reference: `lib/statistics/harmean/harmean.pine`
@@ -0,0 +1,114 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class HurstIndicatorTests
{
[Fact]
public void HurstIndicator_Constructor_SetsDefaults()
{
var indicator = new HurstIndicator();
Assert.Equal(100, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("Hurst - Hurst Exponent", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
Assert.Equal(SourceType.Close, indicator.Source);
}
[Fact]
public void HurstIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new HurstIndicator { Period = 100 };
Assert.Equal(0, HurstIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void HurstIndicator_Initialize_CreatesInternalHurst()
{
var indicator = new HurstIndicator { Period = 20 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist (Hurst line + 0.5 reference line)
Assert.Equal(2, indicator.LinesSeries.Count);
Assert.Equal("Hurst", indicator.LinesSeries[0].Name);
Assert.Equal("0.5", indicator.LinesSeries[1].Name);
}
[Fact]
public void HurstIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new HurstIndicator { Period = 20 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; 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 hurst = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(hurst));
}
[Fact]
public void HurstIndicator_DifferentSourceTypes()
{
var indicator = new HurstIndicator { Period = 20, Source = SourceType.Open };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; 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 hurst = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(hurst));
}
[Fact]
public void HurstIndicator_ShortName_IncludesPeriod()
{
var indicator = new HurstIndicator { Period = 50 };
Assert.Equal("Hurst 50", indicator.ShortName);
}
[Fact]
public void HurstIndicator_NewBar_UpdatesValue()
{
var indicator = new HurstIndicator { Period = 20 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; 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(30), 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));
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class HurstIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 20, 2000, 1, 0)]
public int Period { get; set; } = 100;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Hurst _hurst = null!;
private readonly LineSeries _series;
private readonly LineSeries _halfLine;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"Hurst {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/statistics/hurst/Hurst.Quantower.cs";
public HurstIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "Hurst - Hurst Exponent";
Description = "Measures long-range dependence using Rescaled Range (R/S) analysis. H > 0.5 = trending, H < 0.5 = mean-reverting, H ≈ 0.5 = random walk";
_series = new LineSeries(name: "Hurst", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
_halfLine = new LineSeries(name: "0.5", color: Color.Gray, width: 1, style: LineStyle.Dash);
AddLineSeries(_series);
AddLineSeries(_halfLine);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_hurst = new Hurst(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 = _hurst.Update(input, args.IsNewBar());
_series.SetValue(result.Value, _hurst.IsHot, ShowColdValues);
_halfLine.SetValue(0.5);
}
}
+545
View File
@@ -0,0 +1,545 @@
namespace QuanTAlib.Tests;
// ═══════════════════════════════════════════════════════════════
// A) Constructor Validation
// ═══════════════════════════════════════════════════════════════
public class HurstConstructorTests
{
[Fact]
public void Constructor_ThrowsOnPeriodLessThan20()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(19));
Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(10));
Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(-1));
}
[Fact]
public void Constructor_AcceptsMinimumPeriod()
{
var h = new Hurst(20);
Assert.NotNull(h);
Assert.Equal("Hurst(20)", h.Name);
}
[Fact]
public void Constructor_SetsWarmupPeriod()
{
var h = new Hurst(100);
Assert.Equal(101, h.WarmupPeriod);
}
[Fact]
public void Constructor_ParamName_IsPeriod()
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(5));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_LargePeriodAccepted()
{
var h = new Hurst(500);
Assert.Equal("Hurst(500)", h.Name);
Assert.Equal(501, h.WarmupPeriod);
}
}
// ═══════════════════════════════════════════════════════════════
// B) Basic Calculation
// ═══════════════════════════════════════════════════════════════
public class HurstBasicTests
{
[Fact]
public void Calc_ReturnsValue()
{
var h = new Hurst(20);
TValue result = h.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(result.Value, h.Last.Value);
}
[Fact]
public void Calc_FirstValue_ReturnsDefaultHalf()
{
var h = new Hurst(20);
TValue result = h.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(0.5, result.Value);
}
[Fact]
public void Calc_OutputIsFinite()
{
var h = new Hurst(20);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
var result = h.Update(new TValue(bar.Time, bar.Close));
Assert.True(double.IsFinite(result.Value), $"Result at index {i} is not finite: {result.Value}");
}
}
[Fact]
public void Calc_GBM_ResultNearHalf()
{
// GBM with zero drift should produce H ≈ 0.5 (random walk)
var h = new Hurst(100);
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
TValue lastResult = default;
for (int i = 0; i < 500; i++)
{
var bar = gbm.Next(isNew: true);
lastResult = h.Update(new TValue(bar.Time, bar.Close));
}
// H should be roughly 0.5 for random walk — allow wide tolerance
Assert.InRange(lastResult.Value, 0.2, 0.8);
}
[Fact]
public void IsHot_Accessible()
{
var h = new Hurst(20);
Assert.False(h.IsHot);
}
[Fact]
public void Name_IsAccessible()
{
var h = new Hurst(50);
Assert.Equal("Hurst(50)", h.Name);
}
}
// ═══════════════════════════════════════════════════════════════
// C) State + Bar Correction
// ═══════════════════════════════════════════════════════════════
public class HurstStateCorrectionTests
{
[Fact]
public void IsNew_True_Advances()
{
var h = new Hurst(20);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 25; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close), isNew: true);
}
double v1 = h.Last.Value;
var nextBar = gbm.Next(isNew: true);
h.Update(new TValue(nextBar.Time, nextBar.Close), isNew: true);
double v2 = h.Last.Value;
// Values should differ after advancing
Assert.True(double.IsFinite(v1));
Assert.True(double.IsFinite(v2));
}
[Fact]
public void IsNew_False_Rewrites()
{
var h = new Hurst(20);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
// Build up state
for (int i = 0; i < 25; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close), isNew: true);
}
_ = h.Last.Value;
// Rewrite last value
h.Update(new TValue(DateTime.UtcNow, 200), isNew: false);
double valueAfterRewrite = h.Last.Value;
// Rewrite again with original-like value
h.Update(new TValue(DateTime.UtcNow, 200), isNew: false);
double valueSecondRewrite = h.Last.Value;
// Same rewrite value should produce same result
Assert.Equal(valueAfterRewrite, valueSecondRewrite, 10);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var h = new Hurst(20);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
// Feed 25 new values
TValue lastInput = default;
for (int i = 0; i < 25; i++)
{
var bar = gbm.Next(isNew: true);
lastInput = new TValue(bar.Time, bar.Close);
h.Update(lastInput, isNew: true);
}
double stateAfter25 = h.Last.Value;
// Generate corrections with isNew=false using same last price
h.Update(new TValue(DateTime.UtcNow, lastInput.Value + 10), isNew: false);
h.Update(new TValue(DateTime.UtcNow, lastInput.Value + 20), isNew: false);
// Restore original value
TValue finalResult = h.Update(lastInput, isNew: false);
Assert.Equal(stateAfter25, finalResult.Value, 10);
}
[Fact]
public void Reset_ClearsState()
{
var h = new Hurst(20);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 30; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(h.IsHot);
h.Reset();
Assert.False(h.IsHot);
Assert.Equal(0, h.Last.Value);
}
}
// ═══════════════════════════════════════════════════════════════
// D) Warmup / Convergence
// ═══════════════════════════════════════════════════════════════
public class HurstWarmupTests
{
[Fact]
public void IsHot_BecomesTrueWhenBufferFull()
{
var h = new Hurst(20);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
// Need period+1 = 21 prices to get 20 log returns
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close));
Assert.False(h.IsHot, $"Should not be hot at input {i + 1}");
}
// 21st price → 20th log return → buffer full
var finalBar = gbm.Next(isNew: true);
h.Update(new TValue(finalBar.Time, finalBar.Close));
Assert.True(h.IsHot);
}
[Fact]
public void WarmupPeriod_MatchesPeriodPlusOne()
{
var h = new Hurst(50);
Assert.Equal(51, h.WarmupPeriod);
}
}
// ═══════════════════════════════════════════════════════════════
// E) Robustness
// ═══════════════════════════════════════════════════════════════
public class HurstRobustnessTests
{
[Fact]
public void NaN_UsesLastValidValue()
{
var h = new Hurst(20);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 25; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close));
}
var result = h.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void PositiveInfinity_UsesLastValidValue()
{
var h = new Hurst(20);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 25; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close));
}
var result = h.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void NegativeInfinity_UsesLastValidValue()
{
var h = new Hurst(20);
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 25; i++)
{
var bar = gbm.Next(isNew: true);
h.Update(new TValue(bar.Time, bar.Close));
}
var result = h.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void BatchNaN_Safe()
{
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
double[] source = new double[50];
for (int i = 0; i < 50; i++)
{
source[i] = gbm.Next(isNew: true).Close;
}
source[10] = double.NaN;
source[25] = double.NaN;
double[] output = new double[source.Length];
Hurst.Batch(source.AsSpan(), output.AsSpan(), 20);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]), $"output[{i}] = {output[i]}");
}
}
}
// ═══════════════════════════════════════════════════════════════
// F) Consistency (all modes match)
// ═══════════════════════════════════════════════════════════════
public class HurstConsistencyTests
{
[Fact]
public void AllModes_ProduceSameResult()
{
const int period = 20;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
int count = 100;
var times = new List<long>(count);
var values = new List<double>(count);
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
times.Add(bar.Time);
values.Add(bar.Close);
}
var series = new TSeries(times, values);
// 1. Batch Mode (static method)
var batchSeries = Hurst.Batch(series, period);
double expected = batchSeries.Last.Value;
// 2. Span Mode (static method with spans)
var spanInput = values.ToArray();
var spanOutput = new double[count];
Hurst.Batch(spanInput.AsSpan(), spanOutput.AsSpan(), period);
double spanResult = spanOutput[^1];
// 3. Streaming Mode (instance, one value at a time)
var streamingInd = new Hurst(period);
for (int i = 0; i < count; i++)
{
streamingInd.Update(series[i]);
}
double streamingResult = streamingInd.Last.Value;
// Assert all modes produce identical results
Assert.Equal(expected, spanResult, precision: 9);
Assert.Equal(expected, streamingResult, precision: 9);
}
[Fact]
public void Batch_Matches_Streaming()
{
const int period = 20;
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
int count = 60;
var times = new List<long>(count);
var values = new List<double>(count);
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
times.Add(bar.Time);
values.Add(bar.Close);
}
// Streaming
var h = new Hurst(period);
var streamingResults = new List<double>();
for (int i = 0; i < count; i++)
{
streamingResults.Add(h.Update(new TValue(times[i], values[i])).Value);
}
// Batch
var series = new TSeries(times, values);
var batchResult = Hurst.Batch(series, period);
for (int i = 0; i < count; i++)
{
Assert.Equal(streamingResults[i], batchResult.Values[i], precision: 10);
}
}
}
// ═══════════════════════════════════════════════════════════════
// G) Span API Tests
// ═══════════════════════════════════════════════════════════════
public class HurstSpanTests
{
[Fact]
public void SpanBatch_ValidatesLengths()
{
double[] source = new double[50];
double[] wrongSize = new double[30];
var ex = Assert.Throws<ArgumentException>(() =>
Hurst.Batch(source.AsSpan(), wrongSize.AsSpan(), 20));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void SpanBatch_ValidatesPeriod()
{
double[] source = new double[50];
double[] output = new double[50];
Assert.Throws<ArgumentException>(() =>
Hurst.Batch(source.AsSpan(), output.AsSpan(), 19));
Assert.Throws<ArgumentException>(() =>
Hurst.Batch(source.AsSpan(), output.AsSpan(), 0));
}
[Fact]
public void SpanBatch_MatchesTSeriesBatch()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
int count = 100;
var times = new List<long>(count);
var values = new List<double>(count);
double[] source = new double[count];
double[] output = new double[count];
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
times.Add(bar.Time);
values.Add(bar.Close);
source[i] = bar.Close;
}
var series = new TSeries(times, values);
var tseriesResult = Hurst.Batch(series, 20);
Hurst.Batch(source.AsSpan(), output.AsSpan(), 20);
for (int i = 0; i < count; i++)
{
Assert.Equal(tseriesResult[i].Value, output[i], 1e-10);
}
}
[Fact]
public void SpanBatch_HandlesNaN()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
double[] source = new double[50];
for (int i = 0; i < 50; i++)
{
source[i] = gbm.Next(isNew: true).Close;
}
source[5] = double.NaN;
source[15] = double.NaN;
double[] output = new double[50];
Hurst.Batch(source.AsSpan(), output.AsSpan(), 20);
for (int i = 0; i < source.Length; i++)
{
Assert.True(double.IsFinite(output[i]));
}
}
[Fact]
public void SpanBatch_LargeData_NoStackOverflow()
{
int count = 5000;
var data = new double[count];
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < count; i++)
{
data[i] = gbm.Next(isNew: true).Close;
}
var output = new double[count];
// Should not throw StackOverflowException
Hurst.Batch(data.AsSpan(), output.AsSpan(), 100);
Assert.True(double.IsFinite(output[^1]));
}
}
// ═══════════════════════════════════════════════════════════════
// H) Event / Chainability
// ═══════════════════════════════════════════════════════════════
public class HurstEventTests
{
[Fact]
public void Pub_Fires()
{
var h = new Hurst(20);
int eventCount = 0;
h.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
h.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(1, eventCount);
}
[Fact]
public void EventBased_Chaining_Works()
{
var source = new TSeries();
var h = new Hurst(20);
source.Pub += (object? sender, in TValueEventArgs args) =>
{
h.Update(args.Value);
};
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
for (int i = 0; i < 30; i++)
{
var bar = gbm.Next(isNew: true);
source.Add(new TValue(bar.Time, bar.Close));
}
Assert.True(h.IsHot);
Assert.True(double.IsFinite(h.Last.Value));
}
}
@@ -0,0 +1,182 @@
// HURST Validation Tests - Hurst Exponent via Rescaled Range (R/S) Analysis
// Validated against self-consistency and known mathematical properties
// No external library provides a direct R/S-based Hurst exponent equivalent
namespace QuanTAlib.Tests;
public sealed class HurstValidationTests
{
private static TSeries CreateGbmSeries(int count = 500, double mu = 0.0, double sigma = 0.2, int seed = 42)
{
var gbm = new GBM(startPrice: 100.0, mu: mu, sigma: sigma, seed: seed);
var times = new List<long>(count);
var values = new List<double>(count);
for (int i = 0; i < count; i++)
{
var bar = gbm.Next(isNew: true);
times.Add(bar.Time);
values.Add(bar.Close);
}
return new TSeries(times, values);
}
/// <summary>
/// A pure random walk (GBM with zero drift) should produce H near 0.5.
/// </summary>
[Fact]
public void RandomWalk_HurstNearHalf()
{
const int period = 100;
var series = CreateGbmSeries(count: 1000, mu: 0.0, sigma: 0.2, seed: 42);
var h = new Hurst(period);
for (int i = 0; i < series.Count; i++)
{
h.Update(series[i]);
}
// H should be approximately 0.5 for random walk — allow generous tolerance
Assert.InRange(h.Last.Value, 0.25, 0.75);
}
/// <summary>
/// Multiple independent random walks should all produce H near 0.5.
/// </summary>
[Fact]
public void MultipleRandomWalks_AllNearHalf()
{
const int period = 100;
int[] seeds = [42, 123, 456, 789, 1024];
foreach (int seed in seeds)
{
var series = CreateGbmSeries(count: 500, mu: 0.0, sigma: 0.2, seed: seed);
var h = new Hurst(period);
for (int i = 0; i < series.Count; i++)
{
h.Update(series[i]);
}
Assert.InRange(h.Last.Value, 0.2, 0.8);
}
}
/// <summary>
/// Hurst exponent range — should always produce finite values within theoretically meaningful bounds.
/// </summary>
[Fact]
public void HurstRange_AlwaysFinite()
{
const int period = 50;
var series = CreateGbmSeries(count: 300, mu: 0.05, sigma: 0.2, seed: 42);
var h = new Hurst(period);
for (int i = 0; i < series.Count; i++)
{
var result = h.Update(series[i]);
Assert.True(double.IsFinite(result.Value), $"Value at {i} is not finite: {result.Value}");
}
}
/// <summary>
/// Batch and streaming must produce identical results.
/// </summary>
[Fact]
public void BatchVsStreaming_ExactMatch()
{
const int period = 20;
var series = CreateGbmSeries(count: 200, mu: 0.05, sigma: 0.2, seed: 42);
// Batch
var batchResult = Hurst.Batch(series, period);
// Streaming
var streamingInd = new Hurst(period);
for (int i = 0; i < series.Count; i++)
{
streamingInd.Update(series[i]);
}
Assert.Equal(batchResult.Last.Value, streamingInd.Last.Value, 1e-12);
}
/// <summary>
/// Span batch must match TSeries batch exactly.
/// </summary>
[Fact]
public void SpanBatch_MatchesTSeriesBatch()
{
const int period = 30;
var series = CreateGbmSeries(count: 200, mu: 0.05, sigma: 0.2, seed: 42);
var tseriesResult = Hurst.Batch(series, period);
double[] source = new double[series.Count];
double[] output = new double[series.Count];
for (int i = 0; i < series.Count; i++)
{
source[i] = series[i].Value;
}
Hurst.Batch(source.AsSpan(), output.AsSpan(), period);
for (int i = 0; i < series.Count; i++)
{
Assert.Equal(tseriesResult[i].Value, output[i], 1e-10);
}
}
/// <summary>
/// Constant price series should produce H = 0.5 (degenerate — all log returns = 0).
/// </summary>
[Fact]
public void ConstantSeries_ReturnsDefaultHalf()
{
const int period = 20;
var h = new Hurst(period);
for (int i = 0; i < 50; i++)
{
h.Update(new TValue(DateTime.UtcNow, 100.0));
}
// All log returns are zero → stddev = 0 → no valid R/S → default 0.5
Assert.Equal(0.5, h.Last.Value, 1e-10);
}
/// <summary>
/// Calculate static method returns both results and indicator.
/// </summary>
[Fact]
public void Calculate_ReturnsResultsAndIndicator()
{
var series = CreateGbmSeries(count: 100, mu: 0.05, sigma: 0.2, seed: 42);
var (results, indicator) = Hurst.Calculate(series, 20);
Assert.Equal(series.Count, results.Count);
Assert.True(indicator.IsHot);
Assert.Equal(results.Last.Value, indicator.Last.Value, 1e-12);
}
/// <summary>
/// Deterministic: same input always produces identical output.
/// </summary>
[Fact]
public void Deterministic_SameInputSameOutput()
{
const int period = 30;
var series = CreateGbmSeries(count: 200, mu: 0.05, sigma: 0.2, seed: 42);
var h1 = new Hurst(period);
var h2 = new Hurst(period);
for (int i = 0; i < series.Count; i++)
{
h1.Update(series[i]);
h2.Update(series[i]);
}
Assert.Equal(h1.Last.Value, h2.Last.Value, 1e-15);
}
}
+415
View File
@@ -0,0 +1,415 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// Hurst: Hurst Exponent via Rescaled Range (R/S) analysis.
/// </summary>
/// <remarks>
/// Estimates the Hurst exponent H from a sliding window of log returns using
/// the classical R/S method with OLS log-log regression. H measures long-range
/// dependence: H &gt; 0.5 indicates persistence (trending), H &lt; 0.5 indicates
/// anti-persistence (mean-reverting), and H ≈ 0.5 indicates a random walk.
///
/// Algorithm: for each sub-period size n in [10, period/2], divide the window
/// into floor(period/n) non-overlapping blocks, compute the rescaled range R/S
/// for each block, average, then regress log(R/S) on log(n). The slope is H.
///
/// Complexity: O(period²) per update — sub-period iteration is unavoidable.
/// </remarks>
[SkipLocalsInit]
public sealed class Hurst : AbstractBase
{
private const int MinSubPeriod = 10;
private readonly int _period;
private readonly RingBuffer _buffer;
private double _prevPrice;
private double _prevPriceSaved;
private double _lastValidValue;
private bool _hasPrevPrice;
private bool _hasPrevPriceSaved;
private int _inputCount;
private int _inputCountSaved;
public override bool IsHot => _buffer.IsFull;
/// <summary>
/// Creates a new Hurst Exponent indicator.
/// </summary>
/// <param name="period">The lookback period for log returns (must be &gt;= 20).</param>
public Hurst(int period)
{
if (period < 20)
{
throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than or equal to 20 for Hurst Exponent.");
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Hurst({period})";
WarmupPeriod = period + 1;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
double value = input.Value;
if (!double.IsFinite(value))
{
value = _lastValidValue;
}
else
{
_lastValidValue = value;
}
if (isNew)
{
_prevPriceSaved = _prevPrice;
_hasPrevPriceSaved = _hasPrevPrice;
_inputCountSaved = _inputCount;
}
else
{
_prevPrice = _prevPriceSaved;
_hasPrevPrice = _hasPrevPriceSaved;
_inputCount = _inputCountSaved;
}
double result;
if (!_hasPrevPrice)
{
_prevPrice = value;
_hasPrevPrice = true;
_inputCount = 1;
result = 0.5; // default — random walk assumption before data
}
else
{
double logReturn = (_prevPrice > 0 && value > 0)
? Math.Log(value / _prevPrice)
: 0.0;
if (isNew)
{
_buffer.Add(logReturn);
}
else
{
_buffer.UpdateNewest(logReturn);
}
_prevPrice = value;
_inputCount++;
result = ComputeHurst(_buffer.GetSpan());
}
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);
source.Times.CopyTo(tSpan);
// Reset running state before priming
_buffer.Clear();
_prevPrice = 0;
_hasPrevPrice = false;
_lastValidValue = 0;
_inputCount = 0;
// Prime the state: replay enough bars to reconstruct internal state
int primeStart = Math.Max(0, len - _period - 1);
for (int i = primeStart; i < len; i++)
{
Update(source[i]);
}
return new TSeries(t, v);
}
public override void Reset()
{
_buffer.Clear();
_prevPrice = 0;
_prevPriceSaved = 0;
_hasPrevPrice = false;
_hasPrevPriceSaved = false;
_lastValidValue = 0;
_inputCount = 0;
_inputCountSaved = 0;
Last = default;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
DateTime ts = DateTime.MinValue;
foreach (double value in source)
{
Update(new TValue(ts, value));
if (step.HasValue)
{
ts = ts.Add(step.Value);
}
}
}
public static TSeries Batch(TSeries source, int period)
{
var hurst = new Hurst(period);
return hurst.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (period < 20)
{
throw new ArgumentException("Period must be greater than or equal to 20", nameof(period));
}
int len = source.Length;
if (len == 0)
{
return;
}
CalculateScalarCore(source, output, period);
}
public static (TSeries Results, Hurst Indicator) Calculate(TSeries source, int period)
{
var indicator = new Hurst(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CalculateScalarCore(ReadOnlySpan<double> source, Span<double> output, int period)
{
int len = source.Length;
const int StackallocThreshold = 256;
// Buffer for log returns within the window
double[]? rentedLr = null;
scoped Span<double> lrBuf;
if (period <= StackallocThreshold)
{
lrBuf = stackalloc double[period];
}
else
{
rentedLr = ArrayPool<double>.Shared.Rent(period);
lrBuf = rentedLr.AsSpan(0, period);
}
try
{
for (int i = 0; i < len; i++)
{
if (i == 0)
{
output[i] = 0.5; // No log return possible yet
continue;
}
// Compute log returns for the available window
int windowStart = Math.Max(1, i - period + 1);
int windowLen = i - windowStart + 1;
double prevValid = 0;
for (int j = 0; j < windowLen; j++)
{
int srcIdx = windowStart + j;
double cur = source[srcIdx];
double prev = source[srcIdx - 1];
if (!double.IsFinite(cur)) { cur = prevValid; }
else { prevValid = cur; }
double prevP = prev;
if (!double.IsFinite(prevP)) { prevP = prevValid; }
lrBuf[j] = (prevP > 0 && cur > 0) ? Math.Log(cur / prevP) : 0.0;
}
output[i] = ComputeHurst(lrBuf[..windowLen]);
}
}
finally
{
if (rentedLr is not null)
{
ArrayPool<double>.Shared.Return(rentedLr);
}
}
}
/// <summary>
/// Computes the Hurst exponent from a span of log returns using R/S analysis.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeHurst(ReadOnlySpan<double> logReturns)
{
int length = logReturns.Length;
int maxN = length / 2;
if (maxN < MinSubPeriod)
{
return 0.5; // Not enough data for R/S analysis
}
// Collect log(n) and log(R/S) pairs for OLS regression
// Max possible pairs: maxN - MinSubPeriod + 1
int maxPairs = maxN - MinSubPeriod + 1;
const int StackallocThreshold = 256;
double[]? rentedLogN = null;
double[]? rentedLogRS = null;
scoped Span<double> logNValues;
scoped Span<double> logRSValues;
if (maxPairs <= StackallocThreshold)
{
logNValues = stackalloc double[maxPairs];
logRSValues = stackalloc double[maxPairs];
}
else
{
rentedLogN = ArrayPool<double>.Shared.Rent(maxPairs);
rentedLogRS = ArrayPool<double>.Shared.Rent(maxPairs);
logNValues = rentedLogN.AsSpan(0, maxPairs);
logRSValues = rentedLogRS.AsSpan(0, maxPairs);
}
try
{
int pairCount = 0;
for (int n = MinSubPeriod; n <= maxN; n++)
{
int numSubPeriods = length / n;
if (numSubPeriods == 0) { continue; }
double rsSum = 0.0;
int validSubPeriods = 0;
for (int sp = 0; sp < numSubPeriods; sp++)
{
int startIndex = sp * n;
// Compute mean of sub-period
double subSum = 0.0;
for (int j = 0; j < n; j++)
{
subSum += logReturns[startIndex + j];
}
double subMean = subSum / n;
// Compute cumulative deviations and std dev
double currentSum = 0.0;
double varianceSum = 0.0;
double cumMin = double.MaxValue;
double cumMax = double.MinValue;
for (int j = 0; j < n; j++)
{
double deviation = logReturns[startIndex + j] - subMean;
currentSum += deviation;
varianceSum += deviation * deviation;
if (currentSum < cumMin) { cumMin = currentSum; }
if (currentSum > cumMax) { cumMax = currentSum; }
}
double rangeVal = cumMax - cumMin;
double stdDev = Math.Sqrt(varianceSum / n);
if (stdDev > 1e-15)
{
rsSum += rangeVal / stdDev;
validSubPeriods++;
}
}
if (validSubPeriods > 0)
{
double avgRS = rsSum / validSubPeriods;
if (avgRS > 0)
{
logNValues[pairCount] = Math.Log(n);
logRSValues[pairCount] = Math.Log(avgRS);
pairCount++;
}
}
}
if (pairCount < 2)
{
return 0.5; // Insufficient data points for regression
}
// OLS linear regression: slope of log(R/S) vs log(n)
return OlsSlope(logNValues[..pairCount], logRSValues[..pairCount]);
}
finally
{
if (rentedLogN is not null) { ArrayPool<double>.Shared.Return(rentedLogN); }
if (rentedLogRS is not null) { ArrayPool<double>.Shared.Return(rentedLogRS); }
}
}
/// <summary>
/// Computes the OLS slope: β = (m·Σxy - Σx·Σy) / (m·Σx² - (Σx)²)
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double OlsSlope(ReadOnlySpan<double> x, ReadOnlySpan<double> y)
{
int m = x.Length;
double sumX = 0, sumY = 0, sumXY = 0, sumXSq = 0;
for (int i = 0; i < m; i++)
{
double xi = x[i];
double yi = y[i];
sumX += xi;
sumY += yi;
sumXY += xi * yi;
sumXSq += xi * xi;
}
double denominator = Math.FusedMultiplyAdd(m, sumXSq, -(sumX * sumX));
if (Math.Abs(denominator) < 1e-15)
{
return 0.5; // Degenerate — return random walk
}
return Math.FusedMultiplyAdd(m, sumXY, -(sumX * sumY)) / denominator;
}
}
+159
View File
@@ -0,0 +1,159 @@
# HURST: Hurst Exponent
> "The past is not dead. In fact, it's not even past." — William Faulkner, and also every mean-reverting time series that refuses to forget.
## Introduction
The Hurst Exponent ($H$) quantifies long-range dependence in a time series through Rescaled Range (R/S) analysis. Where autocorrelation decays and dies, the Hurst exponent measures the memory that persists across scales. $H > 0.5$ signals persistence (trending behavior), $H < 0.5$ signals anti-persistence (mean-reversion), and $H = 0.5$ represents the memoryless random walk that efficient market theorists insist you should believe in.
This implementation uses the classical R/S method with ordinary least squares regression on log-log coordinates, matching the PineScript reference. Period must be at least 20 to provide meaningful sub-period decomposition.
## Historical Context
Harold Edwin Hurst spent decades measuring Nile River flood levels before publishing his seminal 1951 paper. He discovered that natural phenomena exhibit stronger clustering than Brownian motion predicts. Benoit Mandelbrot later formalized this as "fractional Brownian motion" and connected it to self-similar processes.
The R/S statistic was the original method. Later alternatives (DFA, wavelet-based estimators, variance-ratio) address known biases in R/S for short series, but R/S remains the most widely implemented in trading platforms due to its intuitive decomposition and computational transparency.
The key insight: for a fractal process, $E[R/S] \propto n^H$, so regressing $\ln(R/S)$ against $\ln(n)$ yields $H$ as the slope. A pure random walk gives $H = 0.5$ (the "expected" value under no memory), while real markets routinely produce values between 0.55 and 0.75 on daily data, suggesting persistent trends are not noise.
## Architecture and Physics
### 1. Log Return Computation
Raw prices are converted to log returns:
$$r_t = \ln\left(\frac{P_t}{P_{t-1}}\right)$$
Log returns are additive, stationary in mean, and scale-independent. The Hurst analysis operates on a sliding window of $L$ consecutive log returns.
### 2. Sub-Period Decomposition
For each sub-period size $n$ where $10 \leq n \leq \lfloor L/2 \rfloor$:
- Divide the $L$ log returns into $\lfloor L/n \rfloor$ non-overlapping blocks
- Each block contains exactly $n$ consecutive log returns
### 3. Rescaled Range for Each Block
For a block of $n$ log returns $\{r_1, r_2, \ldots, r_n\}$:
**Mean:**
$$\bar{r} = \frac{1}{n}\sum_{i=1}^{n} r_i$$
**Cumulative deviations:**
$$Y_k = \sum_{i=1}^{k}(r_i - \bar{r}), \quad k = 1, \ldots, n$$
**Range:**
$$R = \max(Y_1, \ldots, Y_n) - \min(Y_1, \ldots, Y_n)$$
**Standard deviation (population):**
$$S = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(r_i - \bar{r})^2}$$
**Rescaled range:**
$$\frac{R}{S} = \frac{R}{S}, \quad S > 0$$
### 4. OLS Log-Log Regression
Average $R/S$ across all blocks for each $n$, then regress:
$$\ln\left(\overline{R/S}\right) = H \cdot \ln(n) + c$$
The slope $H$ is computed via ordinary least squares:
$$H = \frac{m\sum x_i y_i - \sum x_i \sum y_i}{m\sum x_i^2 - (\sum x_i)^2}$$
where $x_i = \ln(n_i)$, $y_i = \ln(\overline{R/S}_i)$, and $m$ is the number of valid $(n, R/S)$ pairs.
## Mathematical Foundation
### Hurst's Law
For a self-similar process with Hurst parameter $H$:
$$E\left[\frac{R(n)}{S(n)}\right] = c \cdot n^H$$
where $c$ is a constant depending on the distribution. Taking logarithms:
$$\ln E[R/S] = H \ln n + \ln c$$
### Interpretation
| Range | Interpretation | Market Behavior |
|-------|---------------|-----------------|
| $H = 0.5$ | Random walk | No memory, efficient market |
| $0.5 < H < 1.0$ | Persistent | Trends tend to continue |
| $0.0 < H < 0.5$ | Anti-persistent | Reversals more likely |
| $H = 1.0$ | Perfect persistence | Deterministic trend |
| $H = 0.0$ | Perfect anti-persistence | Deterministic oscillation |
### Parameter Mapping
| Parameter | Pine | C# | Default | Constraint |
|-----------|------|----|---------|------------|
| Lookback | `length` | `period` | 100 | $\geq 20$ |
| Min sub-period | `min_n` | `MinSubPeriod` | 10 | Fixed |
| Max sub-period | `max_n` | `period / 2` | 50 | Derived |
## Performance Profile
| Operation | Complexity | Notes |
|-----------|-----------|-------|
| Log return computation | $O(1)$ per bar | Single division + `Math.Log` |
| R/S per sub-period size $n$ | $O(L)$ | Iterates blocks of size $n$ |
| All sub-period sizes | $O(L \cdot (L/2 - 10))$ | $\approx O(L^2)$ |
| OLS regression | $O(m)$ | $m \leq L/2 - 9$ pairs |
| **Total per Update** | **$O(L^2)$** | Dominated by sub-period sweep |
### SIMD Analysis
The inner loops (mean, cumulative deviation, variance) operate on variable-length sub-periods making SIMD vectorization impractical for the streaming path. The Batch path uses `stackalloc` for small buffers and `ArrayPool` for large ones.
### Quality Metrics
| Metric | Score (1-10) | Notes |
|--------|-------------|-------|
| Accuracy | 7 | R/S has known bias for short series; adequate for $L \geq 100$ |
| Responsiveness | 5 | Inherently lagging (needs full window rebuild) |
| Smoothness | 6 | Stable once window fills; sensitive to outliers in small windows |
| Computational cost | 4 | $O(L^2)$ is expensive; consider reducing period for real-time |
## Validation
No external library provides a direct R/S-based Hurst exponent for cross-validation. Validation relies on known mathematical properties:
| Test | Expected | Tolerance |
|------|----------|-----------|
| Constant series | $H = 0.5$ (degenerate) | Exact |
| Random walk (GBM, $\mu = 0$) | $H \approx 0.5$ | $\pm 0.25$ |
| Deterministic (same input) | Identical output | $10^{-15}$ |
| Batch vs streaming | Identical | $10^{-12}$ |
| Span vs TSeries | Identical | $10^{-10}$ |
## Common Pitfalls
1. **Period too short**: With $L < 40$, the range of sub-period sizes ($10$ to $L/2$) is too narrow for reliable regression. Use $L \geq 100$ for production. Impact: $\pm 0.15$ bias.
2. **Confusing trending with persistence**: A series can have $H > 0.5$ even during drawdowns. The Hurst exponent measures autocorrelation structure, not direction. Misinterpretation leads to false directional signals.
3. **Non-stationarity**: The R/S method assumes locally stationary returns. Regime changes (volatility shifts, structural breaks) invalidate the power-law assumption. Impact: $H$ estimates become unreliable across breakpoints.
4. **R/S bias for finite samples**: Anis and Lloyd (1976) showed that $E[R/S]$ for i.i.d. normal data is not exactly $n^{0.5}$ but involves Gamma function corrections. For $n < 20$, this bias can shift $H$ by $0.05$-$0.15$.
5. **Zero-variance blocks**: If all returns in a sub-period are identical (e.g., during market halts), $S = 0$ and $R/S$ is undefined. These blocks are excluded from the average, reducing the effective sample size.
6. **Computational cost**: $O(L^2)$ per bar is expensive. For real-time streaming with $L = 500$, each update processes $\sim 125{,}000$ operations. Consider caching or reducing the update frequency. A period of 100 costs $\sim 4{,}000$ operations per update.
7. **Overfitting the exponent**: Point estimates of $H$ without confidence intervals invite overconfidence. The OLS slope has estimation error that grows as $m$ (number of log-log points) shrinks.
## References
- Hurst, H.E. (1951). "Long-term storage capacity of reservoirs." *Transactions of the American Society of Civil Engineers*, 116, 770-808.
- Mandelbrot, B.B. and Wallis, J.R. (1969). "Robustness of the rescaled range R/S in the measurement of noncyclic long run statistical dependence." *Water Resources Research*, 5(5), 967-988.
- Peters, E.E. (1994). *Fractal Market Analysis*. Wiley.
- Anis, A.A. and Lloyd, E.H. (1976). "The expected value of the adjusted rescaled Hurst range of independent normal summands." *Biometrika*, 63(1), 111-116.
- Lo, A.W. (1991). "Long-term memory in stock market prices." *Econometrica*, 59(5), 1279-1313.
+113
View File
@@ -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));
}
}
+60
View File
@@ -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);
}
}
+504
View File
@@ -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));
}
}
+183
View File
@@ -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})");
}
}
+444
View File
@@ -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);
}
}
+140
View File
@@ -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.
+7 -7
View File
@@ -1,22 +1,22 @@
<svg xmlns='http://www.w3.org/2000/svg'
xmlns:xlink='http://www.w3.org/1999/xlink' width='94.708984' height='20' role='img' aria-label='languages: 5'>
<title># Classes: 938</title>
xmlns:xlink='http://www.w3.org/1999/xlink' width='101.38281' height='20' role='img' aria-label='languages: 5'>
<title># Classes: 1078</title>
<linearGradient id='s' x2='0' y2='100%'>
<stop offset='0' stop-color='#bbb' stop-opacity='.1'/>
<stop offset='1' stop-opacity='.1'/>
</linearGradient>
<clipPath id='r'>
<rect width='94.708984' height='20' rx='3' fill='#fff'/>
<rect width='101.38281' height='20' rx='3' fill='#fff'/>
</clipPath>
<g clip-path='url(#r)'>
<rect width='62.6875' height='20' fill='#555'/>
<rect x='62.6875' width='32.021484' height='20' fill='#007ec6'/>
<rect width='94.708984' height='20' fill='url(#s)'/>
<rect x='62.6875' width='38.695312' height='20' fill='#007ec6'/>
<rect width='101.38281' height='20' fill='url(#s)'/>
</g>
<g fill='#FFF' text-anchor='center' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='110'>
<text aria-hidden='true' x='40' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='526.875'># Classes</text>
<text x='40' y='140' transform='scale(.1)' fill='#FFF' textLength='526.875'># Classes</text>
<text aria-hidden='true' x='666.875' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='200.21484'>938</text>
<text x='666.875' y='140' transform='scale(.1)' fill='#FFF' textLength='200.21484'>938</text>
<text aria-hidden='true' x='666.875' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='266.95312'>1078</text>
<text x='666.875' y='140' transform='scale(.1)' fill='#FFF' textLength='266.95312'>1078</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns='http://www.w3.org/2000/svg'
xmlns:xlink='http://www.w3.org/1999/xlink' width='188.0957' height='20' role='img' aria-label='languages: 5'>
<title>Percentage of Comments: 33.06</title>
<title>Percentage of Comments: 33.02</title>
<linearGradient id='s' x2='0' y2='100%'>
<stop offset='0' stop-color='#bbb' stop-opacity='.1'/>
<stop offset='1' stop-opacity='.1'/>
@@ -16,7 +16,7 @@
<g fill='#FFF' text-anchor='center' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='110'>
<text aria-hidden='true' x='40' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='1360.6641'>Percentage of Comments</text>
<text x='40' y='140' transform='scale(.1)' fill='#FFF' textLength='1360.6641'>Percentage of Comments</text>
<text aria-hidden='true' x='1500.6641' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='300.29297'>33.06</text>
<text x='1500.6641' y='140' transform='scale(.1)' fill='#FFF' textLength='300.29297'>33.06</text>
<text aria-hidden='true' x='1500.6641' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='300.29297'>33.02</text>
<text x='1500.6641' y='140' transform='scale(.1)' fill='#FFF' textLength='300.29297'>33.02</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns='http://www.w3.org/2000/svg'
xmlns:xlink='http://www.w3.org/1999/xlink' width='281.4414' height='20' role='img' aria-label='languages: 5'>
<title>Average Cyclomatic Complexity for Methods: 2.19</title>
<title>Average Cyclomatic Complexity for Methods: 2.12</title>
<linearGradient id='s' x2='0' y2='100%'>
<stop offset='0' stop-color='#bbb' stop-opacity='.1'/>
<stop offset='1' stop-opacity='.1'/>
@@ -16,7 +16,7 @@
<g fill='#FFF' text-anchor='center' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='110'>
<text aria-hidden='true' x='40' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='2360.8594'>Average Cyclomatic Complexity for Methods</text>
<text x='40' y='140' transform='scale(.1)' fill='#FFF' textLength='2360.8594'>Average Cyclomatic Complexity for Methods</text>
<text aria-hidden='true' x='2500.8594' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='233.55469'>2.19</text>
<text x='2500.8594' y='140' transform='scale(.1)' fill='#FFF' textLength='233.55469'>2.19</text>
<text aria-hidden='true' x='2500.8594' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='233.55469'>2.12</text>
<text x='2500.8594' y='140' transform='scale(.1)' fill='#FFF' textLength='233.55469'>2.12</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns='http://www.w3.org/2000/svg'
xmlns:xlink='http://www.w3.org/1999/xlink' width='125.39453' height='20' role='img' aria-label='languages: 5'>
<title># Source Files: 1099</title>
<title># Source Files: 1275</title>
<linearGradient id='s' x2='0' y2='100%'>
<stop offset='0' stop-color='#bbb' stop-opacity='.1'/>
<stop offset='1' stop-opacity='.1'/>
@@ -16,7 +16,7 @@
<g fill='#FFF' text-anchor='center' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='110'>
<text aria-hidden='true' x='40' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='766.9922'># Source Files</text>
<text x='40' y='140' transform='scale(.1)' fill='#FFF' textLength='766.9922'># Source Files</text>
<text aria-hidden='true' x='906.9922' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='266.95312'>1099</text>
<text x='906.9922' y='140' transform='scale(.1)' fill='#FFF' textLength='266.95312'>1099</text>
<text aria-hidden='true' x='906.9922' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='266.95312'>1275</text>
<text x='906.9922' y='140' transform='scale(.1)' fill='#FFF' textLength='266.95312'>1275</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns='http://www.w3.org/2000/svg'
xmlns:xlink='http://www.w3.org/1999/xlink' width='146.10156' height='20' role='img' aria-label='languages: 5'>
<title># Lines of Code: 114549</title>
<title># Lines of Code: 129859</title>
<linearGradient id='s' x2='0' y2='100%'>
<stop offset='0' stop-color='#bbb' stop-opacity='.1'/>
<stop offset='1' stop-opacity='.1'/>
@@ -16,7 +16,7 @@
<g fill='#FFF' text-anchor='center' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='110'>
<text aria-hidden='true' x='40' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='840.58594'># Lines of Code</text>
<text x='40' y='140' transform='scale(.1)' fill='#FFF' textLength='840.58594'># Lines of Code</text>
<text aria-hidden='true' x='980.58594' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='400.4297'>114549</text>
<text x='980.58594' y='140' transform='scale(.1)' fill='#FFF' textLength='400.4297'>114549</text>
<text aria-hidden='true' x='980.58594' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='400.4297'>129859</text>
<text x='980.58594' y='140' transform='scale(.1)' fill='#FFF' textLength='400.4297'>129859</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns='http://www.w3.org/2000/svg'
xmlns:xlink='http://www.w3.org/1999/xlink' width='111.40234' height='20' role='img' aria-label='languages: 5'>
<title># Methods: 12035</title>
<title># Methods: 14066</title>
<linearGradient id='s' x2='0' y2='100%'>
<stop offset='0' stop-color='#bbb' stop-opacity='.1'/>
<stop offset='1' stop-opacity='.1'/>
@@ -16,7 +16,7 @@
<g fill='#FFF' text-anchor='center' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='110'>
<text aria-hidden='true' x='40' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='560.33203'># Methods</text>
<text x='40' y='140' transform='scale(.1)' fill='#FFF' textLength='560.33203'># Methods</text>
<text aria-hidden='true' x='700.33203' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='333.6914'>12035</text>
<text x='700.33203' y='140' transform='scale(.1)' fill='#FFF' textLength='333.6914'>12035</text>
<text aria-hidden='true' x='700.33203' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='333.6914'>14066</text>
<text x='700.33203' y='140' transform='scale(.1)' fill='#FFF' textLength='333.6914'>14066</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns='http://www.w3.org/2000/svg'
xmlns:xlink='http://www.w3.org/1999/xlink' width='127.39844' height='20' role='img' aria-label='languages: 5'>
<title># Public Types: 1086</title>
<title># Public Types: 1225</title>
<linearGradient id='s' x2='0' y2='100%'>
<stop offset='0' stop-color='#bbb' stop-opacity='.1'/>
<stop offset='1' stop-opacity='.1'/>
@@ -16,7 +16,7 @@
<g fill='#FFF' text-anchor='center' font-family='Verdana,Geneva,DejaVu Sans,sans-serif' text-rendering='geometricPrecision' font-size='110'>
<text aria-hidden='true' x='40' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='787.03125'># Public Types</text>
<text x='40' y='140' transform='scale(.1)' fill='#FFF' textLength='787.03125'># Public Types</text>
<text aria-hidden='true' x='927.03125' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='266.95312'>1086</text>
<text x='927.03125' y='140' transform='scale(.1)' fill='#FFF' textLength='266.95312'>1086</text>
<text aria-hidden='true' x='927.03125' y='150' fill='#010101' fill-opacity='.3' transform='scale(.1)' textLength='266.95312'>1225</text>
<text x='927.03125' y='140' transform='scale(.1)' fill='#FFF' textLength='266.95312'>1225</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+246 -94
View File
@@ -1,16 +1,16 @@
# Missing Indicators Report
> Generated: 2026-02-13 (refreshed) | Source: Cross-reference of `_index.md` files vs actual filesystem
> Generated: 2026-02-13 | Refreshed: 2026-02-16 | Source: Cross-reference of `_index.md` files vs actual filesystem + planned additions
## Summary
| Status | Count | Description |
|--------|------:|-------------|
| **Fully Implemented** | 258 | `.cs` + tests + docs |
| **Pine-Only** (has spec, no C#) | 25 | Directory exists with `.pine` file only |
| **No Directory** (listed in master index, no files) | 38 | Planned but nothing on disk |
| **Fully Implemented** | 266 | `.cs` + tests + docs |
| **Pine-Only** (has spec, no C#) | 20 | Directory exists with `.pine` file only |
| **No Directory** (listed in master index or planned, no files) | 100 | Planned but nothing on disk |
| **Doc-Only** | 1 | Only `.md` file exists |
| **Index Discrepancies** | 4 | Count or category mismatches |
| **Index Discrepancies** | 0 | All 7 mismatches in `lib/_index.md` fixed on 2026-02-16 |
---
@@ -18,39 +18,50 @@
| Category | Subdirs | Implemented | Notes |
|----------|--------:|------------:|-------|
| Trends (FIR) | 17 | **17** | All complete |
| Trends (IIR) | 23 | **23** | All complete (LTMA planned, no subdir) |
| Filters | 18 | **18** | All complete |
| Oscillators | 19 | **19** | All complete (16 more planned, no subdir) |
| Dynamics | 17 | **17** | All complete (IMPULSE planned, no subdir) |
| Trends (FIR) | 17 | **17** | All complete; 23 more planned |
| Trends (IIR) | 23 | **23** | All complete; 17 more planned |
| Trends (Adaptive) | | **0** | New category; 5 planned |
| Filters | 18 | **18** | All complete; 12 more planned |
| Oscillators | 19 | **19** | All complete; 20 more planned |
| Dynamics | 18 | **18** | All complete |
| Momentum | 16 | **16** | All complete |
| Volatility | 26 | **26** | All complete |
| Volume | 26 | **26** | All complete |
| Volume | 26 | **26** | All complete; 1 more planned |
| Channels | 23 | **23** | All complete |
| Cycles | 14 | **14** | All complete (PHASOR planned, broken link) |
| Cycles | 14 | **14** | All complete; 2 more planned |
| Errors | 26 | **26** | All complete (no Quantower wrappers) |
| Numerics | 15 | **15** | All complete (incl. ACCEL, JERK, SLOPE) |
| Numerics | 15 | **15** | All complete; 14 distributions planned |
| Forecasts | 1 | **1** | AFIRMA only (MLP planned) |
| Statistics | 30 | **14** | 16 pine-only — see below |
| Reversals | 10 | **0** | 9 pine-only + 1 doc-only — see below |
| Statistics | 30 | **19** | 11 pine-only — see below |
| Reversals | 12 | **2** | 9 pine-only + 1 doc-only + 1 planned |
| Feeds | 3 | **3** | CSV, GBM, IFeed |
---
## Recently Implemented (since last refresh)
| Indicator | Category | Date | Notes |
|-----------|----------|------|-------|
| **ENTROPY** | Statistics | 2026-02-13 | Shannon entropy with Kahan-Babuška summation |
| **GEOMEAN** | Statistics | 2026-02-13 | Geometric mean via log-sum with compensated summation |
| **GRANGER** | Statistics | 2026-02-13 | Granger causality test (F-statistic) |
| **HARMEAN** | Statistics | 2026-02-14 | Harmonic mean via reciprocal sum with Kahan-Babuška |
| **HURST** | Statistics | 2026-02-16 | Hurst Exponent via R/S analysis with OLS log-log regression |
| **IQR** | Statistics | 2026-02-16 | Interquartile Range via sorted-window with BinarySearch insert |
| **CHANDELIER** | Reversals | 2026-02-13 | Chandelier Exit (ATR-based trailing stop) |
| **CKSTOP** | Reversals | 2026-02-13 | Chande Kroll Stop |
| **IMPULSE** | Dynamics | 2026-02-13 | Elder Impulse System (EMA + MACD histogram) |
---
## 1. PINE-ONLY Indicators (spec exists, no C# implementation)
These have directories with `.pine` reference files but **zero `.cs` files**. They need full implementation.
### Statistics (16 indicators — pine-only)
### Statistics (10 indicators — pine-only)
| Indicator | Directory |
|-----------|-----------|
| ENTROPY | `lib/statistics/entropy/` |
| GEOMEAN | `lib/statistics/geomean/` |
| GRANGER | `lib/statistics/granger/` |
| HARMEAN | `lib/statistics/harmean/` |
| HURST | `lib/statistics/hurst/` |
| IQR | `lib/statistics/iqr/` |
| JB | `lib/statistics/jb/` |
| KENDALL | `lib/statistics/kendall/` |
| KURTOSIS | `lib/statistics/kurtosis/` |
@@ -78,30 +89,138 @@ These have directories with `.pine` reference files but **zero `.cs` files**. Th
---
## 2. NO DIRECTORY at All (listed in master index, no files on disk)
## 2. NO DIRECTORY at All (planned, no files on disk)
These indicators appear in `lib/_index.md` as plain text (no markdown link), meaning they are planned but have **no directory, no files**.
These indicators are planned but have **no directory, no files**. Organized by category.
### Oscillators (16 planned)
### Filters (12 planned)
| Indicator | Full Name |
|-----------|-----------|
| BBI | Bulls Bears Index |
| BRAR | BRAR |
| COPPOCK | Coppock Curve |
| CRSI | Connors RSI |
| CTI | Correlation Trend Indicator |
| DOSC | Derivative Oscillator |
| ER | Efficiency Ratio |
| ERI | Elder Ray Index |
| FOSC | Forecast Oscillator |
| KRI | Kairi Relative Index |
| KST | KST Oscillator |
| PSL | Psychological Line |
| QQE | Quantitative Qualitative Estimation |
| RVGI | Relative Vigor Index |
| SQUEEZE | Squeeze |
| TD_SEQ | TD Sequential |
| Indicator | Full Name | Notes |
|-----------|-----------|-------|
| LAGUERRE | Laguerre Filter | Ehlers; 4-element IIR with damping factor |
| ALAGUERRE | Adaptive Laguerre Filter | Ehlers; variable-gamma Laguerre |
| ROOFING | Roofing Filter | Ehlers; HP + super smoother composite |
| VOSS | Voss Predictive Filter | Ehlers; predictive bandpass |
| AGC | Automatic Gain Control | Ehlers; amplitude normalization |
| SPBF | Super Passband Filter | Ehlers; wide-band bandpass |
| LMS | Least Mean Squares Adaptive Filter | Widrow-Hoff adaptive FIR |
| RLS | Recursive Least Squares Adaptive Filter | Faster convergence than LMS |
| WAVELET | Denoising Wavelet Filter | Wavelet-based noise removal (distinct from CWT/DWT transforms) |
| BAXTERKING | Baxter-King Filter | Symmetric band-pass for business cycle extraction |
| CHRISTIANOFITZGERALD | Christiano-Fitzgerald Filter | Asymmetric band-pass; handles endpoints |
| ONEEURO | One Euro Filter | Low-latency jitter removal; speed-adaptive |
### Trends — IIR (17 planned)
| Indicator | Full Name | Notes |
|-----------|-----------|-------|
| DECYCLER | Decycler | Ehlers; HP subtracted from price |
| TRENDFLEX | Trendflex Indicator | Ehlers; trend-following zero-lag |
| PMA | Predictive Moving Average | Ehlers; 2×EMA EMA(EMA) extrapolation |
| REVERSEEMA | Reverse Exponential Moving Average | Ehlers; backward-looking EMA reconstruction |
| EVWMA | Elastic Volume Weighted Moving Average | Volume-elastic EMA variant |
| MAVP | Moving Average Variable Period | TA-Lib; period varies per bar |
| EHMA | Exponential Hull Moving Average | Hull concept with EMA instead of WMA |
| HOLT | Holt Exponential Smoothing | Double exponential smoothing (level + trend) |
| HWTS | Holt-Winters Triple Smoothing | Triple smoothing (level+trend+seasonality); ⚠️ check overlap with HWMA |
| CORAL | Coral Trend Filter | LazyBear; multi-pole IIR with color coding |
| GDEMA | Generalized Double EMA | Generalized DEMA with tunable volume factor |
| NLMA | Non-Lag Moving Average | Zero-lag via Kalman-like error correction |
| LEMA | Leader Exponential Moving Average | EMA + momentum lead term |
| MCNMA | McNicholl EMA | McNicholl's zero-lag EMA variant |
| NYQMA | Nyquist Moving Average | Nyquist-frequency-aware smoothing |
| AHRENS | Ahrens Moving Average | Richard Ahrens' recursive MA |
| RAINBOW | Rainbow Moving Average | Cascaded SMA stack (10 layers averaged) |
### Trends — Adaptive (5 planned — new category)
| Indicator | Full Name | Notes |
|-----------|-----------|-------|
| ADXVMA | ADX Variable Moving Average | ADX-scaled smoothing factor |
| TRAMA | Trend Regularity Adaptive MA | Adapts to trend regularity |
| NMA | Natural Moving Average | Natural cycle-adaptive MA |
| VMA | Variable Moving Average | Tushar Chande; volatility-adaptive |
| EDCF | Distance Coefficient Filter | Distance-based adaptive coefficient |
### Trends — FIR (23 planned)
| Indicator | Full Name | Notes |
|-----------|-----------|-------|
| RWMA | Range Weighted Moving Average | Weights by bar range |
| MIDPOINT | Midpoint | (Highest + Lowest) / 2 over period; ⚠️ name collision with `numerics/midpoint` |
| MIDPRICE | Midprice | (Highest High + Lowest Low) / 2 over period |
| SWMA | Symmetric Weighted Moving Average | Symmetric triangular weights |
| KAISER | Kaiser Window Moving Average | Kaiser-Bessel window FIR |
| BLACKMANHARRIS | Blackman-Harris Window MA | 4-term Blackman-Harris window |
| LANCZOS | Lanczos Window Moving Average | Lanczos (sinc) window FIR |
| PARZEN | Parzen Window Moving Average | Parzen (de la Vallée-Poussin) window |
| NUTTALL | Nuttall Window Moving Average | 4-term Nuttall window; minimal sidelobe |
| BOHMAN | Bohman Window Moving Average | Bohman window FIR |
| DOLPH | Dolph-Chebyshev Window MA | Equiripple sidelobe window |
| TSF | Time Series Forecast | Linear regression extrapolation (moved from Statistics) |
| QRMA | Quadratic Regression MA | 2nd-order polynomial regression |
| CRMA | Cubic Regression MA | 3rd-order polynomial regression |
| NW | Nadaraya-Watson Kernel Regression | Gaussian kernel weighted regression |
| HENDERSON | Henderson Moving Average | Henderson symmetric filter (ABS standard) |
| SP15 | Spencer 15-Point Moving Average | Spencer's classic 15-weight filter |
| SP21 | Spencer 21-Point Moving Average | Spencer's extended 21-weight filter |
| TRIMMED | Trimmed Mean Moving Average | Outlier-trimmed arithmetic mean |
| WINSOR | Winsorized Mean Moving Average | Outlier-capped (Winsorized) mean |
| GEOMMA | Geometric Mean Moving Average | Geometric mean as FIR weight basis |
| HARMMA | Harmonic Mean Moving Average | Harmonic mean as FIR weight basis |
> **Note:** HAMMING and BLACKMAN window MAs already exist as **HAMMA** and **BLMA** respectively.
### Oscillators (20 planned)
| Indicator | Full Name | Notes |
|-----------|-----------|-------|
| BBI | Bulls Bears Index | — |
| BRAR | BRAR | — |
| COPPOCK | Coppock Curve | — |
| CRSI | Connors RSI | — |
| CTI | Correlation Trend Indicator | — |
| DECO | Decycler Oscillator | Ehlers; HP component of Decycler |
| DOSC | Derivative Oscillator | — |
| ER | Efficiency Ratio | — |
| ERI | Elder Ray Index | — |
| FOSC | Forecast Oscillator | — |
| KRI | Kairi Relative Index | — |
| KST | KST Oscillator | — |
| PSL | Psychological Line | — |
| QQE | Quantitative Qualitative Estimation | — |
| REFLEX | Reflex Indicator | Ehlers; momentum with cycle correction |
| RVGI | Relative Vigor Index | — |
| SQUEEZE | Squeeze | — |
| TD_SEQ | TD Sequential | — |
| CYBERCYCLE | Cyber Cycle | Ehlers; 2-pole IIR cycle isolator |
| MSTOCH | MESA Stochastic | Ehlers; Hilbert-based stochastic |
### Cycles (2 planned)
| Indicator | Full Name | Notes |
|-----------|-----------|-------|
| CCOR | Correlation Cycle | Ehlers; cycle detection via autocorrelation |
| GOERTZEL | Goertzel Frequency Detector | Single-bin DFT for dominant cycle |
### Volume (1 planned)
| Indicator | Full Name | Notes |
|-----------|-----------|-------|
| FVE | Finite Volume Elements | Markos Katsanos; volume-price flow |
### Reversals (1 planned)
| Indicator | Full Name | Notes |
|-----------|-----------|-------|
| SAREXT | Parabolic SAR Extended | TA-Lib extended SAR with configurable acceleration |
### Statistics (2 planned)
| Indicator | Full Name | Notes |
|-----------|-----------|-------|
| POLYFIT | Polynomial Fitting | — |
| WAVG | Weighted Average | — |
### Numerics (14 planned — distributions and transforms)
@@ -122,21 +241,6 @@ These indicators appear in `lib/_index.md` as plain text (no markdown link), mea
| TDIST | Student's t-Distribution |
| WEIBULLDIST | Weibull Distribution |
### Statistics (3 planned)
| Indicator | Full Name |
|-----------|-----------|
| POLYFIT | Polynomial Fitting |
| TSF | Time Series Forecast |
| WAVG | Weighted Average |
### Reversals (2 planned)
| Indicator | Full Name |
|-----------|-----------|
| CHANDELIER | Chandelier Exit |
| CKSTOP | Chande Kroll Stop |
### Forecasts (1 planned)
| Indicator | Full Name |
@@ -153,25 +257,34 @@ These indicators appear in `lib/_index.md` as plain text (no markdown link), mea
---
## 4. Index Discrepancies
## 4. Index Discrepancies (FIXED 2026-02-16)
### Category Mismatches
All category mismatches and count errors in `lib/_index.md` have been corrected.
| Issue | Details |
|-------|---------|
| **APO** | Listed under Momentum in `_index.md` (`momentum/apo/Apo.md`) but actually lives at `lib/oscillators/apo/`. Broken link. |
| **PHASOR** | Listed under Cycles with link `cycles/phasor/Phasor.md` but `lib/cycles/phasor/` does not exist. Broken link. |
### Fixes Applied
### Count Mismatches in Master `_index.md` Header
| Issue | Fix | Status |
|-------|-----|--------|
| **APO** category wrong (Momentum → Oscillators) | Path changed to `oscillators/apo/Apo.md`, category to Oscillators | ✅ Fixed |
| **PHASOR** broken link (subdir doesn't exist) | Removed markdown link, kept as plain text planned entry | ✅ Fixed |
| **CHANDELIER** missing link (now implemented) | Added `[CHANDELIER](reversals/chandelier/Chandelier.md)` | ✅ Fixed |
| **CKSTOP** missing link (now implemented) | Added `[CKSTOP](reversals/ckstop/Ckstop.md)` | ✅ Fixed |
| **IMPULSE** missing link (now implemented) | Added `[IMPULSE](dynamics/impulse/Impulse.md)` | ✅ Fixed |
| **TSF** wrong category (Statistics → Trends FIR) | Category changed to `Trends (FIR)` | ✅ Fixed |
| **All category counts** | Updated to match actual subdirectory counts | ✅ Fixed |
| **Total** | Updated from 278 → **284** | ✅ Fixed |
| Category | Claimed | Actual Subdirs | Notes |
|----------|--------:|-----------:|-------|
| Oscillators | 18 | 19 | APO counted here, not in Momentum |
| Dynamics | 16 | 17 | Actual has 17 implemented subdirs |
| Momentum | 17 | 16 | APO missing (lives in oscillators) |
| Channels | 22 | 23 | Actual has 23 implemented subdirs |
| Cycles | 15 | 14 | PHASOR counted but subdir doesn't exist |
| Statistics | 31 | 30 | 30 subdirs (14 implemented + 16 pine-only); 3 more planned with no subdir |
### Name Collisions
| Name | Existing | New (Planned) | Resolution |
|------|----------|---------------|------------|
| MIDPOINT | `lib/numerics/midpoint/` (math function) | Trends (FIR) — (highest+lowest)/2 | Different indicators; use distinct directory paths |
| TUKEY | `lib/errors/tukey/` (Tukey fence metric) | Trends (FIR) — Tukey window MA | Different indicators; use distinct directory paths |
| HAMMING | | Trends (FIR) — Hamming window MA | **Already exists as HAMMA** (`lib/trends_FIR/hamma/`). Do not add. |
| BLACKMAN | | Trends (FIR) — Blackman window MA | **Already exists as BLMA** (`lib/trends_FIR/blma/`). Do not add. |
| HWTS | — | Trends (IIR) — Holt-Winters Triple | **May overlap with HWMA** (`lib/trends_FIR/hwma/`). Review before implementing. |
| WAVELET | CWT/DWT in Numerics (transforms) | Filters — Denoising wavelet filter | Different purpose (denoising vs transform). Both valid. |
| TSF | Was under Statistics planned | Now Trends (FIR) | Moved to correct category |
---
@@ -179,40 +292,79 @@ These indicators appear in `lib/_index.md` as plain text (no markdown link), mea
### Tier 1 — High Value (pine-only, well-specified)
1. **Reversals** (9 pine-only) — Entire category unimplemented in C#
1. **Statistics** (10 pine-only) — Core statistical functionality gap
- KURTOSIS, PERCENTILE, QUANTILE, ZSCORE
- KENDALL, SPEARMAN, JB, ZTEST, THEIL, MODE
2. **Reversals** (9 pine-only) — Large category gap
- PSAR, FRACTALS, PIVOT, PIVOTCAM, PIVOTDEM, PIVOTEXT, PIVOTFIB, PIVOTWOOD, SWINGS
2. **Statistics** (16 pine-only) — Large gap in core functionality
- ENTROPY, GEOMEAN, HARMEAN, KURTOSIS, PERCENTILE, QUANTILE, ZSCORE
- HURST, KENDALL, SPEARMAN, IQR, JB, ZTEST, THEIL, MODE, GRANGER
### Tier 2 — Ehlers DSP Suite (high-value, well-documented algorithms)
### Tier 2 — Medium Value (planned oscillators/dynamics)
1. **Filters** — LAGUERRE, ALAGUERRE, ROOFING, VOSS, AGC, SPBF (all Ehlers)
2. **Oscillators** — DECO, REFLEX, CYBERCYCLE, MSTOCH (all Ehlers)
3. **Cycles** — CCOR, GOERTZEL (Ehlers cycle detection)
4. **Trends (IIR)** — DECYCLER, TRENDFLEX, PMA, REVERSEEMA (Ehlers trend)
1. **Oscillators** (16 planned, no files) — Many well-known indicators
- QQE, RVGI, COPPOCK, KST, CRSI, TD_SEQ, ERI, SQUEEZE
- BBI, BRAR, CTI, DOSC, ER, FOSC, KRI, PSL
### Tier 3 — Window Functions & Regression MAs
2. **Dynamics** — IMPULSE (Elder Impulse System)
3. **Reversals** — CHANDELIER, CKSTOP (no pine spec)
4. **TTM_SCALPER** — doc exists, needs C# implementation
1. **Trends (FIR)** — Window functions: KAISER, BLACKMANHARRIS, LANCZOS, PARZEN, NUTTALL, BOHMAN, DOLPH
2. **Trends (FIR)** — Regression: TSF, QRMA, CRMA, NW, HENDERSON, SP15, SP21
3. **Trends (FIR)** — Robust means: TRIMMED, WINSOR, GEOMMA, HARMMA
### Tier 3Lower Priority (math/distributions/misc)
### Tier 4IIR & Adaptive MAs
1. **Numerics distributions** (14 planned) — Statistical distributions + transforms
2. **Statistics** — POLYFIT, TSF, WAVG
3. **Trends IIR** — LTMA
1. **Trends (IIR)** — EHMA, HOLT, CORAL, GDEMA, NLMA, LEMA, MCNMA, NYQMA, AHRENS, RAINBOW, EVWMA, MAVP, HWTS
2. **Trends (Adaptive)** — ADXVMA, TRAMA, NMA, VMA, EDCF (new category)
### Tier 5 — Adaptive Filters & Miscellaneous
1. **Filters** — LMS, RLS, BAXTERKING, CHRISTIANOFITZGERALD, ONEEURO, WAVELET
2. **Oscillators** — Legacy: QQE, RVGI, COPPOCK, KST, CRSI, TD_SEQ, ERI, SQUEEZE, BBI, BRAR, CTI, DOSC, ER, FOSC, KRI, PSL
3. **Volume** — FVE
4. **Reversals** — SAREXT
### Tier 6 — Lower Priority
1. **Statistics** — POLYFIT, WAVG
2. **Numerics** — 14 distributions + transforms
3. **Trends (IIR)** — LTMA
4. **Forecasts** — MLP
5. **Cycles** — PHASOR
6. **Reversals** — TTM_SCALPER (doc exists, needs C#)
---
## 6. Totals
## 6. Planned Count by Category
| Category | Existing | Pine-Only | Planned (no dir) | Total When Done |
|----------|--------:|----------:|-----------------:|----------------:|
| Trends (FIR) | 17 | 0 | 23 | 40 |
| Trends (IIR) | 23 | 0 | 17 | 40 |
| Trends (Adaptive) | 0 | 0 | 5 | 5 |
| Filters | 18 | 0 | 12 | 30 |
| Oscillators | 19 | 0 | 20 | 39 |
| Dynamics | 18 | 0 | 0 | 18 |
| Momentum | 16 | 0 | 0 | 16 |
| Volatility | 26 | 0 | 0 | 26 |
| Volume | 26 | 0 | 1 | 27 |
| Channels | 23 | 0 | 0 | 23 |
| Cycles | 14 | 0 | 2 | 16 |
| Errors | 26 | 0 | 0 | 26 |
| Numerics | 15 | 0 | 14 | 29 |
| Forecasts | 1 | 0 | 1 | 2 |
| Statistics | 19 | 11 | 2 | 32 |
| Reversals | 2 | 9 | 1 | 12 |
| Feeds | 3 | 0 | 0 | 3 |
| **Total** | **266** | **20** | **98** | **384** |
## 7. Grand Totals
| Type | Count |
|------|------:|
| Pine-only (spec ready, no C#) | **25** |
| No directory (planned only) | **38** |
| Pine-only (spec ready, no C#) | **20** |
| No directory (planned only) | **100** |
| Doc-only | **1** |
| **Total missing indicators** | **64** |
| **Total implemented (C#)** | **258** |
| **Total listed in master index** | **278** |
| **Total missing indicators** | **121** |
| **Total implemented (C#)** | **266** |
| **Grand total (implemented + missing)** | **387** |