perf(ulcer-index): track trailing max with a monotone deque (R1, B-Opus-2)
`UlcerIndex::update` previously scanned the full `period`-window every tick via `prices.iter().fold(NEG_INFINITY, f64::max)`, breaking the `Indicator` trait's O(1) contract. For long windows (e.g. period 50+ on a live tick stream) this turned a constant-time update into an O(period) one, and full-history batch replays into O(n · period). The window of raw prices is replaced with a monotonically-decreasing deque of `(index, price)` pairs. On every push, all back entries `<= input` are popped (they can never be the trailing max again, since they are dominated and at least as old). On every step, the front is popped if its index is older than `count - period + 1`. The deque's front is therefore always the trailing max in O(1). `count: u64` is the 1-based input counter that drives expiration; on `reset()` it returns to zero alongside the deque and the drawdown state. Behaviour is unchanged: same per-tick values, same warmup (`2 * period - 1`), same non-finite-input semantics. A new test `monotone_deque_matches_naive_max_on_adversarial_inputs` compares the deque output bar-by-bar against an independent O(n) trailing-max scan on inputs designed to hit every code path: strictly increasing (full tail pops), strictly decreasing (head expirations only), constants (the `<= input` pop rule keeps a single newest entry), and a sawtooth. The doc comment on `warmup_period()` is also corrected (B-Opus-2): the two windows overlap by one bar, so the formula is `2 * period - 1`, not `2 * period`.
This commit is contained in:
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- `UlcerIndex::update` now tracks the trailing maximum with a monotonically-
|
||||
decreasing deque of `(index, price)` pairs instead of scanning the whole
|
||||
trailing window on every tick. The indicator now honours the `Indicator`
|
||||
trait's O(1)-per-tick contract; values and warmup semantics are unchanged
|
||||
(verified by a new adversarial-input test that compares the deque output
|
||||
bar-by-bar against a naive O(n) trailing-max scan on strictly increasing,
|
||||
strictly decreasing, constant, and sawtooth inputs). The doc comment on
|
||||
`warmup_period()` is also corrected: the two windows overlap by one bar, so
|
||||
the formula is `2 * period - 1`.
|
||||
|
||||
### Added
|
||||
- `RollingVWAP` is now exposed in Python, Node and WASM under that name
|
||||
(previously the rolling-window VWAP existed only in the Rust core, even
|
||||
|
||||
@@ -22,6 +22,11 @@ use crate::traits::Indicator;
|
||||
/// the volatility measure of choice for risk-adjusted return ratios (the
|
||||
/// "Martin ratio" / UPI).
|
||||
///
|
||||
/// Each `update` is amortised O(1): the trailing maximum is tracked with a
|
||||
/// monotonically-decreasing deque of `(index, price)` pairs, so the indicator
|
||||
/// honours the `Indicator` trait's O(1)-per-tick contract even for long
|
||||
/// windows.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
@@ -37,8 +42,12 @@ use crate::traits::Indicator;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UlcerIndex {
|
||||
period: usize,
|
||||
/// Rolling window of the last `period` prices (for the trailing maximum).
|
||||
prices: VecDeque<f64>,
|
||||
/// 1-based count of finite inputs seen so far; used as the monotonic index
|
||||
/// that expires entries from `max_dq`.
|
||||
count: u64,
|
||||
/// Monotonically-decreasing deque of `(index, price)` over the trailing
|
||||
/// `period` inputs. The front holds the current trailing maximum in O(1).
|
||||
max_dq: VecDeque<(u64, f64)>,
|
||||
/// Rolling window of the last `period` squared percentage drawdowns.
|
||||
drawdowns_sq: VecDeque<f64>,
|
||||
sum_sq: f64,
|
||||
@@ -57,7 +66,8 @@ impl UlcerIndex {
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prices: VecDeque::with_capacity(period),
|
||||
count: 0,
|
||||
max_dq: VecDeque::with_capacity(period),
|
||||
drawdowns_sq: VecDeque::with_capacity(period),
|
||||
sum_sq: 0.0,
|
||||
last: None,
|
||||
@@ -84,18 +94,31 @@ impl Indicator for UlcerIndex {
|
||||
// Non-finite input is ignored; state is left untouched.
|
||||
return self.last;
|
||||
}
|
||||
if self.prices.len() == self.period {
|
||||
self.prices.pop_front();
|
||||
self.count += 1;
|
||||
// Drop tail entries that can never be the trailing max again — every
|
||||
// entry `≤ input` is dominated by `input` and at least as old.
|
||||
while let Some(&(_, back)) = self.max_dq.back() {
|
||||
if back <= input {
|
||||
self.max_dq.pop_back();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.prices.push_back(input);
|
||||
if self.prices.len() < self.period {
|
||||
self.max_dq.push_back((self.count, input));
|
||||
// Expire the head once it falls out of the trailing `period`-window.
|
||||
let window_lo = self.count.saturating_sub(self.period as u64 - 1);
|
||||
while let Some(&(idx, _)) = self.max_dq.front() {
|
||||
if idx < window_lo {
|
||||
self.max_dq.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.count < self.period as u64 {
|
||||
return None;
|
||||
}
|
||||
let max_price = self
|
||||
.prices
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
// Front is the trailing max in O(1).
|
||||
let max_price = self.max_dq.front().expect("non-empty").1;
|
||||
let drawdown = if max_price == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
@@ -117,15 +140,18 @@ impl Indicator for UlcerIndex {
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prices.clear();
|
||||
self.count = 0;
|
||||
self.max_dq.clear();
|
||||
self.drawdowns_sq.clear();
|
||||
self.sum_sq = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// `period` prices fill the trailing-max window, then `period` squared
|
||||
// drawdowns fill the RMS window.
|
||||
// `period` inputs fill the trailing-max window; the first drawdown is
|
||||
// computable on bar `period` (the window is full for the first time);
|
||||
// another `period - 1` drawdowns then fill the RMS window. The two
|
||||
// windows overlap by one bar, so `warmup_period() == 2 * period - 1`.
|
||||
2 * self.period - 1
|
||||
}
|
||||
|
||||
@@ -226,4 +252,61 @@ mod tests {
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
/// Monotone-deque equivalence: the O(1) implementation must produce exactly
|
||||
/// the same per-tick values as a naive O(n) trailing-max scan, on inputs
|
||||
/// chosen to exercise every deque-maintenance path:
|
||||
/// strictly increasing (everything is dominated and gets popped),
|
||||
/// strictly decreasing (nothing is popped, head expires when the window
|
||||
/// slides),
|
||||
/// and constants (ties — the `<= input` pop rule keeps a single newest
|
||||
/// entry).
|
||||
#[test]
|
||||
fn monotone_deque_matches_naive_max_on_adversarial_inputs() {
|
||||
fn naive_max(prices: &[f64], period: usize, t: usize) -> f64 {
|
||||
let lo = t + 1 - period;
|
||||
prices[lo..=t]
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max)
|
||||
}
|
||||
|
||||
fn check(prices: &[f64], period: usize) {
|
||||
let mut ui = UlcerIndex::new(period).unwrap();
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
let _ = ui.update(*p);
|
||||
if i + 1 >= period {
|
||||
let trailing_max = ui.max_dq.front().expect("non-empty").1;
|
||||
let naive = naive_max(prices, period, i);
|
||||
assert!(
|
||||
(trailing_max - naive).abs() < 1e-12,
|
||||
"trailing max diverges at t={i}: deque={trailing_max}, naive={naive}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strictly increasing — every push pops the entire deque tail.
|
||||
let increasing: Vec<f64> = (1..=50).map(f64::from).collect();
|
||||
check(&increasing, 5);
|
||||
check(&increasing, 14);
|
||||
|
||||
// Strictly decreasing — pushes never pop the tail; the head expires.
|
||||
let decreasing: Vec<f64> = (1..=50).rev().map(f64::from).collect();
|
||||
check(&decreasing, 5);
|
||||
check(&decreasing, 14);
|
||||
|
||||
// All-equal — `back <= input` pops on equality, leaving a length-1
|
||||
// deque containing only the most recent index.
|
||||
let constant = vec![42.0; 50];
|
||||
check(&constant, 5);
|
||||
check(&constant, 14);
|
||||
|
||||
// Mixed sawtooth — exercises every code path.
|
||||
let mixed: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 20.0)
|
||||
.collect();
|
||||
check(&mixed, 7);
|
||||
check(&mixed, 30);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user