* feat(rvi): add Relative Vigor Index
Dorsey's RVI = SMA(close - open, period) / SMA(high - low, period) over
a rolling window of period candles. Candle input, single parameter
period (default 10). Positive on average-bullish windows, negative on
average-bearish. Holds the previous value if the entire window has
zero range (denominator undefined).
Reference: Donald Dorsey, also pandas-ta rvi.
Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values reference,
RviNode (4-column OHLC batch) + index.d.ts/index.js + indicators.test
.js factory + reference, WasmRvi + make_candle_ohlc helper, candle-fuzz
target + criterion bench, README + CHANGELOG.
* feat(pgo): add Pretty Good Oscillator
Mark Johnson's PGO = (close - SMA(close, period)) / EMA(TR, period).
Counts roughly how many ATR-equivalents the close sits from its
period-bar mean. Candle input, single parameter period (default 14).
Johnson's heuristic uses +3/-3 crossings as entry signals.
Touchpoints: pgo.rs + mod.rs + lib.rs re-export, PyPgo + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-close
reference, PgoNode (h/l/c) + index.d.ts/index.js + indicators.test.js
factory + reference, WasmPgo, candle-fuzz target + bench, README +
CHANGELOG.
* feat(kst): add Know Sure Thing (Pring)
Pring's long-horizon momentum oscillator: weighted sum of four
SMA-smoothed ROC series with fixed weights 1, 2, 3, 4, plus an SMA
signal line. Nine parameters (four ROC periods, four SMA periods, one
signal period); classic() applies Pring's recommended defaults.
Multi-output indicator emitting KstOutput { kst, signal }.
Touchpoints: kst.rs + mod.rs + lib.rs re-export, PyKst + __init__.py
+ test_new_indicators MULTI + test_known_values flat-input reference,
KstNode + KstValue + index.d.ts/index.js + indicators.test.js multi
factory + reference, WasmKst (manual JsValue object), scalar-fuzz
target (handled outside the f64-output drive helper), README +
CHANGELOG.
* feat(smi): add Stochastic Momentum Index (Blau)
Blau's doubly-EMA-smoothed bounded oscillator: measures the close's
displacement from the centre of the recent high-low range, scaled by
the smoothed range. Candle input, three parameters (period, d_period,
d2_period) with defaults 5 / 3 / 3.
Internally feeds both the displacement-EMA stack and the range-EMA
stack on every candle so they warm up in parallel (gating either
behind the other starves the second by one input).
Touchpoints: smi.rs + mod.rs + lib.rs re-export, PySmi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-input
reference, SmiNode + index.d.ts/index.js + indicators.test.js factory
+ reference, WasmSmi, candle-fuzz target, README + CHANGELOG.
* feat(laguerre-rsi): add Ehlers Laguerre RSI
Four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
accumulator. Single gamma in [0, 1] (default 0.5) trades lag for
smoothness. State is seeded by setting all four L_i to the first input
so a constant series stays at the neutral 50. Output clamped to
[0, 100] to absorb floating-point rounding.
Reference: Ehlers, Time Warp - Without Space Travel, 2002.
Touchpoints: laguerre_rsi.rs + mod.rs + lib.rs re-export, PyLaguerreRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, LaguerreRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmLaguerreRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.
* feat(connors-rsi): add Connors RSI (CRSI)
Larry Connors' 3-component aggregate: RSI(close), RSI(streak), and
PercentRank of the 1-period return over the last period_rank returns.
Each component is bounded in [0, 100] so the aggregate is too.
Three parameters (period_rsi, period_streak, period_rank) with
defaults 3 / 2 / 100. Streak tracks consecutive up/down runs (resets
to 0 on unchanged close).
Touchpoints: connors_rsi.rs + mod.rs + lib.rs re-export, PyConnorsRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values bounded
reference, ConnorsRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmConnorsRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.
* feat(inertia): add Dorsey Inertia (RVI + LinReg)
Donald Dorsey's Inertia — a LinearRegression smoothing of the RVI
series. Endpoint of an n-bar least-squares fit of RVI is the indicator
reading. Preserves trend direction while damping the ratio. Candle
input, two parameters (rvi_period, linreg_period) with defaults 14 / 20.
Touchpoints: inertia.rs + mod.rs + lib.rs re-export, PyInertia +
__init__.py + test_new_indicators CANDLE_SCALAR + test_known_values
constant reference, InertiaNode (4-column OHLC batch) + index.d.ts /
index.js + indicators.test.js factory + reference, WasmInertia,
candle-fuzz target, README + CHANGELOG.
* test(kst): Move KST out of MULTI dict (it is scalar-input)
KST sits in the MULTI dict (candle-input, multi-output) but its
update() takes a single f64, not a candle tuple. The shared streaming
loop in test_multi_streaming_matches_batch fed the OHLCV tuple in,
which crashed with `TypeError: argument 'value': must be real number,
not tuple` on every Python matrix entry.
Split into a new MULTI_SCALAR_INPUT dict with its own test function
that feeds the close-price stream as floats. KST is currently the
only such indicator; structure is ready for future scalar-input
multi-output additions (e.g. some MACD-shaped indicators).
* test(coverage): Cover SMI zero-range and ConnorsRsi zero-prev cold paths
codecov/patch on PR 40 flagged two uncovered defensive branches:
- SMI returns self.current early when the smoothed range collapses to
zero (`r2 <= 0.0`) so the formula stays defined. Exercised by feeding
bars where high == low.
- ConnorsRsi skips the ROC ring-buffer update when the previous price
is exactly zero so the divide-by-zero in `(input - prev) / prev` is
impossible. Exercised by seeding the first bar at 0.0.
304 lines
9.1 KiB
Rust
304 lines
9.1 KiB
Rust
//! Know Sure Thing (KST).
|
||
|
||
use crate::error::{Error, Result};
|
||
use crate::indicators::roc::Roc;
|
||
use crate::indicators::sma::Sma;
|
||
use crate::traits::Indicator;
|
||
|
||
/// `KST` output: the indicator line and its `SMA` signal line.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct KstOutput {
|
||
/// Weighted sum of four smoothed `ROC` series.
|
||
pub kst: f64,
|
||
/// `SMA` of `kst` over the signal period.
|
||
pub signal: f64,
|
||
}
|
||
|
||
/// Pring's Know Sure Thing — a long-horizon momentum oscillator that combines
|
||
/// four `ROC` series at different lookbacks, each smoothed by its own `SMA`,
|
||
/// summed with Pring's fixed weights `1, 2, 3, 4`:
|
||
///
|
||
/// ```text
|
||
/// RCMA_i = SMA(ROC(close, roc_i), sma_i) for i = 1..=4
|
||
/// KST = 1·RCMA_1 + 2·RCMA_2 + 3·RCMA_3 + 4·RCMA_4
|
||
/// Signal = SMA(KST, signal_period)
|
||
/// ```
|
||
///
|
||
/// Pring's recommended defaults are
|
||
/// `(roc1, roc2, roc3, roc4) = (10, 15, 20, 30)`,
|
||
/// `(sma1, sma2, sma3, sma4) = (10, 10, 10, 15)`,
|
||
/// `signal_period = 9`. `Kst::classic()` constructs that configuration.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use wickra_core::{Indicator, Kst};
|
||
///
|
||
/// let mut kst = Kst::classic();
|
||
/// let mut last = None;
|
||
/// for i in 0..200 {
|
||
/// last = kst.update(100.0 + f64::from(i));
|
||
/// }
|
||
/// assert!(last.is_some());
|
||
/// ```
|
||
#[derive(Debug, Clone)]
|
||
pub struct Kst {
|
||
roc1_period: usize,
|
||
roc2_period: usize,
|
||
roc3_period: usize,
|
||
roc4_period: usize,
|
||
sma1_period: usize,
|
||
sma2_period: usize,
|
||
sma3_period: usize,
|
||
sma4_period: usize,
|
||
signal_period: usize,
|
||
roc1: Roc,
|
||
roc2: Roc,
|
||
roc3: Roc,
|
||
roc4: Roc,
|
||
sma1: Sma,
|
||
sma2: Sma,
|
||
sma3: Sma,
|
||
sma4: Sma,
|
||
signal_sma: Sma,
|
||
last_line: Option<f64>,
|
||
last_signal: Option<f64>,
|
||
}
|
||
|
||
impl Kst {
|
||
/// # Errors
|
||
/// Returns [`Error::PeriodZero`] if any of the nine periods is zero.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn new(
|
||
roc1: usize,
|
||
roc2: usize,
|
||
roc3: usize,
|
||
roc4: usize,
|
||
sma1: usize,
|
||
sma2: usize,
|
||
sma3: usize,
|
||
sma4: usize,
|
||
signal: usize,
|
||
) -> Result<Self> {
|
||
if [roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal].contains(&0) {
|
||
return Err(Error::PeriodZero);
|
||
}
|
||
Ok(Self {
|
||
roc1_period: roc1,
|
||
roc2_period: roc2,
|
||
roc3_period: roc3,
|
||
roc4_period: roc4,
|
||
sma1_period: sma1,
|
||
sma2_period: sma2,
|
||
sma3_period: sma3,
|
||
sma4_period: sma4,
|
||
signal_period: signal,
|
||
roc1: Roc::new(roc1)?,
|
||
roc2: Roc::new(roc2)?,
|
||
roc3: Roc::new(roc3)?,
|
||
roc4: Roc::new(roc4)?,
|
||
sma1: Sma::new(sma1)?,
|
||
sma2: Sma::new(sma2)?,
|
||
sma3: Sma::new(sma3)?,
|
||
sma4: Sma::new(sma4)?,
|
||
signal_sma: Sma::new(signal)?,
|
||
last_line: None,
|
||
last_signal: None,
|
||
})
|
||
}
|
||
|
||
/// Pring's recommended defaults: `KST(10, 15, 20, 30, 10, 10, 10, 15, 9)`.
|
||
pub fn classic() -> Self {
|
||
Self::new(10, 15, 20, 30, 10, 10, 10, 15, 9).expect("classic KST parameters are valid")
|
||
}
|
||
|
||
/// Configured `(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal)`.
|
||
pub const fn periods(
|
||
&self,
|
||
) -> (
|
||
usize,
|
||
usize,
|
||
usize,
|
||
usize,
|
||
usize,
|
||
usize,
|
||
usize,
|
||
usize,
|
||
usize,
|
||
) {
|
||
(
|
||
self.roc1_period,
|
||
self.roc2_period,
|
||
self.roc3_period,
|
||
self.roc4_period,
|
||
self.sma1_period,
|
||
self.sma2_period,
|
||
self.sma3_period,
|
||
self.sma4_period,
|
||
self.signal_period,
|
||
)
|
||
}
|
||
}
|
||
|
||
impl Indicator for Kst {
|
||
type Input = f64;
|
||
type Output = KstOutput;
|
||
|
||
fn update(&mut self, input: f64) -> Option<KstOutput> {
|
||
// Feed every inner state machine on every input so they warm up in
|
||
// parallel. The KST line waits for all four RCMA branches; the signal
|
||
// line additionally waits for its own SMA to fill.
|
||
let r1 = self.roc1.update(input);
|
||
let r2 = self.roc2.update(input);
|
||
let r3 = self.roc3.update(input);
|
||
let r4 = self.roc4.update(input);
|
||
let rcma1 = r1.and_then(|x| self.sma1.update(x));
|
||
let rcma2 = r2.and_then(|x| self.sma2.update(x));
|
||
let rcma3 = r3.and_then(|x| self.sma3.update(x));
|
||
let rcma4 = r4.and_then(|x| self.sma4.update(x));
|
||
let (rcma1, rcma2, rcma3, rcma4) = (rcma1?, rcma2?, rcma3?, rcma4?);
|
||
let kst = rcma1 + 2.0 * rcma2 + 3.0 * rcma3 + 4.0 * rcma4;
|
||
self.last_line = Some(kst);
|
||
let signal = self.signal_sma.update(kst);
|
||
let signal = signal?;
|
||
self.last_signal = Some(signal);
|
||
Some(KstOutput { kst, signal })
|
||
}
|
||
|
||
fn reset(&mut self) {
|
||
self.roc1.reset();
|
||
self.roc2.reset();
|
||
self.roc3.reset();
|
||
self.roc4.reset();
|
||
self.sma1.reset();
|
||
self.sma2.reset();
|
||
self.sma3.reset();
|
||
self.sma4.reset();
|
||
self.signal_sma.reset();
|
||
self.last_line = None;
|
||
self.last_signal = None;
|
||
}
|
||
|
||
fn warmup_period(&self) -> usize {
|
||
// Each RCMA_i emits once the inner ROC has warmed up (roc_i + 1
|
||
// inputs) AND the SMA has filled (sma_i inputs through it). All four
|
||
// run in parallel so the slowest branch dominates, and the signal SMA
|
||
// adds signal_period − 1 inputs on top of the slowest branch.
|
||
let branch = |roc: usize, sma: usize| roc + sma;
|
||
let slowest = branch(self.roc1_period, self.sma1_period)
|
||
.max(branch(self.roc2_period, self.sma2_period))
|
||
.max(branch(self.roc3_period, self.sma3_period))
|
||
.max(branch(self.roc4_period, self.sma4_period));
|
||
slowest + self.signal_period - 1
|
||
}
|
||
|
||
fn is_ready(&self) -> bool {
|
||
self.last_signal.is_some()
|
||
}
|
||
|
||
fn name(&self) -> &'static str {
|
||
"KST"
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::traits::BatchExt;
|
||
use approx::assert_relative_eq;
|
||
|
||
#[test]
|
||
fn rejects_zero_period() {
|
||
assert!(matches!(
|
||
Kst::new(0, 15, 20, 30, 10, 10, 10, 15, 9),
|
||
Err(Error::PeriodZero)
|
||
));
|
||
assert!(matches!(
|
||
Kst::new(10, 15, 20, 30, 10, 10, 10, 15, 0),
|
||
Err(Error::PeriodZero)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn accessors_and_metadata() {
|
||
let kst = Kst::classic();
|
||
assert_eq!(kst.periods(), (10, 15, 20, 30, 10, 10, 10, 15, 9));
|
||
assert_eq!(kst.name(), "KST");
|
||
// The slowest branch is ROC(30) + SMA(15) = 45; signal_period - 1 = 8.
|
||
assert_eq!(kst.warmup_period(), 53);
|
||
}
|
||
|
||
#[test]
|
||
fn classic_factory_matches_pring_defaults() {
|
||
let kst = Kst::classic();
|
||
let (r1, r2, r3, r4, s1, s2, s3, s4, sig) = kst.periods();
|
||
assert_eq!((r1, r2, r3, r4), (10, 15, 20, 30));
|
||
assert_eq!((s1, s2, s3, s4), (10, 10, 10, 15));
|
||
assert_eq!(sig, 9);
|
||
}
|
||
|
||
#[test]
|
||
fn constant_series_yields_zero() {
|
||
// ROC is zero on a flat series, so every RCMA collapses to zero and
|
||
// KST itself is zero. The signal SMA inherits that.
|
||
let mut kst = Kst::classic();
|
||
let prices = vec![42.0_f64; 80];
|
||
let out = kst.batch(&prices);
|
||
for v in out.iter().skip(kst.warmup_period() - 1).flatten() {
|
||
assert_relative_eq!(v.kst, 0.0, epsilon = 1e-12);
|
||
assert_relative_eq!(v.signal, 0.0, epsilon = 1e-12);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn warmup_emits_first_value_at_warmup_period() {
|
||
let mut kst = Kst::new(2, 3, 4, 5, 2, 2, 2, 3, 2).unwrap();
|
||
// Slowest branch is ROC(5) + SMA(3) = 8; signal − 1 = 1; total 9.
|
||
assert_eq!(kst.warmup_period(), 9);
|
||
let prices: Vec<f64> = (1..=15).map(f64::from).collect();
|
||
let out = kst.batch(&prices);
|
||
for v in out.iter().take(8) {
|
||
assert!(v.is_none());
|
||
}
|
||
assert!(out[8].is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn pure_uptrend_is_positive() {
|
||
// Monotonic uptrend -> every ROC > 0 -> every RCMA > 0 -> KST > 0.
|
||
let mut kst = Kst::classic();
|
||
let prices: Vec<f64> = (1..=120).map(|i| f64::from(i) * 2.0).collect();
|
||
let out = kst.batch(&prices);
|
||
let last = out.iter().rev().flatten().next().unwrap();
|
||
assert!(
|
||
last.kst > 0.0,
|
||
"KST on a clean uptrend should be positive: {}",
|
||
last.kst
|
||
);
|
||
assert!(last.signal > 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn batch_equals_streaming() {
|
||
let prices: Vec<f64> = (1..=120)
|
||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
|
||
.collect();
|
||
let mut a = Kst::classic();
|
||
let mut b = Kst::classic();
|
||
assert_eq!(
|
||
a.batch(&prices),
|
||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reset_clears_state() {
|
||
let mut kst = Kst::classic();
|
||
let prices: Vec<f64> = (1..=120).map(f64::from).collect();
|
||
kst.batch(&prices);
|
||
assert!(kst.is_ready());
|
||
kst.reset();
|
||
assert!(!kst.is_ready());
|
||
}
|
||
}
|