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,