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,
+12
View File
@@ -25,6 +25,12 @@ from __future__ import annotations
from ._wickra import (
__version__,
VolatilityCone,
VolatilityRatio,
BipowerVariation,
VolatilityOfVolatility,
Garch11,
EwmaVolatility,
PpoHistogram,
MacdHistogram,
TsfOscillator,
@@ -476,6 +482,12 @@ from ._wickra import (
)
__all__ = [
"VolatilityCone",
"VolatilityRatio",
"BipowerVariation",
"VolatilityOfVolatility",
"Garch11",
"EwmaVolatility",
"PpoHistogram",
"MacdHistogram",
"TsfOscillator",
+371
View File
@@ -53,6 +53,8 @@ type PivotLevels = (f64, f64, f64, f64, f64, f64, f64);
type FibExtLevels = (f64, f64, f64, f64, f64);
/// `(pp, r1, r2, s1, s2)` pivot levels returned by Woodie pivots.
type WoodieLevels = (f64, f64, f64, f64, f64);
/// `(current, min, median, max, percentile)` volatility-cone envelope.
type ConeBands = (f64, f64, f64, f64, f64);
/// `(tenkan, kijun, senkou_a, senkou_b, chikou)` Ichimoku lines, each optional during warmup.
type IchimokuLines = (
Option<f64>,
@@ -3433,6 +3435,130 @@ impl PyPpoHistogram {
}
}
// ============================== BipowerVariation ==============================
#[pyclass(
name = "BipowerVariation",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyBipowerVariation {
inner: wc::BipowerVariation,
}
#[pymethods]
impl PyBipowerVariation {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::BipowerVariation::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!("BipowerVariation(period={})", self.inner.period())
}
}
// ============================== VolatilityRatio ==============================
#[pyclass(
name = "VolatilityRatio",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyVolatilityRatio {
inner: wc::VolatilityRatio,
}
#[pymethods]
impl PyVolatilityRatio {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VolatilityRatio::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!("VolatilityRatio(period={})", self.inner.period())
}
}
// ============================== Stochastic ==============================
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
@@ -21124,6 +21250,245 @@ impl PyFibTimeZones {
}
}
// ============================== EWMA Volatility ==============================
#[pyclass(
name = "EwmaVolatility",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyEwmaVolatility {
inner: wc::EwmaVolatility,
}
#[pymethods]
impl PyEwmaVolatility {
#[new]
#[pyo3(signature = (lambda_=0.94))]
fn new(lambda_: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::EwmaVolatility::new(lambda_).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 lambda_(&self) -> f64 {
self.inner.lambda()
}
#[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()
}
}
// ============================== GARCH(1,1) ==============================
#[pyclass(name = "Garch11", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyGarch11 {
inner: wc::Garch11,
}
#[pymethods]
impl PyGarch11 {
#[new]
#[pyo3(signature = (omega=0.000_002, alpha=0.1, beta=0.88))]
fn new(omega: f64, alpha: f64, beta: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::Garch11::new(omega, alpha, beta).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) -> (f64, f64, f64) {
self.inner.params()
}
#[getter]
fn unconditional_variance(&self) -> f64 {
self.inner.unconditional_variance()
}
#[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()
}
}
// ============================== Volatility of Volatility ==============================
#[pyclass(
name = "VolatilityOfVolatility",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyVolatilityOfVolatility {
inner: wc::VolatilityOfVolatility,
}
#[pymethods]
impl PyVolatilityOfVolatility {
#[new]
#[pyo3(signature = (vol_window=20, vov_window=20))]
fn new(vol_window: usize, vov_window: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VolatilityOfVolatility::new(vol_window, vov_window).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 windows(&self) -> (usize, usize) {
self.inner.windows()
}
#[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()
}
}
// ============================== Volatility Cone ==============================
#[pyclass(
name = "VolatilityCone",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyVolatilityCone {
inner: wc::VolatilityCone,
}
#[pymethods]
impl PyVolatilityCone {
#[new]
#[pyo3(signature = (window=20, lookback=60))]
fn new(window: usize, lookback: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VolatilityCone::new(window, lookback).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<ConeBands>> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.current, o.min, o.median, o.max, o.percentile)))
}
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 * 5];
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 * 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(numpy::ndarray::Array2::from_shape_vec((n, 5), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn windows(&self) -> (usize, usize) {
self.inner.windows()
}
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()
}
}
#[pymodule]
#[allow(clippy::too_many_lines)]
fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -21563,5 +21928,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyTsfOscillator>()?;
m.add_class::<PyMacdHistogram>()?;
m.add_class::<PyPpoHistogram>()?;
m.add_class::<PyBipowerVariation>()?;
m.add_class::<PyVolatilityRatio>()?;
m.add_class::<PyEwmaVolatility>()?;
m.add_class::<PyGarch11>()?;
m.add_class::<PyVolatilityOfVolatility>()?;
m.add_class::<PyVolatilityCone>()?;
Ok(())
}
@@ -45,6 +45,10 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.BipowerVariation, (20,)),
(ta.VolatilityOfVolatility, (20, 20)),
(ta.Garch11, (0.000002, 0.1, 0.88)),
(ta.EwmaVolatility, (0.94,)),
(ta.PpoHistogram, (3, 6, 3)),
(ta.MacdHistogram, (3, 6, 3)),
(ta.TsfOscillator, (3,)),
@@ -361,6 +365,7 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"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
@@ -899,6 +904,11 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"VolatilityCone": (
lambda: ta.VolatilityCone(20, 60),
lambda ind, h, l, c, v: ind.batch(h, l, c),
5,
),
"KasePermissionStochastic": (
lambda: ta.KasePermissionStochastic(9, 3),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -2890,6 +2900,24 @@ def test_ppo_histogram_reference():
assert t.update(100.0 + i * 2.0) is None
assert t.update(100.0 + 7 * 2.0) == pytest.approx(-0.052098, abs=1e-6)
def test_ewma_volatility_reference():
t = ta.EwmaVolatility(0.94)
assert t.update(100.0) is None
assert t.update(110.0) == pytest.approx(0.09531017980432493)
assert t.update(99.0) == pytest.approx(0.0959428936787596)
def test_garch11_reference():
t = ta.Garch11(0.000002, 0.1, 0.88)
assert t.update(100.0) is None
assert t.update(110.0) == pytest.approx(0.009999999999999995)
assert t.update(99.0) == pytest.approx(0.031597516317477786)
def test_volatility_cone_reference():
t = ta.VolatilityCone(20, 60)
# --- Lifecycle ------------------------------------------------------------
+108
View File
@@ -2476,6 +2476,44 @@ impl WasmKasePermissionStochastic {
}
}
#[wasm_bindgen(js_name = VolatilityRatio)]
pub struct WasmVolatilityRatio {
inner: wc::VolatilityRatio,
}
#[wasm_bindgen(js_class = VolatilityRatio)]
impl WasmVolatilityRatio {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmVolatilityRatio, JsError> {
Ok(Self {
inner: wc::VolatilityRatio::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,
@@ -10656,6 +10694,76 @@ wasm_scalar_indicator!(WasmTrendStrengthIndex, "TREND_STRENGTH_INDEX", wc::Trend
wasm_scalar_indicator!(WasmTsfOscillator, "TsfOscillator", wc::TsfOscillator, period: usize);
wasm_scalar_indicator!(WasmMacdHistogram, "MacdHistogram", wc::MacdHistogram, fast: usize, slow: usize, signal: usize);
wasm_scalar_indicator!(WasmPpoHistogram, "PpoHistogram", wc::PpoHistogram, fast: usize, slow: usize, signal: usize);
wasm_scalar_indicator!(WasmBipowerVariation, "BipowerVariation", wc::BipowerVariation, period: usize);
wasm_scalar_indicator!(WasmEwmaVolatility, "EwmaVolatility", wc::EwmaVolatility, lambda: f64);
wasm_scalar_indicator!(WasmGarch11, "Garch11", wc::Garch11, omega: f64, alpha: f64, beta: f64);
wasm_scalar_indicator!(WasmVolatilityOfVolatility, "VolatilityOfVolatility", wc::VolatilityOfVolatility, vol_window: usize, vov_window: usize);
// --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) ---
#[wasm_bindgen(js_name = VolatilityCone)]
pub struct WasmVolatilityCone {
inner: wc::VolatilityCone,
}
#[wasm_bindgen(js_class = VolatilityCone)]
impl WasmVolatilityCone {
#[wasm_bindgen(constructor)]
pub fn new(window: usize, lookback: usize) -> Result<WasmVolatilityCone, JsError> {
Ok(Self {
inner: wc::VolatilityCone::new(window, lookback).map_err(map_err)?,
})
}
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, &"current".into(), &o.current.into()).ok();
Reflect::set(&obj, &"min".into(), &o.min.into()).ok();
Reflect::set(&obj, &"median".into(), &o.median.into()).ok();
Reflect::set(&obj, &"max".into(), &o.max.into()).ok();
Reflect::set(&obj, &"percentile".into(), &o.percentile.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
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 * 5];
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 * 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(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[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()
}
}
// --- DrawdownDuration: u32 output, no constructor args ---