feat: add 19 indicators for external feature-extractor coverage (377 -> 396) (#175)

Adds 19 streaming indicators so an external trading-bot feature extractor can replace its hand-built features with native, batch/streaming-equivalent ones. Each is a real gap (verified against the existing catalogue), production-only, with full Python/Node/WASM bindings, fuzz drivers, and tests. Five commits, one per family group; counter 377 -> 396.

## What's added

**Price Statistics (6)** — `LogReturn`, `RealizedVolatility` (raw quadratic variation, the un-annualised counterpart to `HistoricalVolatility`), `RollingQuantile`, `RollingIqr`, `RollingPercentileRank`, `SpreadAr1Coefficient` (pairwise AR(1) rho of the spread; complements `OuHalfLife`).

**Price Action (4)** — `CloseVsOpen`, `BodySizePct`, `WickRatio`, `HighLowRange` (stateless per-bar OHLC transforms).

**Regime / Trend / Jump labels (3)** — `TrendLabel` (sign of the rolling OLS slope), `JumpIndicator` (return outliers vs trailing volatility, measured as deviation from the trailing mean so steady drift is not flagged), `RegimeLabel` (volatility-quantile regime split).

**Risk / Performance (2)** — `WinRate`, `Expectancy` (R-multiple).

**Microstructure (4)** — `OrderFlowImbalance` (Cont-Kukanov-Stoikov OFI), `Vpin`, `AmihudIlliquidity`, `RollMeasure`. These reuse the existing `OrderBook` / `Trade` inputs (no new input type).

## Intentionally NOT added (already present, would be duplicates)

- **Population skew / kurtosis** — `skewness.rs` / `kurtosis.rs` are already population moments (divisor n).
- **Hurst R/S** — `hurst_exponent.rs` already uses rescaled-range (R/S) analysis.
- **Queue Imbalance** — exactly `OrderBookImbalanceTop1` ((bidSize - askSize) / (bidSize + askSize)).

## Verification

`cargo test -p wickra-core` (lib 3187 + doc 354), `cargo clippy --workspace --all-targets --all-features -D warnings` clean, node `npm run build && npm test` (471), python `pytest` (784). Counter consistent across `mod.rs`, lib block, README, and docs/README at 396.
This commit is contained in:
kingchenc
2026-06-04 12:00:35 +02:00
committed by GitHub
parent a93af60796
commit fcb221ec03
37 changed files with 6697 additions and 84 deletions
+346
View File
@@ -526,6 +526,11 @@ wasm_pair_indicator!(
);
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
wasm_pair_indicator!(WasmPairwiseBeta, "PairwiseBeta", wc::PairwiseBeta);
wasm_pair_indicator!(
WasmSpreadAr1Coefficient,
"SpreadAr1Coefficient",
wc::SpreadAr1Coefficient
);
wasm_pair_indicator!(
WasmSpearmanCorrelation,
"SpearmanCorrelation",
@@ -1841,6 +1846,210 @@ impl WasmHtPhasor {
}
}
#[wasm_bindgen(js_name = CloseVsOpen)]
pub struct WasmCloseVsOpen {
inner: wc::CloseVsOpen,
}
impl Default for WasmCloseVsOpen {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = CloseVsOpen)]
impl WasmCloseVsOpen {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmCloseVsOpen {
Self {
inner: wc::CloseVsOpen::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
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 = BodySizePct)]
pub struct WasmBodySizePct {
inner: wc::BodySizePct,
}
impl Default for WasmBodySizePct {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = BodySizePct)]
impl WasmBodySizePct {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmBodySizePct {
Self {
inner: wc::BodySizePct::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
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 = WickRatio)]
pub struct WasmWickRatio {
inner: wc::WickRatio,
}
impl Default for WasmWickRatio {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = WickRatio)]
impl WasmWickRatio {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmWickRatio {
Self {
inner: wc::WickRatio::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
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 = HighLowRange)]
pub struct WasmHighLowRange {
inner: wc::HighLowRange,
}
impl Default for WasmHighLowRange {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = HighLowRange)]
impl WasmHighLowRange {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmHighLowRange {
Self {
inner: wc::HighLowRange::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
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,
@@ -7596,6 +7805,133 @@ impl WasmTradeImbalance {
}
}
// Order Flow Imbalance: order-book input with a `period` parameter.
#[wasm_bindgen(js_name = OrderFlowImbalance)]
pub struct WasmOrderFlowImbalance {
inner: wc::OrderFlowImbalance,
}
#[wasm_bindgen(js_class = OrderFlowImbalance)]
impl WasmOrderFlowImbalance {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmOrderFlowImbalance, JsError> {
Ok(Self {
inner: wc::OrderFlowImbalance::new(period).map_err(map_err)?,
})
}
pub fn update(
&mut self,
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> Result<Option<f64>, JsError> {
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
Ok(self.inner.update(book))
}
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()
}
}
// VPIN: trade input, volume-bucketed `(bucket_volume, num_buckets)`.
#[wasm_bindgen(js_name = Vpin)]
pub struct WasmVpin {
inner: wc::Vpin,
}
#[wasm_bindgen(js_class = Vpin)]
impl WasmVpin {
#[wasm_bindgen(constructor)]
pub fn new(bucket_volume: f64, num_buckets: usize) -> Result<WasmVpin, JsError> {
Ok(Self {
inner: wc::Vpin::new(bucket_volume, num_buckets).map_err(map_err)?,
})
}
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<Option<f64>, JsError> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
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()
}
}
// Amihud Illiquidity: trade input with a `period` parameter.
#[wasm_bindgen(js_name = AmihudIlliquidity)]
pub struct WasmAmihudIlliquidity {
inner: wc::AmihudIlliquidity,
}
#[wasm_bindgen(js_class = AmihudIlliquidity)]
impl WasmAmihudIlliquidity {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmAmihudIlliquidity, JsError> {
Ok(Self {
inner: wc::AmihudIlliquidity::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<Option<f64>, JsError> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
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()
}
}
// Roll Measure: trade input with a `period` parameter.
#[wasm_bindgen(js_name = RollMeasure)]
pub struct WasmRollMeasure {
inner: wc::RollMeasure,
}
#[wasm_bindgen(js_class = RollMeasure)]
impl WasmRollMeasure {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmRollMeasure, JsError> {
Ok(Self {
inner: wc::RollMeasure::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<Option<f64>, JsError> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
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()
}
}
// ============================== Microstructure: Price Impact ==============================
//
// Price-impact indicators consume a trade paired with the mid prevailing at
@@ -9867,6 +10203,16 @@ wasm_scalar_indicator!(WasmRocr, "ROCR", wc::Rocr, period: usize);
wasm_scalar_indicator!(WasmRocr100, "ROCR100", wc::Rocr100, period: usize);
wasm_scalar_indicator!(WasmLinRegIntercept, "LINEARREG_INTERCEPT", wc::LinRegIntercept, period: usize);
wasm_scalar_indicator!(WasmTsf, "TSF", wc::Tsf, period: usize);
wasm_scalar_indicator!(WasmLogReturn, "LogReturn", wc::LogReturn, period: usize);
wasm_scalar_indicator!(WasmRealizedVolatility, "RealizedVolatility", wc::RealizedVolatility, period: usize);
wasm_scalar_indicator!(WasmRollingIqr, "RollingIqr", wc::RollingIqr, period: usize);
wasm_scalar_indicator!(WasmRollingPercentileRank, "RollingPercentileRank", wc::RollingPercentileRank, period: usize);
wasm_scalar_indicator!(WasmRollingQuantile, "RollingQuantile", wc::RollingQuantile, period: usize, quantile: f64);
wasm_scalar_indicator!(WasmTrendLabel, "TrendLabel", wc::TrendLabel, period: usize);
wasm_scalar_indicator!(WasmJumpIndicator, "JumpIndicator", wc::JumpIndicator, period: usize, threshold: f64);
wasm_scalar_indicator!(WasmRegimeLabel, "RegimeLabel", wc::RegimeLabel, vol_period: usize, lookback: usize);
wasm_scalar_indicator!(WasmWinRate, "WinRate", wc::WinRate, period: usize);
wasm_scalar_indicator!(WasmExpectancy, "Expectancy", wc::Expectancy, period: usize);
// --- DrawdownDuration: u32 output, no constructor args ---