AroonOscillator accepts MAX_PERIOD but cannot retain its required period + 1 window

Author: wbizmoCreated Sep 15, 2026Updated Sep 15, 2026

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 develop implementation and this does not appear to be intentional.
  • I've reproduced the issue against the published v2.0.0rc5 pre-release wheel.
  • I've confirmed the same capacity invariant is present in current develop at 608200232f17f6944bc247515c9ee8352bbbdaa1.

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:

rust
pub const MAX_PERIOD: usize = 1_024;

and the constructor accepts every period up to and including that value:

rust
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:

rust
let required = self.period + 1;

if !self.initialized && self.total_count >= required {
    self.initialized = true;
}

and calculate_aroon() asserts the same thing:

rust
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:

rust
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:

rust
let period = MAX_PERIOD;

we get:

accepted period        = 1024
required window        = 1025
ArrayDeque capacity    = 1024

update_raw() also only explicitly removes an old value when:

rust
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:

rust
#[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:

rust
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 needs period + 1 elements while the backing ArrayDeque capacity is MAX_PERIOD, so at period = 1024 the window silently caps at 1024 elements (and the debug_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=1024

Reproduction:

python
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.0

With 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 = 0

The 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.0

The published pre-release instead returns:

aroon_up=100.0
aroon_down=100.0
value=0.0

This 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.0

With 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.0

With the current Wrapping capacity, the first observation has already been displaced before calculate_aroon() runs.

The remaining highs are all:

10.0

Because 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.0

instead of:

aroon_up=0.0

This changes the final oscillator value from:

-100.0

to:

0.0

at the exact maximum period accepted by the constructor.

Steps to reproduce

Published pre-release

  1. Install nautilus_trader==2.0.0rc5.
  2. Construct AroonOscillator(1024).
  3. Feed one observation with a unique highest high.
  4. Feed another 1024 observations to reach the required period + 1 window.
  5. Inspect aroon_up, aroon_down, and value.

Minimal reproduction:

python
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.0

Expected:

0.0
100.0
-100.0

Rust boundary regression

A minimal Rust regression case for the underlying window invariant would be:

rust
#[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:

rust
#[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:

  1. Keep MAX_PERIOD = 1024 as the supported user-facing maximum and give the internal Aroon buffers capacity for MAX_PERIOD + 1.
  2. Change the accepted maximum period to MAX_PERIOD - 1 so the existing 1024 element 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 + 1 observations
  • 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.0rc5 wheel
  • Python: 3.14.3
  • Platform: Linux-7.0.0-1009-aws-x86_64-with-glibc2.36
  • Current develop: same capacity invariant present at 608200232f17f6944bc247515c9ee8352bbbdaa1
  • Component: Rust AroonOscillator / Python binding
  • Adapter/venue: n/a

Source: nautechsystems/nautilus_trader