fix: de-duplicate four indicators by correcting their definitions (#300)

* fix(core): de-duplicate 3 indicators by correcting their definitions

Behavioral audit found these computed identically to another indicator:

- AverageDrawdown was the mean per-bar under-water fraction = PainIndex.
  Now the conventional average drawdown: mean of the maximum depths of the
  distinct drawdown episodes in the window.
- IntradayIntensity was a cumulative line = the A/D Line (Adl); its normalized
  form is the Chaikin Money Flow (Cmf). Now the raw per-bar Bostian intensity
  volume*(2c-h-l)/(h-l), distinct from both.
- AwesomeOscillatorHistogram was AO - SMA(AO, n) = AcceleratorOscillator. Now
  the AO momentum AO[t] - AO[t-lookback] (the histogram delta); the 3rd
  parameter is reinterpreted from sma_period to lookback (default 1).

Constructor signatures are unchanged, so the bindings keep their API. Core
unit tests rewritten with the new reference values; workspace tests + clippy
green. Binding value-tests and deep-dive docs are updated separately.

* fix(core): redefine AdOscillator as the A/D Oscillator (was a Wad duplicate)

AdOscillator computed the cumulative volume-free Williams A/D line, identical
to the Wad indicator. Redefine it as the Williams A/D *Oscillator*: the same
line minus its 13-bar SMA, so it oscillates around zero (mean-reverting) while
Wad stays the drifting cumulative line for divergence analysis. The canonical
name AdOscillator is now accurate; the trait name() becomes "ADOSC".

Constructor stays no-arg (internal 13-bar signal). Unit tests rewritten and
cross-checked against Wad - SMA(Wad, 13). The native bindings' "WilliamsAD"
alias is renamed to "ADOSC" separately.

* fix(bindings): rename WilliamsAD alias to ADOSC and update value tests

Follows the core de-duplication: the native bindings exposed the Williams A/D
line as 'WilliamsAD', which is now the A/D Oscillator. Rename the Python /
Node.js / WASM alias to 'ADOSC' (regenerated node index.js / index.d.ts) and
update the binding value-tests for the four redefined indicators
(AverageDrawdown episode mean, AwesomeOscillatorHistogram momentum warmup,
the Wad-line reference test now uses ta.Wad()). Python suite and node suite
both pass (pytest all green, node 584/584).

* docs: record indicator de-duplication in README and CHANGELOG

README volume family: 'Williams A/D' -> 'Williams A/D Oscillator', 'Intraday
Intensity Index' -> 'Intraday Intensity'. CHANGELOG [Unreleased] documents the
four redefinitions and the native WilliamsAD -> ADOSC rename as breaking.

* test(core): cover Default impl and drop dead match arm

Codecov flagged AdOscillator::default() (never exercised) and the unreachable
_ => panic!() arm in the AwesomeOscillatorHistogram test. Exercise Default in
the accessors test and rewrite the histogram check as an if-let, removing the
dead arm.
This commit is contained in:
kingchenc
2026-06-15 03:41:15 +02:00
committed by GitHub
parent a3950bf31b
commit 82d7479011
15 changed files with 320 additions and 208 deletions
+18
View File
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **Breaking — de-duplicated four indicators that computed identically to another
one.** Each is now its own distinct, correctly-defined indicator (the catalogue
stays at the same count):
- `AverageDrawdown` now reports the mean of the maximum depths of the distinct
drawdown episodes in the window (previously the per-bar mean under-water
fraction, which equalled `PainIndex`).
- `IntradayIntensity` now reports the raw per-bar Bostian intensity
`volume * (2*close high low) / (high low)` (previously a cumulative line
that equalled the A/D Line `Adl`; its normalized form is `Cmf`).
- `AwesomeOscillatorHistogram` now reports the AO momentum
`AO[t] AO[tlookback]`; its third parameter is the momentum `lookback`
(default 1) instead of an SMA period (the old `AO SMA(AO, n)` equalled
`AcceleratorOscillator`).
- `AdOscillator` is now the Williams **A/D Oscillator** (`WAD SMA(WAD, 13)`),
distinct from the cumulative Williams A/D line `Wad`. Its native (Python /
Node.js / WASM) alias is renamed **`WilliamsAD``ADOSC`**.
## [0.9.1] - 2026-06-14
### Added
+1 -1
View File
@@ -153,7 +153,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 RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
| 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 Oscillator, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity, 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, Jarque-Bera, Rolling Min-Max Scaler, Shannon Entropy, Sample Entropy, Kendall Tau |
| 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, Highpass Filter, Reflex, Trendflex, Correlation Trend Indicator, Adaptive RSI, Universal Oscillator, Adaptive CCI, Bandpass Filter, Even Better Sinewave, Autocorrelation Periodogram |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag, Central Pivot Range, Murrey Math Lines, Andrews Pitchfork, Volume-Weighted Support/Resistance, Pivot Reversal |
+3 -3
View File
@@ -259,7 +259,7 @@ const candleScalar = {
VolumeOscillator: { make: () => new wickra.VolumeOscillator(14, 28), step: (ind, i) => ind.update(volume[i]), batch: (ind) => ind.batch(volume) },
NVI: { make: () => new wickra.NVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
PVI: { make: () => new wickra.PVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
WilliamsAD: { make: () => new wickra.WilliamsAD(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ADOSC: { make: () => new wickra.ADOSC(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
AnchoredVWAP: { make: () => new wickra.AnchoredVWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
DemandIndex: { make: () => new wickra.DemandIndex(10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
TSV: { make: () => new wickra.TSV(18), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
@@ -1009,8 +1009,8 @@ test('AwesomeOscillatorHistogram on a flat median converges to zero', () => {
Array(n).fill(11),
Array(n).fill(9),
);
// warmup = 5 + 3 - 1 = 7.
for (let i = 6; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
// AO momentum; warmup = slow + lookback = 5 + 3 = 8.
for (let i = 7; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
});
test('STC on a flat series stays at zero', () => {
+2 -2
View File
@@ -2505,8 +2505,8 @@ export declare class KVO {
isReady(): boolean
warmupPeriod(): number
}
export type AdOscillatorNode = WilliamsAD
export declare class WilliamsAD {
export type AdOscillatorNode = ADOSC
export declare class ADOSC {
constructor()
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5926,7 +5926,7 @@ impl KvoNode {
// ============================== Williams A/D ==============================
#[napi(js_name = "WilliamsAD")]
#[napi(js_name = "ADOSC")]
pub struct AdOscillatorNode {
inner: wc::AdOscillator,
}
+2 -2
View File
@@ -236,7 +236,7 @@ from ._wickra import (
VolumeOscillator,
NVI,
PVI,
WilliamsAD,
ADOSC,
AnchoredVWAP,
DemandIndex,
TSV,
@@ -780,7 +780,7 @@ __all__ = [
"VolumeOscillator",
"NVI",
"PVI",
"WilliamsAD",
"ADOSC",
"AnchoredVWAP",
"DemandIndex",
"TSV",
+2 -2
View File
@@ -9450,7 +9450,7 @@ impl PyKvo {
// ============================== Williams A/D Oscillator ==============================
#[pyclass(name = "WilliamsAD", module = "wickra._wickra", skip_from_py_object)]
#[pyclass(name = "ADOSC", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyAdOscillator {
inner: wc::AdOscillator,
@@ -9507,7 +9507,7 @@ impl PyAdOscillator {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
"WilliamsAD()".to_string()
"ADOSC()".to_string()
}
}
+6 -6
View File
@@ -258,9 +258,9 @@ def test_awesome_oscillator_histogram_flat_series_converges_to_zero():
n = 50
high = np.full(n, 11.0)
low = np.full(n, 9.0)
out = ta.AwesomeOscillatorHistogram(3, 5, 3).batch(high, low)
# warmup = slow + sma - 1 = 5 + 3 - 1 = 7.
np.testing.assert_allclose(out[6:], 0.0, atol=1e-12)
out = ta.AwesomeOscillatorHistogram(3, 5, 3).batch(high, low) # AO momentum
# warmup = slow + lookback = 5 + 3 = 8.
np.testing.assert_allclose(out[7:], 0.0, atol=1e-12)
def test_stc_constant_series_yields_zero():
@@ -521,10 +521,10 @@ def test_calmar_ratio_known_path():
def test_average_drawdown_known_window():
# window [100, 120, 90, 110]: dd = 0, 0, 0.25, 10/120;
# mean = (0.25 + 10/120) / 4.
# window [100, 120, 90, 110]: one drawdown episode (peak 120, trough 90),
# never recovering -> depth (120-90)/120 = 0.25; one episode -> AvgDD = 0.25.
out = ta.AverageDrawdown(4).batch(np.array([100.0, 120.0, 90.0, 110.0]))
expected = (0.25 + 10.0 / 120.0) / 4.0
expected = 0.25
assert math.isclose(out[-1], expected, rel_tol=1e-12)
+3 -3
View File
@@ -634,8 +634,8 @@ CANDLE_SCALAR = {
lambda: ta.PVI(),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"WilliamsAD": (
lambda: ta.WilliamsAD(),
"ADOSC": (
lambda: ta.ADOSC(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"AnchoredVWAP": (
@@ -1762,7 +1762,7 @@ def test_wad_reference():
# TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4.
# bar 2: prev=12, today high=11, low=7, close=7 (down day).
# TR_h = max(12, 11) = 12 -> delta = 7 - 12 = -5. AD = 4 - 5 = -1.
ad = ta.WilliamsAD()
ad = ta.Wad()
high = np.array([11.0, 13.0, 11.0])
low = np.array([9.0, 8.0, 7.0])
close = np.array([10.0, 12.0, 7.0])
+2 -2
View File
@@ -3145,12 +3145,12 @@ impl WasmKvo {
}
}
#[wasm_bindgen(js_name = WilliamsAD)]
#[wasm_bindgen(js_name = ADOSC)]
pub struct WasmAdOscillator {
inner: wc::AdOscillator,
}
#[wasm_bindgen(js_class = WilliamsAD)]
#[wasm_bindgen(js_class = ADOSC)]
impl WasmAdOscillator {
#[wasm_bindgen(constructor)]
#[allow(clippy::new_without_default)]
+125 -102
View File
@@ -1,28 +1,35 @@
//! Williams Accumulation/Distribution.
//! Williams A/D Oscillator (ADOSC).
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Larry Williams' Accumulation/Distribution — a cumulative volume-less price
/// flow that classifies each bar as accumulation or distribution based on its
/// close relative to the previous close, then sums the directional component.
/// Smoothing window applied to the Williams A/D line to form the oscillator.
const SIGNAL_PERIOD: usize = 13;
/// Williams **A/D Oscillator** — the volume-free Williams Accumulation/
/// Distribution line measured against its own moving average, so it oscillates
/// around zero instead of drifting like the cumulative line.
///
/// Williams' definition (1972) uses a *true* high/low that includes the prior
/// close as an anchor — the same idea that motivates true range:
/// The underlying line is Larry Williams' volume-less A/D (1972), which uses a
/// *true* high/low anchored on the prior close; the oscillator subtracts its
/// 13-bar simple moving average:
///
/// ```text
/// TR_h_t = max(close_{t1}, high_t)
/// TR_l_t = min(close_{t1}, low_t)
/// AD_t = AD_{t1} + (close_t TR_l_t) if close_t > close_{t1} (accumulation)
/// AD_t = AD_{t1} + (close_t TR_h_t) if close_t < close_{t1} (distribution)
/// AD_t = AD_{t1} if close_t == close_{t1} (no change)
/// WAD_t = WAD_{t1} + (close_t TR_l_t) if close_t > close_{t1}
/// WAD_t = WAD_{t1} + (close_t TR_h_t) if close_t < close_{t1}
/// WAD_t = WAD_{t1} if close_t == close_{t1}
/// ADOSC_t = WAD_t SMA(WAD, 13)_t
/// ```
///
/// Unlike Chaikin's Accumulation/Distribution Line, the Williams A/D ignores
/// volume entirely — Williams argued that the relative position of the close
/// already encodes the day's "true" buying or selling pressure. The series is
/// unbounded and used primarily for divergence analysis. The first candle only
/// seeds the previous close; the first emission lands at bar 2.
/// This is distinct from the raw cumulative line, which Wickra ships as
/// [`Wad`](crate::Wad): `Wad` is the drifting line for divergence analysis,
/// while this oscillator is its zero-centred, mean-reverting form (positive
/// when accumulation is running ahead of its recent average, negative when
/// distribution is). The first bar only seeds the previous close; the first
/// oscillator value lands once the 13-bar average of the line is full.
///
/// # Example
///
@@ -39,30 +46,35 @@ use crate::traits::Indicator;
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
pub struct AdOscillator {
prev_close: Option<f64>,
total: f64,
has_emitted: bool,
line: f64,
signal: Sma,
last: Option<f64>,
}
impl Default for AdOscillator {
fn default() -> Self {
Self::new()
}
}
impl AdOscillator {
/// Construct a new Williams A/D starting at zero.
pub const fn new() -> Self {
/// Construct a new Williams A/D Oscillator.
#[must_use]
pub fn new() -> Self {
Self {
prev_close: None,
total: 0.0,
has_emitted: false,
line: 0.0,
signal: Sma::new(SIGNAL_PERIOD).expect("SIGNAL_PERIOD is non-zero"),
last: None,
}
}
/// Current cumulative value if at least one emission has happened.
/// Current oscillator value if available.
pub const fn value(&self) -> Option<f64> {
if self.has_emitted {
Some(self.total)
} else {
None
}
self.last
}
}
@@ -78,45 +90,47 @@ impl Indicator for AdOscillator {
};
let delta = if candle.close > prev {
// Accumulation: distance from the true low.
let tr_l = prev.min(candle.low);
candle.close - tr_l
candle.close - prev.min(candle.low)
} else if candle.close < prev {
// Distribution: distance from the true high (negative).
let tr_h = prev.max(candle.high);
candle.close - tr_h
candle.close - prev.max(candle.high)
} else {
// Unchanged close contributes nothing.
0.0
};
self.total += delta;
self.line += delta;
self.prev_close = Some(candle.close);
self.has_emitted = true;
Some(self.total)
let signal = self.signal.update(self.line)?;
let osc = self.line - signal;
self.last = Some(osc);
Some(osc)
}
fn reset(&mut self) {
self.prev_close = None;
self.total = 0.0;
self.has_emitted = false;
self.line = 0.0;
self.signal.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
// One seed bar; the second bar is the first emission.
2
// One seed bar establishes the prior close; the line then feeds the
// 13-bar signal SMA, which is full after `SIGNAL_PERIOD` line values.
1 + SIGNAL_PERIOD
}
fn is_ready(&self) -> bool {
self.has_emitted
self.last.is_some()
}
fn name(&self) -> &'static str {
"WilliamsAD"
"ADOSC"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::wad::Wad;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
@@ -127,94 +141,103 @@ mod tests {
#[test]
fn accessors_and_metadata() {
let ad = AdOscillator::new();
assert_eq!(ad.name(), "WilliamsAD");
assert_eq!(ad.warmup_period(), 2);
assert_eq!(ad.value(), None);
}
#[test]
fn value_returns_total_after_first_emission() {
let mut ad = AdOscillator::new();
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
assert_relative_eq!(ad.value().unwrap(), v, epsilon = 1e-12);
}
#[test]
fn first_bar_only_seeds() {
let mut ad = AdOscillator::new();
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 0)), None);
assert_eq!(ad.name(), "ADOSC");
assert_eq!(ad.warmup_period(), 14);
assert!(!ad.is_ready());
assert_eq!(ad.value(), None);
// `Default` matches `new`.
assert_eq!(AdOscillator::default().warmup_period(), 14);
}
#[test]
fn accumulation_adds_distance_from_true_low() {
// prev close = 10, today low = 8, today close = 12 (up day).
// TR_l = min(10, 8) = 8, delta = 12 - 8 = 4. AD = 0 + 4 = 4.
fn seed_bar_returns_none() {
let mut ad = AdOscillator::new();
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
assert_eq!(ad.update(c(100.0, 101.0, 99.0, 100.0, 0)), None);
}
#[test]
fn distribution_adds_distance_from_true_high() {
// prev close = 10, today high = 11, today close = 7 (down day).
// TR_h = max(10, 11) = 11, delta = 7 - 11 = -4. AD = -4.
let mut ad = AdOscillator::new();
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
let v = ad.update(c(10.0, 11.0, 7.0, 7.0, 1)).unwrap();
assert_relative_eq!(v, -4.0, epsilon = 1e-12);
fn equals_wad_line_minus_its_sma() {
// The oscillator is exactly the Williams A/D line minus its 13-SMA, so
// it must match the standalone `Wad` line passed through an SMA(13).
let candles: Vec<Candle> = (0..80_i64)
.map(|i| {
let base = 100.0 + (i as f64 * 0.3).sin() * 6.0;
c(
base,
base + 2.0,
base - 2.0,
base + (i as f64 * 0.5).cos(),
i,
)
})
.collect();
let osc = AdOscillator::new().batch(&candles);
// Reconstruct: Wad line, then line SMA(line, 13).
let line = Wad::new().batch(&candles);
let mut sma = Sma::new(SIGNAL_PERIOD).unwrap();
let expected: Vec<Option<f64>> = line
.iter()
.map(|v| v.and_then(|l| sma.update(l).map(|s| l - s)))
.collect();
assert_eq!(osc, expected);
}
#[test]
fn unchanged_close_keeps_total() {
// close equals prev close -> no contribution.
fn flat_market_oscillates_at_zero() {
// A flat market never accumulates or distributes, so the line is
// constant and the oscillator sits at zero once warm.
let mut ad = AdOscillator::new();
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
let v = ad.update(c(10.0, 12.0, 8.0, 10.0, 1)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
// Every close equals the previous -> AD stays at zero forever.
let candles: Vec<Candle> = (0..40).map(|i| c(10.0, 11.0, 9.0, 10.0, i)).collect();
let mut ad = AdOscillator::new();
for v in ad.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
let candles: Vec<Candle> = (0..40).map(|i| c(50.0, 50.0, 50.0, 50.0, i)).collect();
let out = ad.batch(&candles);
for v in out.iter().skip(ad.warmup_period() - 1).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80i64)
fn warmup_emits_at_warmup_period() {
let mut ad = AdOscillator::new();
let candles: Vec<Candle> = (0..20)
.map(|i| {
let f = i as f64;
let mid = 100.0 + (f * 0.3).sin() * 5.0;
c(mid, mid + 2.0, mid - 2.0, mid + 0.5, i)
let close = 100.0 + f64::from(i);
c(close, close + 2.0, close - 2.0, close, i64::from(i))
})
.collect();
let mut a = AdOscillator::new();
let mut b = AdOscillator::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
let out = ad.batch(&candles);
assert_eq!(ad.warmup_period(), 14);
for v in out.iter().take(13) {
assert!(v.is_none());
}
assert!(out[13].is_some());
}
#[test]
fn reset_clears_state() {
let mut ad = AdOscillator::new();
ad.batch(&[
c(10.0, 11.0, 9.0, 10.0, 0),
c(10.0, 12.0, 9.0, 11.0, 1),
c(11.0, 13.0, 10.0, 12.0, 2),
]);
let candles: Vec<Candle> = (0..30)
.map(|i| {
let close = 100.0 + f64::from(i);
c(close, close + 2.0, close - 2.0, close, i64::from(i))
})
.collect();
ad.batch(&candles);
assert!(ad.is_ready());
ad.reset();
assert!(!ad.is_ready());
assert_eq!(ad.value(), None);
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 3)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..100_i64)
.map(|i| {
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
c(base, base + 1.5, base - 1.5, base + 0.4, i)
})
.collect();
let batch = AdOscillator::new().batch(&candles);
let mut s = AdOscillator::new();
let streamed: Vec<_> = candles.iter().map(|x| s.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -7,19 +7,23 @@ use crate::traits::Indicator;
/// Rolling Average Drawdown.
///
/// Input is treated as an equity-curve sample. The indicator scans the
/// trailing window of `period` values, tracks the running peak inside the
/// window, and reports the **mean** of all bar-by-bar drawdowns (the average
/// "pain" of being under water):
/// Input is treated as an equity-curve sample. Over the trailing window of
/// `period` values the indicator identifies each **distinct drawdown episode**
/// — a stretch where equity is below the running peak — and reports the **mean
/// of the episodes' maximum depths**:
///
/// ```text
/// drawdown_t = (peak_t equity_t) / peak_t (running peak inside window)
/// AvgDD = mean(drawdown_t over window)
/// episode opens when equity < running peak
/// episode closes when equity reaches a new peak (full recovery)
/// depth(episode) = (episode_peak episode_trough) / episode_peak
/// AvgDD = mean(depth over episodes in window) (0 if no drawdown)
/// ```
///
/// Output is non-negative (a fraction; `0.05` ≈ 5 % average drawdown). This
/// is the **Pain Index** under a different name — see [`crate::PainIndex`]
/// for the same metric exposed under its conventional label.
/// This is the conventional "average drawdown" (mean depth across separate
/// drawdowns), which is distinct from the [`crate::PainIndex`] — the latter
/// averages the under-water fraction at *every* bar, so a long shallow
/// drawdown weighs more there than here. Output is a non-negative fraction
/// (`0.05` ≈ 5 % mean episode depth).
///
/// Each `update` is O(period).
#[derive(Debug, Clone)]
@@ -65,16 +69,40 @@ impl Indicator for AverageDrawdown {
return None;
}
let mut peak = f64::NEG_INFINITY;
let mut sum_dd = 0.0_f64;
let mut sum_depth = 0.0_f64;
let mut episodes = 0_u32;
let mut in_dd = false;
let mut episode_peak = 0.0_f64;
let mut episode_trough = 0.0_f64;
for &v in &self.window {
if v > peak {
if v >= peak {
if in_dd {
if episode_peak > 0.0 {
sum_depth += (episode_peak - episode_trough) / episode_peak;
episodes += 1;
}
in_dd = false;
}
peak = v;
}
if peak > 0.0 {
sum_dd += (peak - v) / peak;
} else if in_dd {
if v < episode_trough {
episode_trough = v;
}
} else {
in_dd = true;
episode_peak = peak;
episode_trough = v;
}
}
Some(sum_dd / self.period as f64)
if in_dd && episode_peak > 0.0 {
sum_depth += (episode_peak - episode_trough) / episode_peak;
episodes += 1;
}
Some(if episodes == 0 {
0.0
} else {
sum_depth / f64::from(episodes)
})
}
fn reset(&mut self) {
@@ -124,13 +152,24 @@ mod tests {
#[test]
fn reference_value() {
// window [100, 120, 90, 110]:
// peaks: 100, 120, 120, 120; dd: 0, 0, (30/120)=.25, (10/120)=.0833...
// avg = (.25 + .0833...) / 4 = .0833...
// window [100, 120, 90, 110]: one drawdown episode, opened at 90 (peak
// 120) and never recovering to 120 within the window. Its depth is
// (120 - 90) / 120 = 0.25; 110 stays inside the same episode and does
// not deepen the trough. One episode -> AvgDD = 0.25.
let mut a = AverageDrawdown::new(4).unwrap();
let out = a.batch(&[100.0, 120.0, 90.0, 110.0]);
let expected = (0.25 + (10.0 / 120.0)) / 4.0;
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
assert_relative_eq!(out[3].unwrap(), 0.25, epsilon = 1e-12);
}
#[test]
fn averages_distinct_episodes() {
// [100, 90, 100, 80, 100]: episode 1 troughs at 90 then recovers to 100
// -> depth 0.10; episode 2 troughs at 80 then recovers -> depth 0.20.
// Mean of the two episode depths = 0.15 (distinct from the Pain Index,
// which would weight every under-water bar instead).
let mut a = AverageDrawdown::new(5).unwrap();
let out = a.batch(&[100.0, 90.0, 100.0, 80.0, 100.0]);
assert_relative_eq!(out[4].unwrap(), 0.15, epsilon = 1e-12);
}
#[test]
@@ -1,24 +1,28 @@
//! Awesome Oscillator Histogram.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::awesome_oscillator::AwesomeOscillator;
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// "Awesome Oscillator Histogram" — the difference between the Awesome
/// Oscillator and its `sma_period`-bar `SMA`. Positive bars mean `AO` is
/// trending up (bullish acceleration); negative bars mean `AO` is trending
/// down (bearish acceleration).
/// Awesome Oscillator Histogram — the bar-to-bar **momentum** of the Awesome
/// Oscillator over a `lookback` window. This is the value behind the coloured
/// histogram bars in Bill Williams' charts: each bar shows how much `AO` has
/// changed, so positive values mean `AO` is rising (the histogram "greens up")
/// and negative values mean it is falling.
///
/// ```text
/// AO = SMA(median, fast) SMA(median, slow)
/// AOHist = AO SMA(AO, sma_period)
/// AO = SMA(median, fast) SMA(median, slow)
/// AOHist = AO_t AO_{tlookback}
/// ```
///
/// With Williams' default `sma_period = 5`, this collapses to the existing
/// `AcceleratorOscillator` for `fast = 5, slow = 34, sma_period = 5`; for any
/// other parameterisation this is a more flexible variant.
/// This is distinct from the two related indicators Wickra ships: the raw
/// [`AwesomeOscillator`](crate::AwesomeOscillator) is `AO` itself, and the
/// [`AcceleratorOscillator`](crate::AcceleratorOscillator) is `AO SMA(AO, n)`.
/// The histogram instead reports `AO`'s rate of change. The default `lookback`
/// is `1` (the classic one-bar histogram delta).
///
/// # Example
///
@@ -38,17 +42,18 @@ use crate::traits::Indicator;
pub struct AwesomeOscillatorHistogram {
fast_period: usize,
slow_period: usize,
sma_period: usize,
lookback: usize,
ao: AwesomeOscillator,
sma: Sma,
history: VecDeque<f64>,
emitted: bool,
}
impl AwesomeOscillatorHistogram {
/// # Errors
/// - [`Error::PeriodZero`] if any period is zero.
/// - [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize, sma_period: usize) -> Result<Self> {
if fast == 0 || slow == 0 || sma_period == 0 {
pub fn new(fast: usize, slow: usize, lookback: usize) -> Result<Self> {
if fast == 0 || slow == 0 || lookback == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
@@ -59,20 +64,21 @@ impl AwesomeOscillatorHistogram {
Ok(Self {
fast_period: fast,
slow_period: slow,
sma_period,
lookback,
ao: AwesomeOscillator::new(fast, slow)?,
sma: Sma::new(sma_period)?,
history: VecDeque::with_capacity(lookback + 1),
emitted: false,
})
}
/// Bill Williams' Accelerator-equivalent defaults `(5, 34, 5)`.
/// Bill Williams' defaults with a one-bar histogram delta `(5, 34, 1)`.
pub fn classic() -> Self {
Self::new(5, 34, 5).expect("classic Awesome Oscillator Histogram parameters are valid")
Self::new(5, 34, 1).expect("classic Awesome Oscillator Histogram parameters are valid")
}
/// Configured `(fast_period, slow_period, sma_period)`.
/// Configured `(fast_period, slow_period, lookback)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.fast_period, self.slow_period, self.sma_period)
(self.fast_period, self.slow_period, self.lookback)
}
}
@@ -82,23 +88,29 @@ impl Indicator for AwesomeOscillatorHistogram {
fn update(&mut self, candle: Candle) -> Option<f64> {
let ao = self.ao.update(candle)?;
let sma = self.sma.update(ao)?;
Some(ao - sma)
self.history.push_back(ao);
if self.history.len() <= self.lookback {
return None;
}
let prev = self.history.pop_front().expect("history is non-empty");
self.emitted = true;
Some(ao - prev)
}
fn reset(&mut self) {
self.ao.reset();
self.sma.reset();
self.history.clear();
self.emitted = false;
}
fn warmup_period(&self) -> usize {
// AO emits at `slow` candles; the SMA then needs `sma_period - 1`
// more AO values to fill its window.
self.slow_period + self.sma_period - 1
// AO first emits at `slow` candles; `lookback` more AO values are then
// needed before `AO_t AO_{tlookback}` can be formed.
self.slow_period + self.lookback
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
self.emitted
}
fn name(&self) -> &'static str {
@@ -119,11 +131,11 @@ mod tests {
#[test]
fn rejects_zero_period() {
assert!(matches!(
AwesomeOscillatorHistogram::new(0, 34, 5),
AwesomeOscillatorHistogram::new(0, 34, 1),
Err(Error::PeriodZero)
));
assert!(matches!(
AwesomeOscillatorHistogram::new(5, 0, 5),
AwesomeOscillatorHistogram::new(5, 0, 1),
Err(Error::PeriodZero)
));
assert!(matches!(
@@ -135,7 +147,7 @@ mod tests {
#[test]
fn rejects_fast_geq_slow() {
assert!(matches!(
AwesomeOscillatorHistogram::new(34, 5, 5),
AwesomeOscillatorHistogram::new(34, 5, 1),
Err(Error::InvalidPeriod { .. })
));
}
@@ -143,15 +155,15 @@ mod tests {
#[test]
fn accessors_and_metadata() {
let hist = AwesomeOscillatorHistogram::classic();
assert_eq!(hist.periods(), (5, 34, 5));
assert_eq!(hist.warmup_period(), 38);
assert_eq!(hist.periods(), (5, 34, 1));
assert_eq!(hist.warmup_period(), 35);
assert_eq!(hist.name(), "AwesomeOscillatorHistogram");
}
#[test]
fn constant_series_converges_to_zero() {
// AO of a flat series is 0; SMA of 0 is 0; difference is 0.
let mut hist = AwesomeOscillatorHistogram::new(3, 5, 3).unwrap();
fn constant_series_yields_zero() {
// AO of a flat series is 0, so its momentum is 0.
let mut hist = AwesomeOscillatorHistogram::new(3, 5, 1).unwrap();
let candles: Vec<Candle> = (0..30).map(|i| candle(42.0, i)).collect();
let out = hist.batch(&candles);
for v in out.iter().skip(hist.warmup_period() - 1).flatten() {
@@ -161,7 +173,7 @@ mod tests {
#[test]
fn warmup_emits_first_value_at_warmup_period() {
let mut hist = AwesomeOscillatorHistogram::new(2, 4, 3).unwrap();
let mut hist = AwesomeOscillatorHistogram::new(2, 4, 2).unwrap();
assert_eq!(hist.warmup_period(), 6);
let candles: Vec<Candle> = (0..8)
.map(|i| candle(10.0 + f64::from(i), i64::from(i)))
@@ -173,6 +185,27 @@ mod tests {
assert!(out[5].is_some());
}
#[test]
fn equals_ao_difference() {
// The histogram must equal AO_t AO_{tlookback} bar for bar.
let candles: Vec<Candle> = (0..60_i64)
.map(|i| candle(100.0 + (i as f64 * 0.3).sin() * 5.0, i))
.collect();
let lookback = 1;
let ao_series = AwesomeOscillator::new(5, 34).unwrap().batch(&candles);
let hist = AwesomeOscillatorHistogram::new(5, 34, lookback)
.unwrap()
.batch(&candles);
for i in 0..candles.len() {
if let Some(h) = hist[i] {
let ao_now = ao_series[i].expect("AO present once histogram emits");
let ao_prev =
ao_series[i - lookback].expect("prior AO present once histogram emits");
assert_relative_eq!(h, ao_now - ao_prev, epsilon = 1e-9);
}
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..100_i64)
@@ -1,29 +1,28 @@
//! Intraday Intensity Index (Bostian) — a cumulative volume-weighted close-location line.
//! Intraday Intensity (Bostian) — the per-bar volume-weighted close-location.
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.
/// Intraday Intensity — David Bostian's per-bar measure 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
/// II_t = volume * (2*close high low) / (high low) (0 if high == low)
/// ```
///
/// 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).
/// closes on its high, `1` when it closes on its low, and `0` at the midpoint,
/// so `II_t` is the volume pushed toward the extremes on that single bar —
/// Bostian's proxy for per-bar accumulation (positive) or distribution
/// (negative).
///
/// 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.
/// This emits the **raw per-bar** intensity, which is distinct from the two
/// derived forms Wickra ships separately: the **cumulative** running total is
/// the Accumulation/Distribution Line ([`Adl`](crate::Adl)), and the
/// volume-normalized windowed form ("Intraday Intensity %") is mathematically
/// the Chaikin Money Flow ([`Cmf`](crate::Cmf)). A doji whose `high == low`
/// contributes nothing. Each `update` is O(1) and the first bar already emits a
/// value.
///
/// # Example
///
@@ -41,12 +40,11 @@ use crate::traits::Indicator;
/// ```
#[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.
/// Construct a new Intraday Intensity. It is parameter-free.
#[must_use]
pub fn new() -> Self {
Self::default()
@@ -69,13 +67,11 @@ impl Indicator for IntradayIntensity {
} else {
0.0
};
self.iii += ii;
self.last = Some(self.iii);
Some(self.iii)
self.last = Some(ii);
Some(ii)
}
fn reset(&mut self) {
self.iii = 0.0;
self.last = None;
}
@@ -149,11 +145,14 @@ mod tests {
}
#[test]
fn accumulates_across_bars() {
fn each_bar_is_independent() {
// Per-bar (non-cumulative): each output depends only on that bar, so a
// close-on-high +1000 bar is not carried into the next close-on-low bar.
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);
let a = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
let b = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap();
assert_relative_eq!(a, 1_000.0, epsilon = 1e-9);
assert_relative_eq!(b, -400.0, epsilon = 1e-9);
}
#[test]