Add B10 Ehlers / Cycle deepening (10 indicators) (#199)

Deepens the **Ehlers / Cycle (DSP)** family (B10) with ten indicators (452 -> 462):

- **HighpassFilter**, **Reflex**, **Trendflex**, **CorrelationTrendIndicator**, **AdaptiveRsi**, **UniversalOscillator** — scalar (f64) Ehlers filters/oscillators.
- **AdaptiveCci** — efficiency-ratio-adaptive CCI on typical price (Candle input).
- **BandpassFilter**, **EvenBetterSinewave**, **AutocorrelationPeriodogram** — multi-arg scalar (hand-written bindings; the wasm variadic scalar macro covers wasm).

Verified locally: 3755 core lib + 420 doc tests, clippy clean, 537 node tests, 881 pytest, counter 462.
This commit is contained in:
kingchenc
2026-06-07 04:25:16 +02:00
committed by GitHub
parent 707f29e8e4
commit 80850c81f7
25 changed files with 3603 additions and 71 deletions
+10
View File
@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- **Autocorrelation Periodogram** — Ehlers autocorrelation periodogram: dominant cycle period estimate (`AUTOCORRPGRAM`).
- **Even Better Sinewave** — Ehlers Even Better Sinewave: normalized cycle-phase oscillator (`EVENBETTERSINE`).
- **Bandpass Filter** — Ehlers bandpass filter: isolates a frequency band around the dominant cycle (`BANDPASS`).
- **Adaptive CCI** — Adaptive CCI: efficiency-ratio-adaptive CCI on typical price (`ADAPTIVECCI`).
- **Universal Oscillator** — Ehlers Universal Oscillator: SuperSmoother-based normalized cycle oscillator (`UNIVERSALOSC`).
- **Adaptive RSI** — Adaptive RSI: dominant-cycle-tuned RSI length (Ehlers) (`ADAPTIVERSI`).
- **Correlation Trend Indicator** — Ehlers Correlation Trend Indicator: Pearson correlation of price vs time (`CTI`).
- **Trendflex** — Ehlers Trendflex: trend-following companion to Reflex (`TRENDFLEX`).
- **Reflex** — Ehlers Reflex: trend-cycle oscillator measuring slope-adjusted displacement (`REFLEX`).
- **Highpass Filter** — Ehlers highpass filter: removes low-frequency trend, leaving cyclic component (`HIGHPASS`).
## [0.6.4] - 2026-06-07
- **Kendall Tau** — Kendall rank correlation (tau-b) over a rolling window of paired observations (`KENDALLTAU`).
+6 -6
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=452" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=462" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 452 indicators; start at the
every one of the 462 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -79,7 +79,7 @@ Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
| finta | clean | no | Python | ~80 | stale |
| talipp | clean | yes | Python | ~40 | yes |
Wickra's edge is **breadth with reach**: 452 indicators that all update in O(1)
Wickra's edge is **breadth with reach**: 462 indicators that all update in O(1)
per tick and ship natively to Python, Node.js, WebAssembly and Rust from a
single engine.
@@ -188,7 +188,7 @@ python -m benchmarks.compare_libraries
## Indicators
452 streaming-first indicators across twenty-four families. Every one passes the
462 streaming-first indicators across twenty-four families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
@@ -204,7 +204,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient, Jarque-Bera, Rolling Min-Max Scaler, Shannon Entropy, Sample Entropy, Kendall Tau |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline, Highpass Filter, Reflex, Trendflex, Correlation Trend Indicator, Adaptive RSI, Universal Oscillator, Adaptive CCI, Bandpass Filter, Even Better Sinewave, Autocorrelation Periodogram |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
@@ -297,7 +297,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 452 indicators
│ ├── wickra-core/ core engine + all 462 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
@@ -28,6 +28,15 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
AUTOCORRPGRAM: () => new wickra.AUTOCORRPGRAM(10, 48),
EVENBETTERSINE: () => new wickra.EVENBETTERSINE(40, 10),
BANDPASS: () => new wickra.BANDPASS(20, 0.3),
UNIVERSALOSC: () => new wickra.UNIVERSALOSC(20),
ADAPTIVERSI: () => new wickra.ADAPTIVERSI(14),
CTI: () => new wickra.CTI(20),
TRENDFLEX: () => new wickra.TRENDFLEX(20),
REFLEX: () => new wickra.REFLEX(20),
HIGHPASS: () => new wickra.HIGHPASS(48),
SAMPLEENT: () => new wickra.SAMPLEENT(20, 2, 0.2),
SHANNONENT: () => new wickra.SHANNONENT(20, 8),
ROLLINGMINMAX: () => new wickra.ROLLINGMINMAX(20),
@@ -367,6 +376,7 @@ const candleScalar = {
TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
ADAPTIVECCI: { make: () => new wickra.ADAPTIVECCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
+90
View File
@@ -1070,6 +1070,87 @@ export declare class ROLLINGMINMAX {
isReady(): boolean
warmupPeriod(): number
}
export type HighpassFilterNode = HIGHPASS
export declare class HIGHPASS {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ReflexNode = REFLEX
export declare class REFLEX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TrendflexNode = TRENDFLEX
export declare class TRENDFLEX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type CorrelationTrendIndicatorNode = CTI
export declare class CTI {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AdaptiveRsiNode = ADAPTIVERSI
export declare class ADAPTIVERSI {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type UniversalOscillatorNode = UNIVERSALOSC
export declare class UNIVERSALOSC {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BandpassFilterNode = BANDPASS
export declare class BANDPASS {
constructor(period: number, bandwidth: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EvenBetterSinewaveNode = EVENBETTERSINE
export declare class EVENBETTERSINE {
constructor(hpPeriod: number, ssfLength: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AutocorrelationPeriodogramNode = AUTOCORRPGRAM
export declare class AUTOCORRPGRAM {
constructor(minPeriod: number, maxPeriod: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ShannonEntropyNode = SHANNONENT
export declare class SHANNONENT {
constructor(period: number, bins: number)
@@ -1751,6 +1832,15 @@ export declare class TimeBasedStop {
isReady(): boolean
warmupPeriod(): number
}
export type AdaptiveCciNode = ADAPTIVECCI
export declare class ADAPTIVECCI {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type StochNode = Stochastic
export declare class Stochastic {
constructor(kPeriod: number, dPeriod: number)
+11 -1
View File
File diff suppressed because one or more lines are too long
+176
View File
@@ -231,6 +231,129 @@ node_scalar_indicator!(
"ROLLINGMINMAX",
wc::RollingMinMaxScaler
);
node_scalar_indicator!(HighpassFilterNode, "HIGHPASS", wc::HighpassFilter);
node_scalar_indicator!(ReflexNode, "REFLEX", wc::Reflex);
node_scalar_indicator!(TrendflexNode, "TRENDFLEX", wc::Trendflex);
node_scalar_indicator!(
CorrelationTrendIndicatorNode,
"CTI",
wc::CorrelationTrendIndicator
);
node_scalar_indicator!(AdaptiveRsiNode, "ADAPTIVERSI", wc::AdaptiveRsi);
node_scalar_indicator!(
UniversalOscillatorNode,
"UNIVERSALOSC",
wc::UniversalOscillator
);
// Multi-arg Ehlers scalars: hand-written (node_scalar_indicator! is single-period).
#[napi(js_name = "BANDPASS")]
pub struct BandpassFilterNode {
inner: wc::BandpassFilter,
}
#[napi]
impl BandpassFilterNode {
#[napi(constructor)]
pub fn new(period: u32, bandwidth: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::BandpassFilter::new(period as usize, bandwidth).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "EVENBETTERSINE")]
pub struct EvenBetterSinewaveNode {
inner: wc::EvenBetterSinewave,
}
#[napi]
impl EvenBetterSinewaveNode {
#[napi(constructor)]
pub fn new(hp_period: u32, ssf_length: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::EvenBetterSinewave::new(hp_period as usize, ssf_length as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "AUTOCORRPGRAM")]
pub struct AutocorrelationPeriodogramNode {
inner: wc::AutocorrelationPeriodogram,
}
#[napi]
impl AutocorrelationPeriodogramNode {
#[napi(constructor)]
pub fn new(min_period: u32, max_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::AutocorrelationPeriodogram::new(min_period as usize, max_period as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// Shannon Entropy / Sample Entropy: multi-arg scalar ctors, hand-written
// (node_scalar_indicator! only generates a single-period constructor).
@@ -3056,6 +3179,59 @@ impl TimeBasedStopNode {
}
}
#[napi(js_name = "ADAPTIVECCI")]
pub struct AdaptiveCciNode {
inner: wc::AdaptiveCci,
}
#[napi]
impl AdaptiveCciNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::AdaptiveCci::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct StochValue {
pub k: f64,
+20
View File
@@ -25,6 +25,16 @@ from __future__ import annotations
from ._wickra import (
__version__,
AUTOCORRPGRAM,
EVENBETTERSINE,
BANDPASS,
ADAPTIVECCI,
UNIVERSALOSC,
ADAPTIVERSI,
CTI,
TRENDFLEX,
REFLEX,
HIGHPASS,
SAMPLEENT,
SHANNONENT,
ROLLINGMINMAX,
@@ -506,6 +516,16 @@ from ._wickra import (
)
__all__ = [
"AUTOCORRPGRAM",
"EVENBETTERSINE",
"BANDPASS",
"ADAPTIVECCI",
"UNIVERSALOSC",
"ADAPTIVERSI",
"CTI",
"TRENDFLEX",
"REFLEX",
"HIGHPASS",
"SAMPLEENT",
"SHANNONENT",
"ROLLINGMINMAX",
+529
View File
@@ -3796,6 +3796,362 @@ impl PyRollingMinMaxScaler {
}
}
// ============================== HighpassFilter ==============================
#[pyclass(name = "HIGHPASS", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyHighpassFilter {
inner: wc::HighpassFilter,
}
#[pymethods]
impl PyHighpassFilter {
#[new]
#[pyo3(signature = (period=48))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::HighpassFilter::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("HIGHPASS(period={})", self.inner.period())
}
}
// ============================== Reflex ==============================
#[pyclass(name = "REFLEX", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyReflex {
inner: wc::Reflex,
}
#[pymethods]
impl PyReflex {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Reflex::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("REFLEX(period={})", self.inner.period())
}
}
// ============================== Trendflex ==============================
#[pyclass(name = "TRENDFLEX", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTrendflex {
inner: wc::Trendflex,
}
#[pymethods]
impl PyTrendflex {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Trendflex::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("TRENDFLEX(period={})", self.inner.period())
}
}
// ============================== CorrelationTrendIndicator ==============================
#[pyclass(name = "CTI", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyCorrelationTrendIndicator {
inner: wc::CorrelationTrendIndicator,
}
#[pymethods]
impl PyCorrelationTrendIndicator {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::CorrelationTrendIndicator::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("CTI(period={})", self.inner.period())
}
}
// ============================== AdaptiveRsi ==============================
#[pyclass(name = "ADAPTIVERSI", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyAdaptiveRsi {
inner: wc::AdaptiveRsi,
}
#[pymethods]
impl PyAdaptiveRsi {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::AdaptiveRsi::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("ADAPTIVERSI(period={})", self.inner.period())
}
}
// ============================== UniversalOscillator ==============================
#[pyclass(name = "UNIVERSALOSC", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyUniversalOscillator {
inner: wc::UniversalOscillator,
}
#[pymethods]
impl PyUniversalOscillator {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::UniversalOscillator::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("UNIVERSALOSC(period={})", self.inner.period())
}
}
// ============================== AdaptiveCci ==============================
#[pyclass(name = "ADAPTIVECCI", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyAdaptiveCci {
inner: wc::AdaptiveCci,
}
#[pymethods]
impl PyAdaptiveCci {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::AdaptiveCci::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy columns: high, low, close (all 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("ADAPTIVECCI(period={})", self.inner.period())
}
}
// ============================== Stochastic ==============================
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
@@ -23001,6 +23357,169 @@ impl PyKendallTau {
}
}
// ============================== Bandpass Filter ==============================
#[pyclass(name = "BANDPASS", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyBandpassFilter {
inner: wc::BandpassFilter,
}
#[pymethods]
impl PyBandpassFilter {
#[new]
#[pyo3(signature = (period=20, bandwidth=0.3))]
fn new(period: usize, bandwidth: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::BandpassFilter::new(period, bandwidth).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn params(&self) -> (usize, f64) {
self.inner.params()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (period, bandwidth) = self.inner.params();
format!("BANDPASS(period={period}, bandwidth={bandwidth})")
}
}
// ============================== Even Better Sinewave ==============================
#[pyclass(
name = "EVENBETTERSINE",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyEvenBetterSinewave {
inner: wc::EvenBetterSinewave,
}
#[pymethods]
impl PyEvenBetterSinewave {
#[new]
#[pyo3(signature = (hp_period=40, ssf_length=10))]
fn new(hp_period: usize, ssf_length: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::EvenBetterSinewave::new(hp_period, ssf_length).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn params(&self) -> (usize, usize) {
self.inner.params()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (hp_period, ssf_length) = self.inner.params();
format!("EVENBETTERSINE(hp_period={hp_period}, ssf_length={ssf_length})")
}
}
// ============================== Autocorrelation Periodogram ==============================
#[pyclass(name = "AUTOCORRPGRAM", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyAutocorrelationPeriodogram {
inner: wc::AutocorrelationPeriodogram,
}
#[pymethods]
impl PyAutocorrelationPeriodogram {
#[new]
#[pyo3(signature = (min_period=10, max_period=48))]
fn new(min_period: usize, max_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::AutocorrelationPeriodogram::new(min_period, max_period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn periods(&self) -> (usize, usize) {
self.inner.periods()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (min_period, max_period) = self.inner.periods();
format!("AUTOCORRPGRAM(min_period={min_period}, max_period={max_period})")
}
}
#[pymodule]
#[allow(clippy::too_many_lines)]
fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -23467,7 +23986,17 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyShannonEntropy>()?;
m.add_class::<PySampleEntropy>()?;
m.add_class::<PyKendallTau>()?;
m.add_class::<PyBandpassFilter>()?;
m.add_class::<PyEvenBetterSinewave>()?;
m.add_class::<PyAutocorrelationPeriodogram>()?;
m.add_class::<PyJarqueBera>()?;
m.add_class::<PyRollingMinMaxScaler>()?;
m.add_class::<PyHighpassFilter>()?;
m.add_class::<PyReflex>()?;
m.add_class::<PyTrendflex>()?;
m.add_class::<PyCorrelationTrendIndicator>()?;
m.add_class::<PyAdaptiveRsi>()?;
m.add_class::<PyUniversalOscillator>()?;
m.add_class::<PyAdaptiveCci>()?;
Ok(())
}
@@ -45,6 +45,15 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.AUTOCORRPGRAM, (10, 48)),
(ta.EVENBETTERSINE, (40, 10)),
(ta.BANDPASS, (20, 0.3)),
(ta.UNIVERSALOSC, (20,)),
(ta.ADAPTIVERSI, (14,)),
(ta.CTI, (20,)),
(ta.TRENDFLEX, (20,)),
(ta.REFLEX, (20,)),
(ta.HIGHPASS, (48,)),
(ta.SAMPLEENT, (20, 2, 0.2)),
(ta.SHANNONENT, (20, 8)),
(ta.ROLLINGMINMAX, (20,)),
@@ -373,6 +382,7 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"ADAPTIVECCI": (lambda: ta.ADAPTIVECCI(20), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"BetterVolume": (
lambda: ta.BetterVolume(14),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
+47
View File
@@ -2591,6 +2591,44 @@ impl WasmTimeBasedStop {
}
}
#[wasm_bindgen(js_name = ADAPTIVECCI)]
pub struct WasmAdaptiveCci {
inner: wc::AdaptiveCci,
}
#[wasm_bindgen(js_class = ADAPTIVECCI)]
impl WasmAdaptiveCci {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmAdaptiveCci, JsError> {
Ok(Self {
inner: wc::AdaptiveCci::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = Stochastic)]
pub struct WasmStoch {
inner: wc::Stochastic,
@@ -11261,6 +11299,15 @@ wasm_scalar_indicator!(WasmJarqueBera, "JARQUEBERA", wc::JarqueBera, period: usi
wasm_scalar_indicator!(WasmRollingMinMaxScaler, "ROLLINGMINMAX", wc::RollingMinMaxScaler, period: usize);
wasm_scalar_indicator!(WasmShannonEntropy, "SHANNONENT", wc::ShannonEntropy, period: usize, bins: usize);
wasm_scalar_indicator!(WasmSampleEntropy, "SAMPLEENT", wc::SampleEntropy, period: usize, m: usize, r_factor: f64);
wasm_scalar_indicator!(WasmHighpassFilter, "HIGHPASS", wc::HighpassFilter, period: usize);
wasm_scalar_indicator!(WasmReflex, "REFLEX", wc::Reflex, period: usize);
wasm_scalar_indicator!(WasmTrendflex, "TRENDFLEX", wc::Trendflex, period: usize);
wasm_scalar_indicator!(WasmCorrelationTrendIndicator, "CTI", wc::CorrelationTrendIndicator, period: usize);
wasm_scalar_indicator!(WasmAdaptiveRsi, "ADAPTIVERSI", wc::AdaptiveRsi, period: usize);
wasm_scalar_indicator!(WasmUniversalOscillator, "UNIVERSALOSC", wc::UniversalOscillator, period: usize);
wasm_scalar_indicator!(WasmBandpassFilter, "BANDPASS", wc::BandpassFilter, period: usize, bandwidth: f64);
wasm_scalar_indicator!(WasmEvenBetterSinewave, "EVENBETTERSINE", wc::EvenBetterSinewave, hp_period: usize, ssf_length: usize);
wasm_scalar_indicator!(WasmAutocorrelationPeriodogram, "AUTOCORRPGRAM", wc::AutocorrelationPeriodogram, min_period: usize, max_period: usize);
// --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) ---
@@ -0,0 +1,245 @@
//! Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Adaptive CCI — Lambert's Commodity Channel Index whose centre line is an
/// **efficiency-ratio-adaptive** moving average of typical price instead of a
/// plain SMA, so it leads in trends and stays calm in chop.
///
/// ```text
/// TP = (high + low + close) / 3
/// ER = |TP_t TP_oldest| / Σ |ΔTP| over the window (0..1)
/// sc = ( ER·(2/3 2/31) + 2/31 )²
/// mean += sc·(TP_t mean) (adaptive centre, seeded with SMA)
/// MD = mean(|TP_i mean|) over the window (mean deviation)
/// CCI = (TP_t mean) / (0.015 · MD)
/// ```
///
/// The classic [`Cci`](crate::Cci) centres typical price on its simple moving
/// average; the lag of that SMA delays the oscillator in fast moves. Replacing it
/// with a KAMA-style adaptive average — driven by Kaufman's efficiency ratio —
/// lets the centre line accelerate toward price in a clean trend (so the CCI
/// reaches its `±100` bands sooner) and slow down in noise (fewer false pokes).
/// The `0.015` scaling keeps Lambert's convention that roughly 7080% of readings
/// fall in `[100, +100]`.
///
/// The output is unbounded around `0`; a flat window (zero mean deviation) returns
/// `0`. The first value lands after `period` inputs; each `update` is O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AdaptiveCci};
///
/// let mut indicator = AdaptiveCci::new(20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveCci {
period: usize,
window: VecDeque<f64>,
mean: Option<f64>,
last: Option<f64>,
}
impl AdaptiveCci {
/// Construct an adaptive CCI with the given `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::InvalidPeriod`] if `period < 2` (the efficiency ratio needs a
/// path of at least one step).
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "adaptive CCI needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
mean: None,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for AdaptiveCci {
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 {
self.window.pop_front();
}
self.window.push_back(tp);
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
// Efficiency ratio over the window.
let oldest = self.window[0];
let direction = (tp - oldest).abs();
let mut path = 0.0;
for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
path += (pair[1] - pair[0]).abs();
}
let er = if path > 0.0 {
(direction / path).clamp(0.0, 1.0)
} else {
0.0
};
let fast = 2.0 / 3.0;
let slow = 2.0 / 31.0;
let sc = (er * (fast - slow) + slow).powi(2);
let mean = match self.mean {
None => self.window.iter().sum::<f64>() / n,
Some(prev) => prev + sc * (tp - prev),
};
self.mean = Some(mean);
let md = self.window.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
let cci = if md > 0.0 {
(tp - mean) / (0.015 * md)
} else {
0.0
};
self.last = Some(cci);
Some(cci)
}
fn reset(&mut self) {
self.window.clear();
self.mean = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AdaptiveCci"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(tp: f64) -> Candle {
// open=high=low=close=tp -> typical price == tp.
Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0)
}
#[test]
fn rejects_invalid_period() {
assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero)));
assert!(matches!(
AdaptiveCci::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let c = AdaptiveCci::new(20).unwrap();
assert_eq!(c.period(), 20);
assert_eq!(c.warmup_period(), 20);
assert_eq!(c.name(), "AdaptiveCci");
assert!(!c.is_ready());
assert_eq!(c.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut c = AdaptiveCci::new(4).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(100.0 + f64::from(i))).collect();
let out = c.batch(&candles);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn uptrend_is_positive() {
let mut c = AdaptiveCci::new(10).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + f64::from(i))).collect();
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last > 0.0, "uptrend should give positive CCI, got {last}");
}
#[test]
fn downtrend_is_negative() {
let mut c = AdaptiveCci::new(10).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| candle(200.0 - f64::from(i))).collect();
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last < 0.0, "downtrend should give negative CCI, got {last}");
}
#[test]
fn flat_window_is_zero() {
let mut c = AdaptiveCci::new(5).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0)).collect();
for v in c.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn reset_clears_state() {
let mut c = AdaptiveCci::new(5).unwrap();
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + f64::from(i))).collect();
c.batch(&candles);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.value(), None);
assert_eq!(c.update(candle(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = AdaptiveCci::new(20).unwrap().batch(&candles);
let mut b = AdaptiveCci::new(20).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,296 @@
//! Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Adaptive RSI — Wilder's RSI in which the smoothing of the average gain and
/// average loss **adapts to trendiness** via Kaufman's efficiency ratio, so the
/// oscillator reacts fast in a clean move and smooths through chop.
///
/// ```text
/// ER = |price_t price_{tperiod}| / Σ |Δprice| over the window (efficiency ratio, 0..1)
/// sc = ( ER·(2/3 2/31) + 2/31 )² (KAMA smoothing constant)
/// avg_gain += sc·(gain avg_gain), avg_loss += sc·(loss avg_loss)
/// RSI = 100 · avg_gain / (avg_gain + avg_loss)
/// ```
///
/// A fixed-period [`Rsi`](crate::Rsi) is a compromise: short periods whip in
/// ranges, long ones lag in trends. This adaptive form borrows Kaufman's
/// efficiency ratio (`directional move / total path`) to set the smoothing each
/// bar — near `1` (a clean trend) the averages track gains and losses almost
/// immediately; near `0` (noise) they barely move, filtering the chop. The result
/// is an RSI that is responsive when it should be and quiet when it should be. It
/// is the efficiency-ratio cousin of Ehlers' cycle-adaptive RSI, which instead
/// sets the lookback from the measured dominant cycle.
///
/// Output is bounded in `[0, 100]`; a flat market returns the neutral `50`. The
/// first value lands after `period + 1` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveRsi};
///
/// let mut indicator = AdaptiveRsi::new(14).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveRsi {
period: usize,
prices: VecDeque<f64>,
abs_changes: VecDeque<f64>,
abs_sum: f64,
prev: Option<f64>,
seed_gain: f64,
seed_loss: f64,
seed_count: usize,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last: Option<f64>,
}
impl AdaptiveRsi {
/// Construct an adaptive RSI with the given efficiency-ratio `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prices: VecDeque::with_capacity(period + 1),
abs_changes: VecDeque::with_capacity(period),
abs_sum: 0.0,
prev: None,
seed_gain: 0.0,
seed_loss: 0.0,
seed_count: 0,
avg_gain: None,
avg_loss: None,
last: None,
})
}
/// Configured efficiency-ratio period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
100.0 * (avg_gain / denom)
}
}
fn efficiency_ratio(&self, price: f64) -> f64 {
let oldest = *self.prices.front().expect("window non-empty");
let direction = (price - oldest).abs();
if self.abs_sum == 0.0 {
0.0
} else {
(direction / self.abs_sum).clamp(0.0, 1.0)
}
}
}
impl Indicator for AdaptiveRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let Some(prev) = self.prev else {
self.prev = Some(price);
self.prices.push_back(price);
return None;
};
let change = price - prev;
self.prev = Some(price);
let gain = if change > 0.0 { change } else { 0.0 };
let loss = if change < 0.0 { -change } else { 0.0 };
// Maintain the price window (period + 1) and the |Δ| window (period).
self.prices.push_back(price);
if self.prices.len() > self.period + 1 {
self.prices.pop_front();
}
if self.abs_changes.len() == self.period {
self.abs_sum -= self.abs_changes.pop_front().expect("non-empty");
}
self.abs_changes.push_back(change.abs());
self.abs_sum += change.abs();
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let er = self.efficiency_ratio(price);
let fast = 2.0 / 3.0;
let slow = 2.0 / 31.0;
let sc = (er * (fast - slow) + slow).powi(2);
let new_ag = ag + sc * (gain - ag);
let new_al = al + sc * (loss - al);
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
let v = Self::rsi_from_avgs(new_ag, new_al);
self.last = Some(v);
return Some(v);
}
self.seed_gain += gain;
self.seed_loss += loss;
self.seed_count += 1;
if self.seed_count == self.period {
let ag = self.seed_gain / self.period as f64;
let al = self.seed_loss / self.period as f64;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rsi_from_avgs(ag, al);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prices.clear();
self.abs_changes.clear();
self.abs_sum = 0.0;
self.prev = None;
self.seed_gain = 0.0;
self.seed_loss = 0.0;
self.seed_count = 0;
self.avg_gain = None;
self.avg_loss = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AdaptiveRsi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let r = AdaptiveRsi::new(14).unwrap();
assert_eq!(r.period(), 14);
assert_eq!(r.warmup_period(), 15);
assert_eq!(r.name(), "AdaptiveRsi");
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut r = AdaptiveRsi::new(4).unwrap();
let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn pure_uptrend_is_one_hundred() {
let mut r = AdaptiveRsi::new(5).unwrap();
let last = r
.batch(&(1..=40).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn flat_market_is_neutral() {
let mut r = AdaptiveRsi::new(4).unwrap();
let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-9);
}
#[test]
fn output_in_range() {
let mut r = AdaptiveRsi::new(14).unwrap();
for v in r
.batch(
&(0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!((0.0..=100.0).contains(&v));
}
}
#[test]
fn ignores_non_finite() {
let mut r = AdaptiveRsi::new(4).unwrap();
let ready = r
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(r.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut r = AdaptiveRsi::new(4).unwrap();
r.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.value(), None);
assert_eq!(r.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = AdaptiveRsi::new(14).unwrap().batch(&xs);
let mut b = AdaptiveRsi::new(14).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,340 @@
//! Ehlers Autocorrelation Periodogram — estimates the dominant market cycle.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use std::f64::consts::TAU;
use crate::error::{Error, Result};
use crate::indicators::roofing_filter::RoofingFilter;
use crate::traits::Indicator;
/// Number of bars averaged into each lagged correlation (Ehlers' `AvgLength`).
const AVG_LENGTH: usize = 3;
/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle
/// period** of the market by correlating a roofing-filtered price with lagged
/// copies of itself and reading off the spectral peak.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8):
///
/// ```text
/// Filt = RoofingFilter(price) (detrend + denoise)
/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] ) for lag = 0..max_period
/// for each candidate period:
/// power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))²
/// R[period] = 0.2·power[period] + 0.8·R[period]_{t1} (EMA across time)
/// normalise by a decaying max, then
/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5
/// ```
///
/// The autocorrelation function emphasises whatever cycle is actually present and
/// suppresses noise; transforming it into a periodogram and taking the
/// power-weighted centre of gravity gives a smooth, robust estimate of the
/// dominant cycle length. That cycle is the key input for every *adaptive*
/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The
/// output is a period in bars within `[min_period, max_period]`.
///
/// The first value lands after `max_period + AvgLength` inputs. Each `update` is
/// O(`max_period²`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AutocorrelationPeriodogram};
/// use std::f64::consts::TAU;
///
/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AutocorrelationPeriodogram {
min_period: usize,
max_period: usize,
roof: RoofingFilter,
buffer: VecDeque<f64>,
r: Vec<f64>,
max_pwr: f64,
last: Option<f64>,
}
impl AutocorrelationPeriodogram {
/// Construct an autocorrelation periodogram searching cycles in
/// `[min_period, max_period]`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is `0`, or
/// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1` or
/// `max_period <= min_period`.
pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
if min_period == 0 || max_period == 0 {
return Err(Error::PeriodZero);
}
if min_period < AVG_LENGTH + 1 || max_period <= min_period {
return Err(Error::InvalidPeriod {
message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
});
}
Ok(Self {
min_period,
max_period,
roof: RoofingFilter::new(10, max_period)?,
buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
r: vec![0.0; max_period + 1],
max_pwr: 0.0,
last: None,
})
}
/// Configured `(min_period, max_period)`.
pub const fn periods(&self) -> (usize, usize) {
(self.min_period, self.max_period)
}
/// Current dominant-cycle estimate if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
/// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
/// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
fn correlation(&self, lag: usize) -> f64 {
let len = self.buffer.len();
let filt = |k: usize| self.buffer[len - 1 - k];
let m = AVG_LENGTH as f64;
let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
for count in 0..AVG_LENGTH {
let x = filt(count);
let y = filt(lag + count);
sx += x;
sy += y;
sxx += x * x;
syy += y * y;
sxy += x * y;
}
let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
if denom > 0.0 {
(m * sxy - sx * sy) / denom.sqrt()
} else {
0.0
}
}
}
impl Indicator for AutocorrelationPeriodogram {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let filt = self.roof.update(price)?;
if self.buffer.len() == self.max_period + AVG_LENGTH {
self.buffer.pop_front();
}
self.buffer.push_back(filt);
if self.buffer.len() < self.max_period + AVG_LENGTH {
return None;
}
// Autocorrelation across lags.
let mut corr = vec![0.0; self.max_period + 1];
for (lag, c) in corr.iter_mut().enumerate() {
*c = self.correlation(lag);
}
// Periodogram: spectral power for each candidate period, EMA'd over time.
self.max_pwr *= 0.995;
for period in self.min_period..=self.max_period {
let mut cosine = 0.0;
let mut sine = 0.0;
for (n, &cn) in corr
.iter()
.enumerate()
.take(self.max_period + 1)
.skip(AVG_LENGTH)
{
let angle = TAU * n as f64 / period as f64;
cosine += cn * angle.cos();
sine += cn * angle.sin();
}
let power = cosine * cosine + sine * sine;
self.r[period] = 0.2 * power + 0.8 * self.r[period];
if self.r[period] > self.max_pwr {
self.max_pwr = self.r[period];
}
}
// Power-weighted centre of gravity of the strong periods.
let mut spx = 0.0;
let mut sp = 0.0;
for period in self.min_period..=self.max_period {
let pwr = if self.max_pwr > 0.0 {
self.r[period] / self.max_pwr
} else {
0.0
};
if pwr >= 0.5 {
spx += period as f64 * pwr;
sp += pwr;
}
}
let dominant = if sp > 0.0 {
(spx / sp).clamp(self.min_period as f64, self.max_period as f64)
} else {
self.min_period as f64
};
self.last = Some(dominant);
Some(dominant)
}
fn reset(&mut self) {
self.roof.reset();
self.buffer.clear();
self.r.iter_mut().for_each(|x| *x = 0.0);
self.max_pwr = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.max_period + AVG_LENGTH
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AutocorrelationPeriodogram"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
AutocorrelationPeriodogram::new(0, 48),
Err(Error::PeriodZero)
));
assert!(matches!(
AutocorrelationPeriodogram::new(3, 48),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
AutocorrelationPeriodogram::new(48, 10),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
assert_eq!(p.periods(), (10, 48));
assert_eq!(p.warmup_period(), 51);
assert_eq!(p.name(), "AutocorrelationPeriodogram");
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
let xs: Vec<f64> = (0..40)
.map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
.collect();
let out = p.batch(&xs);
let warmup = p.warmup_period(); // 23
assert_eq!(warmup, 23);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn output_within_period_band() {
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
for v in p.batch(&xs).into_iter().flatten() {
assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
}
}
#[test]
fn detects_injected_cycle() {
// A clean 20-bar sine: the dominant cycle estimate should settle near 20.
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
let xs: Vec<f64> = (0..600)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let last = p.batch(&xs).into_iter().flatten().last().unwrap();
assert!(
(last - 20.0).abs() < 6.0,
"expected ~20-bar cycle, got {last}"
);
}
#[test]
fn ignores_non_finite() {
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
p.batch(
&(0..80)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect::<Vec<_>>(),
);
let before = p.value();
assert_eq!(p.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
p.batch(
&(0..120)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect::<Vec<_>>(),
);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..200)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn flat_input_falls_back_to_min_period() {
// Constant input has zero variance, so every lag correlation is
// degenerate (denom <= 0), the max power is zero and no period clears
// the 0.5 threshold -> the dominant cycle defaults to `min_period`.
let flat = [100.0_f64; 200];
let last = AutocorrelationPeriodogram::new(10, 48)
.unwrap()
.batch(&flat)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 10.0);
}
}
@@ -0,0 +1,243 @@
//! Ehlers Bandpass Filter — isolates the cyclic component around a target period.
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Bandpass Filter — a two-pole resonator that passes the cyclic content
/// around a target `period` and rejects both the trend (low frequencies) and the
/// noise (high frequencies).
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
///
/// ```text
/// beta = cos(2π / period)
/// gamma = 1 / cos(4π · bandwidth / period)
/// alpha = gamma sqrt(gamma² 1)
/// BP_t = 0.5·(1 alpha)·(price_t price_{t2})
/// + beta·(1 + alpha)·BP_{t1} alpha·BP_{t2}
/// ```
///
/// `bandwidth` (a fraction, typically `0.3`) sets how wide a band of periods is
/// admitted: narrow bandwidth gives a sharp, ringing resonator tuned tightly to
/// `period`; wide bandwidth lets more of the spectrum through. The output is a
/// zero-mean oscillator — it swings symmetrically around `0`, peaking when the
/// dominant cycle aligns with `period`. It is the building block for cycle-phase
/// and cycle-amplitude work.
///
/// The recursion needs two prior prices and two prior outputs; until then it emits
/// `0` (Ehlers' initial condition), so `warmup_period` is `1` and a value is
/// produced every bar. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, BandpassFilter};
///
/// let mut indicator = BandpassFilter::new(20, 0.3).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 BandpassFilter {
period: usize,
bandwidth: f64,
beta: f64,
alpha: f64,
prev_price_1: Option<f64>,
prev_price_2: Option<f64>,
bp1: f64,
bp2: f64,
last: Option<f64>,
}
impl BandpassFilter {
/// Construct a bandpass filter tuned to `period` with the given `bandwidth`
/// fraction.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::InvalidParameter`] if `bandwidth` is not finite or outside
/// `(0, 1)`.
pub fn new(period: usize, bandwidth: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !bandwidth.is_finite() || bandwidth <= 0.0 || bandwidth >= 1.0 {
return Err(Error::InvalidParameter {
message: "bandpass bandwidth must be in (0, 1)",
});
}
let period_f = period as f64;
let beta = (2.0 * PI / period_f).cos();
let gamma = 1.0 / (4.0 * PI * bandwidth / period_f).cos();
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
Ok(Self {
period,
bandwidth,
beta,
alpha,
prev_price_1: None,
prev_price_2: None,
bp1: 0.0,
bp2: 0.0,
last: None,
})
}
/// Configured `(period, bandwidth)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.bandwidth)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for BandpassFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let bp = match self.prev_price_2 {
Some(p2) => {
0.5 * (1.0 - self.alpha) * (price - p2) + self.beta * (1.0 + self.alpha) * self.bp1
- self.alpha * self.bp2
}
None => 0.0,
};
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
self.bp2 = self.bp1;
self.bp1 = bp;
self.last = Some(bp);
Some(bp)
}
fn reset(&mut self) {
self.prev_price_1 = None;
self.prev_price_2 = None;
self.bp1 = 0.0;
self.bp2 = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BandpassFilter"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
BandpassFilter::new(0, 0.3),
Err(Error::PeriodZero)
));
assert!(matches!(
BandpassFilter::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BandpassFilter::new(20, 1.0),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bp = BandpassFilter::new(20, 0.3).unwrap();
assert_eq!(bp.params(), (20, 0.3));
assert_eq!(bp.warmup_period(), 1);
assert_eq!(bp.name(), "BandpassFilter");
assert!(!bp.is_ready());
assert_eq!(bp.value(), None);
}
#[test]
fn first_bars_are_zero() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
assert_eq!(bp.update(100.0), Some(0.0));
assert_eq!(bp.update(101.0), Some(0.0));
// From the third bar the recursion is active.
assert!(bp.is_ready());
}
#[test]
fn constant_input_stays_zero() {
// A trend-free flat input has no cyclic content -> output stays 0.
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
for v in bp.batch(&[50.0; 200]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn cyclic_input_oscillates_around_zero() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (2.0 * PI * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let out: Vec<f64> = bp.batch(&xs).into_iter().flatten().skip(100).collect();
let mean = out.iter().sum::<f64>() / out.len() as f64;
assert!(
mean.abs() < 1.0,
"bandpass output should be ~zero mean, got {mean}"
);
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
let before = bp.value();
assert_eq!(bp.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
assert!(bp.is_ready());
bp.reset();
assert!(!bp.is_ready());
assert_eq!(bp.update(100.0), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = BandpassFilter::new(20, 0.3).unwrap().batch(&xs);
let mut b = BandpassFilter::new(20, 0.3).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,258 @@
//! Ehlers Correlation Trend Indicator (CTI) — Pearson correlation of price vs. time.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' **Correlation Trend Indicator** (CTI) — the Pearson correlation
/// coefficient between price and a perfectly straight ramp over the lookback.
///
/// ```text
/// CTI = corr( price over the window , [0, 1, …, period1] )
/// ```
///
/// John Ehlers' CTI asks "how closely does recent price track a straight line?"
/// by correlating the windowed price against the time index itself. A reading near
/// `+1` means price is rising in a near-perfect line (strong uptrend); near `1`
/// means a clean downtrend; near `0` means no linear trend (a range or choppy
/// market). Because correlation is scale- and offset-invariant, the slope's
/// steepness does not matter — only how *linear* the move is — which makes CTI an
/// unusually clean trend/range classifier. It differs from
/// [`Autocorrelation`](crate::Autocorrelation), which correlates price with a
/// *lagged copy of itself* rather than with time.
///
/// The output is in `[1, +1]`; a flat window (zero price variance) returns `0`.
/// The first value lands after `period` inputs; each `update` recomputes the
/// correlation over the window in O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, CorrelationTrendIndicator};
///
/// let mut indicator = CorrelationTrendIndicator::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i)); // a clean uptrend
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct CorrelationTrendIndicator {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl CorrelationTrendIndicator {
/// Construct a CTI over `period` bars.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs two
/// points).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "CTI needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured lookback period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn compute(&self) -> f64 {
let n = self.period as f64;
let mut sum_x = 0.0;
let mut sum_xx = 0.0;
let mut sum_xt = 0.0;
for (i, &x) in self.window.iter().enumerate() {
let t = i as f64;
sum_x += x;
sum_xx += x * x;
sum_xt += x * t;
}
// Time index 0..n-1 has closed-form sums.
let sum_t = n * (n - 1.0) / 2.0;
let sum_tt = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
let cov = n * sum_xt - sum_x * sum_t;
let var_x = n * sum_xx - sum_x * sum_x;
let var_t = n * sum_tt - sum_t * sum_t;
let denom = (var_x * var_t).sqrt();
if denom == 0.0 {
0.0
} else {
(cov / denom).clamp(-1.0, 1.0)
}
}
}
impl Indicator for CorrelationTrendIndicator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let out = self.compute();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"CorrelationTrendIndicator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(matches!(
CorrelationTrendIndicator::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(CorrelationTrendIndicator::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let cti = CorrelationTrendIndicator::new(20).unwrap();
assert_eq!(cti.period(), 20);
assert_eq!(cti.warmup_period(), 20);
assert_eq!(cti.name(), "CorrelationTrendIndicator");
assert!(!cti.is_ready());
assert_eq!(cti.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
let out = cti.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn clean_uptrend_is_one() {
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
let last = cti
.batch(&(0..40).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn clean_downtrend_is_minus_one() {
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
let last = cti
.batch(&(0..40).map(|i| 100.0 - f64::from(i)).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn flat_window_is_zero() {
let mut cti = CorrelationTrendIndicator::new(8).unwrap();
let last = cti.batch(&[7.0; 16]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut cti = CorrelationTrendIndicator::new(20).unwrap();
for v in cti
.batch(
&(0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!((-1.0..=1.0).contains(&v));
}
}
#[test]
fn ignores_non_finite() {
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
let ready = cti
.batch(&[1.0, 2.0, 3.0, 4.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(cti.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
cti.batch(&[1.0, 2.0, 3.0, 4.0]);
assert!(cti.is_ready());
cti.reset();
assert!(!cti.is_ready());
assert_eq!(cti.value(), None);
assert_eq!(cti.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = CorrelationTrendIndicator::new(20).unwrap().batch(&xs);
let mut b = CorrelationTrendIndicator::new(20).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,269 @@
//! Ehlers Even Better Sinewave (EBSW) — a normalised cycle oscillator in [-1, 1].
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' **Even Better Sinewave** (EBSW) — a self-normalising cycle oscillator
/// that swings cleanly in `[1, +1]` regardless of price amplitude.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 12):
///
/// ```text
/// alpha1 = (1 sin(2π/hp_period)) / cos(2π/hp_period)
/// HP_t = 0.5·(1 + alpha1)·(price_t price_{t1}) + alpha1·HP_{t1} (one-pole highpass)
/// Filt = SuperSmoother(HP, ssf_length)
/// Wave = (Filt_t + Filt_{t1} + Filt_{t2}) / 3
/// Pwr = (Filt_t² + Filt_{t1}² + Filt_{t2}²) / 3
/// EBSW = Wave / sqrt(Pwr)
/// ```
///
/// The price is first highpass-filtered to remove the trend, then SuperSmoothed to
/// remove noise, leaving the dominant cycle. Dividing a 3-bar average of that
/// cycle by its RMS power normalises the amplitude, so the output reads like a
/// clean sine wave bounded in `[1, +1]` whatever the instrument. Unlike the
/// classic [`SineWave`](crate::SineWave) (which derives in-phase/quadrature
/// components from the Hilbert transform and can whip in trends), the EBSW stays
/// well-behaved and is read directly: crossing up through `0`/`0.9` is a buy
/// cue, crossing down through `0`/`+0.9` a sell cue.
///
/// The first value lands once three SuperSmoothed samples exist
/// (`warmup_period == 3`). Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, EvenBetterSinewave};
///
/// let mut indicator = EvenBetterSinewave::new(40, 10).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EvenBetterSinewave {
hp_period: usize,
ssf_length: usize,
alpha1: f64,
smoother: SuperSmoother,
prev_price: Option<f64>,
hp: f64,
filt1: Option<f64>,
filt2: Option<f64>,
filt3: Option<f64>,
last: Option<f64>,
}
impl EvenBetterSinewave {
/// Construct an EBSW with the given highpass `hp_period` and SuperSmoother
/// `ssf_length`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either argument is `0`.
pub fn new(hp_period: usize, ssf_length: usize) -> Result<Self> {
if hp_period == 0 || ssf_length == 0 {
return Err(Error::PeriodZero);
}
let w = 2.0 * PI / hp_period as f64;
let alpha1 = (1.0 - w.sin()) / w.cos();
Ok(Self {
hp_period,
ssf_length,
alpha1,
smoother: SuperSmoother::new(ssf_length)?,
prev_price: None,
hp: 0.0,
filt1: None,
filt2: None,
filt3: None,
last: None,
})
}
/// Configured `(hp_period, ssf_length)`.
pub const fn params(&self) -> (usize, usize) {
(self.hp_period, self.ssf_length)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for EvenBetterSinewave {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let hp = match self.prev_price {
Some(prev) => 0.5 * (1.0 + self.alpha1) * (price - prev) + self.alpha1 * self.hp,
None => 0.0,
};
self.prev_price = Some(price);
self.hp = hp;
let filt = self.smoother.update(hp)?;
// Shift the three-deep filter buffer.
self.filt3 = self.filt2;
self.filt2 = self.filt1;
self.filt1 = Some(filt);
let (Some(f1), Some(f2), Some(f3)) = (self.filt1, self.filt2, self.filt3) else {
return None;
};
let wave = (f1 + f2 + f3) / 3.0;
let pwr = (f1 * f1 + f2 * f2 + f3 * f3) / 3.0;
let ebsw = if pwr > 0.0 {
(wave / pwr.sqrt()).clamp(-1.0, 1.0)
} else {
0.0
};
self.last = Some(ebsw);
Some(ebsw)
}
fn reset(&mut self) {
self.smoother.reset();
self.prev_price = None;
self.hp = 0.0;
self.filt1 = None;
self.filt2 = None;
self.filt3 = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"EvenBetterSinewave"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_zero_params() {
assert!(matches!(
EvenBetterSinewave::new(0, 10),
Err(Error::PeriodZero)
));
assert!(matches!(
EvenBetterSinewave::new(40, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let e = EvenBetterSinewave::new(40, 10).unwrap();
assert_eq!(e.params(), (40, 10));
assert_eq!(e.warmup_period(), 3);
assert_eq!(e.name(), "EvenBetterSinewave");
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
let xs: Vec<f64> = (0..12)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 3.0)
.collect();
let out = e.batch(&xs);
for v in out.iter().take(2) {
assert!(v.is_none());
}
assert!(out[2].is_some());
}
#[test]
fn output_in_range() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
.collect();
for v in e.batch(&xs).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "EBSW out of range: {v}");
}
}
#[test]
fn cyclic_input_swings_both_signs() {
let mut e = EvenBetterSinewave::new(30, 8).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
.collect();
let out: Vec<f64> = e.batch(&xs).into_iter().flatten().skip(100).collect();
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
e.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
let before = e.value();
assert_eq!(e.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
e.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = EvenBetterSinewave::new(40, 10).unwrap().batch(&xs);
let mut b = EvenBetterSinewave::new(40, 10).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn flat_input_yields_zero_power() {
// A constant series drives the highpass/smoother outputs to zero, so the
// signal power is zero and the oscillator reports 0.0 (the `pwr == 0` arm).
let flat = [100.0_f64; 200];
let last = EvenBetterSinewave::new(40, 10)
.unwrap()
.batch(&flat)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
}
@@ -0,0 +1,215 @@
//! Ehlers two-pole Highpass Filter — removes the trend, keeps the cycles.
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' two-pole Highpass Filter — strips the low-frequency trend from a price
/// series, leaving the higher-frequency cyclic and noise content.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
///
/// ```text
/// a = 0.707 · 2π / period
/// alpha1 = (cos(a) + sin(a) 1) / cos(a)
/// HP_t = (1 alpha1/2)² · (price_t 2·price_{t1} + price_{t2})
/// + 2·(1 alpha1)·HP_{t1} (1 alpha1)²·HP_{t2}
/// ```
///
/// A highpass filter is the complement of a smoother: where a lowpass keeps the
/// trend, the highpass keeps everything *faster* than the cutoff `period`. The
/// two-pole design gives a steep roll-off so frequencies below the cutoff are
/// firmly removed, detrending the series into a zero-mean wave. This differs from
/// the [`Decycler`](crate::Decycler), which is `price highpass` (the *trend* that
/// remains); the highpass is the cyclic part that the decycler discards.
///
/// The recursion needs two prior prices and two prior outputs; until then it emits
/// `0`, so `warmup_period` is `1`. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HighpassFilter};
///
/// let mut indicator = HighpassFilter::new(48).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.5).sin() * 3.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct HighpassFilter {
period: usize,
alpha1: f64,
prev_price_1: Option<f64>,
prev_price_2: Option<f64>,
hp1: f64,
hp2: f64,
last: Option<f64>,
}
impl HighpassFilter {
/// Construct a two-pole highpass filter with the given cutoff `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let a = 0.707 * 2.0 * PI / period as f64;
let alpha1 = (a.cos() + a.sin() - 1.0) / a.cos();
Ok(Self {
period,
alpha1,
prev_price_1: None,
prev_price_2: None,
hp1: 0.0,
hp2: 0.0,
last: None,
})
}
/// Configured cutoff period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for HighpassFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let hp = match (self.prev_price_1, self.prev_price_2) {
(Some(p1), Some(p2)) => {
let one_minus = 1.0 - self.alpha1;
let half = 1.0 - self.alpha1 / 2.0;
half * half * (price - 2.0 * p1 + p2) + 2.0 * one_minus * self.hp1
- one_minus * one_minus * self.hp2
}
_ => 0.0,
};
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
self.hp2 = self.hp1;
self.hp1 = hp;
self.last = Some(hp);
Some(hp)
}
fn reset(&mut self) {
self.prev_price_1 = None;
self.prev_price_2 = None;
self.hp1 = 0.0;
self.hp2 = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"HighpassFilter"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(HighpassFilter::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let hp = HighpassFilter::new(48).unwrap();
assert_eq!(hp.period(), 48);
assert_eq!(hp.warmup_period(), 1);
assert_eq!(hp.name(), "HighpassFilter");
assert!(!hp.is_ready());
assert_eq!(hp.value(), None);
}
#[test]
fn first_bars_are_zero() {
let mut hp = HighpassFilter::new(48).unwrap();
assert_eq!(hp.update(100.0), Some(0.0));
assert_eq!(hp.update(101.0), Some(0.0));
assert!(hp.is_ready());
}
#[test]
fn constant_input_stays_zero() {
let mut hp = HighpassFilter::new(48).unwrap();
for v in hp.batch(&[50.0; 200]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn pure_trend_is_attenuated() {
// A straight ramp is low-frequency -> the highpass should drive its
// output small after warmup (the trend is removed).
let mut hp = HighpassFilter::new(20).unwrap();
let out: Vec<f64> = hp
.batch(&(0..400).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.skip(200)
.collect();
for v in out {
assert!(v.abs() < 5.0, "trend should be attenuated, got {v}");
}
}
#[test]
fn ignores_non_finite() {
let mut hp = HighpassFilter::new(48).unwrap();
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
let before = hp.value();
assert_eq!(hp.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut hp = HighpassFilter::new(48).unwrap();
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
assert!(hp.is_ready());
hp.reset();
assert!(!hp.is_ready());
assert_eq!(hp.update(100.0), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + f64::from(i) + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = HighpassFilter::new(48).unwrap().batch(&xs);
let mut b = HighpassFilter::new(48).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
+31 -1
View File
@@ -16,8 +16,10 @@ mod acceleration_bands;
mod accelerator_oscillator;
mod ad_oscillator;
mod ad_volume_line;
mod adaptive_cci;
mod adaptive_cycle;
mod adaptive_laguerre_filter;
mod adaptive_rsi;
mod adl;
mod advance_block;
mod advance_decline;
@@ -39,12 +41,14 @@ mod atr_ratchet;
mod atr_trailing_stop;
mod auto_fib;
mod autocorrelation;
mod autocorrelation_periodogram;
mod average_daily_range;
mod average_drawdown;
mod avg_price;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
mod balance_of_power;
mod bandpass_filter;
mod bat;
mod belt_hold;
mod beta;
@@ -81,6 +85,7 @@ mod concealing_baby_swallow;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod correlation_trend_indicator;
mod counterattack;
mod crab;
mod cumulative_volume_index;
@@ -121,6 +126,7 @@ mod elder_safezone;
mod ema;
mod empirical_mode_decomposition;
mod engulfing;
mod even_better_sinewave;
mod evening_doji_star;
mod evwma;
mod ewma_volatility;
@@ -166,6 +172,7 @@ mod heikin_ashi;
mod high_low_index;
mod high_low_range;
mod high_wave;
mod highpass_filter;
mod hikkake;
mod hikkake_modified;
mod hilbert_dominant_cycle;
@@ -300,6 +307,7 @@ mod realized_spread;
mod realized_volatility;
mod recovery_factor;
mod rectangle_range;
mod reflex;
mod regime_label;
mod relative_strength_ab;
mod renko_bars;
@@ -397,6 +405,7 @@ mod trade_imbalance;
mod trade_volume_index;
mod trend_label;
mod trend_strength_index;
mod trendflex;
mod treynor_ratio;
mod triangle;
mod trima;
@@ -418,6 +427,7 @@ mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod unique_three_river;
mod universal_oscillator;
mod up_down_volume_ratio;
mod upside_gap_three_methods;
mod upside_gap_two_crows;
@@ -468,8 +478,10 @@ pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
pub use accelerator_oscillator::AcceleratorOscillator;
pub use ad_oscillator::AdOscillator;
pub use ad_volume_line::AdVolumeLine;
pub use adaptive_cci::AdaptiveCci;
pub use adaptive_cycle::AdaptiveCycle;
pub use adaptive_laguerre_filter::AdaptiveLaguerreFilter;
pub use adaptive_rsi::AdaptiveRsi;
pub use adl::Adl;
pub use advance_block::AdvanceBlock;
pub use advance_decline::AdvanceDecline;
@@ -491,12 +503,14 @@ pub use atr_ratchet::{AtrRatchet, AtrRatchetOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use auto_fib::{AutoFib, AutoFibOutput};
pub use autocorrelation::Autocorrelation;
pub use autocorrelation_periodogram::AutocorrelationPeriodogram;
pub use average_daily_range::AverageDailyRange;
pub use average_drawdown::AverageDrawdown;
pub use avg_price::AvgPrice;
pub use awesome_oscillator::AwesomeOscillator;
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
pub use balance_of_power::BalanceOfPower;
pub use bandpass_filter::BandpassFilter;
pub use bat::Bat;
pub use belt_hold::BeltHold;
pub use beta::Beta;
@@ -533,6 +547,7 @@ pub use concealing_baby_swallow::ConcealingBabySwallow;
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use correlation_trend_indicator::CorrelationTrendIndicator;
pub use counterattack::Counterattack;
pub use crab::Crab;
pub use cumulative_volume_index::CumulativeVolumeIndex;
@@ -573,6 +588,7 @@ pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput};
pub use ema::Ema;
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
pub use even_better_sinewave::EvenBetterSinewave;
pub use evening_doji_star::EveningDojiStar;
pub use evwma::Evwma;
pub use ewma_volatility::EwmaVolatility;
@@ -618,6 +634,7 @@ pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use high_low_index::HighLowIndex;
pub use high_low_range::HighLowRange;
pub use high_wave::HighWave;
pub use highpass_filter::HighpassFilter;
pub use hikkake::Hikkake;
pub use hikkake_modified::HikkakeModified;
pub use hilbert_dominant_cycle::HilbertDominantCycle;
@@ -752,6 +769,7 @@ pub use realized_spread::RealizedSpread;
pub use realized_volatility::RealizedVolatility;
pub use recovery_factor::RecoveryFactor;
pub use rectangle_range::RectangleRange;
pub use reflex::Reflex;
pub use regime_label::RegimeLabel;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_bars::{RenkoBars, RenkoBrick};
@@ -849,6 +867,7 @@ pub use trade_imbalance::TradeImbalance;
pub use trade_volume_index::TradeVolumeIndex;
pub use trend_label::TrendLabel;
pub use trend_strength_index::TrendStrengthIndex;
pub use trendflex::Trendflex;
pub use treynor_ratio::TreynorRatio;
pub use triangle::Triangle;
pub use trima::Trima;
@@ -870,6 +889,7 @@ pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use unique_three_river::UniqueThreeRiver;
pub use universal_oscillator::UniversalOscillator;
pub use up_down_volume_ratio::UpDownVolumeRatio;
pub use upside_gap_three_methods::UpsideGapThreeMethods;
pub use upside_gap_two_crows::UpsideGapTwoCrows;
@@ -1230,6 +1250,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"EmpiricalModeDecomposition",
"EhlersStochastic",
"InstantaneousTrendline",
"HighpassFilter",
"Reflex",
"Trendflex",
"CorrelationTrendIndicator",
"AdaptiveRsi",
"UniversalOscillator",
"AdaptiveCci",
"BandpassFilter",
"EvenBetterSinewave",
"AutocorrelationPeriodogram",
],
),
(
@@ -1510,6 +1540,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, 452, "FAMILIES total drifted from indicator count");
assert_eq!(total, 462, "FAMILIES total drifted from indicator count");
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Ehlers Reflex — a zero-lag cycle oscillator built on a SuperSmoother prefilter.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' **Reflex** — a near-zero-lag oscillator that measures how far the
/// smoothed price has deviated from the straight line connecting its endpoints
/// over the lookback.
///
/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*,
/// Feb 2020):
///
/// ```text
/// Filt = SuperSmoother(price, period)
/// slope = (Filt[period] Filt[0]) / period (line over the window)
/// sum = mean over i=1..period of ( Filt[0] + i·slope Filt[i] )
/// ms = 0.04·sum² + 0.96·ms[1] (adaptive normaliser)
/// Reflex = sum / sqrt(ms) (0 if ms == 0)
/// ```
///
/// Reflex fits a straight line across the SuperSmoothed price over `period` bars
/// and averages the deviation of the curve from that line. Because the line uses
/// both endpoints, the measure has almost no lag — it crosses zero essentially at
/// the cycle turns. The adaptive mean-square normaliser rescales the output to a
/// roughly `±3` range regardless of price, so the same thresholds work on any
/// instrument. Its sibling [`Trendflex`](crate::Trendflex) uses the deviation from
/// the *current* value instead of the line, making it trend- rather than
/// cycle-sensitive.
///
/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
/// is O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Reflex};
///
/// let mut indicator = Reflex::new(20).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Reflex {
period: usize,
smoother: SuperSmoother,
filt: VecDeque<f64>,
ms: f64,
last: Option<f64>,
}
impl Reflex {
/// Construct a Reflex with the given lookback `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
smoother: SuperSmoother::new(period)?,
filt: VecDeque::with_capacity(period + 1),
ms: 0.0,
last: None,
})
}
/// Configured lookback period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Reflex {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let filt = self.smoother.update(price)?;
if self.filt.len() == self.period + 1 {
self.filt.pop_front();
}
self.filt.push_back(filt);
if self.filt.len() < self.period + 1 {
return None;
}
// Newest at index `period`, oldest (period bars ago) at index 0.
let newest = self.filt[self.period];
let oldest = self.filt[0];
let slope = (oldest - newest) / self.period as f64;
let mut sum = 0.0;
for i in 1..=self.period {
sum += (newest + i as f64 * slope) - self.filt[self.period - i];
}
sum /= self.period as f64;
self.ms = 0.04 * sum * sum + 0.96 * self.ms;
let reflex = if self.ms > 0.0 {
sum / self.ms.sqrt()
} else {
0.0
};
self.last = Some(reflex);
Some(reflex)
}
fn reset(&mut self) {
self.smoother.reset();
self.filt.clear();
self.ms = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Reflex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Reflex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let r = Reflex::new(20).unwrap();
assert_eq!(r.period(), 20);
assert_eq!(r.warmup_period(), 21);
assert_eq!(r.name(), "Reflex");
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut r = Reflex::new(5).unwrap();
let xs: Vec<f64> = (0..12)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0)
.collect();
let out = r.batch(&xs);
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn constant_input_is_zero() {
// A flat price is exactly its own straight line -> zero deviation -> 0.
let mut r = Reflex::new(10).unwrap();
for v in r.batch(&[50.0; 100]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn cyclic_input_oscillates_around_zero() {
let mut r = Reflex::new(20).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let out: Vec<f64> = r.batch(&xs).into_iter().flatten().skip(100).collect();
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut r = Reflex::new(10).unwrap();
r.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
let before = r.value();
assert_eq!(r.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut r = Reflex::new(10).unwrap();
r.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = Reflex::new(20).unwrap().batch(&xs);
let mut b = Reflex::new(20).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,234 @@
//! Ehlers Trendflex — a trend-sensitive sibling of Reflex.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' **Trendflex** — the trend-sensitive companion to
/// [`Reflex`](crate::Reflex): it averages how far the SuperSmoothed price sits
/// above or below its values over the lookback, then self-normalises.
///
/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*,
/// Feb 2020):
///
/// ```text
/// Filt = SuperSmoother(price, period)
/// sum = mean over i=1..period of ( Filt[0] Filt[i] )
/// ms = 0.04·sum² + 0.96·ms[1] (adaptive normaliser)
/// Trendflex = sum / sqrt(ms) (0 if ms == 0)
/// ```
///
/// Where Reflex measures deviation from the straight *line* across the window
/// (cycle sensitive, near zero lag), Trendflex measures deviation from the
/// window's *values* (trend sensitive). It stays pinned to one side of zero
/// during a trend and oscillates through zero in a range, so it doubles as a
/// trend/range gauge. The adaptive mean-square normaliser keeps the output near a
/// `±3` band on any instrument.
///
/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
/// is O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Trendflex};
///
/// let mut indicator = Trendflex::new(20).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Trendflex {
period: usize,
smoother: SuperSmoother,
filt: VecDeque<f64>,
ms: f64,
last: Option<f64>,
}
impl Trendflex {
/// Construct a Trendflex with the given lookback `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
smoother: SuperSmoother::new(period)?,
filt: VecDeque::with_capacity(period + 1),
ms: 0.0,
last: None,
})
}
/// Configured lookback period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Trendflex {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let filt = self.smoother.update(price)?;
if self.filt.len() == self.period + 1 {
self.filt.pop_front();
}
self.filt.push_back(filt);
if self.filt.len() < self.period + 1 {
return None;
}
let newest = self.filt[self.period];
let mut sum = 0.0;
for i in 1..=self.period {
sum += newest - self.filt[self.period - i];
}
sum /= self.period as f64;
self.ms = 0.04 * sum * sum + 0.96 * self.ms;
let trendflex = if self.ms > 0.0 {
sum / self.ms.sqrt()
} else {
0.0
};
self.last = Some(trendflex);
Some(trendflex)
}
fn reset(&mut self) {
self.smoother.reset();
self.filt.clear();
self.ms = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Trendflex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Trendflex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let t = Trendflex::new(20).unwrap();
assert_eq!(t.period(), 20);
assert_eq!(t.warmup_period(), 21);
assert_eq!(t.name(), "Trendflex");
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut t = Trendflex::new(5).unwrap();
let xs: Vec<f64> = (0..12).map(f64::from).collect();
let out = t.batch(&xs);
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn constant_input_is_zero() {
let mut t = Trendflex::new(10).unwrap();
for v in t.batch(&[50.0; 100]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn uptrend_is_positive() {
// A steady rise keeps the current filtered value above its past values.
let mut t = Trendflex::new(10).unwrap();
let out: Vec<f64> = t
.batch(&(0..200).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.skip(100)
.collect();
for v in out {
assert!(v > 0.0, "uptrend should be positive, got {v}");
}
}
#[test]
fn downtrend_is_negative() {
let mut t = Trendflex::new(10).unwrap();
let out: Vec<f64> = t
.batch(&(0..200).map(|i| 200.0 - f64::from(i)).collect::<Vec<_>>())
.into_iter()
.flatten()
.skip(100)
.collect();
for v in out {
assert!(v < 0.0, "downtrend should be negative, got {v}");
}
}
#[test]
fn ignores_non_finite() {
let mut t = Trendflex::new(10).unwrap();
t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
let before = t.value();
assert_eq!(t.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut t = Trendflex::new(10).unwrap();
t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = Trendflex::new(20).unwrap().batch(&xs);
let mut b = Trendflex::new(20).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,254 @@
//! Ehlers Universal Oscillator — whitened, SuperSmoothed, AGC-normalised cycle.
#![allow(clippy::doc_markdown)]
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' **Universal Oscillator** — a cycle oscillator that whitens the price
/// series, SuperSmooths it, then normalises with an automatic gain control (AGC)
/// to swing in `[1, +1]`.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
///
/// ```text
/// WhiteNoise = (price_t price_{t2}) / 2 (flat-spectrum prewhitening)
/// Filt = SuperSmoother(WhiteNoise, period)
/// Peak = max(|Filt|, 0.991 · Peak_{t1}) (decaying peak / AGC)
/// Universal = Filt / Peak (0 if Peak == 0)
/// ```
///
/// "Whitening" the input (a two-bar difference) flattens its power spectrum so the
/// SuperSmoother responds equally to all cycles rather than being dominated by the
/// trend. The automatic gain control divides by a slowly-decaying running peak, so
/// the output is amplitude-normalised to `[1, +1]` and behaves consistently
/// across instruments and volatility regimes — hence "universal". Read it like any
/// bounded oscillator: turns near the rails flag cycle extremes, zero-crossings
/// flag cycle direction changes.
///
/// The first value lands once a two-bar difference exists (`warmup_period == 3`).
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, UniversalOscillator};
///
/// let mut indicator = UniversalOscillator::new(20).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 UniversalOscillator {
period: usize,
smoother: SuperSmoother,
prev_price_1: Option<f64>,
prev_price_2: Option<f64>,
peak: f64,
last: Option<f64>,
}
impl UniversalOscillator {
/// Construct a Universal Oscillator with the given SuperSmoother `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
smoother: SuperSmoother::new(period)?,
prev_price_1: None,
prev_price_2: None,
peak: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for UniversalOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let Some(p2) = self.prev_price_2 else {
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
return None;
};
let white_noise = (price - p2) / 2.0;
if !white_noise.is_finite() {
// `price - p2` can overflow to +/-inf even when both are finite;
// skip the bar rather than feeding a non-finite value downstream.
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
return self.last;
}
let filt = self
.smoother
.update(white_noise)
.expect("supersmoother emits");
self.peak = filt.abs().max(0.991 * self.peak);
let universal = if self.peak > 0.0 {
(filt / self.peak).clamp(-1.0, 1.0)
} else {
0.0
};
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
self.last = Some(universal);
Some(universal)
}
fn reset(&mut self) {
self.smoother.reset();
self.prev_price_1 = None;
self.prev_price_2 = None;
self.peak = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"UniversalOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_zero_period() {
assert!(matches!(
UniversalOscillator::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let u = UniversalOscillator::new(20).unwrap();
assert_eq!(u.period(), 20);
assert_eq!(u.warmup_period(), 3);
assert_eq!(u.name(), "UniversalOscillator");
assert!(!u.is_ready());
assert_eq!(u.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut u = UniversalOscillator::new(20).unwrap();
let out = u.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert!(out[2].is_some());
}
#[test]
fn constant_input_is_zero() {
// A flat input whitens to zero -> output 0.
let mut u = UniversalOscillator::new(20).unwrap();
for v in u.batch(&[50.0; 200]).into_iter().flatten() {
assert!(v.abs() < 1e-9);
}
}
#[test]
fn output_in_range() {
let mut u = UniversalOscillator::new(20).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
for v in u.batch(&xs).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "out of range: {v}");
}
}
#[test]
fn cyclic_input_swings_both_signs() {
let mut u = UniversalOscillator::new(20).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let out: Vec<f64> = u.batch(&xs).into_iter().flatten().skip(100).collect();
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut u = UniversalOscillator::new(20).unwrap();
u.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
let before = u.value();
assert_eq!(u.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut u = UniversalOscillator::new(20).unwrap();
u.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
assert!(u.is_ready());
u.reset();
assert!(!u.is_ready());
assert_eq!(u.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = UniversalOscillator::new(20).unwrap().batch(&xs);
let mut b = UniversalOscillator::new(20).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_white_noise_is_skipped() {
// `price - p2` can overflow to infinity even when both prices are
// finite; the non-finite white-noise term must be skipped, not fed to
// the smoother (which would otherwise yield `None` on the first bar).
let mut u = UniversalOscillator::new(20).unwrap();
assert_eq!(u.update(-1e308), None);
assert_eq!(u.update(0.0), None);
// (1e308 - (-1e308)) overflows to +inf -> white_noise non-finite.
assert_eq!(u.update(1e308), None);
}
}
+63 -60
View File
@@ -57,12 +57,13 @@ pub use derivatives::DerivativesTick;
pub use error::{Error, Result};
pub use indicators::{
AbandonedBaby, Abcd, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput,
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl,
AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator,
AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCci, AdaptiveCycle,
AdaptiveLaguerreFilter, AdaptiveRsi, Adl, AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio,
Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi,
AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput,
AtrRatchet, AtrRatchetOutput, AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation,
AutocorrelationPeriodogram, AverageDailyRange, AverageDrawdown, AvgPrice, AwesomeOscillator,
AwesomeOscillatorHistogram, BalanceOfPower, BandpassFilter, Bat, BeltHold, Beta,
BetaNeutralSpread, BetterVolume, BipowerVariation, BodySizePct, BollingerBands,
BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway,
BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput,
@@ -70,29 +71,30 @@ pub use indicators::{
ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex,
ClassicPivots, ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation,
Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi,
Coppock, Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
DecyclerOscillator, Dema, DemandIndex, 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, ElderSafeZone, ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition,
Engulfing, EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama,
FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput,
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11,
GarmanKlassVolatility, Gartley, GatorOscillator, GatorOscillatorOutput, GeneralizedDema,
GeometricMa, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer,
HangingMan, Harami, HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator,
HighLowIndex, HighLowRange, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle,
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
Coppock, CorrelationTrendIndicator, Counterattack, Crab, CumulativeVolumeDelta,
CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher, DayOfWeekProfile,
DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, 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, ElderSafeZone,
ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition, Engulfing, EvenBetterSinewave,
EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs,
FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension,
FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement,
FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput,
FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex,
FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean,
FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley,
GatorOscillator, GatorOscillatorOutput, GeneralizedDema, GeometricMa, GoldenPocket,
GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami,
HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange,
HighWave, HighpassFilter, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility,
Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, JarqueBera, Jma,
JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop,
KaseDevStopOutput, KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion,
@@ -115,37 +117,38 @@ pub use indicators::{
PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, ProjectionBands,
ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe, QqeOutput, Qstick, QuartileBands,
QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100,
RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr,
RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi,
Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SampleEntropy, SarExt, SeasonalZScore,
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
SessionVwap, ShannonEntropy, 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, TimeBasedStop,
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle,
Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice,
UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods,
UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility,
VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator,
VolumePriceTrend, VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd,
VolumeWeightedMacdOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
RecoveryFactor, RectangleRange, Reflex, RegimeLabel, RelativeStrengthAB,
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
RollingCovariance, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile,
RollingVwap, RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SampleEntropy,
SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange,
SessionRangeOutput, SessionVwap, ShannonEntropy, 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, TimeBasedStop, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile,
TpoProfileOutput, TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, Trendflex,
TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi,
Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows,
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UniversalOscillator,
UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, VolatilityCone,
VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop, VolumeByTimeProfile,
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, VolumeWeightedMacdOutput, Vortex,
VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm,
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
+1 -1
View File
@@ -8,7 +8,7 @@ That includes:
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **452 indicators** across
- A per-indicator deep dive for every one of the **462 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
+10 -1
View File
@@ -14,7 +14,7 @@
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, JarqueBera, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SampleEntropy, ShannonEntropy, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, AdaptiveRsi, Alma, AnchoredRsi, Apo, Autocorrelation, AutocorrelationPeriodogram, AverageDrawdown, BandpassFilter, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EvenBetterSinewave, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HighpassFilter, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, JarqueBera, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, Reflex, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SampleEntropy, ShannonEntropy, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trendflex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, UniversalOscillator, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
/// Drive a single streaming + batch run through one scalar indicator. Marked
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
@@ -179,6 +179,15 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| CyberneticCycle::new(10).unwrap(), &data);
drive(|| InstantaneousTrendline::new(20).unwrap(), &data);
drive(|| EhlersStochastic::new(20).unwrap(), &data);
drive(|| HighpassFilter::new(48).unwrap(), &data);
drive(|| Reflex::new(20).unwrap(), &data);
drive(|| Trendflex::new(20).unwrap(), &data);
drive(|| CorrelationTrendIndicator::new(20).unwrap(), &data);
drive(|| AdaptiveRsi::new(14).unwrap(), &data);
drive(|| UniversalOscillator::new(20).unwrap(), &data);
drive(|| BandpassFilter::new(20, 0.3).unwrap(), &data);
drive(|| EvenBetterSinewave::new(40, 10).unwrap(), &data);
drive(|| AutocorrelationPeriodogram::new(10, 48).unwrap(), &data);
drive(|| EmpiricalModeDecomposition::new(20, 0.5).unwrap(), &data);
drive(HilbertDominantCycle::new, &data);
drive(HtDcPhase::new, &data);
+2 -1
View File
@@ -22,7 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, AdaptiveCci, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -118,6 +118,7 @@ fuzz_target!(|data: Vec<f64>| {
// --- Momentum & Oscillators ---
drive(|| Cci::new(20).unwrap(), &candles);
drive(|| AdaptiveCci::new(20).unwrap(), &candles);
drive(|| StochasticCci::new(14).unwrap(), &candles);
drive(|| ElderRay::new(13).unwrap(), &candles);
drive(|| IntradayMomentumIndex::new(14).unwrap(), &candles);