feat(indicators): B3 Trend & Directional batch (413 -> 420) (#181)

Adds the **B3 — Trend & Directional** batch: seven new indicators, taking the
catalog from 413 to 420 (Trend & Directional family).

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `Qstick` | candle → f64 | Chande's SMA of the candle body (close − open) |
| `TtmTrend` | candle → f64 (±1) | John Carter close-vs-median-SMA trend filter |
| `TrendStrengthIndex` | f64 → f64 | signed r² of an OLS regression of price vs time |
| `PolarizedFractalEfficiency` | f64 → f64 | Hannula directional trend efficiency |
| `WavePm` | f64 → f64 | Kase variance-normalised peak-momentum statistic (reconstruction) |
| `GatorOscillator` | candle → struct | Bill Williams Alligator convergence/divergence histogram |
| `KasePermissionStochastic` | candle → struct | double-smoothed stochastic permission filter |

Note: the roadmap's "Directional Indicator +DI/−DI" item is already covered by
the existing standalone `PlusDi` / `MinusDi` / `Dx`, so it is intentionally not
re-added.

All touchpoints wired: core (every-branch unit tests), Python/Node/WASM
bindings, fuzz drivers, Python test registries + reference tests, Node
factories, README/CHANGELOG counters.

Local verify: `cargo test -p wickra-core` (lib 3389 + doc 378), `cargo clippy
--workspace --all-targets --all-features -- -D warnings`, node build + 495
tests, maturin + 815 pytest, counter 420 == 420.
This commit is contained in:
kingchenc
2026-06-04 17:57:24 +02:00
committed by GitHub
parent ac8f6acf08
commit 13bc801f89
22 changed files with 2711 additions and 61 deletions
@@ -28,6 +28,9 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
WAVE_PM: () => new wickra.WAVE_PM(32, 3),
POLARIZED_FRACTAL_EFFICIENCY: () => new wickra.POLARIZED_FRACTAL_EFFICIENCY(10, 5),
TREND_STRENGTH_INDEX: () => new wickra.TREND_STRENGTH_INDEX(20),
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
RMI: () => new wickra.RMI(14, 5),
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
@@ -342,6 +345,8 @@ const candleScalar = {
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -426,6 +431,8 @@ const multi = {
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], 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(multi)) {
+71
View File
@@ -77,6 +77,14 @@ export interface ElderRayValue {
bullPower: number
bearPower: number
}
export interface GatorOscillatorValue {
upper: number
lower: number
}
export interface KasePermissionStochasticValue {
fast: number
slow: number
}
export interface StochValue {
k: number
d: number
@@ -961,6 +969,15 @@ export declare class DynamicMomentumIndex {
isReady(): boolean
warmupPeriod(): number
}
export type TrendStrengthIndexNode = TREND_STRENGTH_INDEX
export declare class TREND_STRENGTH_INDEX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type JumpIndicatorNode = JumpIndicator
export declare class JumpIndicator {
constructor(period: number, threshold: number)
@@ -1494,6 +1511,60 @@ export declare class ElderRay {
isReady(): boolean
warmupPeriod(): number
}
export type TtmTrendNode = TTM_TREND
export declare class TTM_TREND {
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 QstickNode = Qstick
export declare class Qstick {
constructor(period: number)
update(open: number, close: number): number | null
batch(open: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type PolarizedFractalEfficiencyNode = POLARIZED_FRACTAL_EFFICIENCY
export declare class POLARIZED_FRACTAL_EFFICIENCY {
constructor(period: number, smoothing: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type WavePmNode = WAVE_PM
export declare class WAVE_PM {
constructor(length: number, smoothing: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type GatorOscillatorNode = GatorOscillator
export declare class GatorOscillator {
constructor(jawPeriod: number, teethPeriod: number, lipsPeriod: number)
update(high: number, low: number, close: number): GatorOscillatorValue | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type KasePermissionStochasticNode = KasePermissionStochastic
export declare class KasePermissionStochastic {
constructor(length: number, smooth: number)
update(high: number, low: number, close: number): KasePermissionStochasticValue | 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)
File diff suppressed because one or more lines are too long
+327
View File
@@ -214,6 +214,11 @@ node_scalar_indicator!(
"DynamicMomentumIndex",
wc::DynamicMomentumIndex
);
node_scalar_indicator!(
TrendStrengthIndexNode,
"TREND_STRENGTH_INDEX",
wc::TrendStrengthIndex
);
#[napi(js_name = "JumpIndicator")]
pub struct JumpIndicatorNode {
inner: wc::JumpIndicator,
@@ -2295,6 +2300,328 @@ impl ElderRayNode {
}
}
#[napi(js_name = "TTM_TREND")]
pub struct TtmTrendNode {
inner: wc::TtmTrend,
}
#[napi]
impl TtmTrendNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::TtmTrend::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(js_name = "Qstick")]
pub struct QstickNode {
inner: wc::Qstick,
}
#[napi]
impl QstickNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Qstick::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, open: f64, close: f64) -> napi::Result<Option<f64>> {
let hi = open.max(close);
let lo = open.min(close);
Ok(self.inner.update(cnd4(open, hi, lo, close)?))
}
#[napi]
pub fn batch(&mut self, open: Vec<f64>, close: Vec<f64>) -> napi::Result<Vec<f64>> {
if open.len() != close.len() {
return Err(NapiError::from_reason(
"open, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let hi = open[i].max(close[i]);
let lo = open[i].min(close[i]);
out.push(
self.inner
.update(cnd4(open[i], hi, lo, close[i])?)
.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(js_name = "POLARIZED_FRACTAL_EFFICIENCY")]
pub struct PolarizedFractalEfficiencyNode {
inner: wc::PolarizedFractalEfficiency,
}
#[napi]
impl PolarizedFractalEfficiencyNode {
#[napi(constructor)]
pub fn new(period: u32, smoothing: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::PolarizedFractalEfficiency::new(period as usize, smoothing 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 = "WAVE_PM")]
pub struct WavePmNode {
inner: wc::WavePm,
}
#[napi]
impl WavePmNode {
#[napi(constructor)]
pub fn new(length: u32, smoothing: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::WavePm::new(length as usize, smoothing 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(object)]
pub struct GatorOscillatorValue {
pub upper: f64,
pub lower: f64,
}
#[napi(js_name = "GatorOscillator")]
pub struct GatorOscillatorNode {
inner: wc::GatorOscillator,
}
#[napi]
impl GatorOscillatorNode {
#[napi(constructor)]
pub fn new(jaw_period: u32, teeth_period: u32, lips_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::GatorOscillator::new(
jaw_period as usize,
teeth_period as usize,
lips_period as usize,
)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<GatorOscillatorValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| GatorOscillatorValue {
upper: o.upper,
lower: o.lower,
}))
}
#[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 n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.upper;
out[i * 2 + 1] = o.lower;
}
}
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 KasePermissionStochasticValue {
pub fast: f64,
pub slow: f64,
}
#[napi(js_name = "KasePermissionStochastic")]
pub struct KasePermissionStochasticNode {
inner: wc::KasePermissionStochastic,
}
#[napi]
impl KasePermissionStochasticNode {
#[napi(constructor)]
pub fn new(length: u32, smooth: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::KasePermissionStochastic::new(length as usize, smooth as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<KasePermissionStochasticValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| KasePermissionStochasticValue {
fast: o.fast,
slow: o.slow,
}))
}
#[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 n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.fast;
out[i * 2 + 1] = o.slow;
}
}
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,
+14
View File
@@ -25,6 +25,13 @@ from __future__ import annotations
from ._wickra import (
__version__,
Qstick,
GatorOscillator,
KasePermissionStochastic,
WAVE_PM,
POLARIZED_FRACTAL_EFFICIENCY,
TREND_STRENGTH_INDEX,
TTM_TREND,
QQE,
IMI,
ElderRay,
@@ -466,6 +473,13 @@ from ._wickra import (
)
__all__ = [
"Qstick",
"GatorOscillator",
"KasePermissionStochastic",
"WAVE_PM",
"POLARIZED_FRACTAL_EFFICIENCY",
"TREND_STRENGTH_INDEX",
"TTM_TREND",
"QQE",
"IMI",
"ElderRay",
+436
View File
@@ -2866,6 +2866,435 @@ impl PyStochasticCci {
}
}
// ============================== TtmTrend ==============================
#[pyclass(name = "TTM_TREND", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTtmTrend {
inner: wc::TtmTrend,
}
#[pymethods]
impl PyTtmTrend {
#[new]
#[pyo3(signature = (period=6))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TtmTrend::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!("TTM_TREND(period={})", self.inner.period())
}
}
// ============================== TrendStrengthIndex ==============================
#[pyclass(
name = "TREND_STRENGTH_INDEX",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyTrendStrengthIndex {
inner: wc::TrendStrengthIndex,
}
#[pymethods]
impl PyTrendStrengthIndex {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TrendStrengthIndex::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!("TREND_STRENGTH_INDEX(period={})", self.inner.period())
}
}
// ============================== Qstick ==============================
#[pyclass(name = "Qstick", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyQstick {
inner: wc::Qstick,
}
#[pymethods]
impl PyQstick {
#[new]
#[pyo3(signature = (period=10))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Qstick::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 open/close numpy columns (Qstick reads the body close-open).
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if o.len() != c.len() {
return Err(PyValueError::new_err("open, close must be equal length"));
}
let n = o.len();
let mut out = Vec::with_capacity(n);
for i in 0..n {
let hi = o[i].max(c[i]);
let lo = o[i].min(c[i]);
let candle = wc::Candle::new(o[i], hi, lo, 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()
}
}
// ============================== PolarizedFractalEfficiency ==============================
#[pyclass(
name = "POLARIZED_FRACTAL_EFFICIENCY",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyPolarizedFractalEfficiency {
inner: wc::PolarizedFractalEfficiency,
}
#[pymethods]
impl PyPolarizedFractalEfficiency {
#[new]
#[pyo3(signature = (period=10, smoothing=5))]
fn new(period: usize, smoothing: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PolarizedFractalEfficiency::new(period, smoothing).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.periods().0
}
#[getter]
fn smoothing(&self) -> usize {
self.inner.periods().1
}
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()
}
}
// ============================== WavePm ==============================
#[pyclass(name = "WAVE_PM", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyWavePm {
inner: wc::WavePm,
}
#[pymethods]
impl PyWavePm {
#[new]
#[pyo3(signature = (length=32, smoothing=3))]
fn new(length: usize, smoothing: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::WavePm::new(length, smoothing).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 length(&self) -> usize {
self.inner.periods().0
}
#[getter]
fn smoothing(&self) -> usize {
self.inner.periods().1
}
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()
}
}
// ============================== GatorOscillator ==============================
#[pyclass(
name = "GatorOscillator",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyGatorOscillator {
inner: wc::GatorOscillator,
}
#[pymethods]
impl PyGatorOscillator {
#[new]
#[pyo3(signature = (jaw_period=13, teeth_period=8, lips_period=5))]
fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::GatorOscillator::new(jaw_period, teeth_period, lips_period)
.map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.upper, o.lower)))
}
/// Batch over high/low/close numpy columns. Returns shape `(n, 2)` for
/// `[upper, lower]`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<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 n = h.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.upper;
out[i * 2 + 1] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== KasePermissionStochastic ==============================
#[pyclass(
name = "KasePermissionStochastic",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyKasePermissionStochastic {
inner: wc::KasePermissionStochastic,
}
#[pymethods]
impl PyKasePermissionStochastic {
#[new]
#[pyo3(signature = (length=9, smooth=3))]
fn new(length: usize, smooth: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::KasePermissionStochastic::new(length, smooth).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.fast, o.slow)))
}
/// Batch over high/low/close numpy columns. Returns shape `(n, 2)` for
/// `[fast, slow]`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<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 n = h.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.fast;
out[i * 2 + 1] = o.slow;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== Stochastic ==============================
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
@@ -20986,5 +21415,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyRsx>()?;
m.add_class::<PyDynamicMomentumIndex>()?;
m.add_class::<PyStochasticCci>()?;
m.add_class::<PyTtmTrend>()?;
m.add_class::<PyTrendStrengthIndex>()?;
m.add_class::<PyQstick>()?;
m.add_class::<PyPolarizedFractalEfficiency>()?;
m.add_class::<PyWavePm>()?;
m.add_class::<PyGatorOscillator>()?;
m.add_class::<PyKasePermissionStochastic>()?;
Ok(())
}
@@ -45,6 +45,9 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.WAVE_PM, (32, 3)),
(ta.POLARIZED_FRACTAL_EFFICIENCY, (10, 5)),
(ta.TREND_STRENGTH_INDEX, (20,)),
(ta.DerivativeOscillator, (14, 5, 3, 9)),
(ta.RMI, (14, 5)),
(ta.DynamicMomentumIndex, (14,)),
@@ -355,6 +358,7 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"TTM_TREND": (lambda: ta.TTM_TREND(6), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
# Per-bar OHLC transforms (open matters). The streaming harness feeds
# open == close, so batch passes the close column in for open to match.
@@ -892,6 +896,16 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"KasePermissionStochastic": (
lambda: ta.KasePermissionStochastic(9, 3),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"GatorOscillator": (
lambda: ta.GatorOscillator(13, 8, 5),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ElderRay": (
lambda: ta.ElderRay(13),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -2779,6 +2793,75 @@ def test_imi_reference():
assert math.isnan(out[1])
assert out[2] == pytest.approx(75.0)
def test_qstick_reference():
q = ta.Qstick(3)
open_ = np.array([10.0, 10.0, 10.0])
close = np.array([11.0, 11.0, 11.0])
out = q.batch(open_, close)
# Each body is close - open = 1; SMA(3) of [1, 1, 1] = 1.
assert math.isnan(out[0])
assert math.isnan(out[1])
assert out[2] == pytest.approx(1.0)
def test_ttm_trend_reference():
t = ta.TTM_TREND(3)
high = np.array([13.0, 13.0, 13.0])
low = np.array([9.0, 9.0, 9.0])
close = np.array([12.0, 12.0, 12.0])
out = t.batch(high, low, close)
# Median (13 + 9) / 2 = 11; close 12 is above the SMA(3) reference -> +1.
assert math.isnan(out[0])
assert out[2] == pytest.approx(1.0)
def test_trend_strength_index_reference():
tsi = ta.TREND_STRENGTH_INDEX(10)
closes = np.arange(10, dtype=float)
out = tsi.batch(closes)
# A clean ramp is a perfect uptrend -> signed r^2 = +1.
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
def test_polarized_fractal_efficiency_reference():
pfe = ta.POLARIZED_FRACTAL_EFFICIENCY(5, 3)
closes = np.arange(20, dtype=float)
out = pfe.batch(closes)
# On a straight ramp the path equals the diagonal -> efficiency 1 -> +100.
assert math.isclose(out[-1], 100.0, abs_tol=1e-9)
def test_wave_pm_reference():
wpm = ta.WAVE_PM(10, 3)
closes = np.arange(60, dtype=float) * 5.0
out = wpm.batch(closes)
# Constant-slope ramp: momentum equals its energy -> 100 * (1 - e^-0.5).
baseline = 100.0 * (1.0 - math.exp(-0.5))
assert math.isclose(out[-1], baseline, abs_tol=1e-9)
def test_gator_oscillator_reference():
g = ta.GatorOscillator(13, 8, 5)
n = 40
high = np.full(n, 11.0)
low = np.full(n, 9.0)
close = np.full(n, 10.0)
out = g.batch(high, low, close)
# Constant median collapses all three Alligator lines -> both bars zero.
assert out[-1][0] == pytest.approx(0.0)
assert out[-1][1] == pytest.approx(0.0)
def test_kase_permission_stochastic_reference():
k = ta.KasePermissionStochastic(4, 2)
n = 20
flat = np.full(n, 10.0)
out = k.batch(flat, flat, flat)
# HH == LL -> raw %K defaults to the neutral 50 -> both lines at 50.
assert out[-1][0] == pytest.approx(50.0)
assert out[-1][1] == pytest.approx(50.0)
# --- Lifecycle ------------------------------------------------------------
+222
View File
@@ -80,6 +80,14 @@ wasm_scalar_indicator!(WasmTrima, "TRIMA", wc::Trima, period: usize);
wasm_scalar_indicator!(WasmZlema, "ZLEMA", wc::Zlema, period: usize);
wasm_scalar_indicator!(WasmT3, "T3", wc::T3, period: usize, v: f64);
wasm_scalar_indicator!(WasmAlma, "ALMA", wc::Alma, period: usize, offset: f64, sigma: f64);
wasm_scalar_indicator!(
WasmPolarizedFractalEfficiency,
"POLARIZED_FRACTAL_EFFICIENCY",
wc::PolarizedFractalEfficiency,
period: usize,
smoothing: usize
);
wasm_scalar_indicator!(WasmWavePm, "WAVE_PM", wc::WavePm, length: usize, smoothing: usize);
wasm_scalar_indicator!(WasmMcGinleyDynamic, "McGinleyDynamic", wc::McGinleyDynamic, period: usize);
wasm_scalar_indicator!(WasmFrama, "FRAMA", wc::Frama, period: usize);
wasm_scalar_indicator!(WasmVidya, "VIDYA", wc::Vidya, period: usize, cmo_period: usize);
@@ -2255,6 +2263,219 @@ impl WasmElderRay {
}
}
#[wasm_bindgen(js_name = TTM_TREND)]
pub struct WasmTtmTrend {
inner: wc::TtmTrend,
}
#[wasm_bindgen(js_class = TTM_TREND)]
impl WasmTtmTrend {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmTtmTrend, JsError> {
Ok(Self {
inner: wc::TtmTrend::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 = Qstick)]
pub struct WasmQstick {
inner: wc::Qstick,
}
#[wasm_bindgen(js_class = Qstick)]
impl WasmQstick {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmQstick, JsError> {
Ok(Self {
inner: wc::Qstick::new(period).map_err(map_err)?,
})
}
/// Batch over open/close arrays; `NaN` during warmup.
pub fn batch(&mut self, open: &[f64], close: &[f64]) -> Result<Float64Array, JsError> {
let n = open.len();
if close.len() != n {
return Err(JsError::new("open, close must be equal length"));
}
let mut out = vec![f64::NAN; n];
for i in 0..n {
let hi = open[i].max(close[i]);
let lo = open[i].min(close[i]);
let c = make_candle_ohlc(open[i], hi, lo, close[i])?;
if let Some(v) = self.inner.update(c) {
out[i] = v;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update over one candle's open and close.
pub fn update(&mut self, open: f64, close: f64) -> Result<Option<f64>, JsError> {
let hi = open.max(close);
let lo = open.min(close);
let c = make_candle_ohlc(open, hi, lo, close)?;
Ok(self.inner.update(c))
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = GatorOscillator)]
pub struct WasmGatorOscillator {
inner: wc::GatorOscillator,
}
#[wasm_bindgen(js_class = GatorOscillator)]
impl WasmGatorOscillator {
#[wasm_bindgen(constructor)]
pub fn new(
jaw_period: usize,
teeth_period: usize,
lips_period: usize,
) -> Result<WasmGatorOscillator, JsError> {
Ok(Self {
inner: wc::GatorOscillator::new(jaw_period, teeth_period, lips_period)
.map_err(map_err)?,
})
}
/// Returns `[upper0, lower0, upper1, lower1, ...]`, length `2 * n`.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.upper;
out[i * 2 + 1] = o.lower;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update. Returns `{ upper, lower }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = KasePermissionStochastic)]
pub struct WasmKasePermissionStochastic {
inner: wc::KasePermissionStochastic,
}
#[wasm_bindgen(js_class = KasePermissionStochastic)]
impl WasmKasePermissionStochastic {
#[wasm_bindgen(constructor)]
pub fn new(length: usize, smooth: usize) -> Result<WasmKasePermissionStochastic, JsError> {
Ok(Self {
inner: wc::KasePermissionStochastic::new(length, smooth).map_err(map_err)?,
})
}
/// Returns `[fast0, slow0, fast1, slow1, ...]`, length `2 * n`.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.fast;
out[i * 2 + 1] = o.slow;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update. Returns `{ fast, slow }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"fast".into(), &o.fast.into()).ok();
Reflect::set(&obj, &"slow".into(), &o.slow.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = Stochastic)]
pub struct WasmStoch {
inner: wc::Stochastic,
@@ -10431,6 +10652,7 @@ wasm_scalar_indicator!(WasmRsx, "RSX", wc::Rsx, period: usize);
wasm_scalar_indicator!(WasmDynamicMomentumIndex, "DynamicMomentumIndex", wc::DynamicMomentumIndex, period: usize);
wasm_scalar_indicator!(WasmRmi, "RMI", wc::Rmi, period: usize, momentum: usize);
wasm_scalar_indicator!(WasmDerivativeOscillator, "DerivativeOscillator", wc::DerivativeOscillator, rsi_period: usize, smooth1: usize, smooth2: usize, signal_period: usize);
wasm_scalar_indicator!(WasmTrendStrengthIndex, "TREND_STRENGTH_INDEX", wc::TrendStrengthIndex, period: usize);
// --- DrawdownDuration: u32 output, no constructor args ---