AroonOscillator accepts MAX_PERIOD but cannot retain its required period + 1 window
Bug Report
Confirmation
- I've re-read the relevant implementation and tests.
- I've searched existing issues, PRs, and discussions to avoid duplicating active work.
- I've reviewed the current
developimplementation and this does not appear to be intentional. - I've reproduced the issue against the published
v2.0.0rc5pre-release wheel. - I've confirmed the same capacity invariant is present in current
developat608200232f17f6944bc247515c9ee8352bbbdaa1.
I found this while looking through the current Rust AroonOscillator implementation.
Expected behavior
AroonOscillator::new(MAX_PERIOD) should be a valid configuration if the constructor explicitly accepts it.
The current maximum is:
pub const MAX_PERIOD: usize = 1_024;and the constructor accepts every period up to and including that value:
assert!(
period <= MAX_PERIOD,
"AroonOscillator: period must be ≤ {MAX_PERIOD} (received {period})"
);Aroon uses a period + 1 observation window, so a period of 1024 requires the oscillator to retain 1025 highs and 1025 lows.
The implementation itself relies on this invariant:
let required = self.period + 1;
if !self.initialized && self.total_count >= required {
self.initialized = true;
}and calculate_aroon() asserts the same thing:
debug_assert_eq!(self.high_inputs.len(), self.period + 1);So if period = MAX_PERIOD, I would expect both internal buffers to be able to retain MAX_PERIOD + 1 values.
Actual behavior
The buffers are currently defined with capacity MAX_PERIOD, not MAX_PERIOD + 1:
high_inputs: ArrayDeque<f64, MAX_PERIOD, Wrapping>,
low_inputs: ArrayDeque<f64, MAX_PERIOD, Wrapping>,This means the public maximum period and the internal storage invariant disagree.
For:
let period = MAX_PERIOD;we get:
accepted period = 1024
required window = 1025
ArrayDeque capacity = 1024update_raw() also only explicitly removes an old value when:
if self.count == self.period + 1 {
let _ = self.high_inputs.pop_front();
let _ = self.low_inputs.pop_front();
} else {
self.count += 1;
}At the boundary, the deque reaches its fixed capacity before count can reach period + 1.
The repository already has a unit test documenting how Wrapping behaves when a deque is full:
#[rstest]
fn arraydeque_wraps_when_full() {
const CAP: usize = 3;
let mut buf: ArrayDeque<usize, CAP, Wrapping> = ArrayDeque::new();
for i in 0..=CAP {
let _ = buf.push_back(i);
}
assert_eq!(buf.len(), CAP);
assert_eq!(buf.front().copied(), Some(1));
assert_eq!(buf.back().copied(), Some(3));
}So pushing the 1025th Aroon observation into a Wrapping deque with capacity 1024 evicts the oldest observation and leaves the length at 1024.
Because these are ArrayDeque<_, MAX_PERIOD, Wrapping> instances, the storage can never satisfy the invariant checked by:
debug_assert_eq!(self.high_inputs.len(), self.period + 1);for a period of 1024.
In a debug build, calculate_aroon() reaches that assertion expecting 1025 while the deque can only contain 1024.
In a release build, the debug assertion is removed, so calculation can proceed against a window smaller than the period the user requested.
This exact boundary problem was also called out, but intentionally left unfixed, in merged PR #4914:
AroonOscillator::new(MAX_PERIOD)is accepted, but the window needsperiod + 1elements while the backingArrayDequecapacity isMAX_PERIOD, so atperiod = 1024the window silently caps at 1024 elements (and thedebug_assert_eq!fires in debug builds).
Runtime reproduction on the published pre-release
I reproduced the issue directly against the published nautilus_trader==2.0.0rc5 wheel.
Environment:
nautilus_trader=2.0.0rc5
python=3.14.3
platform=Linux-7.0.0-1009-aws-x86_64-with-glibc2.36
period=1024Reproduction:
from nautilus_trader.indicators import AroonOscillator
period = 1024
aroon = AroonOscillator(period)
# Oldest observation is the unique highest high.
aroon.update_raw(1000.0, 5.0)
# Fill the remainder of the required 1025-observation window.
for _ in range(period):
aroon.update_raw(10.0, 1.0)
print(f"initialized={aroon.initialized}")
print(f"count={aroon.count}")
print(f"aroon_up={aroon.aroon_up}")
print(f"aroon_down={aroon.aroon_down}")
print(f"value={aroon.value}")Actual output from the published v2.0.0rc5 wheel:
initialized=True
count=1025
aroon_up=100.0
aroon_down=100.0
value=0.0With a correct 1025 element window, the unique high of 1000.0 is still exactly 1024 periods back when the indicator first initializes.
The expected Aroon Up is therefore:
100 * (1024 - 1024) / 1024 = 0The lowest low is present in the newer observations and, because ties resolve to the most recent occurrence, Aroon Down should be 100.
So the expected state is:
aroon_up=0.0
aroon_down=100.0
value=-100.0The published pre-release instead returns:
aroon_up=100.0
aroon_down=100.0
value=0.0This confirms that the boundary mismatch produces an actual indicator signal error in the published pre-release, not only an internal capacity mismatch.
Why this affects the indicator value
This is not only an internal length mismatch.
For a period of 1024, the first observation in the reproduction contains the unique highest high:
high = 1000.0With the required 1025 observation window, that value should still be present exactly 1024 periods back when the oscillator initializes.
That makes the correct Aroon Up:
0.0With the current Wrapping capacity, the first observation has already been displaced before calculate_aroon() runs.
The remaining highs are all:
10.0Because equal highs resolve to the most recent occurrence, the calculation treats the newest 10.0 high as the current highest high and returns:
aroon_up=100.0instead of:
aroon_up=0.0This changes the final oscillator value from:
-100.0to:
0.0at the exact maximum period accepted by the constructor.
Steps to reproduce
Published pre-release
- Install
nautilus_trader==2.0.0rc5. - Construct
AroonOscillator(1024). - Feed one observation with a unique highest high.
- Feed another
1024observations to reach the requiredperiod + 1window. - Inspect
aroon_up,aroon_down, andvalue.
Minimal reproduction:
from nautilus_trader.indicators import AroonOscillator
period = 1024
aroon = AroonOscillator(period)
aroon.update_raw(1000.0, 5.0)
for _ in range(period):
aroon.update_raw(10.0, 1.0)
print(aroon.aroon_up)
print(aroon.aroon_down)
print(aroon.value)Current v2.0.0rc5 output:
100.0
100.0
0.0Expected:
0.0
100.0
-100.0Rust boundary regression
A minimal Rust regression case for the underlying window invariant would be:
#[test]
fn max_period_retains_period_plus_one_window() {
let mut aroon = AroonOscillator::new(MAX_PERIOD);
for _ in 0..=MAX_PERIOD {
aroon.update_raw(1.0, 0.0);
}
assert_eq!(aroon.high_inputs.len(), MAX_PERIOD + 1);
assert_eq!(aroon.low_inputs.len(), MAX_PERIOD + 1);
}With the current implementation, the backing ArrayDeque cannot retain MAX_PERIOD + 1 elements.
In a debug build, the existing debug_assert_eq!(self.high_inputs.len(), self.period + 1) is reached while the deque is still limited to MAX_PERIOD elements.
Relevant existing tests
There is already a test which documents the intended Aroon window invariant:
#[rstest]
fn test_window_size_period_plus_one() {
let period = 7;
let mut aroon = AroonOscillator::new(period);
for _ in 0..=period {
aroon.update_raw(1.0, 0.0);
}
assert_eq!(aroon.high_inputs.len(), period + 1);
assert_eq!(aroon.low_inputs.len(), period + 1);
}The test is correct for period = 7, but it does not exercise the same invariant at MAX_PERIOD.
The current implementation therefore has an invariant which is tested at a normal period but cannot hold at the constructor's accepted upper bound.
A regression test at MAX_PERIOD should ideally cover both the internal window size and an extrema pattern which makes the lost oldest observation visible in the resulting Aroon value.
Possible direction
I see two possible fixes:
- Keep
MAX_PERIOD = 1024as the supported user-facing maximum and give the internal Aroon buffers capacity forMAX_PERIOD + 1. - Change the accepted maximum period to
MAX_PERIOD - 1so the existing1024element buffers remain sufficient.
I think the first option is less surprising because the current public constructor explicitly says that 1024 is valid, and the implementation already defines the required window as period + 1.
If keeping 1024 valid is the preferred direction, I can send a focused PR with regression coverage for:
- initialization at exactly
MAX_PERIOD + 1observations - correct extrema retention at
MAX_PERIOD - the resulting Aroon values at the boundary
- rollover after the next observation
Specifications
nautilus_trader:v2.0.0rc5- Runtime reproduction: confirmed against the published
2.0.0rc5wheel - Python:
3.14.3 - Platform:
Linux-7.0.0-1009-aws-x86_64-with-glibc2.36 - Current
develop: same capacity invariant present at608200232f17f6944bc247515c9ee8352bbbdaa1 - Component: Rust
AroonOscillator/ Python binding - Adapter/venue: n/a
Source: nautechsystems/nautilus_trader