B5 volatility & bands batch (423 -> 429) (#189)

Adds six **Volatility & Bands** indicators (Part B5 of the expansion roadmap), 423 → 429.

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `EwmaVolatility` | `f64` → `f64` | RiskMetrics exponentially-weighted volatility (λ decay) |
| `Garch11` | `f64` → `f64` | GARCH(1,1) conditional volatility with a long-run-variance anchor |
| `BipowerVariation` | `f64` → `f64` | jump-robust realized bipower variation (π/2 · Σ\|rₜ\|\|rₜ₋₁\|) |
| `VolatilityRatio` | `Candle` → `f64` | Schwager's true range over the EMA of prior true ranges (>2 = wide-ranging day) |
| `VolatilityCone` | `Candle` → `VolatilityConeOutput` | current realized volatility within its min/median/max envelope + percentile |
| `VolatilityOfVolatility` | `f64` → `f64` | sample stddev of a rolling realized-volatility series |

### Notes
- Two B5 roadmap items were dropped as duplicates/by-construction: `RealizedVolatility` already ships (v0.5.4); `Downside Semi-Deviation` is internal to Sortino. `Bipower Variation` confirmed distinct from `JumpIndicator` (a ±1 flag, not a variance measure).
- `VolatilityRatio` implements the widely-charted EMA-of-true-range convention (denominator excludes the current bar so the 2.0 threshold means "twice typical"), distinct from the existing pairwise `variance_ratio`.
- `Garch11` mean-reverts to `ω/(1−β)` on a flat series (does not decay to 0 like EWMA) — pinned by a dedicated test.

### Coverage / verification
- Full core + Python/Node/WASM bindings, fuzz drivers (scalar + candle), registries, CHANGELOG, README + docs counter sync.
- 100% unit-test coverage per indicator (every branch).
- Green locally: `cargo clippy --workspace --all-targets --all-features -D warnings`, core lib (3479) + doc (387), node (504), python (830).

Deep-dive docs for all six are staged for `wickra-docs` and pushed after release (gated).
This commit is contained in:
kingchenc
2026-06-06 22:38:34 +02:00
committed by GitHub
parent db186b18d3
commit 6b8c6a0e7f
21 changed files with 2817 additions and 74 deletions
@@ -28,6 +28,10 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
BipowerVariation: () => new wickra.BipowerVariation(20),
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
EwmaVolatility: () => new wickra.EwmaVolatility(0.94),
PpoHistogram: () => new wickra.PpoHistogram(3, 6, 3),
MacdHistogram: () => new wickra.MacdHistogram(3, 6, 3),
TsfOscillator: () => new wickra.TsfOscillator(3),
@@ -350,6 +354,7 @@ const candleScalar = {
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) },
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), 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)) {
@@ -436,6 +441,7 @@ const multi = {
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) },
VolatilityCone: { make: () => new wickra.VolatilityCone(20, 60), fields: ['current', 'min', 'median', 'max', 'percentile'], 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)) {
+65
View File
@@ -5,6 +5,17 @@
/** Library version (matches the Rust crate version). */
export declare function version(): string
/**
* Volatility-cone result: current realized volatility and its lookback
* envelope (min / median / max) plus the percentile rank of `current`.
*/
export interface VolatilityConeValue {
current: number
min: number
median: number
max: number
percentile: number
}
/** Lead/lag result: the offset that maximises correlation, and that correlation. */
export interface LeadLagValue {
/** Offset that maximises `|corr(a, b shifted)|`. Positive ⇒ `a` leads `b`. */
@@ -987,6 +998,51 @@ export declare class TsfOscillator {
isReady(): boolean
warmupPeriod(): number
}
export type BipowerVariationNode = BipowerVariation
export declare class BipowerVariation {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EwmaVolatilityNode = EwmaVolatility
export declare class EwmaVolatility {
constructor(lambda: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type Garch11Node = Garch11
export declare class Garch11 {
constructor(omega: number, alpha: number, beta: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolatilityOfVolatilityNode = VolatilityOfVolatility
export declare class VolatilityOfVolatility {
constructor(volWindow: number, vovWindow: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolatilityConeNode = VolatilityCone
export declare class VolatilityCone {
constructor(window: number, lookback: number)
update(high: number, low: number, close: number): VolatilityConeValue | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type JumpIndicatorNode = JumpIndicator
export declare class JumpIndicator {
constructor(period: number, threshold: number)
@@ -1574,6 +1630,15 @@ export declare class KasePermissionStochastic {
isReady(): boolean
warmupPeriod(): number
}
export type VolatilityRatioNode = VolatilityRatio
export declare class VolatilityRatio {
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)
File diff suppressed because one or more lines are too long
+246
View File
@@ -220,6 +220,199 @@ node_scalar_indicator!(
wc::TrendStrengthIndex
);
node_scalar_indicator!(TsfOscillatorNode, "TsfOscillator", wc::TsfOscillator);
node_scalar_indicator!(
BipowerVariationNode,
"BipowerVariation",
wc::BipowerVariation
);
#[napi(js_name = "EwmaVolatility")]
pub struct EwmaVolatilityNode {
inner: wc::EwmaVolatility,
}
#[napi]
impl EwmaVolatilityNode {
#[napi(constructor)]
pub fn new(lambda: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::EwmaVolatility::new(lambda).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 = "Garch11")]
pub struct Garch11Node {
inner: wc::Garch11,
}
#[napi]
impl Garch11Node {
#[napi(constructor)]
pub fn new(omega: f64, alpha: f64, beta: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Garch11::new(omega, alpha, beta).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 = "VolatilityOfVolatility")]
pub struct VolatilityOfVolatilityNode {
inner: wc::VolatilityOfVolatility,
}
#[napi]
impl VolatilityOfVolatilityNode {
#[napi(constructor)]
pub fn new(vol_window: u32, vov_window: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolatilityOfVolatility::new(vol_window as usize, vov_window 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
}
}
/// Volatility-cone result: current realized volatility and its lookback
/// envelope (min / median / max) plus the percentile rank of `current`.
#[napi(object)]
pub struct VolatilityConeValue {
pub current: f64,
pub min: f64,
pub median: f64,
pub max: f64,
pub percentile: f64,
}
#[napi(js_name = "VolatilityCone")]
pub struct VolatilityConeNode {
inner: wc::VolatilityCone,
}
#[napi]
impl VolatilityConeNode {
#[napi(constructor)]
pub fn new(window: u32, lookback: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolatilityCone::new(window as usize, lookback as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<VolatilityConeValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| VolatilityConeValue {
current: o.current,
min: o.min,
median: o.median,
max: o.max,
percentile: o.percentile,
}))
}
#[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 * 5];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 5] = o.current;
out[i * 5 + 1] = o.min;
out[i * 5 + 2] = o.median;
out[i * 5 + 3] = o.max;
out[i * 5 + 4] = o.percentile;
}
}
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 = "JumpIndicator")]
pub struct JumpIndicatorNode {
inner: wc::JumpIndicator,
@@ -2623,6 +2816,59 @@ impl KasePermissionStochasticNode {
}
}
#[napi(js_name = "VolatilityRatio")]
pub struct VolatilityRatioNode {
inner: wc::VolatilityRatio,
}
#[napi]
impl VolatilityRatioNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolatilityRatio::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,