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
+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.