* feat(rvi): add Relative Vigor Index
Dorsey's RVI = SMA(close - open, period) / SMA(high - low, period) over
a rolling window of period candles. Candle input, single parameter
period (default 10). Positive on average-bullish windows, negative on
average-bearish. Holds the previous value if the entire window has
zero range (denominator undefined).
Reference: Donald Dorsey, also pandas-ta rvi.
Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values reference,
RviNode (4-column OHLC batch) + index.d.ts/index.js + indicators.test
.js factory + reference, WasmRvi + make_candle_ohlc helper, candle-fuzz
target + criterion bench, README + CHANGELOG.
* feat(pgo): add Pretty Good Oscillator
Mark Johnson's PGO = (close - SMA(close, period)) / EMA(TR, period).
Counts roughly how many ATR-equivalents the close sits from its
period-bar mean. Candle input, single parameter period (default 14).
Johnson's heuristic uses +3/-3 crossings as entry signals.
Touchpoints: pgo.rs + mod.rs + lib.rs re-export, PyPgo + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-close
reference, PgoNode (h/l/c) + index.d.ts/index.js + indicators.test.js
factory + reference, WasmPgo, candle-fuzz target + bench, README +
CHANGELOG.
* feat(kst): add Know Sure Thing (Pring)
Pring's long-horizon momentum oscillator: weighted sum of four
SMA-smoothed ROC series with fixed weights 1, 2, 3, 4, plus an SMA
signal line. Nine parameters (four ROC periods, four SMA periods, one
signal period); classic() applies Pring's recommended defaults.
Multi-output indicator emitting KstOutput { kst, signal }.
Touchpoints: kst.rs + mod.rs + lib.rs re-export, PyKst + __init__.py
+ test_new_indicators MULTI + test_known_values flat-input reference,
KstNode + KstValue + index.d.ts/index.js + indicators.test.js multi
factory + reference, WasmKst (manual JsValue object), scalar-fuzz
target (handled outside the f64-output drive helper), README +
CHANGELOG.
* feat(smi): add Stochastic Momentum Index (Blau)
Blau's doubly-EMA-smoothed bounded oscillator: measures the close's
displacement from the centre of the recent high-low range, scaled by
the smoothed range. Candle input, three parameters (period, d_period,
d2_period) with defaults 5 / 3 / 3.
Internally feeds both the displacement-EMA stack and the range-EMA
stack on every candle so they warm up in parallel (gating either
behind the other starves the second by one input).
Touchpoints: smi.rs + mod.rs + lib.rs re-export, PySmi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-input
reference, SmiNode + index.d.ts/index.js + indicators.test.js factory
+ reference, WasmSmi, candle-fuzz target, README + CHANGELOG.
* feat(laguerre-rsi): add Ehlers Laguerre RSI
Four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
accumulator. Single gamma in [0, 1] (default 0.5) trades lag for
smoothness. State is seeded by setting all four L_i to the first input
so a constant series stays at the neutral 50. Output clamped to
[0, 100] to absorb floating-point rounding.
Reference: Ehlers, Time Warp - Without Space Travel, 2002.
Touchpoints: laguerre_rsi.rs + mod.rs + lib.rs re-export, PyLaguerreRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, LaguerreRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmLaguerreRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.
* feat(connors-rsi): add Connors RSI (CRSI)
Larry Connors' 3-component aggregate: RSI(close), RSI(streak), and
PercentRank of the 1-period return over the last period_rank returns.
Each component is bounded in [0, 100] so the aggregate is too.
Three parameters (period_rsi, period_streak, period_rank) with
defaults 3 / 2 / 100. Streak tracks consecutive up/down runs (resets
to 0 on unchanged close).
Touchpoints: connors_rsi.rs + mod.rs + lib.rs re-export, PyConnorsRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values bounded
reference, ConnorsRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmConnorsRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.
* feat(inertia): add Dorsey Inertia (RVI + LinReg)
Donald Dorsey's Inertia — a LinearRegression smoothing of the RVI
series. Endpoint of an n-bar least-squares fit of RVI is the indicator
reading. Preserves trend direction while damping the ratio. Candle
input, two parameters (rvi_period, linreg_period) with defaults 14 / 20.
Touchpoints: inertia.rs + mod.rs + lib.rs re-export, PyInertia +
__init__.py + test_new_indicators CANDLE_SCALAR + test_known_values
constant reference, InertiaNode (4-column OHLC batch) + index.d.ts /
index.js + indicators.test.js factory + reference, WasmInertia,
candle-fuzz target, README + CHANGELOG.
* test(kst): Move KST out of MULTI dict (it is scalar-input)
KST sits in the MULTI dict (candle-input, multi-output) but its
update() takes a single f64, not a candle tuple. The shared streaming
loop in test_multi_streaming_matches_batch fed the OHLCV tuple in,
which crashed with `TypeError: argument 'value': must be real number,
not tuple` on every Python matrix entry.
Split into a new MULTI_SCALAR_INPUT dict with its own test function
that feeds the close-price stream as floats. KST is currently the
only such indicator; structure is ready for future scalar-input
multi-output additions (e.g. some MACD-shaped indicators).
* test(coverage): Cover SMI zero-range and ConnorsRsi zero-prev cold paths
codecov/patch on PR 40 flagged two uncovered defensive branches:
- SMI returns self.current early when the smoothed range collapses to
zero (`r2 <= 0.0`) so the formula stays defined. Exercised by feeding
bars where high == low.
- ConnorsRsi skips the ROC ring-buffer update when the previous price
is exactly zero so the divide-by-zero in `(input - prev) / prev` is
impossible. Exercised by seeding the first bar at 0.0.
286 lines
8.9 KiB
Rust
286 lines
8.9 KiB
Rust
//! Stochastic Momentum Index (SMI).
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::indicators::ema::Ema;
|
|
use crate::ohlcv::Candle;
|
|
use crate::traits::Indicator;
|
|
|
|
/// William Blau's Stochastic Momentum Index — a doubly-smoothed,
|
|
/// `±100`-bounded oscillator built from the close's distance to the centre
|
|
/// of the recent high-low range.
|
|
///
|
|
/// Over the lookback `period`, let `HH = max(high)`, `LL = min(low)`,
|
|
/// `C = (HH + LL) / 2` and `R = HH - LL`. The raw displacement is
|
|
/// `d_t = close_t - C_t`. Both `d` and `R` are smoothed twice with `EMA`s,
|
|
/// then combined into the bounded reading:
|
|
///
|
|
/// ```text
|
|
/// D_smoothed = EMA(EMA(d, d_period), d2_period)
|
|
/// HL_smoothed = EMA(EMA(R, d_period), d2_period)
|
|
/// SMI = 100 · D_smoothed / (HL_smoothed / 2)
|
|
/// ```
|
|
///
|
|
/// Blau's recommended defaults are `(period = 5, d = 3, d2 = 3)`. Wickra
|
|
/// publishes the SMI value only; the optional signal `EMA(SMI, k)` is left
|
|
/// to the consumer via `Chain` / their own `Ema`.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use wickra_core::{Candle, Indicator, Smi};
|
|
///
|
|
/// let mut smi = Smi::new(5, 3, 3).unwrap();
|
|
/// let mut last = None;
|
|
/// for i in 0..40 {
|
|
/// let p = 100.0 + f64::from(i);
|
|
/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap();
|
|
/// last = smi.update(candle);
|
|
/// }
|
|
/// assert!(last.is_some());
|
|
/// ```
|
|
#[derive(Debug, Clone)]
|
|
pub struct Smi {
|
|
period: usize,
|
|
d_period: usize,
|
|
d2_period: usize,
|
|
highs: VecDeque<f64>,
|
|
lows: VecDeque<f64>,
|
|
ema_d1: Ema,
|
|
ema_d2: Ema,
|
|
ema_r1: Ema,
|
|
ema_r2: Ema,
|
|
current: Option<f64>,
|
|
}
|
|
|
|
impl Smi {
|
|
/// # Errors
|
|
/// Returns [`Error::PeriodZero`] if any period is zero.
|
|
pub fn new(period: usize, d_period: usize, d2_period: usize) -> Result<Self> {
|
|
if period == 0 || d_period == 0 || d2_period == 0 {
|
|
return Err(Error::PeriodZero);
|
|
}
|
|
Ok(Self {
|
|
period,
|
|
d_period,
|
|
d2_period,
|
|
highs: VecDeque::with_capacity(period),
|
|
lows: VecDeque::with_capacity(period),
|
|
ema_d1: Ema::new(d_period)?,
|
|
ema_d2: Ema::new(d2_period)?,
|
|
ema_r1: Ema::new(d_period)?,
|
|
ema_r2: Ema::new(d2_period)?,
|
|
current: None,
|
|
})
|
|
}
|
|
|
|
/// Blau's recommended defaults `(period = 5, d = 3, d2 = 3)`.
|
|
pub fn classic() -> Self {
|
|
Self::new(5, 3, 3).expect("classic SMI parameters are valid")
|
|
}
|
|
|
|
/// Configured `(period, d_period, d2_period)`.
|
|
pub const fn periods(&self) -> (usize, usize, usize) {
|
|
(self.period, self.d_period, self.d2_period)
|
|
}
|
|
}
|
|
|
|
impl Indicator for Smi {
|
|
type Input = Candle;
|
|
type Output = f64;
|
|
|
|
fn update(&mut self, candle: Candle) -> Option<f64> {
|
|
if self.highs.len() == self.period {
|
|
self.highs.pop_front();
|
|
self.lows.pop_front();
|
|
}
|
|
self.highs.push_back(candle.high);
|
|
self.lows.push_back(candle.low);
|
|
if self.highs.len() < self.period {
|
|
return None;
|
|
}
|
|
let hh = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
|
let ll = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
|
let center = f64::midpoint(hh, ll);
|
|
let displacement = candle.close - center;
|
|
let range = hh - ll;
|
|
|
|
// Feed every EMA on every candle so both stacks warm in parallel —
|
|
// gating the range stack behind the displacement stack would starve
|
|
// it by one input.
|
|
let d1 = self.ema_d1.update(displacement);
|
|
let r1 = self.ema_r1.update(range);
|
|
let d2 = d1.and_then(|x| self.ema_d2.update(x));
|
|
let r2 = r1.and_then(|x| self.ema_r2.update(x));
|
|
let (d2, r2) = (d2?, r2?);
|
|
|
|
if r2 <= 0.0 {
|
|
// Window where the smoothed range collapses to zero: the formula
|
|
// is undefined. Hold the previous reading rather than emit inf.
|
|
return self.current;
|
|
}
|
|
let value = 100.0 * d2 / (r2 / 2.0);
|
|
self.current = Some(value);
|
|
Some(value)
|
|
}
|
|
|
|
fn reset(&mut self) {
|
|
self.highs.clear();
|
|
self.lows.clear();
|
|
self.ema_d1.reset();
|
|
self.ema_d2.reset();
|
|
self.ema_r1.reset();
|
|
self.ema_r2.reset();
|
|
self.current = None;
|
|
}
|
|
|
|
fn warmup_period(&self) -> usize {
|
|
// The high-low window needs `period` candles; then both EMA stacks
|
|
// need `d_period + d2_period - 1` more values to fully warm up.
|
|
self.period + self.d_period + self.d2_period - 2
|
|
}
|
|
|
|
fn is_ready(&self) -> bool {
|
|
self.current.is_some()
|
|
}
|
|
|
|
fn name(&self) -> &'static str {
|
|
"SMI"
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::traits::BatchExt;
|
|
use approx::assert_relative_eq;
|
|
|
|
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
|
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_zero_period() {
|
|
assert!(matches!(Smi::new(0, 3, 3), Err(Error::PeriodZero)));
|
|
assert!(matches!(Smi::new(5, 0, 3), Err(Error::PeriodZero)));
|
|
assert!(matches!(Smi::new(5, 3, 0), Err(Error::PeriodZero)));
|
|
}
|
|
|
|
#[test]
|
|
fn accessors_and_metadata() {
|
|
let smi = Smi::new(5, 3, 3).unwrap();
|
|
assert_eq!(smi.periods(), (5, 3, 3));
|
|
assert_eq!(smi.warmup_period(), 9);
|
|
assert_eq!(smi.name(), "SMI");
|
|
}
|
|
|
|
#[test]
|
|
fn classic_factory() {
|
|
let smi = Smi::classic();
|
|
assert_eq!(smi.periods(), (5, 3, 3));
|
|
}
|
|
|
|
#[test]
|
|
fn close_at_high_pushes_toward_plus_100() {
|
|
// Every candle's close equals its high in a rising series: the
|
|
// displacement is at the top of the range every bar, so SMI sits in
|
|
// the strongly positive region. After enough double-smoothing it
|
|
// approaches the upper bound.
|
|
let mut smi = Smi::classic();
|
|
let mut last = None;
|
|
for i in 0..80 {
|
|
let h = 100.0 + f64::from(i);
|
|
let l = h - 2.0;
|
|
last = smi.update(candle(h, l, h, i64::from(i)));
|
|
}
|
|
let v = last.expect("SMI is warm");
|
|
assert!(
|
|
v > 50.0,
|
|
"close-at-high series should drive SMI well above 0: {v}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn close_at_low_pushes_toward_minus_100() {
|
|
let mut smi = Smi::classic();
|
|
let mut last = None;
|
|
for i in 0..80 {
|
|
let h = 100.0 - f64::from(i);
|
|
let l = h - 2.0;
|
|
last = smi.update(candle(h, l, l, i64::from(i)));
|
|
}
|
|
let v = last.expect("SMI is warm");
|
|
assert!(
|
|
v < -50.0,
|
|
"close-at-low series should drive SMI well below 0: {v}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn warmup_emits_first_value_at_warmup_period() {
|
|
let mut smi = Smi::new(3, 2, 2).unwrap();
|
|
// period 3 + d 2 + d2 2 - 2 = 5.
|
|
assert_eq!(smi.warmup_period(), 5);
|
|
let mut got = None;
|
|
for i in 0..5 {
|
|
got = smi.update(candle(11.0, 9.0, 10.0, i));
|
|
}
|
|
assert!(got.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn flat_close_yields_zero_displacement() {
|
|
// Every close is exactly at the centre of the range -> displacement
|
|
// is 0 every bar -> SMI converges to 0.
|
|
let mut smi = Smi::classic();
|
|
let mut last = None;
|
|
for i in 0..60 {
|
|
// High and low straddle a constant close.
|
|
last = smi.update(candle(11.0, 9.0, 10.0, i));
|
|
}
|
|
let v = last.unwrap();
|
|
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_equals_streaming() {
|
|
let candles: Vec<Candle> = (0..80_i64)
|
|
.map(|i| {
|
|
let c = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
|
candle(c + 1.0, c - 1.0, c, i)
|
|
})
|
|
.collect();
|
|
let batch = Smi::classic().batch(&candles);
|
|
let mut b = Smi::classic();
|
|
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
|
assert_eq!(batch, streamed);
|
|
}
|
|
|
|
#[test]
|
|
fn reset_clears_state() {
|
|
let mut smi = Smi::classic();
|
|
for i in 0..40 {
|
|
smi.update(candle(11.0, 9.0, 10.0, i));
|
|
}
|
|
assert!(smi.is_ready());
|
|
smi.reset();
|
|
assert!(!smi.is_ready());
|
|
}
|
|
|
|
#[test]
|
|
fn zero_range_holds_previous_value() {
|
|
// High == low on every bar -> instantaneous range is zero, the
|
|
// EMA of (range / 2) settles to zero, so `r2 <= 0.0` after warmup
|
|
// and the indicator must hold its previous value (None here, since
|
|
// r2 was zero from the very first warm bar) rather than divide by
|
|
// zero.
|
|
let mut smi = Smi::new(3, 2, 2).unwrap();
|
|
// warmup_period = 3 + 2 + 2 - 2 = 5; feed warmup + 2 extra bars.
|
|
for i in 0..7 {
|
|
let v = smi.update(candle(10.0, 10.0, 10.0, i));
|
|
assert_eq!(v, None, "zero-range SMI must hold None, got {v:?}");
|
|
}
|
|
}
|
|
}
|