Deepens the **Momentum Oscillators** family with ten widely-used oscillators (403 → 413 indicators), the second batch of Part B (family deepening). | Indicator | Binding | Input → Output | |-----------|---------|----------------| | `DisparityIndex` | `DisparityIndex` | scalar → scalar | | `FisherRsi` | `FisherRSI` | scalar → scalar | | `Rmi` | `RMI` | scalar (period, momentum) → scalar | | `DerivativeOscillator` | `DerivativeOscillator` | scalar (4 periods) → scalar | | `Rsx` | `RSX` | scalar → scalar | | `DynamicMomentumIndex` | `DynamicMomentumIndex` | scalar → scalar | | `IntradayMomentumIndex` | `IMI` | candle (open+close) → scalar | | `StochasticCci` | `StochasticCCI` | candle → scalar | | `ElderRay` | `ElderRay` | candle → struct (bull/bear) | | `Qqe` | `QQE` | scalar → struct (rsi_ma/trailing) | LSMA was dropped from the planned set: it already ships as `LinearRegression`. The single-period scalars use generated macro bindings; `Rmi` / `DerivativeOscillator` use hand node/python bindings with the typed wasm macro; `ElderRay`/`Qqe` use custom struct bindings; `IntradayMomentumIndex` uses custom candle bindings carrying the open. Full coverage: core modules with per-branch unit tests, mod/lib catalogue, FAMILIES + assert, README + docs counters, CHANGELOG, all three bindings (regenerated `index.d.ts`/`index.js`), fuzz drivers, and the python/node test registries. Local verification: `cargo test -p wickra-core` (lib 3335 + doc 371), `cargo clippy --workspace --all-targets --all-features -D warnings` clean, node `npm run build && npm test` (488), python `pytest` (802).
277 lines
8.5 KiB
Rust
277 lines
8.5 KiB
Rust
//! Relative Momentum Index (RMI).
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::traits::Indicator;
|
|
|
|
/// Relative Momentum Index — RSI generalised to a multi-bar momentum lookback.
|
|
///
|
|
/// Wilder's [`Rsi`](crate::Rsi) compares each close to the *previous* close.
|
|
/// The RMI (Roger Altman, 1993) compares it to the close `momentum` bars ago,
|
|
/// then applies the same Wilder-smoothed up/down accumulator over `period`:
|
|
///
|
|
/// ```text
|
|
/// change_t = close_t - close_{t-momentum}
|
|
/// gain = max(change, 0), loss = max(-change, 0)
|
|
/// avg_gain, avg_loss = Wilder-smoothed over `period`
|
|
/// RMI = 100 * avg_gain / (avg_gain + avg_loss)
|
|
/// ```
|
|
///
|
|
/// `momentum = 1` reduces the RMI exactly to the RSI. Larger `momentum` makes
|
|
/// the oscillator smoother and slower to flip, holding overbought/oversold
|
|
/// readings longer in a trend. Output is bounded in `[0, 100]`; a flat market
|
|
/// (no gains and no losses) returns the neutral `50`.
|
|
///
|
|
/// The first value lands after `momentum + period` inputs: `momentum` to fill
|
|
/// the lookback, then `period` changes to seed Wilder's averages.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use wickra_core::{Indicator, Rmi};
|
|
///
|
|
/// let mut indicator = Rmi::new(14, 5).unwrap();
|
|
/// let mut last = None;
|
|
/// for i in 0..80 {
|
|
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
|
/// }
|
|
/// assert!(last.is_some());
|
|
/// ```
|
|
#[derive(Debug, Clone)]
|
|
pub struct Rmi {
|
|
period: usize,
|
|
momentum: usize,
|
|
/// The last `momentum` prices, oldest at the front.
|
|
window: VecDeque<f64>,
|
|
seed_gains: Vec<f64>,
|
|
seed_losses: Vec<f64>,
|
|
avg_gain: Option<f64>,
|
|
avg_loss: Option<f64>,
|
|
last_value: Option<f64>,
|
|
}
|
|
|
|
impl Rmi {
|
|
/// Construct an RMI with the given smoothing `period` and `momentum`
|
|
/// lookback.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`Error::PeriodZero`] if either `period` or `momentum` is `0`.
|
|
pub fn new(period: usize, momentum: usize) -> Result<Self> {
|
|
if period == 0 || momentum == 0 {
|
|
return Err(Error::PeriodZero);
|
|
}
|
|
Ok(Self {
|
|
period,
|
|
momentum,
|
|
window: VecDeque::with_capacity(momentum),
|
|
seed_gains: Vec::with_capacity(period),
|
|
seed_losses: Vec::with_capacity(period),
|
|
avg_gain: None,
|
|
avg_loss: None,
|
|
last_value: None,
|
|
})
|
|
}
|
|
|
|
/// Configured smoothing period.
|
|
pub const fn period(&self) -> usize {
|
|
self.period
|
|
}
|
|
|
|
/// Configured momentum lookback.
|
|
pub const fn momentum(&self) -> usize {
|
|
self.momentum
|
|
}
|
|
|
|
/// Current value if available.
|
|
pub const fn value(&self) -> Option<f64> {
|
|
self.last_value
|
|
}
|
|
|
|
fn rmi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
|
let denom = avg_gain + avg_loss;
|
|
if denom == 0.0 {
|
|
50.0
|
|
} else {
|
|
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
|
|
100.0 * (avg_gain / denom)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Indicator for Rmi {
|
|
type Input = f64;
|
|
type Output = f64;
|
|
|
|
fn update(&mut self, input: f64) -> Option<f64> {
|
|
if !input.is_finite() {
|
|
return self.last_value;
|
|
}
|
|
if self.window.len() < self.momentum {
|
|
// Still filling the momentum lookback; no change to measure yet.
|
|
self.window.push_back(input);
|
|
return None;
|
|
}
|
|
let past = self.window.pop_front().expect("window full");
|
|
self.window.push_back(input);
|
|
|
|
let change = input - past;
|
|
let gain = if change > 0.0 { change } else { 0.0 };
|
|
let loss = if change < 0.0 { -change } else { 0.0 };
|
|
|
|
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
|
|
let n = self.period as f64;
|
|
let new_ag = (ag * (n - 1.0) + gain) / n;
|
|
let new_al = (al * (n - 1.0) + loss) / n;
|
|
self.avg_gain = Some(new_ag);
|
|
self.avg_loss = Some(new_al);
|
|
let v = Self::rmi_from_avgs(new_ag, new_al);
|
|
self.last_value = Some(v);
|
|
return Some(v);
|
|
}
|
|
|
|
self.seed_gains.push(gain);
|
|
self.seed_losses.push(loss);
|
|
if self.seed_gains.len() == self.period {
|
|
let ag = self.seed_gains.iter().sum::<f64>() / self.period as f64;
|
|
let al = self.seed_losses.iter().sum::<f64>() / self.period as f64;
|
|
self.avg_gain = Some(ag);
|
|
self.avg_loss = Some(al);
|
|
let v = Self::rmi_from_avgs(ag, al);
|
|
self.last_value = Some(v);
|
|
return Some(v);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn reset(&mut self) {
|
|
self.window.clear();
|
|
self.seed_gains.clear();
|
|
self.seed_losses.clear();
|
|
self.avg_gain = None;
|
|
self.avg_loss = None;
|
|
self.last_value = None;
|
|
}
|
|
|
|
fn warmup_period(&self) -> usize {
|
|
self.momentum + self.period
|
|
}
|
|
|
|
fn is_ready(&self) -> bool {
|
|
self.last_value.is_some()
|
|
}
|
|
|
|
fn name(&self) -> &'static str {
|
|
"RMI"
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::indicators::Rsi;
|
|
use crate::traits::BatchExt;
|
|
use approx::assert_relative_eq;
|
|
|
|
#[test]
|
|
fn rejects_zero_params() {
|
|
assert!(matches!(Rmi::new(0, 5), Err(Error::PeriodZero)));
|
|
assert!(matches!(Rmi::new(14, 0), Err(Error::PeriodZero)));
|
|
}
|
|
|
|
/// Cover the const accessors `period` + `momentum` + `value` and the
|
|
/// Indicator-impl `warmup_period` + `name`.
|
|
#[test]
|
|
fn accessors_and_metadata() {
|
|
let rmi = Rmi::new(14, 5).unwrap();
|
|
assert_eq!(rmi.period(), 14);
|
|
assert_eq!(rmi.momentum(), 5);
|
|
assert_eq!(rmi.value(), None);
|
|
assert_eq!(rmi.warmup_period(), 19);
|
|
assert_eq!(rmi.name(), "RMI");
|
|
}
|
|
|
|
#[test]
|
|
fn momentum_one_equals_rsi() {
|
|
// With momentum = 1 the RMI is exactly Wilder's RSI.
|
|
let prices: Vec<f64> = (0..60)
|
|
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
|
.collect();
|
|
let mut rmi = Rmi::new(14, 1).unwrap();
|
|
let mut rsi = Rsi::new(14).unwrap();
|
|
for (i, &p) in prices.iter().enumerate() {
|
|
let got = rmi.update(p);
|
|
let want = rsi.update(p);
|
|
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
|
if let (Some(a), Some(b)) = (got, want) {
|
|
assert_relative_eq!(a, b, epsilon = 1e-9);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn warmup_then_emits() {
|
|
// momentum + period = 3 + 2 = 5 inputs before the first value.
|
|
let mut rmi = Rmi::new(2, 3).unwrap();
|
|
let out = rmi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
|
for (i, v) in out.iter().enumerate().take(4) {
|
|
assert!(v.is_none(), "index {i} must be None during warmup");
|
|
}
|
|
assert!(out[4].is_some(), "first value at warmup_period - 1");
|
|
}
|
|
|
|
#[test]
|
|
fn pure_uptrend_is_one_hundred() {
|
|
// Every momentum-spaced change is positive -> avg_loss 0 -> RMI 100.
|
|
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
|
let mut rmi = Rmi::new(5, 3).unwrap();
|
|
let last = rmi.batch(&prices).into_iter().flatten().last().unwrap();
|
|
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn flat_market_is_neutral() {
|
|
// No change -> no gains and no losses -> neutral 50.
|
|
let mut rmi = Rmi::new(3, 2).unwrap();
|
|
let last = rmi.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
|
|
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_non_finite_input() {
|
|
let mut rmi = Rmi::new(2, 2).unwrap();
|
|
let ready = rmi
|
|
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
|
.into_iter()
|
|
.flatten()
|
|
.last()
|
|
.unwrap();
|
|
assert_eq!(rmi.update(f64::NAN), Some(ready));
|
|
assert_eq!(rmi.update(f64::INFINITY), Some(ready));
|
|
}
|
|
|
|
#[test]
|
|
fn reset_clears_state() {
|
|
let mut rmi = Rmi::new(3, 2).unwrap();
|
|
rmi.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
|
assert!(rmi.is_ready());
|
|
rmi.reset();
|
|
assert!(!rmi.is_ready());
|
|
assert_eq!(rmi.update(1.0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_equals_streaming() {
|
|
let prices: Vec<f64> = (1..=40)
|
|
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
|
.collect();
|
|
let mut a = Rmi::new(14, 5).unwrap();
|
|
let mut b = Rmi::new(14, 5).unwrap();
|
|
assert_eq!(
|
|
a.batch(&prices),
|
|
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
}
|