Files
wickra/crates/wickra-core/src/indicators/kst.rs
T
kingchencandGitHub 24e723fa7d feat: Family 02 Momentum Oscillators — RVI / PGO / KST / SMI / Laguerre / Connors / Inertia (#40)
* 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.
2026-05-25 15:28:56 +02:00

304 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}