* test(wma): cover period/warmup/name + kill dead naive panic arm
Codecov flagged 10 lines in crates/wickra-core/src/indicators/wma.rs
(file at 92.48%): const accessor period (56-58), Indicator-impl
warmup_period (111-113), name (119-121), and line 186 — the
`_ => panic!("warmup mismatch")` arm in matches_naive_over_random_
inputs, an invariant guard that never fires when both streams share
a warmup period.
Add accessors_and_metadata covering the three metadata methods.
Refactor matches_naive_over_random_inputs to assert the warmup-shape
invariant via assert_eq!(g.is_some(), w.is_some()) + if let,
removing the dead panic arm.
wma.rs is now at 133/133 lines, no behavioural change.
* test(aroon_oscillator): cover period/value accessors + name metadata
Codecov flagged 9 lines in crates/wickra-core/src/indicators/aroon_
oscillator.rs (file at 90.42%): const accessors period (57-59),
value (62-64) and Indicator-impl name (90-92). warmup_period is
already covered by warmup_period_matches_aroon.
Add accessors_and_metadata asserting period == 7, name ==
"AroonOscillator", and value() across the None (pre-warmup) and
Some (post-warmup) branches.
aroon_oscillator.rs is now at 94/94 lines, no behavioural change.
* test(atr): cover period/value accessors + name metadata
Codecov flagged 9 lines in crates/wickra-core/src/indicators/atr.rs
(file at 93.70%): const accessors period (54-57), value (59-62) and
Indicator-impl name body (103-105). warmup_period is exercised
indirectly via downstream indicators; the metadata getters were
never queried directly.
Add accessors_and_metadata asserting period == 14, name == "ATR",
and value() across the None (pre-warmup) and Some (post-warmup)
branches.
atr.rs is now at 143/143 lines, no behavioural change.
* test(awesome_oscillator): cover periods accessor + warmup/name metadata
Codecov flagged 9 lines in crates/wickra-core/src/indicators/awesome_
oscillator.rs (file at 88.15%): const accessor periods (59-61),
Indicator-impl warmup_period (83-85), name (91-93). The classic()
constructor is covered indirectly through the existing tests; only
the metadata methods were dead.
Add accessors_and_metadata asserting periods == (5, 34),
warmup_period == 34 (= slow_period), name == "AwesomeOscillator".
awesome_oscillator.rs is now at 76/76 lines, no behavioural change.
* test(cci): cover period accessor + warmup/name metadata
Codecov flagged 9 lines in crates/wickra-core/src/indicators/cci.rs
(file at 89.65%): const accessor period (68-70), Indicator-impl
warmup_period (102-104), name (110-112). Existing tests never
inspected the metadata surface.
Add accessors_and_metadata asserting period == 20, warmup_period ==
20, name == "CCI".
cci.rs is now at 87/87 lines, no behavioural change.
178 lines
4.7 KiB
Rust
178 lines
4.7 KiB
Rust
//! Commodity Channel Index (CCI).
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::ohlcv::Candle;
|
|
use crate::traits::Indicator;
|
|
|
|
/// Commodity Channel Index.
|
|
///
|
|
/// `CCI = (TP - SMA(TP)) / (0.015 * mean absolute deviation of TP)`, where
|
|
/// `TP = (high + low + close) / 3`.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use wickra_core::{Candle, Indicator, Cci};
|
|
///
|
|
/// let mut indicator = Cci::new(5).unwrap();
|
|
/// let mut last = None;
|
|
/// for i in 0..80 {
|
|
/// let base = 100.0 + f64::from(i);
|
|
/// let candle =
|
|
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
|
/// last = indicator.update(candle);
|
|
/// }
|
|
/// assert!(last.is_some());
|
|
/// ```
|
|
#[derive(Debug, Clone)]
|
|
pub struct Cci {
|
|
period: usize,
|
|
factor: f64,
|
|
window: VecDeque<f64>,
|
|
sum: f64,
|
|
}
|
|
|
|
impl Cci {
|
|
/// Construct a new CCI with the canonical 0.015 scaling factor.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
|
pub fn new(period: usize) -> Result<Self> {
|
|
Self::with_factor(period, 0.015)
|
|
}
|
|
|
|
/// Construct a CCI with a custom scaling factor (the standard literature
|
|
/// uses 0.015 to put roughly 70 % of values inside ±100).
|
|
///
|
|
/// # Errors
|
|
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
|
/// [`Error::NonPositiveMultiplier`] if `factor <= 0`.
|
|
pub fn with_factor(period: usize, factor: f64) -> Result<Self> {
|
|
if period == 0 {
|
|
return Err(Error::PeriodZero);
|
|
}
|
|
if !factor.is_finite() || factor <= 0.0 {
|
|
return Err(Error::NonPositiveMultiplier);
|
|
}
|
|
Ok(Self {
|
|
period,
|
|
factor,
|
|
window: VecDeque::with_capacity(period),
|
|
sum: 0.0,
|
|
})
|
|
}
|
|
|
|
/// Configured period.
|
|
pub const fn period(&self) -> usize {
|
|
self.period
|
|
}
|
|
}
|
|
|
|
impl Indicator for Cci {
|
|
type Input = Candle;
|
|
type Output = f64;
|
|
|
|
fn update(&mut self, candle: Candle) -> Option<f64> {
|
|
let tp = candle.typical_price();
|
|
if self.window.len() == self.period {
|
|
let old = self.window.pop_front().expect("non-empty");
|
|
self.sum -= old;
|
|
}
|
|
self.window.push_back(tp);
|
|
self.sum += tp;
|
|
if self.window.len() < self.period {
|
|
return None;
|
|
}
|
|
let n = self.period as f64;
|
|
let mean = self.sum / n;
|
|
let mad: f64 = self.window.iter().map(|v| (v - mean).abs()).sum::<f64>() / n;
|
|
if mad == 0.0 {
|
|
return Some(0.0);
|
|
}
|
|
Some((tp - mean) / (self.factor * mad))
|
|
}
|
|
|
|
fn reset(&mut self) {
|
|
self.window.clear();
|
|
self.sum = 0.0;
|
|
}
|
|
|
|
fn warmup_period(&self) -> usize {
|
|
self.period
|
|
}
|
|
|
|
fn is_ready(&self) -> bool {
|
|
self.window.len() == self.period
|
|
}
|
|
|
|
fn name(&self) -> &'static str {
|
|
"CCI"
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::traits::BatchExt;
|
|
use approx::assert_relative_eq;
|
|
|
|
fn c(h: f64, l: f64, cl: f64) -> Candle {
|
|
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn flat_candles_yield_zero() {
|
|
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
|
let mut cci = Cci::new(20).unwrap();
|
|
for v in cci.batch(&candles).into_iter().flatten() {
|
|
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_input() {
|
|
assert!(Cci::new(0).is_err());
|
|
assert!(Cci::with_factor(20, 0.0).is_err());
|
|
assert!(Cci::with_factor(20, -1.0).is_err());
|
|
}
|
|
|
|
/// Cover the const accessor `period` (68-70) and the Indicator-impl
|
|
/// `warmup_period` (102-104) + `name` (110-112). Existing tests never
|
|
/// inspect these metadata methods.
|
|
#[test]
|
|
fn accessors_and_metadata() {
|
|
let cci = Cci::new(20).unwrap();
|
|
assert_eq!(cci.period(), 20);
|
|
assert_eq!(cci.warmup_period(), 20);
|
|
assert_eq!(cci.name(), "CCI");
|
|
}
|
|
|
|
#[test]
|
|
fn batch_equals_streaming() {
|
|
let candles: Vec<Candle> = (0..60)
|
|
.map(|i| {
|
|
let m = 50.0 + (f64::from(i) * 0.2).sin() * 10.0;
|
|
c(m + 1.0, m - 1.0, m)
|
|
})
|
|
.collect();
|
|
let mut a = Cci::new(20).unwrap();
|
|
let mut b = Cci::new(20).unwrap();
|
|
assert_eq!(
|
|
a.batch(&candles),
|
|
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reset_clears_state() {
|
|
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
|
let mut cci = Cci::new(20).unwrap();
|
|
cci.batch(&candles);
|
|
assert!(cci.is_ready());
|
|
cci.reset();
|
|
assert!(!cci.is_ready());
|
|
}
|
|
}
|