diff --git a/bindings/node/index.js b/bindings/node/index.js index c0900276..b9348f46 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -355,6 +355,11 @@ module.exports.SuperTrend = SuperTrend module.exports.ChandelierExit = ChandelierExit module.exports.ChandeKrollStop = ChandeKrollStop module.exports.AtrTrailingStop = AtrTrailingStop +module.exports.TypicalPrice = TypicalPrice +module.exports.MedianPrice = MedianPrice +module.exports.WeightedClose = WeightedClose +module.exports.LinearRegression = LinearRegression +module.exports.LinRegSlope = LinRegSlope module.exports.MACD = MACD module.exports.BollingerBands = BollingerBands module.exports.ATR = ATR diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 667298ad..daa8e4ba 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1782,6 +1782,258 @@ impl AtrTrailingStopNode { } } +// ============================== Typical Price ============================== + +#[napi(js_name = "TypicalPrice")] +pub struct TypicalPriceNode { + inner: wc::TypicalPrice, +} + +impl Default for TypicalPriceNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl TypicalPriceNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::TypicalPrice::new(), + } + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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 + } +} + +// ============================== Median Price ============================== + +#[napi(js_name = "MedianPrice")] +pub struct MedianPriceNode { + inner: wc::MedianPrice, +} + +impl Default for MedianPriceNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl MedianPriceNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::MedianPrice::new(), + } + } + #[napi] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, low, 0.0)?)) + } + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high and low 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], low[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 + } +} + +// ============================== Weighted Close ============================== + +#[napi(js_name = "WeightedClose")] +pub struct WeightedCloseNode { + inner: wc::WeightedClose, +} + +impl Default for WeightedCloseNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl WeightedCloseNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::WeightedClose::new(), + } + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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 + } +} + +// ============================== Linear Regression ============================== + +#[napi(js_name = "LinearRegression")] +pub struct LinearRegressionNode { + inner: wc::LinearRegression, +} + +#[napi] +impl LinearRegressionNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::LinearRegression::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 + } +} + +// ============================== Linear Regression Slope ============================== + +#[napi(js_name = "LinRegSlope")] +pub struct LinRegSlopeNode { + inner: wc::LinRegSlope, +} + +#[napi] +impl LinRegSlopeNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::LinRegSlope::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 + } +} + // ============================== Bollinger Bandwidth ============================== #[napi(js_name = "BollingerBandwidth")] diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 84570533..b5c3dcbb 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -237,6 +237,64 @@ class AtrTrailingStop: @property def params(self) -> Tuple[int, float]: ... +class TypicalPrice: + def __init__(self) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + +class MedianPrice: + def __init__(self) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + +class WeightedClose: + def __init__(self) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + +class LinearRegression: + def __init__(self, period: int = 14) -> None: ... + def update(self, value: float) -> Optional[float]: ... + def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + +class LinRegSlope: + def __init__(self, period: int = 14) -> None: ... + def update(self, value: float) -> Optional[float]: ... + def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + class BollingerBandwidth: def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ... def update(self, value: float) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 14b1431f..ad08aca7 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -3577,6 +3577,285 @@ impl PyAtrTrailingStop { } } +// ============================== Typical Price ============================== + +#[pyclass(name = "TypicalPrice", module = "wickra._wickra")] +#[derive(Clone)] +struct PyTypicalPrice { + inner: wc::TypicalPrice, +} + +#[pymethods] +impl PyTypicalPrice { + #[new] + fn new() -> Self { + Self { + inner: wc::TypicalPrice::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns high, low, close (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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_bound(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() + } + fn __repr__(&self) -> String { + "TypicalPrice()".to_string() + } +} + +// ============================== Median Price ============================== + +#[pyclass(name = "MedianPrice", module = "wickra._wickra")] +#[derive(Clone)] +struct PyMedianPrice { + inner: wc::MedianPrice, +} + +#[pymethods] +impl PyMedianPrice { + #[new] + fn new() -> Self { + Self { + inner: wc::MedianPrice::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns high, low (both equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low must be equal length")); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(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() + } + fn __repr__(&self) -> String { + "MedianPrice()".to_string() + } +} + +// ============================== Weighted Close ============================== + +#[pyclass(name = "WeightedClose", module = "wickra._wickra")] +#[derive(Clone)] +struct PyWeightedClose { + inner: wc::WeightedClose, +} + +#[pymethods] +impl PyWeightedClose { + #[new] + fn new() -> Self { + Self { + inner: wc::WeightedClose::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns high, low, close (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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_bound(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() + } + fn __repr__(&self) -> String { + "WeightedClose()".to_string() + } +} + +// ============================== Linear Regression ============================== + +#[pyclass(name = "LinearRegression", module = "wickra._wickra")] +#[derive(Clone)] +struct PyLinearRegression { + inner: wc::LinearRegression, +} + +#[pymethods] +impl PyLinearRegression { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::LinearRegression::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(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!("LinearRegression(period={})", self.inner.period()) + } +} + +// ============================== Linear Regression Slope ============================== + +#[pyclass(name = "LinRegSlope", module = "wickra._wickra")] +#[derive(Clone)] +struct PyLinRegSlope { + inner: wc::LinRegSlope, +} + +#[pymethods] +impl PyLinRegSlope { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::LinRegSlope::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(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!("LinRegSlope(period={})", self.inner.period()) + } +} + // ============================== Module ============================== #[pymodule] @@ -3640,5 +3919,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index a4961d49..52b5711c 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -92,6 +92,8 @@ wasm_scalar_indicator!(WasmUlcerIndex, "UlcerIndex", wc::UlcerIndex, period: usi wasm_scalar_indicator!(WasmHistoricalVolatility, "HistoricalVolatility", wc::HistoricalVolatility, period: usize, trading_periods: usize); wasm_scalar_indicator!(WasmBollingerBandwidth, "BollingerBandwidth", wc::BollingerBandwidth, period: usize, multiplier: f64); wasm_scalar_indicator!(WasmPercentB, "PercentB", wc::PercentB, period: usize, multiplier: f64); +wasm_scalar_indicator!(WasmLinearRegression, "LinearRegression", wc::LinearRegression, period: usize); +wasm_scalar_indicator!(WasmLinRegSlope, "LinRegSlope", wc::LinRegSlope, period: usize); // ---------- KAMA (three params) ---------- @@ -839,6 +841,135 @@ impl WasmAtrTrailingStop { } } +#[wasm_bindgen(js_name = TypicalPrice)] +pub struct WasmTypicalPrice { + inner: wc::TypicalPrice, +} + +impl Default for WasmTypicalPrice { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = TypicalPrice)] +impl WasmTypicalPrice { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmTypicalPrice { + Self { + inner: wc::TypicalPrice::new(), + } + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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 { + 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::with_capacity(n); + for i in 0..n { + 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 = MedianPrice)] +pub struct WasmMedianPrice { + inner: wc::MedianPrice, +} + +impl Default for WasmMedianPrice { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = MedianPrice)] +impl WasmMedianPrice { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmMedianPrice { + Self { + inner: wc::MedianPrice::new(), + } + } + pub fn update(&mut self, high: f64, low: f64) -> Result, JsError> { + let c = make_candle(high, low, low, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high and low 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], low[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 = WeightedClose)] +pub struct WasmWeightedClose { + inner: wc::WeightedClose, +} + +impl Default for WasmWeightedClose { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = WeightedClose)] +impl WasmWeightedClose { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmWeightedClose { + Self { + inner: wc::WeightedClose::new(), + } + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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 { + 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::with_capacity(n); + for i in 0..n { + 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 = NATR)] pub struct WasmNatr { inner: wc::Natr, diff --git a/crates/wickra-core/src/indicators/linreg.rs b/crates/wickra-core/src/indicators/linreg.rs new file mode 100644 index 00000000..7fe36eea --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg.rs @@ -0,0 +1,198 @@ +//! Linear Regression (rolling least-squares endpoint). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Linear Regression — the endpoint of a rolling least-squares fit. +/// +/// Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`, it fits +/// the line `y = a + b·x` by ordinary least squares and reports the line's +/// value at the most recent point: +/// +/// ```text +/// b (slope) = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// a (intercept) = (Σy − b·Σx) / n +/// LinearReg = a + b·(period − 1) +/// ``` +/// +/// This is TA-Lib's `LINEARREG`: a smoothed price that lags less than an SMA +/// because it extrapolates the *local trend* forward to the current bar +/// instead of averaging it away. The `Σx` terms depend only on `period`, so +/// they are computed once; each `update` is O(period). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LinearRegression}; +/// +/// let mut indicator = LinearRegression::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LinearRegression { + period: usize, + window: VecDeque, + sum_x: f64, + denom: f64, +} + +impl LinearRegression { + /// Construct a new rolling linear regression over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is + /// undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "linear regression needs period >= 2", + }); + } + let n = period as f64; + // Closed forms for x = 0, 1, …, period − 1. + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Ordinary-least-squares `(slope, endpoint)` over the current full window. + fn fit(&self) -> (f64, f64) { + let n = self.period as f64; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + for (x, &y) in self.window.iter().enumerate() { + sum_y += y; + sum_xy += x as f64 * y; + } + let slope = (n * sum_xy - self.sum_x * sum_y) / self.denom; + let intercept = (sum_y - slope * self.sum_x) / n; + (slope, intercept + slope * (n - 1.0)) + } +} + +impl Indicator for LinearRegression { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + Some(self.fit().1) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "LinearRegression" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn reference_values() { + // period 3 over [1, 2, 9]: fit y = 0 + 4x, endpoint = 0 + 4·2 = 8. + let mut lr = LinearRegression::new(3).unwrap(); + let out = lr.batch(&[1.0, 2.0, 9.0]); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert_relative_eq!(out[2].unwrap(), 8.0, epsilon = 1e-9); + } + + #[test] + fn perfect_line_returns_current_value() { + // The regression of a perfectly linear series is that line itself, so + // its endpoint equals the current value. + let prices: Vec = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut lr = LinearRegression::new(10).unwrap(); + for (i, v) in lr.batch(&prices).into_iter().enumerate() { + if let Some(v) = v { + assert_relative_eq!(v, 2.0 * i as f64 + 5.0, epsilon = 1e-6); + } + } + } + + #[test] + fn constant_series_returns_the_constant() { + let mut lr = LinearRegression::new(8).unwrap(); + for v in lr.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 42.0, epsilon = 1e-9); + } + } + + #[test] + fn first_value_on_period_th_input() { + let mut lr = LinearRegression::new(5).unwrap(); + let out = lr.batch(&[1.0, 3.0, 2.0, 5.0, 4.0, 6.0]); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some(), "first value lands at index period - 1"); + assert_eq!(lr.warmup_period(), 5); + } + + #[test] + fn rejects_period_below_two() { + assert!(LinearRegression::new(0).is_err()); + assert!(LinearRegression::new(1).is_err()); + assert!(LinearRegression::new(2).is_ok()); + } + + #[test] + fn reset_clears_state() { + let mut lr = LinearRegression::new(5).unwrap(); + lr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(lr.is_ready()); + lr.reset(); + assert!(!lr.is_ready()); + assert_eq!(lr.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = LinearRegression::new(14).unwrap(); + let mut b = LinearRegression::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/linreg_slope.rs b/crates/wickra-core/src/indicators/linreg_slope.rs new file mode 100644 index 00000000..5b0c4343 --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg_slope.rs @@ -0,0 +1,195 @@ +//! Linear Regression Slope. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Linear Regression Slope — the slope of a rolling least-squares fit. +/// +/// Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`, it fits +/// the line `y = a + b·x` by ordinary least squares and reports the slope: +/// +/// ```text +/// b = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// ``` +/// +/// This is TA-Lib's `LINEARREG_SLOPE`: a momentum-like reading of how steeply +/// price is trending over the window — positive while it rises, negative +/// while it falls, near zero when it is flat — without the band-pass quirks +/// of a difference-based oscillator. The `Σx` terms depend only on `period`, +/// so they are computed once; each `update` is O(period). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LinRegSlope}; +/// +/// let mut indicator = LinRegSlope::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LinRegSlope { + period: usize, + window: VecDeque, + sum_x: f64, + denom: f64, +} + +impl LinRegSlope { + /// Construct a new rolling linear-regression slope over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is + /// undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "linear regression slope needs period >= 2", + }); + } + let n = period as f64; + // Closed forms for x = 0, 1, …, period − 1. + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for LinRegSlope { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + for (x, &y) in self.window.iter().enumerate() { + sum_y += y; + sum_xy += x as f64 * y; + } + Some((n * sum_xy - self.sum_x * sum_y) / self.denom) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "LinRegSlope" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn reference_values() { + // period 3 over [1, 2, 9]: fit y = 0 + 4x, so the slope is 4. + let mut ls = LinRegSlope::new(3).unwrap(); + let out = ls.batch(&[1.0, 2.0, 9.0]); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert_relative_eq!(out[2].unwrap(), 4.0, epsilon = 1e-9); + } + + #[test] + fn perfect_line_returns_its_step() { + // A series rising by a fixed step has exactly that slope. + let prices: Vec = (0..40).map(|i| 2.5 * f64::from(i) + 7.0).collect(); + let mut ls = LinRegSlope::new(10).unwrap(); + for v in ls.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v, 2.5, epsilon = 1e-6); + } + } + + #[test] + fn constant_series_has_zero_slope() { + let mut ls = LinRegSlope::new(8).unwrap(); + for v in ls.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn falling_series_has_negative_slope() { + let prices: Vec = (0..30).map(|i| 100.0 - f64::from(i)).collect(); + let mut ls = LinRegSlope::new(10).unwrap(); + for v in ls.batch(&prices).into_iter().flatten() { + assert!(v < 0.0, "a falling series must have a negative slope"); + } + } + + #[test] + fn first_value_on_period_th_input() { + let mut ls = LinRegSlope::new(5).unwrap(); + let out = ls.batch(&[1.0, 3.0, 2.0, 5.0, 4.0, 6.0]); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some(), "first value lands at index period - 1"); + assert_eq!(ls.warmup_period(), 5); + } + + #[test] + fn rejects_period_below_two() { + assert!(LinRegSlope::new(0).is_err()); + assert!(LinRegSlope::new(1).is_err()); + assert!(LinRegSlope::new(2).is_ok()); + } + + #[test] + fn reset_clears_state() { + let mut ls = LinRegSlope::new(5).unwrap(); + ls.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(ls.is_ready()); + ls.reset(); + assert!(!ls.is_ready()); + assert_eq!(ls.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = LinRegSlope::new(14).unwrap(); + let mut b = LinRegSlope::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/median_price.rs b/crates/wickra-core/src/indicators/median_price.rs new file mode 100644 index 00000000..995e220b --- /dev/null +++ b/crates/wickra-core/src/indicators/median_price.rs @@ -0,0 +1,121 @@ +//! Median Price. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Median Price — the bar's `(high + low) / 2`. +/// +/// The midpoint of the bar's range, ignoring where it opened or closed. It is +/// the price series Bill Williams' [`AwesomeOscillator`](crate::AwesomeOscillator) +/// is built on, and a smoother stand-in for the close when feeding other +/// indicators. As a stateless per-bar transform it emits a value from the +/// very first candle. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MedianPrice}; +/// +/// let mut indicator = MedianPrice::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct MedianPrice { + has_emitted: bool, +} + +impl MedianPrice { + /// Construct a new Median Price transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for MedianPrice { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + Some(candle.median_price()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MedianPrice" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // (high + low) / 2 = (12 + 8) / 2 = 10. + let mut mp = MedianPrice::new(); + assert_relative_eq!( + mp.update(candle(10.0, 12.0, 8.0, 11.0, 0)).unwrap(), + 10.0, + epsilon = 1e-12 + ); + } + + #[test] + fn emits_from_first_candle() { + let mut mp = MedianPrice::new(); + assert_eq!(mp.warmup_period(), 1); + assert!(!mp.is_ready()); + assert!(mp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(mp.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut mp = MedianPrice::new(); + mp.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(mp.is_ready()); + mp.reset(); + assert!(!mp.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = MedianPrice::new(); + let mut b = MedianPrice::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index d4e21342..48d7a177 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -30,8 +30,11 @@ mod historical_volatility; mod hma; mod kama; mod keltner; +mod linreg; +mod linreg_slope; mod macd; mod mass_index; +mod median_price; mod mfi; mod mom; mod natr; @@ -53,12 +56,14 @@ mod tema; mod trima; mod trix; mod tsi; +mod typical_price; mod ulcer_index; mod ultimate_oscillator; mod vortex; mod vpt; mod vwap; mod vwma; +mod weighted_close; mod williams_r; mod wma; mod zlema; @@ -89,8 +94,11 @@ pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; pub use kama::Kama; pub use keltner::{Keltner, KeltnerOutput}; +pub use linreg::LinearRegression; +pub use linreg_slope::LinRegSlope; pub use macd::{MacdIndicator, MacdOutput}; pub use mass_index::MassIndex; +pub use median_price::MedianPrice; pub use mfi::Mfi; pub use mom::Mom; pub use natr::Natr; @@ -112,12 +120,14 @@ pub use tema::Tema; pub use trima::Trima; pub use trix::Trix; pub use tsi::Tsi; +pub use typical_price::TypicalPrice; pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; pub use vortex::{Vortex, VortexOutput}; pub use vpt::VolumePriceTrend; pub use vwap::{RollingVwap, Vwap}; pub use vwma::Vwma; +pub use weighted_close::WeightedClose; pub use williams_r::WilliamsR; pub use wma::Wma; pub use zlema::Zlema; diff --git a/crates/wickra-core/src/indicators/typical_price.rs b/crates/wickra-core/src/indicators/typical_price.rs new file mode 100644 index 00000000..14cdd431 --- /dev/null +++ b/crates/wickra-core/src/indicators/typical_price.rs @@ -0,0 +1,121 @@ +//! Typical Price. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Typical Price — the bar's `(high + low + close) / 3`. +/// +/// A single representative price per bar that weights the close no more +/// heavily than the two extremes. It is the price series that +/// [`Cci`](crate::Cci) and [`Mfi`](crate::Mfi) are built on, and a common +/// input to feed other indicators in place of the raw close. As a stateless +/// per-bar transform it emits a value from the very first candle. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TypicalPrice}; +/// +/// let mut indicator = TypicalPrice::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TypicalPrice { + has_emitted: bool, +} + +impl TypicalPrice { + /// Construct a new Typical Price transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for TypicalPrice { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + Some(candle.typical_price()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TypicalPrice" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9. + let mut tp = TypicalPrice::new(); + assert_relative_eq!( + tp.update(candle(9.0, 12.0, 6.0, 9.0, 0)).unwrap(), + 9.0, + epsilon = 1e-12 + ); + } + + #[test] + fn emits_from_first_candle() { + let mut tp = TypicalPrice::new(); + assert_eq!(tp.warmup_period(), 1); + assert!(!tp.is_ready()); + assert!(tp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(tp.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut tp = TypicalPrice::new(); + tp.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(tp.is_ready()); + tp.reset(); + assert!(!tp.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = TypicalPrice::new(); + let mut b = TypicalPrice::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/weighted_close.rs b/crates/wickra-core/src/indicators/weighted_close.rs new file mode 100644 index 00000000..34e7ff9a --- /dev/null +++ b/crates/wickra-core/src/indicators/weighted_close.rs @@ -0,0 +1,120 @@ +//! Weighted Close. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Weighted Close — the bar's `(high + low + 2·close) / 4`. +/// +/// A representative per-bar price that, unlike the [`TypicalPrice`](crate::TypicalPrice), +/// gives the close double weight — useful when the closing print matters more +/// than the extremes for your strategy. As a stateless per-bar transform it +/// emits a value from the very first candle. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, WeightedClose}; +/// +/// let mut indicator = WeightedClose::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct WeightedClose { + has_emitted: bool, +} + +impl WeightedClose { + /// Construct a new Weighted Close transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for WeightedClose { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + Some(candle.weighted_close()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "WeightedClose" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // (high + low + 2·close) / 4 = (12 + 8 + 2·11) / 4 = 42 / 4 = 10.5. + let mut wc = WeightedClose::new(); + assert_relative_eq!( + wc.update(candle(10.0, 12.0, 8.0, 11.0, 0)).unwrap(), + 10.5, + epsilon = 1e-12 + ); + } + + #[test] + fn emits_from_first_candle() { + let mut wc = WeightedClose::new(); + assert_eq!(wc.warmup_period(), 1); + assert!(!wc.is_ready()); + assert!(wc.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(wc.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut wc = WeightedClose::new(); + wc.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(wc.is_ready()); + wc.reset(); + assert!(!wc.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = WeightedClose::new(); + let mut b = WeightedClose::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 1e948a06..ed913e93 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -48,11 +48,12 @@ pub use indicators::{ AwesomeOscillator, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, - ForceIndex, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator, MacdOutput, - MassIndex, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, - StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, - Trix, Tsi, UlcerIndex, UltimateOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, - WilliamsR, Wma, Zlema, T3, + ForceIndex, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, LinRegSlope, + LinearRegression, MacdIndicator, MacdOutput, MassIndex, MedianPrice, Mfi, Mom, Natr, Obv, + PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic, + StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, Trix, Tsi, TypicalPrice, + UlcerIndex, UltimateOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, + WeightedClose, WilliamsR, Wma, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 42a584b0..428ae813 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -140,6 +140,14 @@ Rust / Python / Node examples. They are grouped by family, mirroring the - [Indicator-ForceIndex.md](indicators/volume/Indicator-ForceIndex.md) - [Indicator-EaseOfMovement.md](indicators/volume/Indicator-EaseOfMovement.md) +**Statistics** — price transforms and rolling regressions. + +- [Indicator-TypicalPrice.md](indicators/statistics/Indicator-TypicalPrice.md) +- [Indicator-MedianPrice.md](indicators/statistics/Indicator-MedianPrice.md) +- [Indicator-WeightedClose.md](indicators/statistics/Indicator-WeightedClose.md) +- [Indicator-LinearRegression.md](indicators/statistics/Indicator-LinearRegression.md) +- [Indicator-LinRegSlope.md](indicators/statistics/Indicator-LinRegSlope.md) + ## See also - Source code: diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md index 782ce3b8..1b159158 100644 --- a/docs/wiki/Indicators-Overview.md +++ b/docs/wiki/Indicators-Overview.md @@ -1,11 +1,11 @@ # Indicators Overview -Wickra ships 58 indicators, organised in source under the four classical -families — trend, momentum, volatility, volume — that map directly to the -directory structure of `crates/wickra-core/src/indicators/`. The same family -labels are used here, plus a second-level grouping that reflects how the -indicators actually behave (which output range they live in, what data they -need, what question they answer). +Wickra ships 63 indicators, organised under the four classical families — +trend, momentum, volatility, volume — plus a fifth **statistics** group for +price transforms and rolling regressions. The same family labels are used +here, with a second-level grouping that reflects how the indicators actually +behave (which output range they live in, what data they need, what question +they answer). Every indicator is an O(1) state machine that consumes one input at a time and produces either `Option` (Rust), `float | None` (Python), or @@ -185,6 +185,32 @@ price closes within each bar and how much volume backed the move. | `ForceIndex` | `EMA((close − prev_close) · volume, period)`; the conviction behind a move. | `Candle` | `f64` | unbounded around zero | `period = 13` (Python) | `period + 1` | [Indicator-ForceIndex.md](indicators/volume/Indicator-ForceIndex.md) | | `EaseOfMovement` | `SMA` of distance travelled per unit of volume. | `Candle` | `f64` | unbounded around zero | `(period=14, divisor=1e8)` (Python) | `period + 1` | [Indicator-EaseOfMovement.md](indicators/volume/Indicator-EaseOfMovement.md) | +## Statistics + +Price transforms and rolling regressions. The transforms collapse a full +OHLC bar to a single representative price; the regressions fit a +least-squares line to a sliding window of prices. + +### Price transforms + +Stateless per-bar reductions of an OHLC candle to one price. Each emits from +the very first candle (`warmup = 1`). + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive | +|-----------|-----------|-------|--------|-------|----------|--------|-----------| +| `TypicalPrice` | `(high + low + close) / 3`. | `Candle` | `f64` | unbounded (price scale) | (no parameters) | `1` | [Indicator-TypicalPrice.md](indicators/statistics/Indicator-TypicalPrice.md) | +| `MedianPrice` | `(high + low) / 2`. | `Candle` | `f64` | unbounded (price scale) | (no parameters) | `1` | [Indicator-MedianPrice.md](indicators/statistics/Indicator-MedianPrice.md) | +| `WeightedClose` | `(high + low + 2·close) / 4`. | `Candle` | `f64` | unbounded (price scale) | (no parameters) | `1` | [Indicator-WeightedClose.md](indicators/statistics/Indicator-WeightedClose.md) | + +### Regression + +Rolling ordinary-least-squares fits over the last `period` prices. + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive | +|-----------|-----------|-------|--------|-------|----------|--------|-----------| +| `LinearRegression` | Endpoint of the rolling least-squares line — a low-lag smoothed price. | `f64` | `f64` | unbounded (price scale) | `period = 14` (Python) | `period` | [Indicator-LinearRegression.md](indicators/statistics/Indicator-LinearRegression.md) | +| `LinRegSlope` | Slope of the rolling least-squares line — trend steepness per bar. | `f64` | `f64` | unbounded around zero | `period = 14` (Python) | `period` | [Indicator-LinRegSlope.md](indicators/statistics/Indicator-LinRegSlope.md) | + ## Pick the right indicator for… A short cheat-sheet of "I want X, which indicator?" answers, grounded in diff --git a/docs/wiki/indicators/statistics/Indicator-LinRegSlope.md b/docs/wiki/indicators/statistics/Indicator-LinRegSlope.md new file mode 100644 index 00000000..22ed8167 --- /dev/null +++ b/docs/wiki/indicators/statistics/Indicator-LinRegSlope.md @@ -0,0 +1,150 @@ +# LinRegSlope + +> Linear Regression Slope — the slope of a rolling ordinary-least-squares +> fit over the last `period` prices. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Statistics | +| Sub-category | Regression | +| Input type | `f64` (price) | +| Output type | `f64` | +| Output range | unbounded around zero (price units per bar) | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period` | +| Interpretation | How steeply price trends; positive up, negative down, zero flat. | + +## Formula + +Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`: + +``` +b = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +``` + +`LinRegSlope` fits a straight line to the window by ordinary least squares — +the same fit as [`LinearRegression`](Indicator-LinearRegression.md) — but +reports the *slope* `b` instead of the endpoint. The slope is in price units +per bar: positive while price trends up, negative while it trends down, near +zero when it is ranging. This is TA-Lib's `LINEARREG_SLOPE`. + +## Parameters + +`period` — the regression window. Must be at least `2` (a line needs two +points). The Python binding defaults it to `14`; the Rust and Node +constructors require it explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/linreg_slope.rs`: + +```rust +impl Indicator for LinRegSlope { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +`LinRegSlope` is a **scalar** indicator: it consumes one `f64` price per step. +Because `Input = f64` it can sit inside a [`Chain`](../../Indicator-Chaining.md). + +## Warmup + +`LinRegSlope::new(14).warmup_period() == 14`. The first value lands once the +window holds a full `period` prices — on input index `period − 1`. + +## Edge cases + +- **`period < 2`.** Rejected at construction — a regression line is undefined + for fewer than two points. +- **Perfect line.** Fed a series rising by a fixed step, the slope is exactly + that step (`perfect_line_returns_its_step` pins this). +- **Constant series.** A flat input returns a slope of `0`. +- **Falling series.** A descending input returns a negative slope. +- **Reset.** `ls.reset()` clears the rolling window. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, LinRegSlope}; + +fn main() -> Result<(), Box> { + let mut ls = LinRegSlope::new(3)?; + // Fit over [1, 2, 9]: the least-squares line is y = 4x, slope 4. + let out = ls.batch(&[1.0, 2.0, 9.0]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, None, Some(4.0)] +``` + +This matches the `reference_values` test in +`crates/wickra-core/src/indicators/linreg_slope.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +ls = ta.LinRegSlope(3) +print(ls.batch(np.array([1.0, 2.0, 9.0]))) +``` + +Output: + +``` +[nan nan 4.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const ls = new ta.LinRegSlope(3); +console.log(ls.batch([1, 2, 9])); +``` + +Output: + +``` +[ NaN, NaN, 4 ] +``` + +## Interpretation + +`LinRegSlope` is a momentum gauge: its sign is the trend direction and its +magnitude is the trend's steepness in price-per-bar. A slope crossing zero +marks a trend change; a slope that flattens while price still rises warns the +trend is losing pace. Unlike a difference-based oscillator it uses every bar +in the window, so it is less jumpy. + +## Common pitfalls + +- **Comparing slopes across instruments.** The slope is in the instrument's + own price units per bar — normalise (e.g. divide by price) to compare. +- **Tiny periods.** `period = 2` reduces the slope to the last simple + difference; use a meaningful window. + +## References + +The slope of an ordinary least-squares fit to a rolling price window; matches +TA-Lib's `LINEARREG_SLOPE`. + +## See also + +- [Indicator-LinearRegression.md](Indicator-LinearRegression.md) — the + endpoint of the same rolling fit. +- [Indicator-Mom.md](../momentum/Indicator-Mom.md) — raw price-difference + momentum, the unsmoothed cousin. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/statistics/Indicator-LinearRegression.md b/docs/wiki/indicators/statistics/Indicator-LinearRegression.md new file mode 100644 index 00000000..f205357c --- /dev/null +++ b/docs/wiki/indicators/statistics/Indicator-LinearRegression.md @@ -0,0 +1,152 @@ +# LinearRegression + +> Linear Regression — the endpoint of a rolling ordinary-least-squares fit +> over the last `period` prices. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Statistics | +| Sub-category | Regression | +| Input type | `f64` (price) | +| Output type | `f64` | +| Output range | unbounded (price scale) | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period` | +| Interpretation | A low-lag smoothed price — the trend line extrapolated to now. | + +## Formula + +Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`: + +``` +b (slope) = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +a (intercept) = (Σy − b·Σx) / n +LinearReg = a + b·(period − 1) +``` + +The indicator fits a straight line to the window by ordinary least squares, +then reports that line's value at the most recent bar. Because it +extrapolates the *local trend* forward rather than averaging it away, it lags +a same-period [`Sma`](../trend/Indicator-Sma.md) noticeably less. This is +TA-Lib's `LINEARREG`. + +## Parameters + +`period` — the regression window. Must be at least `2` (a line needs two +points). The Python binding defaults it to `14`; the Rust and Node +constructors require it explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/linreg.rs`: + +```rust +impl Indicator for LinearRegression { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +`LinearRegression` is a **scalar** indicator: it consumes one `f64` price per +step. Because `Input = f64` it can sit inside a [`Chain`](../../Indicator-Chaining.md). + +## Warmup + +`LinearRegression::new(14).warmup_period() == 14`. The first value lands once +the window holds a full `period` prices — on input index `period − 1`. + +## Edge cases + +- **`period < 2`.** Rejected at construction — a regression line is undefined + for fewer than two points. +- **Perfect line.** Fed a perfectly linear series, the fit *is* that line, so + the endpoint equals the current value (`perfect_line_returns_current_value` + pins this). +- **Constant series.** A flat input returns that constant. +- **Reset.** `lr.reset()` clears the rolling window. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, LinearRegression}; + +fn main() -> Result<(), Box> { + let mut lr = LinearRegression::new(3)?; + // Fit over [1, 2, 9]: the least-squares line is y = 4x, endpoint 4·2 = 8. + let out = lr.batch(&[1.0, 2.0, 9.0]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, None, Some(8.0)] +``` + +This matches the `reference_values` test in +`crates/wickra-core/src/indicators/linreg.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +lr = ta.LinearRegression(3) +print(lr.batch(np.array([1.0, 2.0, 9.0]))) +``` + +Output: + +``` +[nan nan 8.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const lr = new ta.LinearRegression(3); +console.log(lr.batch([1, 2, 9])); +``` + +Output: + +``` +[ NaN, NaN, 8 ] +``` + +## Interpretation + +Read `LinearRegression` as a low-lag moving average: it tracks price more +closely than an SMA of the same period because it projects the window's trend +to the current bar instead of centring on the window. A shorter `period` +hugs price; a longer one is a smoother trend line. Pair it with +[`LinRegSlope`](Indicator-LinRegSlope.md) to read the same fit's steepness. + +## Common pitfalls + +- **Confusing it with an SMA.** It is a *projected* fit, not a centred + average, so it leads an SMA of the same period. +- **Tiny periods.** `period = 2` is allowed but the "fit" just passes through + the last two points; use a meaningful window. + +## References + +Ordinary least-squares linear regression applied to a rolling price window; +the endpoint formulation matches TA-Lib's `LINEARREG`. + +## See also + +- [Indicator-LinRegSlope.md](Indicator-LinRegSlope.md) — the slope of the same + rolling fit. +- [Indicator-Sma.md](../trend/Indicator-Sma.md) — the centred average it is + often compared against. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/statistics/Indicator-MedianPrice.md b/docs/wiki/indicators/statistics/Indicator-MedianPrice.md new file mode 100644 index 00000000..9e9c0817 --- /dev/null +++ b/docs/wiki/indicators/statistics/Indicator-MedianPrice.md @@ -0,0 +1,136 @@ +# MedianPrice + +> Median Price — the bar's `(high + low) / 2`, the midpoint of its range. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Statistics | +| Sub-category | Price transforms | +| Input type | `Candle` (uses `high`, `low`) | +| Output type | `f64` | +| Output range | unbounded (price scale) | +| Default parameters | none (no parameters) | +| Warmup period | `1` | +| Interpretation | The midpoint of the bar's range, ignoring open and close. | + +## Formula + +``` +MedianPrice = (high + low) / 2 +``` + +The median price is the centre of the bar's range — it discards where the bar +opened and closed entirely. It is the price series Bill Williams' +[`AwesomeOscillator`](../momentum/Indicator-AwesomeOscillator.md) is built on, +and a useful close substitute when the close is noisy relative to the range. + +## Parameters + +`MedianPrice` takes **no parameters** — `MedianPrice::new()` in Rust, +`wickra.MedianPrice()` in Python, `new ta.MedianPrice()` in Node. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/median_price.rs`: + +```rust +impl Indicator for MedianPrice { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`MedianPrice` is a **candle-input** indicator that reads `high` and `low`. In +Python the streaming `update` accepts a 6-tuple or a dict; the batch helper +takes `high`, `low` numpy arrays. Node and WASM expose `update(high, low)` and +the matching `batch`. + +## Warmup + +`MedianPrice::new().warmup_period() == 1`. It is a stateless per-bar transform +— it emits a value from the very first candle. + +## Edge cases + +- **No warmup.** Every candle produces a value immediately. +- **Reset.** `mp.reset()` only clears the `is_ready` flag; there is no + rolling state to discard. + +## Examples + +### Rust + +```rust +use wickra::{Candle, Indicator, MedianPrice}; + +fn main() -> Result<(), Box> { + let mut mp = MedianPrice::new(); + let v = mp.update(Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0)?); + println!("{:?}", v); + Ok(()) +} +``` + +Output: + +``` +Some(10.0) +``` + +`(12 + 8) / 2 = 10`. This matches the `reference_value` test in +`crates/wickra-core/src/indicators/median_price.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +mp = ta.MedianPrice() +print(mp.batch(np.array([12.0]), np.array([8.0]))) +``` + +Output: + +``` +[10.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const mp = new ta.MedianPrice(); +console.log(mp.batch([12], [8])); +``` + +Output: + +``` +[ 10 ] +``` + +## Interpretation + +The median price is the most range-centric of the three transforms — it is +blind to the close. Use it when the question is "where did this bar trade?" +rather than "where did it settle?", or as the input to a Bill Williams setup. + +## Common pitfalls + +- **Expecting the close to matter.** It does not — by definition the median + price ignores both the open and the close. + +## References + +The Median Price; the `(H + L) / 2` definition is standard (TA-Lib's +`MEDPRICE`). + +## See also + +- [Indicator-TypicalPrice.md](Indicator-TypicalPrice.md) — `(H + L + C) / 3`. +- [Indicator-WeightedClose.md](Indicator-WeightedClose.md) — `(H + L + 2C) / 4`. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/statistics/Indicator-TypicalPrice.md b/docs/wiki/indicators/statistics/Indicator-TypicalPrice.md new file mode 100644 index 00000000..2557b80d --- /dev/null +++ b/docs/wiki/indicators/statistics/Indicator-TypicalPrice.md @@ -0,0 +1,137 @@ +# TypicalPrice + +> Typical Price — the bar's `(high + low + close) / 3`, a single +> representative price per candle. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Statistics | +| Sub-category | Price transforms | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `f64` | +| Output range | unbounded (price scale) | +| Default parameters | none (no parameters) | +| Warmup period | `1` | +| Interpretation | A representative per-bar price; a smoother stand-in for the close. | + +## Formula + +``` +TypicalPrice = (high + low + close) / 3 +``` + +The typical price collapses a full OHLC bar to one number, giving the close +no more weight than the two extremes. It is the price series that +[`Cci`](../momentum/Indicator-Cci.md) and [`Mfi`](../momentum/Indicator-Mfi.md) +are defined on, and a common input to feed any close-driven indicator when you +want the bar's range reflected in the value. + +## Parameters + +`TypicalPrice` takes **no parameters** — `TypicalPrice::new()` in Rust, +`wickra.TypicalPrice()` in Python, `new ta.TypicalPrice()` in Node. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/typical_price.rs`: + +```rust +impl Indicator for TypicalPrice { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`TypicalPrice` is a **candle-input** indicator that reads `high`, `low` and +`close`. In Python the streaming `update` accepts a 6-tuple or a dict; the +batch helper takes `high`, `low`, `close` numpy arrays. Node and WASM expose +`update(high, low, close)` and the matching `batch`. + +## Warmup + +`TypicalPrice::new().warmup_period() == 1`. It is a stateless per-bar +transform — it emits a value from the very first candle. + +## Edge cases + +- **No warmup.** Every candle produces a value immediately. +- **Reset.** `tp.reset()` only clears the `is_ready` flag; there is no + rolling state to discard. + +## Examples + +### Rust + +```rust +use wickra::{Candle, Indicator, TypicalPrice}; + +fn main() -> Result<(), Box> { + let mut tp = TypicalPrice::new(); + let v = tp.update(Candle::new(9.0, 12.0, 6.0, 9.0, 1.0, 0)?); + println!("{:?}", v); + Ok(()) +} +``` + +Output: + +``` +Some(9.0) +``` + +`(12 + 6 + 9) / 3 = 9`. This matches the `reference_value` test in +`crates/wickra-core/src/indicators/typical_price.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +tp = ta.TypicalPrice() +print(tp.batch(np.array([12.0]), np.array([6.0]), np.array([9.0]))) +``` + +Output: + +``` +[9.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const tp = new ta.TypicalPrice(); +console.log(tp.batch([12], [6], [9])); +``` + +Output: + +``` +[ 9 ] +``` + +## Interpretation + +Use it wherever you would use the close but want the bar's range to count — +feeding a moving average, an oscillator, or a band. It is marginally smoother +than the raw close because a wild close is pulled back toward the bar's mid. + +## Common pitfalls + +- **Feeding it scalar prices.** It needs the full `high`/`low`/`close` bar. + +## References + +The Typical Price (also "pivot price"); the `(H + L + C) / 3` definition is +standard (StockCharts, TA-Lib's `TYPPRICE`). + +## See also + +- [Indicator-MedianPrice.md](Indicator-MedianPrice.md) — `(H + L) / 2`. +- [Indicator-WeightedClose.md](Indicator-WeightedClose.md) — `(H + L + 2C) / 4`. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/statistics/Indicator-WeightedClose.md b/docs/wiki/indicators/statistics/Indicator-WeightedClose.md new file mode 100644 index 00000000..7160e9d5 --- /dev/null +++ b/docs/wiki/indicators/statistics/Indicator-WeightedClose.md @@ -0,0 +1,137 @@ +# WeightedClose + +> Weighted Close — the bar's `(high + low + 2·close) / 4`, a per-bar price +> that gives the close double weight. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Statistics | +| Sub-category | Price transforms | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `f64` | +| Output range | unbounded (price scale) | +| Default parameters | none (no parameters) | +| Warmup period | `1` | +| Interpretation | A representative per-bar price that leans on the close. | + +## Formula + +``` +WeightedClose = (high + low + 2·close) / 4 +``` + +Like the [`TypicalPrice`](Indicator-TypicalPrice.md), the weighted close +collapses an OHLC bar to one number — but it counts the close twice, so the +result sits closer to where the bar settled than to its range. Reach for it +when the closing print carries more signal than the extremes. + +## Parameters + +`WeightedClose` takes **no parameters** — `WeightedClose::new()` in Rust, +`wickra.WeightedClose()` in Python, `new ta.WeightedClose()` in Node. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/weighted_close.rs`: + +```rust +impl Indicator for WeightedClose { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`WeightedClose` is a **candle-input** indicator that reads `high`, `low` and +`close`. In Python the streaming `update` accepts a 6-tuple or a dict; the +batch helper takes `high`, `low`, `close` numpy arrays. Node and WASM expose +`update(high, low, close)` and the matching `batch`. + +## Warmup + +`WeightedClose::new().warmup_period() == 1`. It is a stateless per-bar +transform — it emits a value from the very first candle. + +## Edge cases + +- **No warmup.** Every candle produces a value immediately. +- **Reset.** `wc.reset()` only clears the `is_ready` flag; there is no + rolling state to discard. + +## Examples + +### Rust + +```rust +use wickra::{Candle, Indicator, WeightedClose}; + +fn main() -> Result<(), Box> { + let mut wc = WeightedClose::new(); + let v = wc.update(Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0)?); + println!("{:?}", v); + Ok(()) +} +``` + +Output: + +``` +Some(10.5) +``` + +`(12 + 8 + 2·11) / 4 = 42 / 4 = 10.5`. This matches the `reference_value` +test in `crates/wickra-core/src/indicators/weighted_close.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +wc = ta.WeightedClose() +print(wc.batch(np.array([12.0]), np.array([8.0]), np.array([11.0]))) +``` + +Output: + +``` +[10.5] +``` + +### Node + +```javascript +const ta = require('wickra'); +const wc = new ta.WeightedClose(); +console.log(wc.batch([12], [8], [11])); +``` + +Output: + +``` +[ 10.5 ] +``` + +## Interpretation + +The weighted close sits on the spectrum between the raw close and the +[`TypicalPrice`](Indicator-TypicalPrice.md): closer to the close, but still +nudged by the bar's range. Use it as a drop-in close replacement when you want +the settlement to dominate without ignoring the extremes entirely. + +## Common pitfalls + +- **Feeding it scalar prices.** It needs the full `high`/`low`/`close` bar. + +## References + +The Weighted Close; the `(H + L + 2C) / 4` definition is standard (TA-Lib's +`WCLPRICE`). + +## See also + +- [Indicator-TypicalPrice.md](Indicator-TypicalPrice.md) — `(H + L + C) / 3`. +- [Indicator-MedianPrice.md](Indicator-MedianPrice.md) — `(H + L) / 2`. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.