Deepen Momentum Oscillators family with ten additions (#179)
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).
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
//! Derivative Oscillator (Constance Brown).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Derivative Oscillator — Constance Brown's double-smoothed RSI histogram.
|
||||
///
|
||||
/// The RSI is smoothed twice with EMAs, then a simple moving average of that
|
||||
/// double-smoothed line is subtracted as a signal, leaving a zero-centered
|
||||
/// histogram:
|
||||
///
|
||||
/// ```text
|
||||
/// rsi = RSI(price, rsi_period)
|
||||
/// s1 = EMA(rsi, smooth1)
|
||||
/// s2 = EMA(s1, smooth2) // double-smoothed RSI
|
||||
/// signal = SMA(s2, signal_period)
|
||||
/// DerivativeOscillator = s2 - signal
|
||||
/// ```
|
||||
///
|
||||
/// The double EMA smoothing strips the RSI's high-frequency noise, and
|
||||
/// subtracting the SMA signal removes the residual level, so the result
|
||||
/// oscillates around zero: positive (and rising) bars mark accelerating bullish
|
||||
/// momentum, negative bars bearish. Brown's defaults are `rsi_period = 14`,
|
||||
/// `smooth1 = 5`, `smooth2 = 3`, `signal_period = 9`.
|
||||
///
|
||||
/// The first value lands after `rsi_period + smooth1 + smooth2 + signal_period − 2`
|
||||
/// inputs, the point at which the whole RSI → EMA → EMA → SMA chain is seeded.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativeOscillator, Indicator};
|
||||
///
|
||||
/// let mut indicator = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DerivativeOscillator {
|
||||
rsi: Rsi,
|
||||
ema1: Ema,
|
||||
ema2: Ema,
|
||||
signal: Sma,
|
||||
warmup: usize,
|
||||
}
|
||||
|
||||
impl DerivativeOscillator {
|
||||
/// Construct a Derivative Oscillator with the RSI, two EMA smoothing, and
|
||||
/// SMA signal periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is `0`.
|
||||
pub fn new(
|
||||
rsi_period: usize,
|
||||
smooth1: usize,
|
||||
smooth2: usize,
|
||||
signal_period: usize,
|
||||
) -> Result<Self> {
|
||||
if rsi_period == 0 || smooth1 == 0 || smooth2 == 0 || signal_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
rsi: Rsi::new(rsi_period)?,
|
||||
ema1: Ema::new(smooth1)?,
|
||||
ema2: Ema::new(smooth2)?,
|
||||
signal: Sma::new(signal_period)?,
|
||||
// RSI seeds at rsi_period + 1, then each stage adds (len - 1).
|
||||
warmup: rsi_period + smooth1 + smooth2 + signal_period - 2,
|
||||
})
|
||||
}
|
||||
|
||||
/// Total warmup length (also returned by `warmup_period`).
|
||||
pub const fn warmup(&self) -> usize {
|
||||
self.warmup
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DerivativeOscillator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let rsi = self.rsi.update(input)?;
|
||||
let s1 = self.ema1.update(rsi)?;
|
||||
let s2 = self.ema2.update(s1)?;
|
||||
let signal = self.signal.update(s2)?;
|
||||
Some(s2 - signal)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
self.ema1.reset();
|
||||
self.ema2.reset();
|
||||
self.signal.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.warmup
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.signal.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DerivativeOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_periods() {
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(0, 5, 3, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(14, 0, 3, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(14, 5, 0, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(14, 5, 3, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessor `warmup` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
// 14 + 5 + 3 + 9 - 2 = 29.
|
||||
assert_eq!(d.warmup(), 29);
|
||||
assert_eq!(d.warmup_period(), 29);
|
||||
assert_eq!(d.name(), "DerivativeOscillator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
|
||||
.collect();
|
||||
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
let out = d.batch(&prices);
|
||||
let warmup = d.warmup_period();
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"first value must land at warmup_period - 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_manual_chain() {
|
||||
// Equals RSI -> EMA -> EMA, minus the SMA signal of that line.
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
||||
.collect();
|
||||
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
let mut rsi = Rsi::new(14).unwrap();
|
||||
let mut e1 = Ema::new(5).unwrap();
|
||||
let mut e2 = Ema::new(3).unwrap();
|
||||
let mut sig = Sma::new(9).unwrap();
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let got = d.update(p);
|
||||
let want = rsi
|
||||
.update(p)
|
||||
.and_then(|r| e1.update(r))
|
||||
.and_then(|x| e2.update(x))
|
||||
.and_then(|s2| sig.update(s2).map(|s| s2 - s));
|
||||
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 reset_clears_state() {
|
||||
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Disparity Index.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Disparity Index — the percentage gap between price and its moving average.
|
||||
///
|
||||
/// ```text
|
||||
/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period)
|
||||
/// ```
|
||||
///
|
||||
/// Originating in Japanese technical analysis (*kairi*), the disparity index
|
||||
/// expresses how far price has stretched from its `period`-bar simple moving
|
||||
/// average, as a percentage of that average. Positive readings mean price is
|
||||
/// above the mean (potentially overbought / strong), negative readings mean it
|
||||
/// is below (potentially oversold / weak); the magnitude measures how
|
||||
/// over-extended the move is.
|
||||
///
|
||||
/// The first output lands once the inner SMA is ready (input `period`). If the
|
||||
/// moving average is exactly zero the gap percentage is undefined and the index
|
||||
/// returns `0.0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DisparityIndex, Indicator};
|
||||
///
|
||||
/// let mut indicator = DisparityIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DisparityIndex {
|
||||
period: usize,
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl DisparityIndex {
|
||||
/// Construct a disparity index over `period` inputs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
sma: Sma::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DisparityIndex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let mean = self.sma.update(input)?;
|
||||
if mean == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(100.0 * (input - mean) / mean)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DisparityIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(DisparityIndex::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let di = DisparityIndex::new(14).unwrap();
|
||||
assert_eq!(di.period(), 14);
|
||||
assert_eq!(di.warmup_period(), 14);
|
||||
assert_eq!(di.name(), "DisparityIndex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_known_value() {
|
||||
// SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50.
|
||||
let mut di = DisparityIndex::new(3).unwrap();
|
||||
assert_eq!(di.update(2.0), None);
|
||||
assert_eq!(di.update(4.0), None);
|
||||
assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_is_zero() {
|
||||
// Price equals its own mean -> zero disparity.
|
||||
let mut di = DisparityIndex::new(5).unwrap();
|
||||
for v in di.batch(&[42.0; 20]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_when_below_mean() {
|
||||
// SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25.
|
||||
let mut di = DisparityIndex::new(3).unwrap();
|
||||
let v = di.batch(&[10.0, 8.0, 6.0]);
|
||||
assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_mean_returns_zero() {
|
||||
// A window summing to zero (mean 0) makes the percentage undefined; the
|
||||
// index returns 0.0 rather than a non-finite value.
|
||||
let mut di = DisparityIndex::new(2).unwrap();
|
||||
assert_eq!(di.update(-3.0), None);
|
||||
// SMA(2) of [-3, 3] = 0 -> guarded to 0.0.
|
||||
assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut di = DisparityIndex::new(5).unwrap();
|
||||
di.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(di.is_ready());
|
||||
di.reset();
|
||||
assert!(!di.is_ready());
|
||||
assert_eq!(di.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=30)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = DisparityIndex::new(7).unwrap();
|
||||
let mut b = DisparityIndex::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Dynamic Momentum Index (Chande's volatility-adaptive RSI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::indicators::std_dev::StdDev;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
// Chande's definitional constants.
|
||||
const STD_PERIOD: usize = 5; // volatility window
|
||||
const STD_AVG_PERIOD: usize = 10; // smoothing of the volatility
|
||||
const MIN_PERIOD: usize = 5; // fastest RSI lookback
|
||||
const MAX_PERIOD: usize = 30; // slowest RSI lookback
|
||||
|
||||
/// Dynamic Momentum Index — Tushar Chande's RSI whose lookback shrinks in
|
||||
/// volatile markets and lengthens in calm ones.
|
||||
///
|
||||
/// A standard RSI uses a fixed period; the DMI varies it from the recent
|
||||
/// volatility so the oscillator stays responsive when the market is fast and
|
||||
/// smooth when it is quiet:
|
||||
///
|
||||
/// ```text
|
||||
/// vol = StdDev(close, 5)
|
||||
/// vol_avg = SMA(vol, 10)
|
||||
/// Vi = vol / vol_avg (volatility index)
|
||||
/// td = clamp(round(period / Vi), 5, 30) (dynamic lookback)
|
||||
/// avg_gain, avg_loss = simple means of the last `td` price changes
|
||||
/// DMI = 100 * avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// High volatility (`Vi > 1`) shortens `td` toward `5` (faster); low volatility
|
||||
/// lengthens it toward `30` (slower). The averages of gains and losses are
|
||||
/// simple means over the last `td` changes (not Wilder-smoothed), recomputed as
|
||||
/// the window length flexes. Output is bounded in `[0, 100]`; a flat market
|
||||
/// returns the neutral `50`.
|
||||
///
|
||||
/// The first value lands after `MAX_PERIOD + 1 = 31` inputs, so the change
|
||||
/// buffer always holds enough history for any dynamic lookback up to `30`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DynamicMomentumIndex, Indicator};
|
||||
///
|
||||
/// let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = dmi.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DynamicMomentumIndex {
|
||||
period: usize,
|
||||
vol: StdDev,
|
||||
vol_avg: Sma,
|
||||
prev_close: Option<f64>,
|
||||
/// The last `MAX_PERIOD` price changes, oldest at the front.
|
||||
changes: VecDeque<f64>,
|
||||
last_vol_avg: Option<f64>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl DynamicMomentumIndex {
|
||||
/// Construct a DMI with the given base RSI period (Chande uses 14).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
vol: StdDev::new(STD_PERIOD)?,
|
||||
vol_avg: Sma::new(STD_AVG_PERIOD)?,
|
||||
prev_close: None,
|
||||
changes: VecDeque::with_capacity(MAX_PERIOD),
|
||||
last_vol_avg: None,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured base period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
/// Dynamic lookback for the current volatility, clamped to `[5, 30]`.
|
||||
fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
|
||||
if vol_avg <= 0.0 || vol <= 0.0 {
|
||||
// No measurable volatility -> slowest (calmest) lookback.
|
||||
return MAX_PERIOD;
|
||||
}
|
||||
let vi = vol / vol_avg;
|
||||
let td = (self.period as f64 / vi).round();
|
||||
// td is finite and positive here; clamp into the valid band.
|
||||
(td as usize).clamp(MIN_PERIOD, MAX_PERIOD)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DynamicMomentumIndex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
// Track the smoothed volatility on every close.
|
||||
if let Some(v) = self.vol.update(input) {
|
||||
self.last_vol_avg = self.vol_avg.update(v);
|
||||
}
|
||||
|
||||
// Record the price change.
|
||||
if let Some(prev) = self.prev_close {
|
||||
let change = input - prev;
|
||||
if self.changes.len() == MAX_PERIOD {
|
||||
self.changes.pop_front();
|
||||
}
|
||||
self.changes.push_back(change);
|
||||
}
|
||||
self.prev_close = Some(input);
|
||||
|
||||
let vol = self.vol.value()?;
|
||||
let vol_avg = self.last_vol_avg?;
|
||||
if self.changes.len() < MAX_PERIOD {
|
||||
return None;
|
||||
}
|
||||
|
||||
let td = self.dynamic_period(vol, vol_avg);
|
||||
// Average gains and losses over the last `td` changes.
|
||||
let mut sum_gain = 0.0;
|
||||
let mut sum_loss = 0.0;
|
||||
for &c in self.changes.iter().skip(MAX_PERIOD - td) {
|
||||
if c > 0.0 {
|
||||
sum_gain += c;
|
||||
} else if c < 0.0 {
|
||||
sum_loss -= c;
|
||||
}
|
||||
}
|
||||
let denom = sum_gain + sum_loss;
|
||||
let v = if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
|
||||
100.0 * (sum_gain / denom)
|
||||
};
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.vol.reset();
|
||||
self.vol_avg.reset();
|
||||
self.prev_close = None;
|
||||
self.changes.clear();
|
||||
self.last_vol_avg = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The change buffer (MAX_PERIOD changes => MAX_PERIOD + 1 inputs) is the
|
||||
// binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter.
|
||||
MAX_PERIOD + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DynamicMomentumIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
DynamicMomentumIndex::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `period` + `value` and the Indicator-impl
|
||||
/// `warmup_period` + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(dmi.period(), 14);
|
||||
assert_eq!(dmi.value(), None);
|
||||
assert_eq!(dmi.warmup_period(), 31);
|
||||
assert_eq!(dmi.name(), "DynamicMomentumIndex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let prices: Vec<f64> = (0..50)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
|
||||
.collect();
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let out = dmi.batch(&prices);
|
||||
for (i, v) in out.iter().enumerate().take(30) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[30].is_some(), "first value at warmup_period - 1 = 30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_one_hundred() {
|
||||
// Every change positive -> avg_loss 0 -> 100, regardless of dynamic period.
|
||||
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let last = dmi.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// Constant prices: no volatility (dynamic period -> max) and no changes
|
||||
// -> neutral 50.
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let last = dmi.batch(&[42.0; 50]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_in_range() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + (f64::from(i) * 0.07).cos() * 4.0)
|
||||
.collect();
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
for v in dmi.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "DMI {v} left [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_volatility_shortens_period() {
|
||||
let dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
// Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7.
|
||||
assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
|
||||
// Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28.
|
||||
assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
|
||||
// Extreme calm clamps to MAX_PERIOD; extreme volatility clamps to MIN.
|
||||
assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD);
|
||||
assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD);
|
||||
// Zero volatility -> slowest lookback.
|
||||
assert_eq!(dmi.dynamic_period(0.0, 1.0), MAX_PERIOD);
|
||||
assert_eq!(dmi.dynamic_period(1.0, 0.0), MAX_PERIOD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let ready = dmi
|
||||
.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(dmi.update(f64::NAN), Some(ready));
|
||||
assert_eq!(dmi.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
dmi.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
|
||||
assert!(dmi.is_ready());
|
||||
dmi.reset();
|
||||
assert!(!dmi.is_ready());
|
||||
assert_eq!(dmi.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = DynamicMomentumIndex::new(14).unwrap();
|
||||
let mut b = DynamicMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Elder Ray — Bull Power and Bear Power.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One Elder Ray reading: the bull and bear power for a bar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ElderRayOutput {
|
||||
/// `high − EMA(close)`: how far buyers pushed price above the trend mean.
|
||||
pub bull_power: f64,
|
||||
/// `low − EMA(close)`: how far sellers pushed price below the trend mean
|
||||
/// (negative in a normal market).
|
||||
pub bear_power: f64,
|
||||
}
|
||||
|
||||
/// Elder Ray — Alexander Elder's Bull Power / Bear Power oscillator.
|
||||
///
|
||||
/// An EMA of the close marks the market's consensus of value; the bar's high and
|
||||
/// low relative to it measure how far the bulls and bears could push price away
|
||||
/// from that consensus:
|
||||
///
|
||||
/// ```text
|
||||
/// ema = EMA(close, period)
|
||||
/// BullPower = high - ema
|
||||
/// BearPower = low - ema
|
||||
/// ```
|
||||
///
|
||||
/// Bull Power is normally positive (the high prints above the mean) and Bear
|
||||
/// Power normally negative (the low prints below it). Their behaviour relative
|
||||
/// to zero and to the EMA's slope drives Elder's signals: e.g. in an uptrend
|
||||
/// (rising EMA), a bounce in a negative-but-rising Bear Power is a buy setup.
|
||||
///
|
||||
/// The first reading lands once the inner EMA is seeded, at bar `period`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, ElderRay, Indicator};
|
||||
///
|
||||
/// let mut er = ElderRay::new(13).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)).unwrap();
|
||||
/// last = er.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ElderRay {
|
||||
period: usize,
|
||||
ema: Ema,
|
||||
}
|
||||
|
||||
impl ElderRay {
|
||||
/// Construct an Elder Ray with the given EMA period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
ema: Ema::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ElderRay {
|
||||
type Input = Candle;
|
||||
type Output = ElderRayOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ElderRayOutput> {
|
||||
let ema = self.ema.update(candle.close)?;
|
||||
Some(ElderRayOutput {
|
||||
bull_power: candle.high - ema,
|
||||
bear_power: candle.low - ema,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ElderRay"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(ElderRay::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let er = ElderRay::new(13).unwrap();
|
||||
assert_eq!(er.period(), 13);
|
||||
assert_eq!(er.warmup_period(), 13);
|
||||
assert_eq!(er.name(), "ElderRay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_known_value() {
|
||||
// EMA(3) seeds at bar 3 with SMA([10,12,14]) = 12 (closes).
|
||||
// bar 3: high 16, low 13 -> bull = 16 - 12 = 4, bear = 13 - 12 = 1.
|
||||
let mut er = ElderRay::new(3).unwrap();
|
||||
assert_eq!(er.update(candle(11.0, 9.0, 10.0)), None);
|
||||
assert_eq!(er.update(candle(13.0, 11.0, 12.0)), None);
|
||||
let v = er.update(candle(16.0, 13.0, 14.0)).unwrap();
|
||||
assert_relative_eq!(v.bull_power, 4.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.bear_power, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_manual_ema() {
|
||||
let bars: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
candle(base + 2.0, base - 2.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut er = ElderRay::new(13).unwrap();
|
||||
let mut ema = Ema::new(13).unwrap();
|
||||
for (i, c) in bars.iter().enumerate() {
|
||||
let got = er.update(*c);
|
||||
let want = ema.update(c.close).map(|e| (c.high - e, c.low - e));
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(g), Some((b, be))) = (got, want) {
|
||||
assert_relative_eq!(g.bull_power, b, epsilon = 1e-9);
|
||||
assert_relative_eq!(g.bear_power, be, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut er = ElderRay::new(5).unwrap();
|
||||
er.batch(
|
||||
&(0..20)
|
||||
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(er.is_ready());
|
||||
er.reset();
|
||||
assert!(!er.is_ready());
|
||||
assert_eq!(er.update(candle(2.0, 0.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 50.0 + f64::from(i);
|
||||
candle(base + 1.5, base - 1.5, base)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ElderRay::new(7).unwrap();
|
||||
let mut b = ElderRay::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Fisher-transformed RSI.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Fisher RSI — the Fisher transform applied to a normalised [`Rsi`](crate::Rsi).
|
||||
///
|
||||
/// The RSI is bounded in `[0, 100]` and its distribution piles up near the
|
||||
/// middle, which blurs turning points. The Fisher transform reshapes a bounded
|
||||
/// input toward a Gaussian, sharpening the extremes into clear, near-symmetric
|
||||
/// peaks:
|
||||
///
|
||||
/// ```text
|
||||
/// rsi = RSI(price, period) in [0, 100]
|
||||
/// x = clamp((rsi - 50) / 50, ±0.999) normalise to (-1, 1)
|
||||
/// Fisher = 0.5 * ln((1 + x) / (1 - x))
|
||||
/// ```
|
||||
///
|
||||
/// The clamp keeps the logarithm finite when the RSI pins at `0` or `100`. The
|
||||
/// output is unbounded but in practice oscillates in roughly `[-3, 3]`, with
|
||||
/// sharp excursions marking momentum extremes. The first value lands with the
|
||||
/// inner RSI, after `period + 1` inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{FisherRsi, Indicator};
|
||||
///
|
||||
/// let mut indicator = FisherRsi::new(9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FisherRsi {
|
||||
period: usize,
|
||||
rsi: Rsi,
|
||||
}
|
||||
|
||||
impl FisherRsi {
|
||||
/// Construct a Fisher RSI with the given RSI period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
rsi: Rsi::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FisherRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let rsi = self.rsi.update(input)?;
|
||||
let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999);
|
||||
Some(0.5 * ((1.0 + x) / (1.0 - x)).ln())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.rsi.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.rsi.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FisherRSI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(FisherRsi::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let f = FisherRsi::new(9).unwrap();
|
||||
assert_eq!(f.period(), 9);
|
||||
// RSI warmup is period + 1.
|
||||
assert_eq!(f.warmup_period(), 10);
|
||||
assert_eq!(f.name(), "FisherRSI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_matches_rsi() {
|
||||
let mut f = FisherRsi::new(3).unwrap();
|
||||
// RSI(3) needs 4 inputs; the first three return None.
|
||||
assert_eq!(f.update(1.0), None);
|
||||
assert_eq!(f.update(2.0), None);
|
||||
assert_eq!(f.update(3.0), None);
|
||||
assert!(f.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_fisher_of_rsi() {
|
||||
// Fisher RSI must equal the Fisher transform of the standalone RSI.
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
||||
.collect();
|
||||
let mut fr = FisherRsi::new(9).unwrap();
|
||||
let mut rsi = Rsi::new(9).unwrap();
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let got = fr.update(p);
|
||||
let want = rsi.update(p).map(|r| {
|
||||
let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999);
|
||||
0.5 * ((1.0 + x) / (1.0 - x)).ln()
|
||||
});
|
||||
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-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_uptrend_is_positive() {
|
||||
// A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher.
|
||||
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
||||
let mut f = FisherRsi::new(9).unwrap();
|
||||
let last = f.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 1.0,
|
||||
"strong uptrend should give a large positive value, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_keeps_output_finite_at_extremes() {
|
||||
// Monotonic rise pins RSI at 100; the clamp must keep Fisher finite.
|
||||
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
|
||||
let mut f = FisherRsi::new(5).unwrap();
|
||||
for v in f.batch(&prices).into_iter().flatten() {
|
||||
assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut f = FisherRsi::new(5).unwrap();
|
||||
f.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(f.is_ready());
|
||||
f.reset();
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.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 = FisherRsi::new(9).unwrap();
|
||||
let mut b = FisherRsi::new(9).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Intraday Momentum Index (IMI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Intraday Momentum Index — Tushar Chande's RSI built from the open-to-close
|
||||
/// move instead of the close-to-close move.
|
||||
///
|
||||
/// For each bar the body is an up-move when `close > open` and a down-move
|
||||
/// otherwise; the IMI sums those bodies over `period` bars and forms the
|
||||
/// RSI-style ratio:
|
||||
///
|
||||
/// ```text
|
||||
/// gain = max(close - open, 0), loss = max(open - close, 0)
|
||||
/// IMI = 100 * Σ gain / (Σ gain + Σ loss) over the last `period` bars
|
||||
/// ```
|
||||
///
|
||||
/// Because it measures *intraday* (body) momentum rather than the gap-inclusive
|
||||
/// close-to-close change, the IMI is a candle-pattern-flavoured overbought /
|
||||
/// oversold gauge: persistent white bodies push it up, black bodies down. It is
|
||||
/// bounded in `[0, 100]`; a window of doji-like bars (no net bodies) returns the
|
||||
/// neutral `50`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, IntradayMomentumIndex, Indicator};
|
||||
///
|
||||
/// let mut imi = IntradayMomentumIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
|
||||
/// last = imi.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IntradayMomentumIndex {
|
||||
period: usize,
|
||||
/// Per-bar `(gain, loss)` bodies, oldest at the front.
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_gain: f64,
|
||||
sum_loss: f64,
|
||||
}
|
||||
|
||||
impl IntradayMomentumIndex {
|
||||
/// Construct an IMI over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_gain: 0.0,
|
||||
sum_loss: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if the window is full.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.window.len() != self.period {
|
||||
return None;
|
||||
}
|
||||
let denom = self.sum_gain + self.sum_loss;
|
||||
if denom == 0.0 {
|
||||
Some(50.0)
|
||||
} else {
|
||||
Some(100.0 * self.sum_gain / denom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for IntradayMomentumIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let body = candle.close - candle.open;
|
||||
let gain = if body > 0.0 { body } else { 0.0 };
|
||||
let loss = if body < 0.0 { -body } else { 0.0 };
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let (old_g, old_l) = self.window.pop_front().expect("window full");
|
||||
self.sum_gain -= old_g;
|
||||
self.sum_loss -= old_l;
|
||||
}
|
||||
self.window.push_back((gain, loss));
|
||||
self.sum_gain += gain;
|
||||
self.sum_loss += loss;
|
||||
self.value()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_gain = 0.0;
|
||||
self.sum_loss = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"IMI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, close: f64) -> Candle {
|
||||
let hi = open.max(close) + 1.0;
|
||||
let lo = open.min(close) - 1.0;
|
||||
Candle::new(open, hi, lo, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
IntradayMomentumIndex::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let imi = IntradayMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(imi.period(), 14);
|
||||
assert_eq!(imi.warmup_period(), 14);
|
||||
assert_eq!(imi.name(), "IMI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_up_bodies_is_one_hundred() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 11.0), candle(11.0, 13.0), candle(13.0, 14.0)];
|
||||
let out = imi.batch(&bars);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert_relative_eq!(out[2].unwrap(), 100.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_down_bodies_is_zero() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(14.0, 13.0), candle(13.0, 11.0), candle(11.0, 10.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value_mixed_bodies() {
|
||||
// bodies: +1, -1, +2 -> sum_gain = 3, sum_loss = 1 -> 100*3/4 = 75.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 11.0), candle(11.0, 10.0), candle(10.0, 12.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 75.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_window_is_neutral() {
|
||||
// close == open every bar -> no bodies -> neutral 50.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 10.0), candle(11.0, 11.0), candle(12.0, 12.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_window() {
|
||||
// After [+1,-1,+2] (75) add +0 body window -> [-1,+2,0]: gain 2, loss 1 -> 66.67.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [
|
||||
candle(10.0, 11.0),
|
||||
candle(11.0, 10.0),
|
||||
candle(10.0, 12.0),
|
||||
candle(12.0, 12.0),
|
||||
];
|
||||
let out = imi.batch(&bars);
|
||||
assert_relative_eq!(out[3].unwrap(), 100.0 * 2.0 / 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
imi.batch(&[candle(10.0, 11.0), candle(11.0, 12.0), candle(12.0, 13.0)]);
|
||||
assert!(imi.is_ready());
|
||||
imi.reset();
|
||||
assert!(!imi.is_ready());
|
||||
assert_eq!(imi.update(candle(1.0, 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base, base + (f64::from(i) * 0.5).sin())
|
||||
})
|
||||
.collect();
|
||||
let mut a = IntradayMomentumIndex::new(7).unwrap();
|
||||
let mut b = IntradayMomentumIndex::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,9 @@ mod dema;
|
||||
mod demand_index;
|
||||
mod demark_pivots;
|
||||
mod depth_slope;
|
||||
mod derivative_oscillator;
|
||||
mod detrended_std_dev;
|
||||
mod disparity_index;
|
||||
mod distance_ssd;
|
||||
mod doji;
|
||||
mod doji_star;
|
||||
@@ -104,11 +106,13 @@ mod dpo;
|
||||
mod dragonfly_doji;
|
||||
mod drawdown_duration;
|
||||
mod dx;
|
||||
mod dynamic_momentum_index;
|
||||
mod ease_of_movement;
|
||||
mod effective_spread;
|
||||
mod ehlers_stochastic;
|
||||
mod ehma;
|
||||
mod elder_impulse;
|
||||
mod elder_ray;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
@@ -126,6 +130,7 @@ mod fib_projection;
|
||||
mod fib_retracement;
|
||||
mod fib_time_zones;
|
||||
mod fibonacci_pivots;
|
||||
mod fisher_rsi;
|
||||
mod fisher_transform;
|
||||
mod flag_pennant;
|
||||
mod footprint;
|
||||
@@ -173,6 +178,7 @@ mod inertia;
|
||||
mod information_ratio;
|
||||
mod initial_balance;
|
||||
mod instantaneous_trendline;
|
||||
mod intraday_momentum_index;
|
||||
mod intraday_volatility_profile;
|
||||
mod inverse_fisher_transform;
|
||||
mod inverted_hammer;
|
||||
@@ -264,6 +270,7 @@ mod ppo;
|
||||
mod profit_factor;
|
||||
mod psar;
|
||||
mod pvi;
|
||||
mod qqe;
|
||||
mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod realized_spread;
|
||||
@@ -276,6 +283,7 @@ mod renko_bars;
|
||||
mod renko_trailing_stop;
|
||||
mod rickshaw_man;
|
||||
mod rising_three_methods;
|
||||
mod rmi;
|
||||
mod roc;
|
||||
mod rocp;
|
||||
mod rocr;
|
||||
@@ -289,6 +297,7 @@ mod rolling_percentile_rank;
|
||||
mod rolling_quantile;
|
||||
mod roofing_filter;
|
||||
mod rsi;
|
||||
mod rsx;
|
||||
mod rvi;
|
||||
mod rvi_volatility;
|
||||
mod rwi;
|
||||
@@ -325,6 +334,7 @@ mod step_trailing_stop;
|
||||
mod stick_sandwich;
|
||||
mod stoch_rsi;
|
||||
mod stochastic;
|
||||
mod stochastic_cci;
|
||||
mod super_smoother;
|
||||
mod super_trend;
|
||||
mod t3;
|
||||
@@ -494,7 +504,9 @@ pub use dema::Dema;
|
||||
pub use demand_index::DemandIndex;
|
||||
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
|
||||
pub use depth_slope::DepthSlope;
|
||||
pub use derivative_oscillator::DerivativeOscillator;
|
||||
pub use detrended_std_dev::DetrendedStdDev;
|
||||
pub use disparity_index::DisparityIndex;
|
||||
pub use distance_ssd::DistanceSsd;
|
||||
pub use doji::Doji;
|
||||
pub use doji_star::DojiStar;
|
||||
@@ -507,11 +519,13 @@ pub use dpo::Dpo;
|
||||
pub use dragonfly_doji::DragonflyDoji;
|
||||
pub use drawdown_duration::DrawdownDuration;
|
||||
pub use dx::Dx;
|
||||
pub use dynamic_momentum_index::DynamicMomentumIndex;
|
||||
pub use ease_of_movement::EaseOfMovement;
|
||||
pub use effective_spread::EffectiveSpread;
|
||||
pub use ehlers_stochastic::EhlersStochastic;
|
||||
pub use ehma::Ehma;
|
||||
pub use elder_impulse::ElderImpulse;
|
||||
pub use elder_ray::{ElderRay, ElderRayOutput};
|
||||
pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
@@ -529,6 +543,7 @@ pub use fib_projection::{FibProjection, FibProjectionOutput};
|
||||
pub use fib_retracement::{FibRetracement, FibRetracementOutput};
|
||||
pub use fib_time_zones::{FibTimeZones, FibTimeZonesOutput};
|
||||
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
|
||||
pub use fisher_rsi::FisherRsi;
|
||||
pub use fisher_transform::FisherTransform;
|
||||
pub use flag_pennant::FlagPennant;
|
||||
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
|
||||
@@ -576,6 +591,7 @@ pub use inertia::Inertia;
|
||||
pub use information_ratio::InformationRatio;
|
||||
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
|
||||
pub use instantaneous_trendline::InstantaneousTrendline;
|
||||
pub use intraday_momentum_index::IntradayMomentumIndex;
|
||||
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
|
||||
pub use inverse_fisher_transform::InverseFisherTransform;
|
||||
pub use inverted_hammer::InvertedHammer;
|
||||
@@ -667,6 +683,7 @@ pub use ppo::Ppo;
|
||||
pub use profit_factor::ProfitFactor;
|
||||
pub use psar::Psar;
|
||||
pub use pvi::Pvi;
|
||||
pub use qqe::{Qqe, QqeOutput};
|
||||
pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use realized_spread::RealizedSpread;
|
||||
@@ -679,6 +696,7 @@ pub use renko_bars::{RenkoBars, RenkoBrick};
|
||||
pub use renko_trailing_stop::RenkoTrailingStop;
|
||||
pub use rickshaw_man::RickshawMan;
|
||||
pub use rising_three_methods::RisingThreeMethods;
|
||||
pub use rmi::Rmi;
|
||||
pub use roc::Roc;
|
||||
pub use rocp::Rocp;
|
||||
pub use rocr::Rocr;
|
||||
@@ -692,6 +710,7 @@ pub use rolling_percentile_rank::RollingPercentileRank;
|
||||
pub use rolling_quantile::RollingQuantile;
|
||||
pub use roofing_filter::RoofingFilter;
|
||||
pub use rsi::Rsi;
|
||||
pub use rsx::Rsx;
|
||||
pub use rvi::Rvi;
|
||||
pub use rvi_volatility::RviVolatility;
|
||||
pub use rwi::{Rwi, RwiOutput};
|
||||
@@ -728,6 +747,7 @@ pub use step_trailing_stop::StepTrailingStop;
|
||||
pub use stick_sandwich::StickSandwich;
|
||||
pub use stoch_rsi::StochRsi;
|
||||
pub use stochastic::{Stochastic, StochasticOutput};
|
||||
pub use stochastic_cci::StochasticCci;
|
||||
pub use super_smoother::SuperSmoother;
|
||||
pub use super_trend::{SuperTrend, SuperTrendOutput};
|
||||
pub use t3::T3;
|
||||
@@ -880,6 +900,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Rocp",
|
||||
"Rocr",
|
||||
"Rocr100",
|
||||
"DisparityIndex",
|
||||
"FisherRsi",
|
||||
"Rsx",
|
||||
"DynamicMomentumIndex",
|
||||
"StochasticCci",
|
||||
"Rmi",
|
||||
"DerivativeOscillator",
|
||||
"ElderRay",
|
||||
"IntradayMomentumIndex",
|
||||
"Qqe",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1363,6 +1393,6 @@ mod family_tests {
|
||||
// the actual indicator count is the early-warning signal that an
|
||||
// indicator was added without being assigned a family.
|
||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||
assert_eq!(total, 403, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 413, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//! QQE — Quantitative Qualitative Estimation.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One QQE reading: the smoothed RSI and its volatility-trailing line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct QqeOutput {
|
||||
/// The EMA-smoothed RSI (the fast QQE line).
|
||||
pub rsi_ma: f64,
|
||||
/// The trailing line (the slow QQE line): an ATR-of-RSI trailing stop that
|
||||
/// the smoothed RSI rides above in an uptrend and below in a downtrend.
|
||||
pub trailing_line: f64,
|
||||
}
|
||||
|
||||
/// QQE — Quantitative Qualitative Estimation (Igor Livshin).
|
||||
///
|
||||
/// QQE smooths the RSI, then builds an "ATR of the RSI" trailing stop around it.
|
||||
/// Crossovers of the smoothed RSI and that trailing line give cleaner momentum
|
||||
/// signals than the raw RSI:
|
||||
///
|
||||
/// ```text
|
||||
/// rsi_ma = EMA(RSI(price, rsi_period), smoothing)
|
||||
/// atr_rsi = |rsi_ma − rsi_ma_prev|
|
||||
/// ma_atr = EMA(atr_rsi, 2·rsi_period − 1) // Wilder length
|
||||
/// dar = EMA(ma_atr, 2·rsi_period − 1) · factor // smoothed band width
|
||||
///
|
||||
/// long_band = (rsi_ma_prev > long_band_prev && rsi_ma > long_band_prev)
|
||||
/// ? max(long_band_prev, rsi_ma − dar) : rsi_ma − dar
|
||||
/// short_band = (rsi_ma_prev < short_band_prev && rsi_ma < short_band_prev)
|
||||
/// ? min(short_band_prev, rsi_ma + dar) : rsi_ma + dar
|
||||
/// trend = cross-up of short_band → +1, cross-down of long_band → −1, else hold
|
||||
/// trailing = trend == +1 ? long_band : short_band
|
||||
/// ```
|
||||
///
|
||||
/// The trailing line ratchets in the trend direction (only ever tightening until
|
||||
/// the smoothed RSI crosses it), exactly like a [`SuperTrend`](crate::SuperTrend)
|
||||
/// on the RSI. Livshin's defaults are `rsi_period = 14`, `smoothing = 5`,
|
||||
/// `factor = 4.236`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Qqe};
|
||||
///
|
||||
/// let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = qqe.update(100.0 + (f64::from(i) * 0.1).sin() * 8.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Qqe {
|
||||
rsi: Rsi,
|
||||
rsi_ma: Ema,
|
||||
ma_atr: Ema,
|
||||
dar_ema: Ema,
|
||||
factor: f64,
|
||||
prev_rsi_ma: Option<f64>,
|
||||
bands: Option<(f64, f64, i8)>, // (long_band, short_band, trend)
|
||||
last_value: Option<QqeOutput>,
|
||||
}
|
||||
|
||||
impl Qqe {
|
||||
/// Construct a QQE with the RSI period, RSI smoothing, and band `factor`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `rsi_period` or `smoothing` is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `factor` is non-finite or not positive.
|
||||
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<Self> {
|
||||
if rsi_period == 0 || smoothing == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !factor.is_finite() || factor <= 0.0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "QQE factor must be a finite positive value",
|
||||
});
|
||||
}
|
||||
let wilders = 2 * rsi_period - 1;
|
||||
Ok(Self {
|
||||
rsi: Rsi::new(rsi_period)?,
|
||||
rsi_ma: Ema::new(smoothing)?,
|
||||
ma_atr: Ema::new(wilders)?,
|
||||
dar_ema: Ema::new(wilders)?,
|
||||
factor,
|
||||
prev_rsi_ma: None,
|
||||
bands: None,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured band factor.
|
||||
pub const fn factor(&self) -> f64 {
|
||||
self.factor
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<QqeOutput> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Qqe {
|
||||
type Input = f64;
|
||||
type Output = QqeOutput;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<QqeOutput> {
|
||||
let rsi = self.rsi.update(price)?;
|
||||
let rsi_ma = self.rsi_ma.update(rsi)?;
|
||||
|
||||
let Some(prev_ma) = self.prev_rsi_ma else {
|
||||
self.prev_rsi_ma = Some(rsi_ma);
|
||||
return None;
|
||||
};
|
||||
let atr_rsi = (rsi_ma - prev_ma).abs();
|
||||
self.prev_rsi_ma = Some(rsi_ma);
|
||||
|
||||
let ma_atr = self.ma_atr.update(atr_rsi)?;
|
||||
let dar = self.dar_ema.update(ma_atr)? * self.factor;
|
||||
|
||||
let new_long = rsi_ma - dar;
|
||||
let new_short = rsi_ma + dar;
|
||||
|
||||
let (long_band, short_band, trend) = match self.bands {
|
||||
Some((lb_prev, sb_prev, tr_prev)) => {
|
||||
let lb = if prev_ma > lb_prev && rsi_ma > lb_prev {
|
||||
lb_prev.max(new_long)
|
||||
} else {
|
||||
new_long
|
||||
};
|
||||
let sb = if prev_ma < sb_prev && rsi_ma < sb_prev {
|
||||
sb_prev.min(new_short)
|
||||
} else {
|
||||
new_short
|
||||
};
|
||||
let tr = if prev_ma <= sb_prev && rsi_ma > sb_prev {
|
||||
1
|
||||
} else if prev_ma >= lb_prev && rsi_ma < lb_prev {
|
||||
-1
|
||||
} else {
|
||||
tr_prev
|
||||
};
|
||||
(lb, sb, tr)
|
||||
}
|
||||
None => (new_long, new_short, 1),
|
||||
};
|
||||
self.bands = Some((long_band, short_band, trend));
|
||||
|
||||
let trailing_line = if trend == 1 { long_band } else { short_band };
|
||||
let out = QqeOutput {
|
||||
rsi_ma,
|
||||
trailing_line,
|
||||
};
|
||||
self.last_value = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
self.rsi_ma.reset();
|
||||
self.ma_atr.reset();
|
||||
self.dar_ema.reset();
|
||||
self.prev_rsi_ma = None;
|
||||
self.bands = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// RSI (rsi_period + 1) -> rsi_ma EMA -> one bar for the first atr_rsi ->
|
||||
// ma_atr EMA -> dar EMA. Expressed via the component warmups so it stays
|
||||
// correct if those change.
|
||||
self.rsi.warmup_period()
|
||||
+ self.rsi_ma.warmup_period()
|
||||
+ self.ma_atr.warmup_period()
|
||||
+ self.dar_ema.warmup_period()
|
||||
- 2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"QQE"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Independent reference replaying the full QQE recurrence.
|
||||
fn naive(
|
||||
prices: &[f64],
|
||||
rsi_period: usize,
|
||||
smoothing: usize,
|
||||
factor: f64,
|
||||
) -> Vec<Option<QqeOutput>> {
|
||||
let mut rsi = Rsi::new(rsi_period).unwrap();
|
||||
let mut rsi_ma = Ema::new(smoothing).unwrap();
|
||||
let wilders = 2 * rsi_period - 1;
|
||||
let mut ma_atr = Ema::new(wilders).unwrap();
|
||||
let mut dar_ema = Ema::new(wilders).unwrap();
|
||||
let mut prev_ma: Option<f64> = None;
|
||||
let mut bands: Option<(f64, f64, i8)> = None;
|
||||
let mut out = Vec::with_capacity(prices.len());
|
||||
for &p in prices {
|
||||
let v = (|| {
|
||||
let r = rsi.update(p)?;
|
||||
let m = rsi_ma.update(r)?;
|
||||
let Some(pm) = prev_ma else {
|
||||
prev_ma = Some(m);
|
||||
return None;
|
||||
};
|
||||
let atr = (m - pm).abs();
|
||||
prev_ma = Some(m);
|
||||
let ma = ma_atr.update(atr)?;
|
||||
let dar = dar_ema.update(ma)? * factor;
|
||||
let nl = m - dar;
|
||||
let ns = m + dar;
|
||||
let (lb, sb, tr) = match bands {
|
||||
Some((lbp, sbp, trp)) => {
|
||||
let lb = if pm > lbp && m > lbp { lbp.max(nl) } else { nl };
|
||||
let sb = if pm < sbp && m < sbp { sbp.min(ns) } else { ns };
|
||||
let tr = if pm <= sbp && m > sbp {
|
||||
1
|
||||
} else if pm >= lbp && m < lbp {
|
||||
-1
|
||||
} else {
|
||||
trp
|
||||
};
|
||||
(lb, sb, tr)
|
||||
}
|
||||
None => (nl, ns, 1),
|
||||
};
|
||||
bands = Some((lb, sb, tr));
|
||||
Some(QqeOutput {
|
||||
rsi_ma: m,
|
||||
trailing_line: if tr == 1 { lb } else { sb },
|
||||
})
|
||||
})();
|
||||
out.push(v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_params() {
|
||||
assert!(matches!(Qqe::new(0, 5, 4.236), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Qqe::new(14, 0, 4.236), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
Qqe::new(14, 5, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Qqe::new(14, 5, f64::NAN),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `factor` + `value` and the Indicator-impl
|
||||
/// `name`. `warmup_period` is covered by `first_emission_matches_warmup`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
assert_relative_eq!(qqe.factor(), 4.236, epsilon = 1e-12);
|
||||
assert_eq!(qqe.value(), None);
|
||||
assert_eq!(qqe.name(), "QQE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup() {
|
||||
// A long trend-up-then-down series exercises both trend flips and the
|
||||
// band tighten/reset branches.
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.06).sin() * 20.0)
|
||||
.collect();
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let out = qqe.batch(&prices);
|
||||
let warmup = qqe.warmup_period();
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"first value at warmup_period - 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_naive_over_full_cycle() {
|
||||
// Up, range, and down phases so every band/trend branch is traversed.
|
||||
let prices: Vec<f64> = (0..220)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
100.0 + (t * 0.05).sin() * 18.0 + (t * 0.2).cos() * 4.0
|
||||
})
|
||||
.collect();
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let got = qqe.batch(&prices);
|
||||
let want = naive(&prices, 14, 5, 4.236);
|
||||
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
|
||||
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (g, w) {
|
||||
assert_relative_eq!(a.rsi_ma, b.rsi_ma, epsilon = 1e-9);
|
||||
assert_relative_eq!(a.trailing_line, b.trailing_line, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_line_below_rsi_ma_in_uptrend() {
|
||||
// Sustained rise: trend resolves to +1 and the trailing (long) band sits
|
||||
// below the smoothed RSI.
|
||||
let prices: Vec<f64> = (1..=120).map(f64::from).collect();
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let last = qqe.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last.trailing_line <= last.rsi_ma,
|
||||
"uptrend trailing {} should sit at/below rsi_ma {}",
|
||||
last.trailing_line,
|
||||
last.rsi_ma
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
qqe.batch(
|
||||
&(0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 8.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(qqe.is_ready());
|
||||
qqe.reset();
|
||||
assert!(!qqe.is_ready());
|
||||
assert_eq!(qqe.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..150)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.12).sin() * 12.0)
|
||||
.collect();
|
||||
let mut a = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let mut b = Qqe::new(14, 5, 4.236).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! 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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! RSX — Jurik-style smoothed RSI.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// RSX — a noise-free RSI built from Jurik's three-stage smoothing cascade.
|
||||
///
|
||||
/// Where Wilder's [`Rsi`](crate::Rsi) smooths the up/down moves with a single
|
||||
/// EMA, the RSX runs the signed price change *and* its absolute value through
|
||||
/// three cascaded "double-EMA with overshoot" stages (each stage is
|
||||
/// `x = 1.5·a − 0.5·b`, the same lag-cancelling trick as a DEMA), then forms the
|
||||
/// RSI-style ratio from the two smoothed streams:
|
||||
///
|
||||
/// ```text
|
||||
/// f18 = 3 / (length + 2), f20 = 1 - f18
|
||||
/// each stage: a = f20·a + f18·in; b = f18·a + f20·b; out = 1.5·a − 0.5·b
|
||||
/// v14 = stage3(signed change), v1C = stage3(|change|)
|
||||
/// RSX = clamp((v14 / v1C + 1) · 50, 0, 100) (50 when v1C == 0)
|
||||
/// ```
|
||||
///
|
||||
/// The result is an oscillator in `[0, 100]` that tracks the RSI but is far
|
||||
/// smoother for the same responsiveness — it has very little of the RSI's
|
||||
/// bar-to-bar jitter, so threshold crosses and divergences are cleaner. A flat
|
||||
/// market returns the neutral `50`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Rsx};
|
||||
///
|
||||
/// let mut indicator = Rsx::new(14).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 Rsx {
|
||||
length: usize,
|
||||
f18: f64,
|
||||
f20: f64,
|
||||
prev: Option<f64>,
|
||||
count: usize,
|
||||
// Signed-change cascade (three stages: a/b pairs).
|
||||
s_a0: f64,
|
||||
s_b0: f64,
|
||||
s_a1: f64,
|
||||
s_b1: f64,
|
||||
s_a2: f64,
|
||||
s_b2: f64,
|
||||
// Absolute-change cascade.
|
||||
a_a0: f64,
|
||||
a_b0: f64,
|
||||
a_a1: f64,
|
||||
a_b1: f64,
|
||||
a_a2: f64,
|
||||
a_b2: f64,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Rsx {
|
||||
/// Construct an RSX with the given smoothing length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `length == 0`.
|
||||
pub fn new(length: usize) -> Result<Self> {
|
||||
if length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let f18 = 3.0 / (length as f64 + 2.0);
|
||||
Ok(Self {
|
||||
length,
|
||||
f18,
|
||||
f20: 1.0 - f18,
|
||||
prev: None,
|
||||
count: 0,
|
||||
s_a0: 0.0,
|
||||
s_b0: 0.0,
|
||||
s_a1: 0.0,
|
||||
s_b1: 0.0,
|
||||
s_a2: 0.0,
|
||||
s_b2: 0.0,
|
||||
a_a0: 0.0,
|
||||
a_b0: 0.0,
|
||||
a_a1: 0.0,
|
||||
a_b1: 0.0,
|
||||
a_a2: 0.0,
|
||||
a_b2: 0.0,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured length.
|
||||
pub const fn length(&self) -> usize {
|
||||
self.length
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
/// One double-EMA-with-overshoot stage: updates the `(a, b)` pair in place
|
||||
/// and returns `1.5·a − 0.5·b`.
|
||||
fn stage(&self, a: &mut f64, b: &mut f64, input: f64) -> f64 {
|
||||
*a = self.f20 * *a + self.f18 * input;
|
||||
*b = self.f18 * *a + self.f20 * *b;
|
||||
1.5 * *a - 0.5 * *b
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Rsx {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(price);
|
||||
return None;
|
||||
};
|
||||
self.prev = Some(price);
|
||||
|
||||
let change = price - prev;
|
||||
|
||||
// Signed-change cascade.
|
||||
let (mut sa0, mut sb0) = (self.s_a0, self.s_b0);
|
||||
let v_c = self.stage(&mut sa0, &mut sb0, change);
|
||||
self.s_a0 = sa0;
|
||||
self.s_b0 = sb0;
|
||||
let (mut sa1, mut sb1) = (self.s_a1, self.s_b1);
|
||||
let v_10 = self.stage(&mut sa1, &mut sb1, v_c);
|
||||
self.s_a1 = sa1;
|
||||
self.s_b1 = sb1;
|
||||
let (mut sa2, mut sb2) = (self.s_a2, self.s_b2);
|
||||
let v_14 = self.stage(&mut sa2, &mut sb2, v_10);
|
||||
self.s_a2 = sa2;
|
||||
self.s_b2 = sb2;
|
||||
|
||||
// Absolute-change cascade.
|
||||
let abs = change.abs();
|
||||
let (mut aa0, mut ab0) = (self.a_a0, self.a_b0);
|
||||
let v_c1 = self.stage(&mut aa0, &mut ab0, abs);
|
||||
self.a_a0 = aa0;
|
||||
self.a_b0 = ab0;
|
||||
let (mut aa1, mut ab1) = (self.a_a1, self.a_b1);
|
||||
let v_18 = self.stage(&mut aa1, &mut ab1, v_c1);
|
||||
self.a_a1 = aa1;
|
||||
self.a_b1 = ab1;
|
||||
let (mut aa2, mut ab2) = (self.a_a2, self.a_b2);
|
||||
let v_1c = self.stage(&mut aa2, &mut ab2, v_18);
|
||||
self.a_a2 = aa2;
|
||||
self.a_b2 = ab2;
|
||||
|
||||
let v4 = if v_1c > 0.0 {
|
||||
(v_14 / v_1c + 1.0) * 50.0
|
||||
} else {
|
||||
50.0
|
||||
};
|
||||
let rsx = v4.clamp(0.0, 100.0);
|
||||
|
||||
self.count += 1;
|
||||
self.last_value = Some(rsx);
|
||||
if self.count >= self.length {
|
||||
Some(rsx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
*self = Self::new(self.length).expect("length already validated");
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One input to seed `prev`, then `length` changes to settle the cascade.
|
||||
self.length + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.count >= self.length
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RSX"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_length() {
|
||||
assert!(matches!(Rsx::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `length` + `value` and the Indicator-impl
|
||||
/// `warmup_period` + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let rsx = Rsx::new(14).unwrap();
|
||||
assert_eq!(rsx.length(), 14);
|
||||
assert_eq!(rsx.value(), None);
|
||||
assert_eq!(rsx.warmup_period(), 15);
|
||||
assert_eq!(rsx.name(), "RSX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_emits() {
|
||||
let mut rsx = Rsx::new(3).unwrap();
|
||||
// 1 input seeds prev; then 3 changes settle -> first Some on input 4.
|
||||
assert_eq!(rsx.update(10.0), None);
|
||||
assert_eq!(rsx.update(11.0), None);
|
||||
assert_eq!(rsx.update(12.0), None);
|
||||
assert!(rsx.update(13.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// No movement -> absolute cascade is zero -> neutral 50.
|
||||
let mut rsx = Rsx::new(5).unwrap();
|
||||
let last = rsx.batch(&[7.0; 40]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_in_range() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.35).sin() * 12.0)
|
||||
.collect();
|
||||
let mut rsx = Rsx::new(14).unwrap();
|
||||
for v in rsx.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "RSX {v} left [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_uptrend_is_high() {
|
||||
// A sustained rise drives RSX well above the neutral 50.
|
||||
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
|
||||
let mut rsx = Rsx::new(14).unwrap();
|
||||
let last = rsx.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 80.0,
|
||||
"strong uptrend should push RSX high, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut rsx = Rsx::new(3).unwrap();
|
||||
let ready = rsx
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(rsx.update(f64::NAN), Some(ready));
|
||||
assert_eq!(rsx.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut rsx = Rsx::new(5).unwrap();
|
||||
rsx.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(rsx.is_ready());
|
||||
rsx.reset();
|
||||
assert!(!rsx.is_ready());
|
||||
assert_eq!(rsx.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = Rsx::new(14).unwrap();
|
||||
let mut b = Rsx::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Stochastic CCI — a stochastic oscillator applied to the CCI.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::cci::Cci;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Stochastic CCI — the stochastic oscillator computed over the
|
||||
/// [`Cci`](crate::Cci) instead of price.
|
||||
///
|
||||
/// The CCI is unbounded and spends most of its time inside `±100`, which makes
|
||||
/// fixed overbought/oversold lines awkward. Running a stochastic over the CCI
|
||||
/// re-scales it to `[0, 100]` relative to its own recent range, turning it into
|
||||
/// a bounded, self-normalising momentum oscillator:
|
||||
///
|
||||
/// ```text
|
||||
/// cci = CCI(typical price, period)
|
||||
/// %K = 100 * (cci - lowest(cci, period)) / (highest(cci, period) - lowest(cci, period))
|
||||
/// ```
|
||||
///
|
||||
/// The same `period` is used for the CCI and the stochastic lookback. When the
|
||||
/// CCI range over the window is zero (a flat market, where the CCI is pinned at
|
||||
/// `0`) the oscillator returns the neutral `50`. The first value lands after
|
||||
/// `2·period − 1` bars: `period` to seed the CCI, then `period` CCI values to
|
||||
/// fill the stochastic window.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, StochasticCci, Indicator};
|
||||
///
|
||||
/// let mut sc = StochasticCci::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
|
||||
/// last = sc.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StochasticCci {
|
||||
period: usize,
|
||||
cci: Cci,
|
||||
/// The last `period` CCI values.
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl StochasticCci {
|
||||
/// Construct a Stochastic CCI with the given period (shared by the CCI and
|
||||
/// the stochastic lookback).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
cci: Cci::new(period)?,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for StochasticCci {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let cci = self.cci.update(candle)?;
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(cci);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut lo = f64::MAX;
|
||||
let mut hi = f64::MIN;
|
||||
for &v in &self.window {
|
||||
if v < lo {
|
||||
lo = v;
|
||||
}
|
||||
if v > hi {
|
||||
hi = v;
|
||||
}
|
||||
}
|
||||
let range = hi - lo;
|
||||
if range == 0.0 {
|
||||
return Some(50.0);
|
||||
}
|
||||
// Ratio first, then scale: `100 * x / x` can round to 100.0000…1.
|
||||
Some(100.0 * ((cci - lo) / range))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.cci.reset();
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// CCI seeds at `period`, then `period` CCI values fill the stochastic window.
|
||||
2 * self.period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"StochasticCCI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(StochasticCci::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let sc = StochasticCci::new(14).unwrap();
|
||||
assert_eq!(sc.period(), 14);
|
||||
assert_eq!(sc.warmup_period(), 27);
|
||||
assert_eq!(sc.name(), "StochasticCCI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let bars: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.4).sin() * 8.0;
|
||||
candle(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut sc = StochasticCci::new(5).unwrap();
|
||||
let out = sc.batch(&bars);
|
||||
let warmup = sc.warmup_period();
|
||||
assert_eq!(warmup, 9);
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_zero_to_hundred() {
|
||||
let bars: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.35).sin() * 12.0;
|
||||
candle(base + 2.0, base - 2.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut sc = StochasticCci::new(9).unwrap();
|
||||
for v in sc.batch(&bars).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "%K {v} left [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// Constant candles -> CCI pinned at 0 -> zero range -> neutral 50.
|
||||
let mut sc = StochasticCci::new(4).unwrap();
|
||||
let bars = vec![candle(10.0, 10.0, 10.0); 20];
|
||||
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_cci_in_window_is_hundred() {
|
||||
// When the latest CCI is the window maximum, %K must be 100.
|
||||
// A long rise then makes the final CCI the highest in its window.
|
||||
let mut bars: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
// Strong final push so the last CCI tops its window.
|
||||
bars.push(candle(100.0, 98.0, 100.0));
|
||||
let mut sc = StochasticCci::new(5).unwrap();
|
||||
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut sc = StochasticCci::new(5).unwrap();
|
||||
sc.batch(
|
||||
&(0..30)
|
||||
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(sc.is_ready());
|
||||
sc.reset();
|
||||
assert!(!sc.is_ready());
|
||||
assert_eq!(sc.update(candle(2.0, 0.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 50.0 + (f64::from(i) * 0.5).sin() * 10.0;
|
||||
candle(base + 1.5, base - 1.5, base)
|
||||
})
|
||||
.collect();
|
||||
let mut a = StochasticCci::new(9).unwrap();
|
||||
let mut b = StochasticCci::new(9).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,15 +72,16 @@ pub use indicators::{
|
||||
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
|
||||
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
|
||||
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
|
||||
DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar,
|
||||
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
|
||||
DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji,
|
||||
DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse,
|
||||
Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy,
|
||||
DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev,
|
||||
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
|
||||
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
|
||||
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex,
|
||||
EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay,
|
||||
ElderRayOutput, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy,
|
||||
FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence,
|
||||
FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection,
|
||||
FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput,
|
||||
FibonacciPivots, FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint,
|
||||
FibonacciPivots, FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint,
|
||||
FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis,
|
||||
FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite,
|
||||
GarmanKlassVolatility, Gartley, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
|
||||
@@ -90,10 +91,10 @@ pub use indicators::{
|
||||
HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput,
|
||||
HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia,
|
||||
InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform,
|
||||
InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama,
|
||||
KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
|
||||
Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
|
||||
IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
|
||||
InverseFisherTransform, InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio,
|
||||
KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength,
|
||||
Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
|
||||
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
|
||||
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
|
||||
LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt,
|
||||
@@ -107,35 +108,35 @@ pub use indicators::{
|
||||
OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore,
|
||||
PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, PercentB,
|
||||
PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo,
|
||||
ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
|
||||
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
|
||||
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100,
|
||||
RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr,
|
||||
RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility,
|
||||
Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput,
|
||||
SessionRange, SessionRangeOutput, SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine,
|
||||
SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio,
|
||||
SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands,
|
||||
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
|
||||
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
|
||||
StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
|
||||
SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker,
|
||||
TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
|
||||
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
|
||||
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
|
||||
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
|
||||
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TrendLabel, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix,
|
||||
TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows,
|
||||
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio,
|
||||
UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
|
||||
VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile,
|
||||
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
|
||||
VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
|
||||
Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals,
|
||||
WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput,
|
||||
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
|
||||
Zlema, FAMILIES, T3,
|
||||
ProfitFactor, Psar, Pvi, Qqe, QqeOutput, QuotedSpread, RSquared, RealizedSpread,
|
||||
RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB,
|
||||
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
|
||||
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
|
||||
RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, RollingVwap,
|
||||
RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
|
||||
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
|
||||
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
|
||||
SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
|
||||
SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
|
||||
StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
|
||||
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
|
||||
StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
|
||||
TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
|
||||
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
|
||||
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
|
||||
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeOfDayReturnProfile,
|
||||
TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput, TradeImbalance, TrendLabel,
|
||||
TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, Tsi, Tsv,
|
||||
TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex,
|
||||
UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods,
|
||||
UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
|
||||
VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput,
|
||||
VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput,
|
||||
Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput,
|
||||
Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate,
|
||||
Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
|
||||
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
||||
// line so the indicator-count tooling (which scans the braced block above and
|
||||
|
||||
Reference in New Issue
Block a user