feat: add new indicators (Decay, Edecay, MinusDi, MinusDm, PlusDi, PlusDm, Maxindex, Minindex, Sarext) and update pine scripts, core libs, validation tests, and python bindings

This commit is contained in:
Miha Kralj
2026-03-09 13:45:46 -07:00
parent 8e43d62cbb
commit 031f1b5fe6
491 changed files with 6156 additions and 5590 deletions
+2
View File
@@ -18,12 +18,14 @@ Basic mathematical transforms and utility functions for time series. These build
| [FFT](fft/Fft.md) | Fast Fourier Transform | Frequency-domain decomposition via FFT algorithm. |
| [GAMMADIST](gammadist/Gammadist.md) | Gamma Distribution | Gamma probability distribution transform. |
| [HIGHEST](highest/Highest.md) | Rolling Maximum | Maximum value over lookback window. |
| [MAXINDEX](maxindex/Maxindex.md) | Rolling Maximum Index | Position of maximum value in rolling window. |
| [IFFT](ifft/Ifft.md) | Inverse Fast Fourier Transform | Frequency-to-time domain reconstruction. |
| [JERK](jerk/Jerk.md) | Jerk | Rate of acceleration; third derivative of price. |
| [LINEARTRANS](lineartrans/Lineartrans.md) | Linear Transform | y = ax + b scaling transformation. |
| [LOGNORMDIST](lognormdist/Lognormdist.md) | Log-normal Distribution | Log-normal probability distribution transform. |
| [LOGTRANS](logtrans/Logtrans.md) | Logarithmic Transform | Natural log for percentage-based analysis. |
| [LOWEST](lowest/Lowest.md) | Rolling Minimum | Minimum value over lookback window. |
| [MININDEX](minindex/Minindex.md) | Rolling Minimum Index | Position of minimum value in rolling window. |
| [NORMDIST](normdist/Normdist.md) | Normal Distribution | Gaussian probability distribution transform. |
| [NORMALIZE](normalize/Normalize.md) | Min-Max Normalization | Scale to [0,1] range using rolling min/max. |
| [POISSONDIST](poissondist/Poissondist.md) | Poisson Distribution | Poisson probability distribution transform. |
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Acceleration (Slope of Slope) (ACCEL)", "ACCEL", overlay=false, precision=8)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Beta Distribution CDF (BETADIST)", "BETADIST", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Binomial Distribution CDF (BINOMDIST)", "BINOMDIST", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Percentage Change (CHANGE)", "CHANGE", overlay=false, format=format.percent)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Continuous Wavelet Transform (CWT)", "CWT", overlay=false, precision=6)
+27
View File
@@ -322,6 +322,33 @@ public class DwtValidationTests
}
}
[Fact]
public void Dwt_Correction_Recomputes()
{
var ind = new Dwt(levels: 4);
var t0 = DateTime.MinValue;
// Build state well past warmup (WarmupPeriod = 2^4 = 16)
for (int i = 0; i < 50; i++)
{
ind.Update(new TValue(t0.AddSeconds(i), 100.0 + i * 0.5));
}
// Anchor bar
var anchorTime = t0.AddSeconds(50);
const double anchorPrice = 125.0;
ind.Update(new TValue(anchorTime, anchorPrice), isNew: true);
double anchorResult = ind.Last.Value;
// Correction with dramatically different value — DWT uses anchor at lag 0
ind.Update(new TValue(anchorTime, anchorPrice * 10), isNew: false);
Assert.NotEqual(anchorResult, ind.Last.Value);
// Correction back to original — must exactly restore
ind.Update(new TValue(anchorTime, anchorPrice), isNew: false);
Assert.Equal(anchorResult, ind.Last.Value, 1e-9);
}
// ─── Helper ──────────────────────────────────────────────────────────────
private static double Variance(List<double> vals)
+8
View File
@@ -313,6 +313,14 @@ public sealed class Dwt : AbstractBase
return result;
}
/// <summary>
/// Primes the indicator with historical values.
/// </summary>
/// <remarks>
/// Synthetic timestamps are generated by subtracting <c>step × source.Length</c>
/// from <see cref="DateTime.UtcNow"/>. For deterministic or replay-safe pipelines
/// use <see cref="Update(TValue, bool)"/> directly with explicit timestamps.
/// </remarks>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Discrete Wavelet Transform (DWT)", "DWT", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Exponential Distribution CDF (EXPDIST)", "EXPDIST", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Exponential Transformation (EXP)", "Exptrans", overlay=false)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("F-Distribution CDF (FDIST)", "FDIST", overlay=false, precision=6)
+28
View File
@@ -233,4 +233,32 @@ public class FftValidationTests
$"Output at {i} must be finite, got {dst[i]}");
}
}
[Fact]
public void Fft_Correction_StateRestores()
{
// The Hanning window maps idx=0 to the newest bar and _hanning[0]=0, so
// corrections to the anchor bar carry zero spectral weight. isNew=false
// determinism is still correct: restoring the original value reproduces the
// original result exactly regardless of any intermediate correction.
var ind = new Fft(windowSize: 32, maxPeriod: 16);
var t0 = DateTime.MinValue;
for (int i = 0; i < 50; i++)
{
ind.Update(new TValue(t0.AddSeconds(i), 100.0 + 10.0 * Math.Sin(2 * Math.PI * i / 8.0)));
}
var anchorTime = t0.AddSeconds(50);
const double anchorPrice = 100.0;
ind.Update(new TValue(anchorTime, anchorPrice), isNew: true);
double anchorResult = ind.Last.Value;
// Apply an arbitrary correction — spectral output is unchanged due to zero Hanning weight
ind.Update(new TValue(anchorTime, anchorPrice * 100), isNew: false);
// Restoring to original must exactly reproduce the original result
ind.Update(new TValue(anchorTime, anchorPrice), isNew: false);
Assert.Equal(anchorResult, ind.Last.Value, 1e-9);
}
}
+8
View File
@@ -212,6 +212,14 @@ public sealed class Fft : AbstractBase
return result;
}
/// <summary>
/// Primes the indicator with historical values.
/// </summary>
/// <remarks>
/// Synthetic timestamps are generated by subtracting <c>step × source.Length</c>
/// from <see cref="DateTime.UtcNow"/>. For deterministic or replay-safe pipelines
/// use <see cref="Update(TValue, bool)"/> directly with explicit timestamps.
/// </remarks>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("FFT Dominant Cycle (Radix-2 FFT)", "FFT-DC", overlay=false, precision=2)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Gamma Distribution CDF (GAMMADIST)", "GAMMADIST", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Highest Value (HIGHEST)", "HIGHEST", overlay=true)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Inverse Fast Fourier Transform (IFFT)", "IFFT", overlay=true, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Acceleration, Slope of Slope (JERK)", "JERK", overlay=false, precision=8)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Linear Transformation (LINEAR)", "Lineartrans", overlay=false)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Log-Normal Distribution CDF (LOGNORMDIST)", "LOGNORMDIST", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Logarithmic Transformation (LOG)", "Logtrans", overlay=false)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Lowest Value (LOWEST)", "LOWEST", overlay=true)
+272
View File
@@ -0,0 +1,272 @@
using System.Runtime.CompilerServices;
namespace QuanTAlib;
/// <summary>
/// MAXINDEX: Rolling Maximum Index
/// Returns the position of the maximum value within a rolling window.
/// Streaming mode: bars-ago offset (0=current, period-1=oldest).
/// Batch span mode: absolute array index (TA-Lib compatible).
/// </summary>
/// <remarks>
/// Key properties:
/// - Returns the index/position of the highest value, not the value itself
/// - Streaming output is "bars-ago" offset for natural streaming consumption
/// - Batch(ReadOnlySpan) output is absolute array index matching TA-Lib MAXINDEX
/// - Tie-breaking: last occurrence wins (most recent bar, using >= comparison)
/// - Can be cross-validated: source[Maxindex.Batch[i]] == Highest.Batch[i]
/// </remarks>
[SkipLocalsInit]
public sealed class Maxindex : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private record struct State(double LastValid);
private State _state, _p_state;
public override bool IsHot => _buffer.Count >= _period;
/// <summary>
/// Initializes a new Maxindex indicator with specified lookback period.
/// </summary>
/// <param name="period">Lookback window size (must be >= 2)</param>
public Maxindex(int period)
{
if (period < 2)
{
throw new ArgumentException("Period must be >= 2", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Maxindex({period})";
WarmupPeriod = period;
}
/// <summary>
/// Initializes a new Maxindex indicator with source for event-based chaining.
/// </summary>
/// <param name="source">Source indicator for chaining</param>
/// <param name="period">Lookback window size</param>
public Maxindex(ITValuePublisher source, int period) : this(period)
{
source.Pub += HandleUpdate;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
double value = double.IsFinite(input.Value) ? input.Value : _state.LastValid;
_state = new State(value);
_buffer.Add(value, isNew);
// Scan the ring buffer to find the bars-ago index of the maximum value.
// Tie-breaking: >= means last occurrence (most recent) wins.
ReadOnlySpan<double> span = _buffer.GetSpan();
int len = span.Length;
double maxVal = span[0];
int maxPos = 0;
for (int i = 1; i < len; i++)
{
if (span[i] >= maxVal)
{
maxVal = span[i];
maxPos = i;
}
}
// Convert to bars-ago: newest element is at index (len - 1), oldest at 0.
// bars-ago = (len - 1) - maxPos
double result = (len - 1) - maxPos;
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
var result = new TSeries(source.Count);
ReadOnlySpan<double> values = source.Values;
ReadOnlySpan<long> times = source.Times;
for (int i = 0; i < source.Count; i++)
{
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
result.Add(tv, true);
}
return result;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
DateTime time = DateTime.UtcNow - (interval * source.Length);
for (int i = 0; i < source.Length; i++)
{
Update(input: new TValue(time, source[i]), isNew: true);
time += interval;
}
}
public static TSeries Batch(TSeries source, int period)
{
var indicator = new Maxindex(period);
return indicator.Update(source);
}
/// <summary>
/// Calculates rolling maximum index over a span of values.
/// Output contains ABSOLUTE array indices (TA-Lib MAXINDEX compatible).
/// </summary>
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length == 0)
{
throw new ArgumentException("Source cannot be empty", nameof(source));
}
if (output.Length < source.Length)
{
throw new ArgumentException("Output length must be >= source length", nameof(output));
}
if (period < 2)
{
throw new ArgumentException("Period must be >= 2", nameof(period));
}
int len = source.Length;
// Use monotonic deque algorithm — same as Highest but output index, not value.
int[]? rentedDeque = null;
double[]? rentedValues = null;
#pragma warning disable S1121 // Assignments should not be made from within sub-expressions
Span<int> deque = period <= 256
? stackalloc int[period]
: (rentedDeque = System.Buffers.ArrayPool<int>.Shared.Rent(period)).AsSpan(0, period);
Span<double> values = len <= 256
? stackalloc double[len]
: (rentedValues = System.Buffers.ArrayPool<double>.Shared.Rent(len)).AsSpan(0, len);
#pragma warning restore S1121
try
{
// First pass: store corrected values (handle NaN/Infinity)
double lastValid = 0.0;
for (int i = 0; i < len; i++)
{
double val = source[i];
if (double.IsFinite(val))
{
lastValid = val;
values[i] = val;
}
else
{
values[i] = lastValid;
}
}
// Second pass: compute rolling max index using monotonic deque.
// Circular buffer indexing — branch-based wrapping is faster than modulo.
int head = 0; // front of deque (oldest/max)
int tail = 0; // back of deque (newest)
int count = 0; // number of elements in deque
int capacity = deque.Length;
for (int i = 0; i < len; i++)
{
double value = values[i];
// Remove indices outside window from front
while (count > 0 && deque[head] <= i - period)
{
head++;
if (head >= capacity)
{
head -= capacity;
}
count--;
}
// Remove smaller-or-equal values from back (>= tie-breaking: last occurrence wins)
while (count > 0)
{
int backIdx = tail - 1;
if (backIdx < 0)
{
backIdx += capacity;
}
if (values[deque[backIdx]] <= value)
{
tail = backIdx;
count--;
}
else
{
break;
}
}
// Add current index at tail
deque[tail] = i;
tail++;
if (tail >= capacity)
{
tail -= capacity;
}
count++;
// Output the ABSOLUTE index of the maximum (not the value)
output[i] = deque[head];
}
}
finally
{
if (rentedDeque != null)
{
System.Buffers.ArrayPool<int>.Shared.Return(rentedDeque);
}
if (rentedValues != null)
{
System.Buffers.ArrayPool<double>.Shared.Return(rentedValues);
}
}
}
public static (TSeries Results, Maxindex Indicator) Calculate(TSeries source, int period)
{
var indicator = new Maxindex(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_buffer.Clear();
_state = default;
_p_state = default;
Last = default;
}
}
+167
View File
@@ -0,0 +1,167 @@
# MAXINDEX: Rolling Maximum Index
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Numeric |
| **Inputs** | Source (close) |
| **Parameters** | `period` (default=14, min=2) |
| **Outputs** | Single series (Maxindex) |
| **Output range** | Streaming: 0 to period-1 (bars-ago); Batch span: absolute array index |
| **Warmup** | `period` bars |
### TL;DR
- MAXINDEX finds the position (index) of the maximum value within a rolling lookback window.
- Parameterized by `period` (minimum 2).
- Streaming mode outputs bars-ago offset (0 = current bar holds the max, period-1 = oldest bar).
- Batch span mode outputs absolute array indices (TA-Lib MAXINDEX compatible).
- Tie-breaking: last occurrence wins (most recent bar, `>=` comparison).
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Cross-validation: `source[Maxindex.Batch[i]] == Highest.Batch[i]` for all bars after warmup.
> "It's not just about the peak — it's about *when* the peak occurred."
MAXINDEX identifies the position of the maximum value within a rolling window. While HIGHEST tells you the peak *value*, MAXINDEX tells you *where* that peak is relative to the current bar. This is essential for pattern recognition, timing analysis, and detecting how "stale" a high is.
## Historical Context
The MAXINDEX function originates from TA-Lib (TA_MAXINDEX), used in quantitative trading systems to identify when the highest price in a lookback window occurred. This timing information is critical for:
- **Breakout freshness**: A max at position 0 means the breakout is happening *now*; at position period-1, the high is stale and fading.
- **Pattern detection**: Identifying head-and-shoulders, double tops, and other formations requires knowing *when* peaks occurred.
- **Momentum analysis**: The position of the high within the window indicates whether momentum is building or decaying.
## Architecture & Physics
### 1. Streaming Mode — Bars-Ago Offset
In streaming mode, the output represents how many bars ago the maximum occurred:
$$
\text{Maxindex}_t = t - \arg\max_{t-n+1 \leq k \leq t} V_k
$$
where $n$ is the lookback period. A value of 0 means the current bar is the maximum; a value of $n-1$ means the oldest bar in the window holds the maximum.
### 2. Batch Span Mode — Absolute Index
In the `Batch(ReadOnlySpan)` method, output is the absolute array index:
$$
\text{output}[i] = \arg\max_{i-n+1 \leq k \leq i} V_k
$$
This matches TA-Lib's MAXINDEX convention and enables direct array lookup: `source[output[i]]` yields the maximum value.
### 3. Tie-Breaking
When multiple values in the window are equal to the maximum, the **most recent** (rightmost) occurrence wins:
$$
\text{Maxindex}_t = \max \{ k : V_k = \max(\text{window}) \}
$$
This is achieved using `>=` comparison, matching TA-Lib behavior.
### 4. Monotonic Deque (Batch Mode)
The batch span method uses the same O(n) monotonic deque algorithm as Highest, but outputs the index stored at the deque head rather than the value at that index:
```
// Highest: output[i] = values[deque.PeekHead()] → the VALUE
// Maxindex: output[i] = deque.PeekHead() → the INDEX
```
### 5. Bar Correction via Rollback
When `isNew=false`, the indicator:
1. Restores previous state (`_state = _p_state`)
2. Replaces the last value in the buffer
3. Re-scans the buffer to find the new maximum position
## Mathematical Foundation
### Rolling Maximum Index Definition
$$
\text{Maxindex}_t = \arg\max_{t-n+1 \leq k \leq t} V_k
$$
where $n$ is the lookback period and ties are broken in favor of the most recent occurrence.
### Partial Window Behavior
Before the window is full:
$$
\text{Maxindex}_t = \arg\max_{0 \leq k \leq t} V_k \quad \text{for } t < n
$$
### Complexity Analysis
| Operation | Streaming | Batch (Deque) |
| :--- | :---: | :---: |
| Per-update (worst) | O(n) | O(n) |
| Per-update (amortized) | O(n) | O(1) |
| Total for N updates | O(N×n) | O(N) |
Streaming uses a linear scan of the RingBuffer, which is O(period) per bar — acceptable for typical periods (530). Batch mode uses the monotonic deque for O(1) amortized.
## Performance Profile
### Streaming Mode (Linear Scan)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| CMP (scan) | period | 1 | period |
| Array access | period | 3 | 3×period |
| Index arithmetic | 2 | 1 | 2 |
| **Total** | — | — | **~4×period cycles** |
### Batch Mode (Monotonic Deque)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| CMP (expired check) | 1 | 1 | 1 |
| CMP (monotonicity) | ~2 avg | 1 | 2 |
| Array access | 3 | 3 | 9 |
| Index arithmetic | 2 | 1 | 2 |
| **Total** | **~8** | — | **~14 cycles** |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 10/10 | Exact index of maximum |
| **Timeliness** | 10/10 | Zero lag for index detection |
| **Smoothness** | 2/10 | Discrete jumps as window slides |
| **Computational Cost** | 8/10 | O(period) streaming, O(1) batch |
| **Memory** | 7/10 | O(n) for buffer + deque |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib MAXINDEX** | ✅ | Batch span output matches absolute indices |
| **Cross-validation** | ✅ | `source[Maxindex[i]] == Highest[i]` for all valid bars |
| **Known Values** | ✅ | Manual verification |
## Common Pitfalls
1. **Two Output Modes**: Streaming returns bars-ago offset; Batch(ReadOnlySpan) returns absolute array index. Do not mix them up.
2. **Period Minimum is 2**: Unlike Highest (which accepts period=1), Maxindex requires period >= 2, since the index of a single element is trivially 0.
3. **Tie-Breaking**: Uses `>=` so the most recent (rightmost) occurrence wins ties. This matches TA-Lib convention.
4. **Window Boundary Effects**: When the previous max expires from the window, the index can jump abruptly. This is expected behavior.
5. **Warmup Period**: `IsHot` becomes true after `period` values. Before warmup, returns index within available data.
6. **Using isNew Incorrectly**: Use `isNew: false` only when correcting the current bar. New bars must use `isNew: true`.
## References
- TA-Lib: MAXINDEX function documentation.
- Lemire, Daniel. (2006). "Streaming Maximum-Minimum Filter Using No More than Three Comparisons per Element."
- Tarjan, Robert E. (1985). "Amortized Computational Complexity." SIAM Journal on Algebraic Discrete Methods.
+272
View File
@@ -0,0 +1,272 @@
using System.Runtime.CompilerServices;
namespace QuanTAlib;
/// <summary>
/// MININDEX: Rolling Minimum Index
/// Returns the position of the minimum value within a rolling window.
/// Streaming mode: bars-ago offset (0=current, period-1=oldest).
/// Batch span mode: absolute array index (TA-Lib compatible).
/// </summary>
/// <remarks>
/// Key properties:
/// - Returns the index/position of the lowest value, not the value itself
/// - Streaming output is "bars-ago" offset for natural streaming consumption
/// - Batch(ReadOnlySpan) output is absolute array index matching TA-Lib MININDEX
/// - Tie-breaking: last occurrence wins (most recent bar, using &lt;= comparison)
/// - Can be cross-validated: source[Minindex.Batch[i]] == Lowest.Batch[i]
/// </remarks>
[SkipLocalsInit]
public sealed class Minindex : AbstractBase
{
private readonly int _period;
private readonly RingBuffer _buffer;
private record struct State(double LastValid);
private State _state, _p_state;
public override bool IsHot => _buffer.Count >= _period;
/// <summary>
/// Initializes a new Minindex indicator with specified lookback period.
/// </summary>
/// <param name="period">Lookback window size (must be >= 2)</param>
public Minindex(int period)
{
if (period < 2)
{
throw new ArgumentException("Period must be >= 2", nameof(period));
}
_period = period;
_buffer = new RingBuffer(period);
Name = $"Minindex({period})";
WarmupPeriod = period;
}
/// <summary>
/// Initializes a new Minindex indicator with source for event-based chaining.
/// </summary>
/// <param name="source">Source indicator for chaining</param>
/// <param name="period">Lookback window size</param>
public Minindex(ITValuePublisher source, int period) : this(period)
{
source.Pub += HandleUpdate;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void HandleUpdate(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
if (isNew)
{
_p_state = _state;
}
else
{
_state = _p_state;
}
double value = double.IsFinite(input.Value) ? input.Value : _state.LastValid;
_state = new State(value);
_buffer.Add(value, isNew);
// Scan the ring buffer to find the bars-ago index of the minimum value.
// Tie-breaking: <= means last occurrence (most recent) wins.
ReadOnlySpan<double> span = _buffer.GetSpan();
int len = span.Length;
double minVal = span[0];
int minPos = 0;
for (int i = 1; i < len; i++)
{
if (span[i] <= minVal)
{
minVal = span[i];
minPos = i;
}
}
// Convert to bars-ago: newest element is at index (len - 1), oldest at 0.
// bars-ago = (len - 1) - minPos
double result = (len - 1) - minPos;
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
public override TSeries Update(TSeries source)
{
var result = new TSeries(source.Count);
ReadOnlySpan<double> values = source.Values;
ReadOnlySpan<long> times = source.Times;
for (int i = 0; i < source.Count; i++)
{
var tv = Update(new TValue(new DateTime(times[i], DateTimeKind.Utc), values[i]), true);
result.Add(tv, true);
}
return result;
}
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
TimeSpan interval = step ?? TimeSpan.FromSeconds(1);
DateTime time = DateTime.UtcNow - (interval * source.Length);
for (int i = 0; i < source.Length; i++)
{
Update(input: new TValue(time, source[i]), isNew: true);
time += interval;
}
}
public static TSeries Batch(TSeries source, int period)
{
var indicator = new Minindex(period);
return indicator.Update(source);
}
/// <summary>
/// Calculates rolling minimum index over a span of values.
/// Output contains ABSOLUTE array indices (TA-Lib MININDEX compatible).
/// </summary>
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
{
if (source.Length == 0)
{
throw new ArgumentException("Source cannot be empty", nameof(source));
}
if (output.Length < source.Length)
{
throw new ArgumentException("Output length must be >= source length", nameof(output));
}
if (period < 2)
{
throw new ArgumentException("Period must be >= 2", nameof(period));
}
int len = source.Length;
// Use monotonic deque algorithm — same as Lowest but output index, not value.
int[]? rentedDeque = null;
double[]? rentedValues = null;
#pragma warning disable S1121 // Assignments should not be made from within sub-expressions
Span<int> deque = period <= 256
? stackalloc int[period]
: (rentedDeque = System.Buffers.ArrayPool<int>.Shared.Rent(period)).AsSpan(0, period);
Span<double> values = len <= 256
? stackalloc double[len]
: (rentedValues = System.Buffers.ArrayPool<double>.Shared.Rent(len)).AsSpan(0, len);
#pragma warning restore S1121
try
{
// First pass: store corrected values (handle NaN/Infinity)
double lastValid = 0.0;
for (int i = 0; i < len; i++)
{
double val = source[i];
if (double.IsFinite(val))
{
lastValid = val;
values[i] = val;
}
else
{
values[i] = lastValid;
}
}
// Second pass: compute rolling min index using monotonic deque.
// Circular buffer indexing — branch-based wrapping is faster than modulo.
int head = 0; // front of deque (oldest/min)
int tail = 0; // back of deque (newest)
int count = 0; // number of elements in deque
int capacity = deque.Length;
for (int i = 0; i < len; i++)
{
double value = values[i];
// Remove indices outside window from front
while (count > 0 && deque[head] <= i - period)
{
head++;
if (head >= capacity)
{
head -= capacity;
}
count--;
}
// Remove larger-or-equal values from back (<= tie-breaking: last occurrence wins)
while (count > 0)
{
int backIdx = tail - 1;
if (backIdx < 0)
{
backIdx += capacity;
}
if (values[deque[backIdx]] >= value)
{
tail = backIdx;
count--;
}
else
{
break;
}
}
// Add current index at tail
deque[tail] = i;
tail++;
if (tail >= capacity)
{
tail -= capacity;
}
count++;
// Output the ABSOLUTE index of the minimum (not the value)
output[i] = deque[head];
}
}
finally
{
if (rentedDeque != null)
{
System.Buffers.ArrayPool<int>.Shared.Return(rentedDeque);
}
if (rentedValues != null)
{
System.Buffers.ArrayPool<double>.Shared.Return(rentedValues);
}
}
}
public static (TSeries Results, Minindex Indicator) Calculate(TSeries source, int period)
{
var indicator = new Minindex(period);
TSeries results = indicator.Update(source);
return (results, indicator);
}
public override void Reset()
{
_buffer.Clear();
_state = default;
_p_state = default;
Last = default;
}
}
+167
View File
@@ -0,0 +1,167 @@
# MININDEX: Rolling Minimum Index
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Numeric |
| **Inputs** | Source (close) |
| **Parameters** | `period` (default=14, min=2) |
| **Outputs** | Single series (Minindex) |
| **Output range** | Streaming: 0 to period-1 (bars-ago); Batch span: absolute array index |
| **Warmup** | `period` bars |
### TL;DR
- MININDEX finds the position (index) of the minimum value within a rolling lookback window.
- Parameterized by `period` (minimum 2).
- Streaming mode outputs bars-ago offset (0 = current bar holds the min, period-1 = oldest bar).
- Batch span mode outputs absolute array indices (TA-Lib MININDEX compatible).
- Tie-breaking: last occurrence wins (most recent bar, `<=` comparison).
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Cross-validation: `source[Minindex.Batch[i]] == Lowest.Batch[i]` for all bars after warmup.
> "Finding support isn't just about the price — it's about *when* the floor was set."
MININDEX identifies the position of the minimum value within a rolling window. While LOWEST tells you the trough *value*, MININDEX tells you *where* that trough is relative to the current bar. This is essential for support analysis, timing studies, and detecting how "stale" a low is.
## Historical Context
The MININDEX function originates from TA-Lib (TA_MININDEX), used in quantitative trading systems to identify when the lowest price in a lookback window occurred. This timing information is critical for:
- **Support freshness**: A min at position 0 means support is being tested *now*; at position period-1, the low is stale and potentially irrelevant.
- **Pattern detection**: Identifying double bottoms, inverse head-and-shoulders, and other formations requires knowing *when* troughs occurred.
- **Exhaustion analysis**: The position of the low within the window indicates whether selling pressure is current or historical.
## Architecture & Physics
### 1. Streaming Mode — Bars-Ago Offset
In streaming mode, the output represents how many bars ago the minimum occurred:
$$
\text{Minindex}_t = t - \arg\min_{t-n+1 \leq k \leq t} V_k
$$
where $n$ is the lookback period. A value of 0 means the current bar is the minimum; a value of $n-1$ means the oldest bar in the window holds the minimum.
### 2. Batch Span Mode — Absolute Index
In the `Batch(ReadOnlySpan)` method, output is the absolute array index:
$$
\text{output}[i] = \arg\min_{i-n+1 \leq k \leq i} V_k
$$
This matches TA-Lib's MININDEX convention and enables direct array lookup: `source[output[i]]` yields the minimum value.
### 3. Tie-Breaking
When multiple values in the window are equal to the minimum, the **most recent** (rightmost) occurrence wins:
$$
\text{Minindex}_t = \max \{ k : V_k = \min(\text{window}) \}
$$
This is achieved using `<=` comparison, matching TA-Lib behavior.
### 4. Monotonic Deque (Batch Mode)
The batch span method uses the same O(n) monotonic deque algorithm as Lowest, but outputs the index stored at the deque head rather than the value at that index:
```
// Lowest: output[i] = values[deque.PeekHead()] → the VALUE
// Minindex: output[i] = deque.PeekHead() → the INDEX
```
### 5. Bar Correction via Rollback
When `isNew=false`, the indicator:
1. Restores previous state (`_state = _p_state`)
2. Replaces the last value in the buffer
3. Re-scans the buffer to find the new minimum position
## Mathematical Foundation
### Rolling Minimum Index Definition
$$
\text{Minindex}_t = \arg\min_{t-n+1 \leq k \leq t} V_k
$$
where $n$ is the lookback period and ties are broken in favor of the most recent occurrence.
### Partial Window Behavior
Before the window is full:
$$
\text{Minindex}_t = \arg\min_{0 \leq k \leq t} V_k \quad \text{for } t < n
$$
### Complexity Analysis
| Operation | Streaming | Batch (Deque) |
| :--- | :---: | :---: |
| Per-update (worst) | O(n) | O(n) |
| Per-update (amortized) | O(n) | O(1) |
| Total for N updates | O(N×n) | O(N) |
Streaming uses a linear scan of the RingBuffer, which is O(period) per bar — acceptable for typical periods (530). Batch mode uses the monotonic deque for O(1) amortized.
## Performance Profile
### Streaming Mode (Linear Scan)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| CMP (scan) | period | 1 | period |
| Array access | period | 3 | 3×period |
| Index arithmetic | 2 | 1 | 2 |
| **Total** | — | — | **~4×period cycles** |
### Batch Mode (Monotonic Deque)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| CMP (expired check) | 1 | 1 | 1 |
| CMP (monotonicity) | ~2 avg | 1 | 2 |
| Array access | 3 | 3 | 9 |
| Index arithmetic | 2 | 1 | 2 |
| **Total** | **~8** | — | **~14 cycles** |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 10/10 | Exact index of minimum |
| **Timeliness** | 10/10 | Zero lag for index detection |
| **Smoothness** | 2/10 | Discrete jumps as window slides |
| **Computational Cost** | 8/10 | O(period) streaming, O(1) batch |
| **Memory** | 7/10 | O(n) for buffer + deque |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib MININDEX** | ✅ | Batch span output matches absolute indices |
| **Cross-validation** | ✅ | `source[Minindex[i]] == Lowest[i]` for all valid bars |
| **Known Values** | ✅ | Manual verification |
## Common Pitfalls
1. **Two Output Modes**: Streaming returns bars-ago offset; Batch(ReadOnlySpan) returns absolute array index. Do not mix them up.
2. **Period Minimum is 2**: Unlike Lowest (which accepts period=1), Minindex requires period >= 2, since the index of a single element is trivially 0.
3. **Tie-Breaking**: Uses `<=` so the most recent (rightmost) occurrence wins ties. This matches TA-Lib convention.
4. **Window Boundary Effects**: When the previous min expires from the window, the index can jump abruptly. This is expected behavior.
5. **Warmup Period**: `IsHot` becomes true after `period` values. Before warmup, returns index within available data.
6. **Using isNew Incorrectly**: Use `isNew: false` only when correcting the current bar. New bars must use `isNew: true`.
## References
- TA-Lib: MININDEX function documentation.
- Lemire, Daniel. (2006). "Streaming Maximum-Minimum Filter Using No More than Three Comparisons per Element."
- Tarjan, Robert E. (1985). "Amortized Computational Complexity." SIAM Journal on Algebraic Discrete Methods.
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Min-Max Normalization (NORMALIZE)", "NORMALIZE", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Normal Distribution CDF (NORMDIST)", "NORMDIST", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Poisson Distribution CDF (POISSONDIST)", "POISSONDIST", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Rectified Linear Unit (ReLU)", "ReLU", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Logistic Function (SIGMOID)", "SIGMOID", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Slope, Linear Regression (SLOPE)", "SLOPE", overlay=false, precision=8)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Square Root Transformation (SQRT)", "Sqrttrans", overlay=false)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Student's t-Distribution CDF (TDIST)", "TDIST", overlay=false, precision=6)
+1 -1
View File
@@ -1,4 +1,4 @@
// The MIT License (MIT)
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Weibull Distribution CDF (WEIBULLDIST)", "WEIBULLDIST", overlay=false, precision=6)