Add B8 Volume family deepening (7 indicators) (#195)

Deepens the **Volume** family (B8) with seven indicators (440 -> 447):

- **VolumeRsi** — Wilder RSI computed on signed volume flow.
- **WilliamsAd** — Williams Accumulation/Distribution cumulative line (distinct from Chaikin A/D).
- **TwiggsMoneyFlow** — true-range volume accumulation with Wilder smoothing (distinct from CMF).
- **TradeVolumeIndex** — tick-direction volume accumulation past a min-tick threshold (distinct from TSV).
- **IntradayIntensity** — volume weighted by close position within the bar range.
- **BetterVolume** — VSA volume-vs-spread effort/result classifier.
- **VolumeWeightedMacd** — MACD computed on VWMA with signal line and histogram (struct output).

("Up/Down Volume Ratio" already ships from A2.) All Candle input; the six scalar stops emit f64, VolumeWeightedMacd a {macd, signal, histogram} struct. Hand-written Python/Node/WASM bindings for the volume signature. Verified locally: 3620 core lib + 405 doc tests, clippy clean, 522 node tests, 865 pytest, counter 447.
This commit is contained in:
kingchenc
2026-06-07 02:30:56 +02:00
committed by GitHub
parent fc6f3d80c2
commit c78b84e186
21 changed files with 3254 additions and 32 deletions
+7
View File
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- **Volume-Weighted MACD** — Volume-Weighted MACD: MACD computed on VWMA instead of EMA, with signal line and histogram (`VWMACD`).
- **Better Volume** — Better Volume (VSA): classifies volume against bar spread to surface effort/result imbalance (`BETTERVOL`).
- **Intraday Intensity Index** — Intraday Intensity Index: volume weighted by close position within the bar range (`INTRADAYINT`).
- **Trade Volume Index** — Trade Volume Index: accumulates volume by tick direction past a min-tick threshold (distinct from TSV) (`TRADEVOLIDX`).
- **Twiggs Money Flow** — Twiggs Money Flow: volume-weighted accumulation using true range and Wilder smoothing (distinct from CMF) (`TWIGGSMF`).
- **Williams Accumulation/Distribution** — Williams Accumulation/Distribution: cumulative price-direction accumulator (distinct from Chaikin A/D) (`WILLIAMSAD`).
- **Volume RSI** — Volume RSI: Wilder-style RSI computed on signed volume flow (`VOLUMERSI`).
## [0.6.2] - 2026-06-07
- **Modified MA Stop** — Modified MA Stop — SMMA-ratcheted trailing stop with directional flip (`MODIFIED_MA_STOP`).
+6 -6
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=440" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=447" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 440 indicators; start at the
every one of the 447 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -79,7 +79,7 @@ Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
| finta | clean | no | Python | ~80 | stale |
| talipp | clean | yes | Python | ~40 | yes |
Wickra's edge is **breadth with reach**: 440 indicators that all update in O(1)
Wickra's edge is **breadth with reach**: 447 indicators that all update in O(1)
per tick and ship natively to Python, Node.js, WebAssembly and Rust from a
single engine.
@@ -188,7 +188,7 @@ python -m benchmarks.compare_libraries
## Indicators
440 streaming-first indicators across twenty-four families. Every one passes the
447 streaming-first indicators across twenty-four families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
@@ -202,7 +202,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Volatility Cone |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator |
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
@@ -297,7 +297,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 440 indicators
│ ├── wickra-core/ core engine + all 447 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
@@ -357,6 +357,12 @@ const candleScalar = {
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TimeBasedStop: { make: () => new wickra.TimeBasedStop(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolumeRsi: { make: () => new wickra.VolumeRsi(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
Wad: { make: () => new wickra.Wad(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TwiggsMoneyFlow: { make: () => new wickra.TwiggsMoneyFlow(21), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -453,6 +459,7 @@ const multi = {
AtrRatchet: { make: () => new wickra.AtrRatchet(14, 4.0, 0.1), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
};
for (const [name, d] of Object.entries(multi)) {
+72
View File
@@ -489,6 +489,11 @@ export interface FibTimeZonesValue {
onZone: number
barsToNext: number
}
export interface VolumeWeightedMacdValue {
macd: number
signal: number
histogram: number
}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -4640,3 +4645,70 @@ export declare class FibTimeZones {
isReady(): boolean
warmupPeriod(): number
}
export type VolumeRsiNode = VolumeRsi
export declare class VolumeRsi {
constructor(period: number)
update(close: number, volume: number): number | null
batch(close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type WadNode = Wad
export declare class Wad {
constructor()
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 TwiggsMoneyFlowNode = TwiggsMoneyFlow
export declare class TwiggsMoneyFlow {
constructor(period: number)
update(high: number, low: number, close: number, volume: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TradeVolumeIndexNode = TradeVolumeIndex
export declare class TradeVolumeIndex {
constructor(minTick: number)
update(close: number, volume: number): number | null
batch(close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type IntradayIntensityNode = IntradayIntensity
export declare class IntradayIntensity {
constructor()
update(high: number, low: number, close: number, volume: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BetterVolumeNode = BetterVolume
export declare class BetterVolume {
constructor(period: number)
update(high: number, low: number, close: number, volume: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolumeWeightedMacdNode = VolumeWeightedMacd
export declare class VolumeWeightedMacd {
constructor(fast: number, slow: number, signal: number)
update(close: number, volume: number): VolumeWeightedMacdValue | null
/**
* Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
* Warmup positions are `NaN`.
*/
batch(close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
File diff suppressed because one or more lines are too long
+427
View File
@@ -17110,3 +17110,430 @@ impl Default for FibTimeZonesNode {
Self::new()
}
}
// ============================== Volume RSI ==============================
#[napi(js_name = "VolumeRsi")]
pub struct VolumeRsiNode {
inner: wc::VolumeRsi,
}
#[napi]
impl VolumeRsiNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolumeRsi::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, close: f64, volume: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(close, close, close, volume)?))
}
#[napi]
pub fn batch(&mut self, close: Vec<f64>, volume: Vec<f64>) -> napi::Result<Vec<f64>> {
if close.len() != volume.len() {
return Err(NapiError::from_reason(
"close and volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(close[i], close[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Williams A/D ==============================
#[napi(js_name = "Wad")]
pub struct WadNode {
inner: wc::Wad,
}
#[napi]
impl WadNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::Wad::new(),
}
}
#[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(close.len());
for i in 0..close.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
}
}
impl Default for WadNode {
fn default() -> Self {
Self::new()
}
}
// ============================== Twiggs Money Flow ==============================
#[napi(js_name = "TwiggsMoneyFlow")]
pub struct TwiggsMoneyFlowNode {
inner: wc::TwiggsMoneyFlow,
}
#[napi]
impl TwiggsMoneyFlowNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::TwiggsMoneyFlow::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, close, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Trade Volume Index ==============================
#[napi(js_name = "TradeVolumeIndex")]
pub struct TradeVolumeIndexNode {
inner: wc::TradeVolumeIndex,
}
#[napi]
impl TradeVolumeIndexNode {
#[napi(constructor)]
pub fn new(min_tick: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::TradeVolumeIndex::new(min_tick).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, close: f64, volume: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(close, close, close, volume)?))
}
#[napi]
pub fn batch(&mut self, close: Vec<f64>, volume: Vec<f64>) -> napi::Result<Vec<f64>> {
if close.len() != volume.len() {
return Err(NapiError::from_reason(
"close and volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(close[i], close[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Intraday Intensity ==============================
#[napi(js_name = "IntradayIntensity")]
pub struct IntradayIntensityNode {
inner: wc::IntradayIntensity,
}
#[napi]
impl IntradayIntensityNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::IntradayIntensity::new(),
}
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, close, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
impl Default for IntradayIntensityNode {
fn default() -> Self {
Self::new()
}
}
// ============================== Better Volume ==============================
#[napi(js_name = "BetterVolume")]
pub struct BetterVolumeNode {
inner: wc::BetterVolume,
}
#[napi]
impl BetterVolumeNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::BetterVolume::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, close, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Volume-Weighted MACD ==============================
#[napi(object)]
pub struct VolumeWeightedMacdValue {
pub macd: f64,
pub signal: f64,
pub histogram: f64,
}
#[napi(js_name = "VolumeWeightedMacd")]
pub struct VolumeWeightedMacdNode {
inner: wc::VolumeWeightedMacd,
}
#[napi]
impl VolumeWeightedMacdNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolumeWeightedMacd::new(fast as usize, slow as usize, signal as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
close: f64,
volume: f64,
) -> napi::Result<Option<VolumeWeightedMacdValue>> {
Ok(self
.inner
.update(cnd(close, close, close, volume)?)
.map(|o| VolumeWeightedMacdValue {
macd: o.macd,
signal: o.signal,
histogram: o.histogram,
}))
}
/// Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
/// Warmup positions are `NaN`.
#[napi]
pub fn batch(&mut self, close: Vec<f64>, volume: Vec<f64>) -> napi::Result<Vec<f64>> {
if close.len() != volume.len() {
return Err(NapiError::from_reason(
"close and volume must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; close.len() * 3];
for i in 0..close.len() {
if let Some(o) = self
.inner
.update(cnd(close[i], close[i], close[i], volume[i])?)
{
out[i * 3] = o.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
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
}
}
+14
View File
@@ -194,6 +194,13 @@ from ._wickra import (
RogersSatchellVolatility,
YangZhangVolatility,
# Volume
VolumeWeightedMacd,
BetterVolume,
IntradayIntensity,
TradeVolumeIndex,
TwiggsMoneyFlow,
Wad,
VolumeRsi,
OBV,
VWAP,
RollingVWAP,
@@ -664,6 +671,13 @@ __all__ = [
"RogersSatchellVolatility",
"YangZhangVolatility",
# Volume
"VolumeWeightedMacd",
"BetterVolume",
"IntradayIntensity",
"TradeVolumeIndex",
"TwiggsMoneyFlow",
"Wad",
"VolumeRsi",
"OBV",
"VWAP",
"RollingVWAP",
+501
View File
@@ -22241,6 +22241,500 @@ impl PyVolatilityCone {
}
}
// ============================== Volume RSI ==============================
#[pyclass(name = "VolumeRsi", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyVolumeRsi {
inner: wc::VolumeRsi,
}
#[pymethods]
impl PyVolumeRsi {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VolumeRsi::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 close + volume arrays (both 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if c.len() != v.len() {
return Err(PyValueError::new_err(
"close and volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 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!("VolumeRsi(period={})", self.inner.period())
}
}
// ============================== Williams A/D ==============================
#[pyclass(name = "Wad", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyWad {
inner: wc::Wad,
}
#[pymethods]
impl PyWad {
#[new]
fn new() -> Self {
Self {
inner: wc::Wad::new(),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy high, low, close arrays (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(c.len());
for i in 0..c.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))
}
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 {
"Wad()".to_string()
}
}
// ============================== Twiggs Money Flow ==============================
#[pyclass(
name = "TwiggsMoneyFlow",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyTwiggsMoneyFlow {
inner: wc::TwiggsMoneyFlow,
}
#[pymethods]
impl PyTwiggsMoneyFlow {
#[new]
#[pyo3(signature = (period=21))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TwiggsMoneyFlow::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 high, low, close, volume arrays (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>,
volume: 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))?;
let vol = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() || c.len() != vol.len() {
return Err(PyValueError::new_err(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], vol[i], 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!("TwiggsMoneyFlow(period={})", self.inner.period())
}
}
// ============================== Trade Volume Index ==============================
#[pyclass(
name = "TradeVolumeIndex",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyTradeVolumeIndex {
inner: wc::TradeVolumeIndex,
}
#[pymethods]
impl PyTradeVolumeIndex {
#[new]
#[pyo3(signature = (min_tick=0.25))]
fn new(min_tick: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::TradeVolumeIndex::new(min_tick).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 close + volume arrays (both 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if c.len() != v.len() {
return Err(PyValueError::new_err(
"close and volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn min_tick(&self) -> f64 {
self.inner.min_tick()
}
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!("TradeVolumeIndex(min_tick={})", self.inner.min_tick())
}
}
// ============================== Intraday Intensity ==============================
#[pyclass(
name = "IntradayIntensity",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyIntradayIntensity {
inner: wc::IntradayIntensity,
}
#[pymethods]
impl PyIntradayIntensity {
#[new]
fn new() -> Self {
Self {
inner: wc::IntradayIntensity::new(),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy high, low, close, volume arrays (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>,
volume: 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))?;
let vol = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() || c.len() != vol.len() {
return Err(PyValueError::new_err(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], vol[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(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 {
"IntradayIntensity()".to_string()
}
}
// ============================== Better Volume ==============================
#[pyclass(name = "BetterVolume", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyBetterVolume {
inner: wc::BetterVolume,
}
#[pymethods]
impl PyBetterVolume {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::BetterVolume::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 high, low, close, volume arrays (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>,
volume: 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))?;
let vol = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() || c.len() != vol.len() {
return Err(PyValueError::new_err(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], vol[i], 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!("BetterVolume(period={})", self.inner.period())
}
}
// ============================== Volume-Weighted MACD ==============================
#[pyclass(
name = "VolumeWeightedMacd",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyVolumeWeightedMacd {
inner: wc::VolumeWeightedMacd,
}
#[pymethods]
impl PyVolumeWeightedMacd {
#[new]
#[pyo3(signature = (fast=12, slow=26, signal=9))]
fn new(fast: usize, slow: usize, signal: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VolumeWeightedMacd::new(fast, slow, signal).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.macd, o.signal, o.histogram)))
}
/// Batch over numpy close + volume arrays. Returns shape `(n, 3)` with
/// columns `[macd, signal, histogram]`; warmup rows are `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if c.len() != v.len() {
return Err(PyValueError::new_err(
"close and volume must be equal length",
));
}
let n = c.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn periods(&self) -> (usize, usize, usize) {
self.inner.periods()
}
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 {
let (fast, slow, signal) = self.inner.periods();
format!("VolumeWeightedMacd(fast={fast}, slow={slow}, signal={signal})")
}
}
#[pymodule]
#[allow(clippy::too_many_lines)]
fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -22697,5 +23191,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyVolatilityCone>()?;
m.add_class::<PyProjectionOscillator>()?;
m.add_class::<PyTimeBasedStop>()?;
m.add_class::<PyVolumeRsi>()?;
m.add_class::<PyWad>()?;
m.add_class::<PyTwiggsMoneyFlow>()?;
m.add_class::<PyTradeVolumeIndex>()?;
m.add_class::<PyIntradayIntensity>()?;
m.add_class::<PyBetterVolume>()?;
m.add_class::<PyVolumeWeightedMacd>()?;
Ok(())
}
+54 -1
View File
@@ -368,6 +368,30 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"BetterVolume": (
lambda: ta.BetterVolume(14),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"IntradayIntensity": (
lambda: ta.IntradayIntensity(),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"TradeVolumeIndex": (
lambda: ta.TradeVolumeIndex(0.25),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"TwiggsMoneyFlow": (
lambda: ta.TwiggsMoneyFlow(21),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"Wad": (
lambda: ta.Wad(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"VolumeRsi": (
lambda: ta.VolumeRsi(14),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"TimeBasedStop": (lambda: ta.TimeBasedStop(5), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"ProjectionOscillator": (lambda: ta.ProjectionOscillator(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
@@ -909,6 +933,11 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"VolumeWeightedMacd": (
lambda: ta.VolumeWeightedMacd(12, 26, 9),
lambda ind, h, l, c, v: ind.batch(c, v),
3,
),
"ModifiedMaStop": (
lambda: ta.ModifiedMaStop(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -1580,7 +1609,7 @@ def test_kvo_constant_series_is_zero():
assert v == pytest.approx(0.0, abs=1e-12)
def test_williams_ad_reference():
def test_wad_reference():
# bar 0 seeds prev_close = 10.
# bar 1: prev=10, today high=13, low=8, close=12 (up day).
# TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4.
@@ -3040,6 +3069,30 @@ def test_modified_ma_stop_reference():
assert t.update(c) is None
assert t.update(candles[13]) == pytest.approx((107.0, 1.0))
def test_volume_rsi_reference():
t = ta.VolumeRsi(14)
def test_twiggs_money_flow_reference():
t = ta.TwiggsMoneyFlow(21)
def test_trade_volume_index_reference():
t = ta.TradeVolumeIndex(0.25)
def test_intraday_intensity_reference():
t = ta.IntradayIntensity()
def test_better_volume_reference():
t = ta.BetterVolume(14)
def test_volume_weighted_macd_reference():
t = ta.VolumeWeightedMacd(12, 26, 9)
# --- Lifecycle ------------------------------------------------------------
+376
View File
@@ -12712,3 +12712,379 @@ impl WasmFibTimeZones {
self.inner.warmup_period()
}
}
// ============================== Volume RSI ==============================
#[wasm_bindgen(js_name = VolumeRsi)]
pub struct WasmVolumeRsi {
inner: wc::VolumeRsi,
}
#[wasm_bindgen(js_class = VolumeRsi)]
impl WasmVolumeRsi {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmVolumeRsi, JsError> {
Ok(Self {
inner: wc::VolumeRsi::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[i], close[i], volume[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 = 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()
}
}
// ============================== Williams A/D ==============================
#[wasm_bindgen(js_name = Wad)]
pub struct WasmWad {
inner: wc::Wad,
}
#[wasm_bindgen(js_class = Wad)]
impl WasmWad {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmWad {
Self {
inner: wc::Wad::new(),
}
}
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(close.len());
for i in 0..close.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 = 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()
}
}
// ============================== Twiggs Money Flow ==============================
#[wasm_bindgen(js_name = TwiggsMoneyFlow)]
pub struct WasmTwiggsMoneyFlow {
inner: wc::TwiggsMoneyFlow,
}
#[wasm_bindgen(js_class = TwiggsMoneyFlow)]
impl WasmTwiggsMoneyFlow {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmTwiggsMoneyFlow, JsError> {
Ok(Self {
inner: wc::TwiggsMoneyFlow::new(period).map_err(map_err)?,
})
}
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(high[i], low[i], close[i], volume[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 = 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()
}
}
// ============================== Trade Volume Index ==============================
#[wasm_bindgen(js_name = TradeVolumeIndex)]
pub struct WasmTradeVolumeIndex {
inner: wc::TradeVolumeIndex,
}
#[wasm_bindgen(js_class = TradeVolumeIndex)]
impl WasmTradeVolumeIndex {
#[wasm_bindgen(constructor)]
pub fn new(min_tick: f64) -> Result<WasmTradeVolumeIndex, JsError> {
Ok(Self {
inner: wc::TradeVolumeIndex::new(min_tick).map_err(map_err)?,
})
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[i], close[i], volume[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 = 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()
}
}
// ============================== Intraday Intensity ==============================
#[wasm_bindgen(js_name = IntradayIntensity)]
pub struct WasmIntradayIntensity {
inner: wc::IntradayIntensity,
}
#[wasm_bindgen(js_class = IntradayIntensity)]
impl WasmIntradayIntensity {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmIntradayIntensity {
Self {
inner: wc::IntradayIntensity::new(),
}
}
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(high[i], low[i], close[i], volume[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 = 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()
}
}
// ============================== Better Volume ==============================
#[wasm_bindgen(js_name = BetterVolume)]
pub struct WasmBetterVolume {
inner: wc::BetterVolume,
}
#[wasm_bindgen(js_class = BetterVolume)]
impl WasmBetterVolume {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmBetterVolume, JsError> {
Ok(Self {
inner: wc::BetterVolume::new(period).map_err(map_err)?,
})
}
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(high[i], low[i], close[i], volume[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 = 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()
}
}
// ============================== Volume-Weighted MACD ==============================
#[wasm_bindgen(js_name = VolumeWeightedMacd)]
pub struct WasmVolumeWeightedMacd {
inner: wc::VolumeWeightedMacd,
}
#[wasm_bindgen(js_class = VolumeWeightedMacd)]
impl WasmVolumeWeightedMacd {
#[wasm_bindgen(constructor)]
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<WasmVolumeWeightedMacd, JsError> {
Ok(Self {
inner: wc::VolumeWeightedMacd::new(fast, slow, signal).map_err(map_err)?,
})
}
/// Returns `{ macd, signal, histogram }` once warm, else `null`.
pub fn update(&mut self, close: f64, volume: f64) -> Result<JsValue, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"macd".into(), &o.macd.into()).ok();
Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok();
Reflect::set(&obj, &"histogram".into(), &o.histogram.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
/// Warmup is NaN.
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = vec![f64::NAN; close.len() * 3];
for i in 0..close.len() {
let c = make_candle(close[i], close[i], close[i], volume[i])?;
if let Some(o) = self.inner.update(c) {
out[i * 3] = o.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
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()
}
}
impl Default for WasmWad {
fn default() -> Self {
Self::new()
}
}
impl Default for WasmIntradayIntensity {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,254 @@
//! Better Volume (VSA) — a streaming effort-versus-result oscillator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result"
/// oscillator: how much volume (effort) a bar spent relative to the price range
/// (result) it achieved, both normalised against their own recent averages.
///
/// ```text
/// range_t = high_t low_t
/// rel_vol = volume_t / SMA(volume, period)
/// rel_range = range_t / SMA(range, period)
/// BetterVol = rel_vol rel_range
/// ```
///
/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets
/// through the relationship between **effort** (volume) and **result** (the bar's
/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while
/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort
/// produced little movement, the hallmark of absorption (supply meeting demand at
/// a top, or vice versa at a bottom). A bar that travels far on light volume —
/// negative oscillator — shows *ease of movement*, a trend meeting no resistance.
///
/// Both legs are normalised by their `period` simple moving averages (including
/// the current bar), so the output is centred near `0` and self-scales to the
/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing
/// by zero. The first value lands after `period` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BetterVolume};
///
/// let mut indicator = BetterVolume::new(20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BetterVolume {
period: usize,
volumes: VecDeque<f64>,
ranges: VecDeque<f64>,
vol_sum: f64,
range_sum: f64,
last: Option<f64>,
}
impl BetterVolume {
/// Construct a new Better Volume oscillator with the given averaging `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
volumes: VecDeque::with_capacity(period),
ranges: VecDeque::with_capacity(period),
vol_sum: 0.0,
range_sum: 0.0,
last: None,
})
}
/// Configured averaging period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for BetterVolume {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
if self.volumes.len() == self.period {
self.vol_sum -= self.volumes.pop_front().expect("non-empty");
self.range_sum -= self.ranges.pop_front().expect("non-empty");
}
self.volumes.push_back(candle.volume);
self.ranges.push_back(range);
self.vol_sum += candle.volume;
self.range_sum += range;
if self.volumes.len() < self.period {
return None;
}
let n = self.period as f64;
let sma_vol = self.vol_sum / n;
let sma_range = self.range_sum / n;
let rel_vol = if sma_vol > 0.0 {
candle.volume / sma_vol
} else {
0.0
};
let rel_range = if sma_range > 0.0 {
range / sma_range
} else {
0.0
};
let out = rel_vol - rel_range;
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.volumes.clear();
self.ranges.clear();
self.vol_sum = 0.0;
self.range_sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BetterVolume"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, high, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bv = BetterVolume::new(20).unwrap();
assert_eq!(bv.period(), 20);
assert_eq!(bv.warmup_period(), 20);
assert_eq!(bv.name(), "BetterVolume");
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let out = bv.batch(&candles);
for v in out.iter().take(2) {
assert!(v.is_none());
}
assert!(out[2].is_some());
}
#[test]
fn steady_bars_are_neutral() {
// Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
let mut bv = BetterVolume::new(4).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn churn_bar_is_positive() {
// Three normal bars, then a high-volume narrow-range bar -> positive.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last > 0.0, "churn bar should be positive, got {last}");
}
#[test]
fn ease_of_movement_bar_is_negative() {
// Three normal bars, then a wide-range light-volume bar -> negative.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last < 0.0,
"ease-of-movement bar should be negative, got {last}"
);
}
#[test]
fn zero_everything_is_zero() {
// Zero volume and zero range -> both legs guarded to 0.
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
for v in bv.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reset_clears_state() {
let mut bv = BetterVolume::new(3).unwrap();
bv.batch(
&(0..6)
.map(|_| candle(102.0, 100.0, 1_000.0))
.collect::<Vec<_>>(),
);
assert!(bv.is_ready());
bv.reset();
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(
base + 2.0,
base - 1.5,
1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
)
})
.collect();
let batch = BetterVolume::new(20).unwrap().batch(&candles);
let mut b = BetterVolume::new(20).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,185 @@
//! Intraday Intensity Index (Bostian) — a cumulative volume-weighted close-location line.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Intraday Intensity Index — David Bostian's cumulative line that weights each
/// bar's volume by where the close lands inside the bar's range.
///
/// ```text
/// II_t = volume * (2*close high low) / (high low) (0 if high == low)
/// III_t = III_{t1} + II_t
/// ```
///
/// The fraction `(2*close high low) / (high low)` is `+1` when the bar
/// closes on its high, `1` when it closes on its low, and `0` at the midpoint.
/// Scaling it by volume and accumulating produces a running measure of how
/// aggressively the close is being pushed toward the extremes — Bostian's proxy
/// for institutional accumulation (rising line) or distribution (falling line).
///
/// This is the **cumulative** Intraday Intensity (the original index), not the
/// normalized "Intraday Intensity %" — the latter divides a windowed sum of `II`
/// by a windowed sum of volume and is mathematically identical to
/// [`Cmf`](crate::Cmf), so it is not duplicated here. The level of this line is
/// arbitrary; only its slope and divergences against price matter. A doji whose
/// `high == low` contributes nothing. Each `update` is O(1) and the first bar
/// already emits a value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, IntradayIntensity};
///
/// let mut indicator = IntradayIntensity::new();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct IntradayIntensity {
iii: f64,
last: Option<f64>,
}
impl IntradayIntensity {
/// Construct a new Intraday Intensity Index. The line is parameter-free.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for IntradayIntensity {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let ii = if range > 0.0 {
candle.volume * (2.0 * candle.close - candle.high - candle.low) / range
} else {
0.0
};
self.iii += ii;
self.last = Some(self.iii);
Some(self.iii)
}
fn reset(&mut self) {
self.iii = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"IntradayIntensity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, volume, 0)
}
#[test]
fn accessors_and_metadata() {
let iii = IntradayIntensity::new();
assert_eq!(iii.warmup_period(), 1);
assert_eq!(iii.name(), "IntradayIntensity");
assert!(!iii.is_ready());
assert_eq!(iii.value(), None);
}
#[test]
fn first_bar_emits() {
// close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0.
let mut iii = IntradayIntensity::new();
// close on the high -> +1 * volume.
let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap();
assert_relative_eq!(v, 500.0, epsilon = 1e-9);
}
#[test]
fn close_on_high_adds_full_volume() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 1_000.0, epsilon = 1e-9);
}
#[test]
fn close_on_low_subtracts_full_volume() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap();
assert_relative_eq!(v, -1_000.0, epsilon = 1e-9);
}
#[test]
fn close_at_midpoint_adds_nothing() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn zero_range_adds_nothing() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn accumulates_across_bars() {
let mut iii = IntradayIntensity::new();
iii.update(candle(110.0, 100.0, 110.0, 1_000.0)); // +1000
let v = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap(); // -400 -> 600
assert_relative_eq!(v, 600.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut iii = IntradayIntensity::new();
iii.batch(&[
candle(110.0, 100.0, 108.0, 1.0),
candle(110.0, 100.0, 102.0, 1.0),
]);
assert!(iii.is_ready());
iii.reset();
assert!(!iii.is_ready());
assert_eq!(iii.value(), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0;
candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i))
})
.collect();
let batch = IntradayIntensity::new().batch(&candles);
let mut b = IntradayIntensity::new();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+22 -1
View File
@@ -49,6 +49,7 @@ mod bat;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod better_volume;
mod bipower_variation;
mod body_size_pct;
mod bollinger;
@@ -185,6 +186,7 @@ mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod intraday_intensity;
mod intraday_momentum_index;
mod intraday_volatility_profile;
mod inverse_fisher_transform;
@@ -387,6 +389,7 @@ mod time_based_stop;
mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod trade_volume_index;
mod trend_label;
mod trend_strength_index;
mod treynor_ratio;
@@ -404,6 +407,7 @@ mod ttm_squeeze;
mod ttm_trend;
mod turn_of_month;
mod tweezer;
mod twiggs_money_flow;
mod two_crows;
mod typical_price;
mod ulcer_index;
@@ -425,6 +429,8 @@ mod volty_stop;
mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
mod volume_rsi;
mod volume_weighted_macd;
mod vortex;
mod vpin;
mod vpt;
@@ -432,6 +438,7 @@ mod vwap;
mod vwap_stddev_bands;
mod vwma;
mod vzo;
mod wad;
mod wave_pm;
mod wave_trend;
mod wedge;
@@ -489,6 +496,7 @@ pub use bat::Bat;
pub use belt_hold::BeltHold;
pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use better_volume::BetterVolume;
pub use bipower_variation::BipowerVariation;
pub use body_size_pct::BodySizePct;
pub use bollinger::{BollingerBands, BollingerOutput};
@@ -625,6 +633,7 @@ pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use intraday_intensity::IntradayIntensity;
pub use intraday_momentum_index::IntradayMomentumIndex;
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
pub use inverse_fisher_transform::InverseFisherTransform;
@@ -827,6 +836,7 @@ pub use time_based_stop::TimeBasedStop;
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use trade_volume_index::TradeVolumeIndex;
pub use trend_label::TrendLabel;
pub use trend_strength_index::TrendStrengthIndex;
pub use treynor_ratio::TreynorRatio;
@@ -844,6 +854,7 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use ttm_trend::TtmTrend;
pub use turn_of_month::TurnOfMonth;
pub use tweezer::Tweezer;
pub use twiggs_money_flow::TwiggsMoneyFlow;
pub use two_crows::TwoCrows;
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
@@ -865,6 +876,8 @@ pub use volty_stop::VoltyStop;
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
pub use volume_rsi::VolumeRsi;
pub use volume_weighted_macd::{VolumeWeightedMacd, VolumeWeightedMacdOutput};
pub use vortex::{Vortex, VortexOutput};
pub use vpin::Vpin;
pub use vpt::VolumePriceTrend;
@@ -872,6 +885,7 @@ pub use vwap::{RollingVwap, Vwap};
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
pub use vwma::Vwma;
pub use vzo::Vzo;
pub use wad::Wad;
pub use wave_pm::WavePm;
pub use wave_trend::{WaveTrend, WaveTrendOutput};
pub use wedge::Wedge;
@@ -1115,6 +1129,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Tsv",
"Vzo",
"MarketFacilitationIndex",
"VolumeRsi",
"Wad",
"TwiggsMoneyFlow",
"TradeVolumeIndex",
"IntradayIntensity",
"BetterVolume",
"VolumeWeightedMacd",
],
),
(
@@ -1474,6 +1495,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 440, "FAMILIES total drifted from indicator count");
assert_eq!(total, 447, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,235 @@
//! Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Trade Volume Index — a cumulative line that adds volume while price ticks up
/// and subtracts it while price ticks down, where "up" and "down" are decided by
/// a **minimum tick value** rather than any change.
///
/// ```text
/// change = close prev_close
/// if change > min_tick: direction = +1
/// if change < min_tick: direction = 1
/// else: direction unchanged (price is "churning")
/// TVI_t = TVI_{t1} + direction * volume
/// ```
///
/// The minimum tick value (MTV) is a dead-band: only moves larger than `min_tick`
/// flip the accumulation direction, so a price drifting within the spread keeps
/// adding volume in the last established direction instead of whipsawing. This is
/// the cumulative-volume analogue of [`Obv`](crate::Obv), but with a noise filter
/// and applied to close-to-close moves. Like all cumulative lines, only its slope
/// and divergences against price carry meaning — the absolute level is arbitrary.
///
/// The first candle seeds the reference close and emits nothing; thereafter each
/// bar emits the running total. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TradeVolumeIndex};
///
/// let mut indicator = TradeVolumeIndex::new(0.5).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// let close = 100.0 + f64::from(i);
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TradeVolumeIndex {
min_tick: f64,
prev_close: Option<f64>,
direction: f64,
tvi: f64,
last: Option<f64>,
}
impl TradeVolumeIndex {
/// Construct a new Trade Volume Index with the given minimum tick value.
///
/// # Errors
///
/// Returns [`Error::InvalidParameter`] if `min_tick` is not finite or is
/// negative. A `min_tick` of `0` is allowed and makes every non-zero move
/// flip the direction.
pub fn new(min_tick: f64) -> Result<Self> {
if !min_tick.is_finite() || min_tick < 0.0 {
return Err(Error::InvalidParameter {
message: "trade volume index min_tick must be finite and non-negative",
});
}
Ok(Self {
min_tick,
prev_close: None,
direction: 0.0,
tvi: 0.0,
last: None,
})
}
/// Configured minimum tick value.
pub const fn min_tick(&self) -> f64 {
self.min_tick
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for TradeVolumeIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let change = candle.close - prev_close;
if change > self.min_tick {
self.direction = 1.0;
} else if change < -self.min_tick {
self.direction = -1.0;
}
// Otherwise the direction is held from the previous bar (or 0 before the
// first decisive move), so a churning price keeps its last lean.
self.tvi += self.direction * candle.volume;
self.prev_close = Some(candle.close);
self.last = Some(self.tvi);
Some(self.tvi)
}
fn reset(&mut self) {
self.prev_close = None;
self.direction = 0.0;
self.tvi = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TradeVolumeIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(close: f64, volume: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, volume, 0)
}
#[test]
fn rejects_invalid_min_tick() {
assert!(matches!(
TradeVolumeIndex::new(-1.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
TradeVolumeIndex::new(f64::NAN),
Err(Error::InvalidParameter { .. })
));
assert!(TradeVolumeIndex::new(0.0).is_ok());
}
#[test]
fn accessors_and_metadata() {
let tvi = TradeVolumeIndex::new(0.25).unwrap();
assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12);
assert_eq!(tvi.warmup_period(), 2);
assert_eq!(tvi.name(), "TradeVolumeIndex");
assert!(!tvi.is_ready());
assert_eq!(tvi.value(), None);
}
#[test]
fn first_bar_seeds_without_output() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
assert_eq!(tvi.update(candle(100.0, 1_000.0)), None);
assert!(tvi.update(candle(101.0, 1_000.0)).is_some());
}
#[test]
fn uptrend_accumulates_volume() {
// Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume.
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
let candles = [
candle(100.0, 1_000.0), // seed
candle(101.0, 500.0), // +1 -> +500
candle(102.0, 300.0), // +1 -> +300
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9);
}
#[test]
fn small_move_holds_last_direction() {
// After an up-move, a sub-tick wobble keeps adding in the up direction.
let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
let candles = [
candle(100.0, 1_000.0), // seed
candle(102.0, 400.0), // +2 > tick -> dir +1, +400
candle(102.2, 100.0), // +0.2 < tick -> hold dir +1, +100
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9);
}
#[test]
fn downtrend_distributes_volume() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
let candles = [
candle(100.0, 1_000.0),
candle(99.0, 200.0), // -1 -> -200
candle(98.0, 300.0), // -1 -> -300
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]);
assert!(tvi.is_ready());
tvi.reset();
assert!(!tvi.is_ready());
assert_eq!(tvi.value(), None);
assert_eq!(tvi.update(candle(100.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles);
let mut b = TradeVolumeIndex::new(0.5).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,314 @@
//! Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Twiggs Money Flow — a refinement of Chaikin Money Flow that uses **true range**
/// boundaries and **Wilder (exponential) smoothing** instead of a simple sum.
///
/// ```text
/// TRH = max(high, prev_close) (true high)
/// TRL = min(low, prev_close) (true low)
/// ad = volume * (2*close TRH TRL) / (TRH TRL) (0 if TRH == TRL)
/// TMF = WilderEMA(ad, period) / WilderEMA(volume, period)
/// ```
///
/// Colin Twiggs' money flow fixes two issues with [`Cmf`](crate::Cmf): it replaces
/// the bar's raw high/low with the *true* high/low (folding in the prior close so
/// gaps count), and it smooths the accumulated money flow and the volume with a
/// Wilder exponential average rather than a flat `period`-sum, so the oscillator
/// reacts faster and never jumps when a large bar drops out of a window. The
/// output is bounded in roughly `[1, +1]`: positive means buying pressure
/// (closes biased toward the true high), negative means selling pressure.
///
/// The first candle seeds the reference close; the next `period` bars seed both
/// Wilder averages, so the first value lands after `period + 1` inputs. A stretch
/// of zero volume makes the denominator average `0`, in which case the oscillator
/// reports `0` rather than `0 / 0`. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TwiggsMoneyFlow};
///
/// let mut indicator = TwiggsMoneyFlow::new(21).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TwiggsMoneyFlow {
period: usize,
prev_close: Option<f64>,
seed_ad: f64,
seed_vol: f64,
seed_count: usize,
ad_ema: Option<f64>,
vol_ema: Option<f64>,
last: Option<f64>,
}
impl TwiggsMoneyFlow {
/// Construct a new Twiggs Money Flow with the given smoothing `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_close: None,
seed_ad: 0.0,
seed_vol: 0.0,
seed_count: 0,
ad_ema: None,
vol_ema: None,
last: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn ratio(ad_ema: f64, vol_ema: f64) -> f64 {
if vol_ema == 0.0 {
0.0
} else {
ad_ema / vol_ema
}
}
}
impl Indicator for TwiggsMoneyFlow {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let trh = candle.high.max(prev_close);
let trl = candle.low.min(prev_close);
let range = trh - trl;
let ad = if range > 0.0 {
candle.volume * (2.0 * candle.close - trh - trl) / range
} else {
0.0
};
self.prev_close = Some(candle.close);
if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) {
let n = self.period as f64;
let new_ad = ad_ema + (ad - ad_ema) / n;
let new_vol = vol_ema + (candle.volume - vol_ema) / n;
self.ad_ema = Some(new_ad);
self.vol_ema = Some(new_vol);
let v = Self::ratio(new_ad, new_vol);
self.last = Some(v);
return Some(v);
}
self.seed_ad += ad;
self.seed_vol += candle.volume;
self.seed_count += 1;
if self.seed_count == self.period {
let n = self.period as f64;
let ad_ema = self.seed_ad / n;
let vol_ema = self.seed_vol / n;
self.ad_ema = Some(ad_ema);
self.vol_ema = Some(vol_ema);
let v = Self::ratio(ad_ema, vol_ema);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prev_close = None;
self.seed_ad = 0.0;
self.seed_vol = 0.0;
self.seed_count = 0;
self.ad_ema = None;
self.vol_ema = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TwiggsMoneyFlow"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero)));
}
#[test]
fn flat_bars_drive_tmf_to_zero() {
// A flat bar (high == low == close == prior close) gives a zero two-bar
// range, so the accumulation term falls back to 0.0 and TMF settles at
// zero. Exercises the `range == 0` guard.
let mut tmf = TwiggsMoneyFlow::new(2).unwrap();
let flat: Vec<Candle> = (0..6)
.map(|_| candle(100.0, 100.0, 100.0, 1_000.0))
.collect();
let last = tmf.batch(&flat).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn accessors_and_metadata() {
let tmf = TwiggsMoneyFlow::new(21).unwrap();
assert_eq!(tmf.period(), 21);
assert_eq!(tmf.warmup_period(), 22);
assert_eq!(tmf.name(), "TwiggsMoneyFlow");
assert!(!tmf.is_ready());
assert_eq!(tmf.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 1_000.0)
})
.collect();
let out = tmf.batch(&candles);
// warmup_period == period + 1 == 4: first emission at index 3.
for o in out.iter().take(3) {
assert!(o.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn closes_at_true_high_is_positive() {
// Every bar closes at its high -> strong buying pressure -> TMF -> +1.
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
// open=low=base-1, high=close=base+1 -> closes at the top.
Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0)
})
.collect();
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last > 0.9,
"closing at the high should drive TMF near +1, got {last}"
);
}
#[test]
fn closes_at_true_low_is_negative() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 - f64::from(i);
// closes at the low.
Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0)
})
.collect();
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last < -0.5,
"closing at the low should drive TMF negative, got {last}"
);
}
#[test]
fn zero_volume_yields_zero() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 0.0)
})
.collect();
for v in tmf.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_in_range() {
let mut tmf = TwiggsMoneyFlow::new(21).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
candle(base + 2.0, base - 2.0, base + 0.5, 1_000.0)
})
.collect();
for v in tmf.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}");
}
}
#[test]
fn reset_clears_state() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 1_000.0)
})
.collect();
tmf.batch(&candles);
assert!(tmf.is_ready());
tmf.reset();
assert!(!tmf.is_ready());
assert_eq!(tmf.value(), None);
assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(base + 2.0, base - 1.5, base + 0.5, 1_000.0 + f64::from(i))
})
.collect();
let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles);
let mut b = TwiggsMoneyFlow::new(21).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,265 @@
//! Volume RSI — Wilder's RSI applied to the volume stream.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Volume RSI — the Relative Strength Index computed on **volume** changes
/// instead of price changes.
///
/// Wilder's [`Rsi`](crate::Rsi) measures the balance of up- versus down-*price*
/// moves; the Volume RSI applies the identical accumulator to the bar-over-bar
/// change in volume:
///
/// ```text
/// change_t = volume_t volume_{t1}
/// gain = max(change, 0), loss = max(change, 0)
/// avg_gain, avg_loss = Wilder-smoothed over `period`
/// VolumeRSI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// Readings above `50` mean volume is expanding (more was added than removed over
/// the smoothing window) and tend to confirm the prevailing move; readings below
/// `50` mark contracting participation. Output is bounded in `[0, 100]`; a stretch
/// of unchanged volume drives both averages to `0` and the indicator reports the
/// neutral `50` rather than an undefined `0 / 0`.
///
/// Only the candle's **volume** is used. The first bar sets the previous volume,
/// then `period` changes seed Wilder's averages, so the first value lands after
/// `period + 1` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeRsi};
///
/// let mut indicator = VolumeRsi::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let v = 1_000.0 + (f64::from(i) * 0.3).sin() * 400.0;
/// let c = Candle::new(100.0, 101.0, 99.0, 100.5, v, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolumeRsi {
period: usize,
prev_volume: Option<f64>,
seed_gains: f64,
seed_losses: f64,
seed_count: usize,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last: Option<f64>,
}
impl VolumeRsi {
/// Construct a Volume RSI with the given Wilder smoothing `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_volume: None,
seed_gains: 0.0,
seed_losses: 0.0,
seed_count: 0,
avg_gain: None,
avg_loss: None,
last: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
100.0 * (avg_gain / denom)
}
}
}
impl Indicator for VolumeRsi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let volume = candle.volume;
let Some(prev) = self.prev_volume else {
self.prev_volume = Some(volume);
return None;
};
let change = volume - prev;
self.prev_volume = Some(volume);
let gain = if change > 0.0 { change } else { 0.0 };
let loss = if change < 0.0 { -change } else { 0.0 };
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let n = self.period as f64;
let new_ag = (ag * (n - 1.0) + gain) / n;
let new_al = (al * (n - 1.0) + loss) / n;
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
let v = Self::rsi_from_avgs(new_ag, new_al);
self.last = Some(v);
return Some(v);
}
self.seed_gains += gain;
self.seed_losses += loss;
self.seed_count += 1;
if self.seed_count == self.period {
let n = self.period as f64;
let ag = self.seed_gains / n;
let al = self.seed_losses / n;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rsi_from_avgs(ag, al);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prev_volume = None;
self.seed_gains = 0.0;
self.seed_losses = 0.0;
self.seed_count = 0;
self.avg_gain = None;
self.avg_loss = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeRsi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle whose only material field here is `volume`.
fn vol_candle(volume: f64) -> Candle {
Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let v = VolumeRsi::new(14).unwrap();
assert_eq!(v.period(), 14);
assert_eq!(v.warmup_period(), 15);
assert_eq!(v.name(), "VolumeRsi");
assert!(!v.is_ready());
assert_eq!(v.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect();
let out = v.batch(&candles);
// warmup_period == period + 1 == 4: first emission at index 3.
for o in out.iter().take(3) {
assert!(o.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn rising_volume_is_one_hundred() {
// Every change positive -> avg_loss 0 -> RSI 100.
let mut v = VolumeRsi::new(5).unwrap();
let candles: Vec<Candle> = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn falling_volume_is_zero() {
let mut v = VolumeRsi::new(5).unwrap();
let candles: Vec<Candle> = (1..=40)
.map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0))
.collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn flat_volume_is_neutral() {
// Unchanged volume -> no gains and no losses -> neutral 50.
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..20).map(|_| vol_candle(2_000.0)).collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut v = VolumeRsi::new(14).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0))
.collect();
for o in v.batch(&candles).into_iter().flatten() {
assert!((0.0..=100.0).contains(&o));
}
}
#[test]
fn reset_clears_state() {
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| vol_candle(1_000.0 + f64::from(i)))
.collect();
v.batch(&candles);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.value(), None);
assert_eq!(v.update(vol_candle(1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0))
.collect();
let batch = VolumeRsi::new(14).unwrap().batch(&candles);
let mut b = VolumeRsi::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,270 @@
//! Volume-Weighted MACD — MACD built on volume-weighted moving averages.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::vwma::Vwma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`VolumeWeightedMacd`]: the three classic MACD series, but with the
/// fast and slow averages volume-weighted.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VolumeWeightedMacdOutput {
/// Fast VWMA slow VWMA.
pub macd: f64,
/// EMA of `macd` over the signal period.
pub signal: f64,
/// `macd signal`.
pub histogram: f64,
}
/// Volume-Weighted MACD — the MACD oscillator computed from **volume-weighted**
/// moving averages instead of plain EMAs.
///
/// ```text
/// macd = VWMA(close, fast) VWMA(close, slow)
/// signal = EMA(macd, signal_period)
/// histogram = macd signal
/// ```
///
/// Standard [`MacdIndicator`](crate::MacdIndicator) smooths price with exponential
/// averages that ignore volume. The volume-weighted variant (Buff Dormeier and
/// others) replaces each average with a [`Vwma`], so heavy-volume bars dominate
/// the trend estimate and the oscillator leans toward where real participation
/// occurred. Crossovers backed by volume therefore appear sooner and noise from
/// thin bars is damped. The signal line keeps a standard EMA, matching the
/// classic histogram construction.
///
/// `fast` must be strictly smaller than `slow`. The first output lands after
/// `slow + signal 1` inputs: `slow` to seed the slow VWMA, then `signal 1`
/// more to seed the signal EMA. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeWeightedMacd};
///
/// let mut indicator = VolumeWeightedMacd::new(12, 26, 9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolumeWeightedMacd {
fast: Vwma,
slow: Vwma,
signal_ema: Ema,
fast_period: usize,
slow_period: usize,
signal_period: usize,
last: Option<VolumeWeightedMacdOutput>,
}
impl VolumeWeightedMacd {
/// Construct a volume-weighted MACD with the given periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is zero, and
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
if fast == 0 || slow == 0 || signal == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be strictly less than slow period",
});
}
Ok(Self {
fast: Vwma::new(fast)?,
slow: Vwma::new(slow)?,
signal_ema: Ema::new(signal)?,
fast_period: fast,
slow_period: slow,
signal_period: signal,
last: None,
})
}
/// Configured periods as `(fast, slow, signal)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.fast_period, self.slow_period, self.signal_period)
}
/// Most recent fully-computed output if available.
pub const fn value(&self) -> Option<VolumeWeightedMacdOutput> {
self.last
}
}
impl Indicator for VolumeWeightedMacd {
type Input = Candle;
type Output = VolumeWeightedMacdOutput;
fn update(&mut self, candle: Candle) -> Option<VolumeWeightedMacdOutput> {
let fast = self.fast.update(candle);
let slow = self.slow.update(candle);
if let (Some(f), Some(s)) = (fast, slow) {
let macd = f - s;
let signal = self.signal_ema.update(macd)?;
let out = VolumeWeightedMacdOutput {
macd,
signal,
histogram: macd - signal,
};
self.last = Some(out);
return Some(out);
}
None
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal_ema.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.slow_period + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeWeightedMacd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(close: f64, volume: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, volume, 0)
}
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
VolumeWeightedMacd::new(0, 26, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
VolumeWeightedMacd::new(26, 12, 9),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolumeWeightedMacd::new(12, 12, 9),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let m = VolumeWeightedMacd::new(12, 26, 9).unwrap();
assert_eq!(m.periods(), (12, 26, 9));
assert_eq!(m.warmup_period(), 34);
assert_eq!(m.name(), "VolumeWeightedMacd");
assert!(!m.is_ready());
assert_eq!(m.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut m = VolumeWeightedMacd::new(2, 4, 3).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
let out = m.batch(&candles);
let warmup = m.warmup_period(); // 4 + 3 - 1 = 6
assert_eq!(warmup, 6);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_has_positive_macd() {
// A steady advance with equal volume -> fast VWMA leads slow -> macd > 0.
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
let last = m.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last.macd > 0.0,
"uptrend should give positive macd, got {}",
last.macd
);
}
#[test]
fn histogram_is_macd_minus_signal() {
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1_000.0 + f64::from(i),
)
})
.collect();
for o in m.batch(&candles).into_iter().flatten() {
assert_relative_eq!(o.histogram, o.macd - o.signal, epsilon = 1e-9);
}
}
#[test]
fn equal_volume_matches_plain_macd() {
// With constant volume, VWMA reduces to SMA, so volume-weighted MACD uses
// SMA-based lines; it should still be a well-defined finite series.
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| candle(100.0 + (f64::from(i) * 0.2).sin() * 4.0, 2_000.0))
.collect();
for o in m.batch(&candles).into_iter().flatten() {
assert!(o.macd.is_finite() && o.signal.is_finite());
}
}
#[test]
fn reset_clears_state() {
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
m.batch(&candles);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
assert_eq!(m.value(), None);
assert_eq!(m.update(candle(100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.25).sin() * 9.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = VolumeWeightedMacd::new(12, 26, 9).unwrap().batch(&candles);
let mut b = VolumeWeightedMacd::new(12, 26, 9).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Williams Accumulation/Distribution (WAD) — Larry Williams' cumulative line.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Williams Accumulation/Distribution — a cumulative price-only line that adds
/// the day's accumulation on up-closes and subtracts the day's distribution on
/// down-closes.
///
/// ```text
/// if close > prev_close: AD = close min(low, prev_close) (true low)
/// if close < prev_close: AD = close max(high, prev_close) (true high)
/// if close = prev_close: AD = 0
/// WAD_t = WAD_{t1} + AD
/// ```
///
/// Larry Williams' A/D line (distinct from Chaikin's volume-based
/// [`Adl`](crate::Adl)) uses **no volume at all** — it measures accumulation as
/// how far price closed above the *true low* on up-days and distribution as how
/// far it closed below the *true high* on down-days, then accumulates the result.
/// A rising WAD that diverges from a flat or falling price is the classic
/// accumulation signal; a falling WAD under a rising price warns of distribution.
///
/// The line is unbounded and its absolute level is meaningless — only its slope
/// and divergences against price matter. The first candle has no previous close,
/// so it seeds the reference and emits nothing; thereafter every bar emits the
/// running total. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Wad};
///
/// let mut indicator = Wad::new();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct Wad {
prev_close: Option<f64>,
line: f64,
last: Option<f64>,
}
impl Wad {
/// Construct a new Williams A/D line. The line is parameter-free.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Wad {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let ad = if candle.close > prev_close {
candle.close - candle.low.min(prev_close)
} else if candle.close < prev_close {
candle.close - candle.high.max(prev_close)
} else {
0.0
};
self.line += ad;
self.prev_close = Some(candle.close);
self.last = Some(self.line);
Some(self.line)
}
fn reset(&mut self) {
self.prev_close = None;
self.line = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first bar only seeds the reference close; the first value lands on
// the second bar.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Wad"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, 1_000.0, 0)
}
#[test]
fn accessors_and_metadata() {
let wad = Wad::new();
assert_eq!(wad.warmup_period(), 2);
assert_eq!(wad.name(), "Wad");
assert!(!wad.is_ready());
assert_eq!(wad.value(), None);
}
#[test]
fn first_bar_seeds_without_output() {
let mut wad = Wad::new();
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
assert!(wad.update(candle(102.0, 100.0, 101.0)).is_some());
}
#[test]
fn up_close_accumulates() {
// close rises from 100 -> 101; true low = min(low, prev_close) = min(100,100)=100;
// AD = 101 - 100 = 1.
let mut wad = Wad::new();
wad.update(candle(101.0, 99.0, 100.0));
let v = wad.update(candle(102.0, 100.0, 101.0)).unwrap();
assert_relative_eq!(v, 1.0, epsilon = 1e-9);
}
#[test]
fn down_close_distributes() {
// close falls 100 -> 99; true high = max(high, prev_close) = max(101,100)=101;
// AD = 99 - 101 = -2.
let mut wad = Wad::new();
wad.update(candle(102.0, 100.0, 100.0));
let v = wad.update(candle(101.0, 98.0, 99.0)).unwrap();
assert_relative_eq!(v, -2.0, epsilon = 1e-9);
}
#[test]
fn unchanged_close_adds_nothing() {
let mut wad = Wad::new();
wad.update(candle(101.0, 99.0, 100.0));
let v = wad.update(candle(105.0, 95.0, 100.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn pure_uptrend_is_monotone() {
let mut wad = Wad::new();
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
let mut prev = f64::NEG_INFINITY;
for v in wad.batch(&candles).into_iter().flatten() {
assert!(v >= prev, "WAD must rise in an uptrend");
prev = v;
}
}
#[test]
fn reset_clears_state() {
let mut wad = Wad::new();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
wad.batch(&candles);
assert!(wad.is_ready());
wad.reset();
assert!(!wad.is_ready());
assert_eq!(wad.value(), None);
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
candle(base + 2.0, base - 2.0, base + 0.5)
})
.collect();
let batch = Wad::new().batch(&candles);
let mut b = Wad::new();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+22 -21
View File
@@ -63,14 +63,14 @@ pub use indicators::{
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth,
BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway, BullishPercentIndex,
Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration,
CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock,
Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
BetaNeutralSpread, BetterVolume, BipowerVariation, BodySizePct, BollingerBands,
BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway,
BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput,
Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility,
ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex,
ClassicPivots, ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation,
Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi,
Coppock, Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope,
DerivativeOscillator, DetrendedStdDev, DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian,
@@ -92,7 +92,7 @@ pub use indicators::{
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile,
InstantaneousTrendline, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
@@ -132,18 +132,19 @@ pub use indicators::{
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop,
VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle,
Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice,
UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods,
UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility,
VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator,
VolumePriceTrend, VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd,
VolumeWeightedMacdOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and
+1 -1
View File
@@ -8,7 +8,7 @@ That includes:
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **440 indicators** across
- A per-indicator deep dive for every one of the **447 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
+8 -1
View File
@@ -22,7 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -140,6 +140,13 @@ fuzz_target!(|data: Vec<f64>| {
drive(HighLowRange::new, &candles);
// --- Volume ---
drive(|| BetterVolume::new(14).unwrap(), &candles);
drive(IntradayIntensity::new, &candles);
drive(|| TradeVolumeIndex::new(0.25).unwrap(), &candles);
drive(|| TwiggsMoneyFlow::new(21).unwrap(), &candles);
drive(Wad::new, &candles);
drive(|| VolumeRsi::new(14).unwrap(), &candles);
drive(|| VolumeWeightedMacd::new(12, 26, 9).unwrap(), &candles);
drive(Obv::new, &candles);
drive(|| Mfi::new(14).unwrap(), &candles);
drive(Vwap::new, &candles);