diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 00000000..88f68448 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,108 @@ +# Wickra + +Wickra is a streaming-first technical-indicators library. Every indicator is +implemented in Rust as an O(1) state machine that consumes one input at a +time, and the same engine is exposed through ergonomic bindings for Python, +Node.js, WebAssembly, and Rust itself. The same `update` call you write inside +a live trading loop also drives the historical backtest of that same +strategy — there is no second code path that drifts behind the streaming one. + +The project ships 25 indicators across the four classical families (trend, +momentum, volatility, volume) and a small set of supporting types (`Candle`, +`Tick`, `Chain`). The Rust core forbids `unsafe`, so every binding inherits a +memory-safe implementation. Install is one command on every supported +platform: `pip install wickra`, `cargo add wickra`, `npm install wickra` — no +system compilers, no C dependencies, no headers. + +Wickra is licensed under the **PolyForm Noncommercial 1.0.0** license. +Personal projects, research, hobby trading bots, education, non-profits, and +government use are all permitted; commercial sale of the software or of +services built around it is not. If you want to use Wickra commercially, +open an issue on GitHub to discuss a separate license. + +## Published versions + +| Registry | Package | Version | +|-----------|----------------|---------| +| crates.io | `wickra` | 0.1.4 | +| crates.io | `wickra-core` | 0.1.4 | +| crates.io | `wickra-data` | 0.1.4 | +| PyPI | `wickra` | 0.1.4 | +| npm | `wickra` | 0.1.4 | +| npm | `wickra-wasm` | 0.1.4 | + +Release notes and tagged builds: +. + +## Wiki contents + +- [Quickstart: Python](Quickstart-Python.md) — `pip install wickra`, a batch + RSI on a NumPy array, a streaming RSI loop, and the multi-column NaN + pattern that MACD and friends share. +- [Quickstart: Rust](Quickstart-Rust.md) — `cargo add wickra`, batch and + streaming via the `Indicator` and `BatchExt` traits, and the `Chain` + combinator. +- [Quickstart: Node](Quickstart-Node.md) — `npm install wickra`, basic + `SMA` and `MACD` calls, and the current Windows install caveat + (`wickra-win32-x64-msvc@0.1.4` is held by the npm spam filter). +- [Streaming vs Batch](Streaming-vs-Batch.md) — the conceptual difference + between Wickra's O(1) `update` and the recompute-everything loops in + batch-only libraries, with the benchmark numbers from the project README. +- [Warmup Periods](Warmup-Periods.md) — a verified table of every + indicator's `warmup_period()`, plus the reasoning behind the off-by-one + cases (RSI(14) needs 15 inputs because it needs 14 diffs). +- [Indicator Chaining](Indicator-Chaining.md) — `Chain::new(first, second)` + and `.then(third)`, with a worked EMA(14) → RSI(7) example and the rule + for stacked warmups. + +### Indicator reference + +Start with [Indicators-Overview.md](Indicators-Overview.md) for the +cross-cutting taxonomy (trend / momentum / volatility / volume) and the +shared `Indicator` trait surface. The per-indicator pages below cover +formulas, parameters, warmup behaviour, edge cases, and verified +Rust / Python / Node examples. They are grouped by family, mirroring the +`indicators//` directory layout. + +**Trend** — smooth the price series to surface direction. + +- [Indicator-Sma.md](indicators/trend/Indicator-Sma.md) +- [Indicator-Ema.md](indicators/trend/Indicator-Ema.md) +- [Indicator-Wma.md](indicators/trend/Indicator-Wma.md) +- [Indicator-Dema.md](indicators/trend/Indicator-Dema.md) +- [Indicator-Tema.md](indicators/trend/Indicator-Tema.md) +- [Indicator-Hma.md](indicators/trend/Indicator-Hma.md) +- [Indicator-Kama.md](indicators/trend/Indicator-Kama.md) + +**Momentum** — measure the rate of price change rather than the level. + +- [Indicator-Rsi.md](indicators/momentum/Indicator-Rsi.md) +- [Indicator-MacdIndicator.md](indicators/momentum/Indicator-MacdIndicator.md) +- [Indicator-Stochastic.md](indicators/momentum/Indicator-Stochastic.md) +- [Indicator-Cci.md](indicators/momentum/Indicator-Cci.md) +- [Indicator-Roc.md](indicators/momentum/Indicator-Roc.md) +- [Indicator-WilliamsR.md](indicators/momentum/Indicator-WilliamsR.md) +- [Indicator-Adx.md](indicators/momentum/Indicator-Adx.md) +- [Indicator-Mfi.md](indicators/momentum/Indicator-Mfi.md) +- [Indicator-Trix.md](indicators/momentum/Indicator-Trix.md) +- [Indicator-AwesomeOscillator.md](indicators/momentum/Indicator-AwesomeOscillator.md) +- [Indicator-Aroon.md](indicators/momentum/Indicator-Aroon.md) + +**Volatility** — envelope width and per-bar dispersion measures. + +- [Indicator-BollingerBands.md](indicators/volatility/Indicator-BollingerBands.md) +- [Indicator-Atr.md](indicators/volatility/Indicator-Atr.md) +- [Indicator-Keltner.md](indicators/volatility/Indicator-Keltner.md) +- [Indicator-Donchian.md](indicators/volatility/Indicator-Donchian.md) +- [Indicator-Psar.md](indicators/volatility/Indicator-Psar.md) + +**Volume** — price moves weighted or confirmed by traded volume. + +- [Indicator-Obv.md](indicators/volume/Indicator-Obv.md) +- [Indicator-Vwap.md](indicators/volume/Indicator-Vwap.md) + +## See also + +- Source code: +- Releases: +- Issue tracker: diff --git a/docs/wiki/Indicator-Chaining.md b/docs/wiki/Indicator-Chaining.md new file mode 100644 index 00000000..ebad4556 --- /dev/null +++ b/docs/wiki/Indicator-Chaining.md @@ -0,0 +1,179 @@ +# Indicator Chaining + +`Chain` wires the output of one indicator straight into the input of +another. Both stages must agree on `f64` as the bridging type, which is the +case for the vast majority of price-in / value-out indicators. The chain +itself is an `Indicator`, so chains can be nested arbitrarily and used +anywhere a single indicator is accepted. + +This page documents the public API of `Chain` in +`crates/wickra-core/src/traits.rs`, the worked EMA(14) → RSI(7) example +that the doctest pins, and the warmup-stacking rule. + +## Construction + +```rust +use wickra::{Chain, Ema, Rsi}; + +// Two stages. +let chain = Chain::new(Ema::new(14)?, Rsi::new(7)?); + +// Three stages — note the `.then(third)` builder method. +let triple = Chain::new(Ema::new(14)?, Ema::new(5)?).then(Rsi::new(7)?); +``` + +The type of `triple` is `Chain, Rsi>`. You can keep chaining +indefinitely; the `Chain` produced at each step also implements +`Indicator`, which is the constraint +`.then(third)` needs to satisfy. + +Both `first()` and `second()` accessors return references to the underlying +stages if you need to inspect them; the chain owns its stages by value. + +## Worked example: EMA(14) → RSI(7) + +This is the canonical chain example from the doctest on `Chain` in +`crates/wickra-core/src/traits.rs`: + +```rust +use wickra::{Chain, Ema, Indicator, Rsi}; + +let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?); +for i in 1..=21 { + chain.update(f64::from(i)); +} +assert!(chain.is_ready()); +``` + +The semantic shape of the chain is: smooth the input series with an +EMA(14), then compute an RSI(7) over the **smoothed** series — not the +raw inputs. `chain.update(price)` is the only thing your caller code ever +sees; the EMA-then-RSI plumbing is internal to the `Chain` value. + +The chain emits its first non-`None` value at input **21**: + +```rust +let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?); +for i in 1..=22 { + if let Some(v) = chain.update(f64::from(i)) { + println!("chain emitted at input #{i}: {v}"); + } +} +println!("chain.warmup_period() = {}", chain.warmup_period()); +``` + +Output: + +``` +chain emitted at input #21: 100 +chain emitted at input #22: 100 +chain.warmup_period() = 22 +``` + +(The value is `100` because the input is the monotonic ramp `1, 2, ..., 22`; +an RSI on a strictly increasing series is `100` by construction. The point +is the *timing* of the first emission.) + +## Why first emission is at input 21, and `warmup_period` is 22 + +`Chain::warmup_period` is implemented conservatively: + +```rust +fn warmup_period(&self) -> usize { + self.first.warmup_period() + self.second.warmup_period() +} +``` + +For `Chain::new(Ema::new(14)?, Rsi::new(7)?)` this expands to +`14 + 8 = 22` (RSI(7)'s warmup is `period + 1 = 8` — see +[Warmup Periods](Warmup-Periods.md) for the off-by-one detail). + +In practice the chain emits one input earlier than that conservative sum +because the moment EMA(14) starts producing values is input 14, and RSI(7) +needs 8 EMA outputs to seed, so RSI(7) is ready on EMA output number 8, +which corresponds to input 14 + 7 = **21**. The conservative formula +`first.warmup + second.warmup` ignores this overlap; treat +`warmup_period()` as an upper bound and `is_ready()` as the source of truth +for "can I read a value yet": + +```rust +if chain.is_ready() { + if let Some(v) = chain.update(price) { + // ... + } +} +``` + +## State, reset, and `Send` + +`Chain` propagates `reset()` to both stages: + +```rust +fn reset(&mut self) { + self.first.reset(); + self.second.reset(); +} +``` + +So calling `chain.reset()` returns the whole pipeline to the state of a +freshly constructed `Chain::new(A::new(...), B::new(...))`. Because both +stages are owned by value and the trait `Indicator` is auto-derive-friendly, +the chain inherits `Clone`, `Debug`, and (where each stage is) `Send`. + +The `batch` extension comes through `BatchExt` automatically — there's +nothing chain-specific to call: + +```rust +use wickra::{BatchExt, Chain, Ema, Rsi}; + +let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?); +let out: Vec> = chain.batch(&prices); +``` + +## Stages from different families + +The bridging type is `f64`, so anything `Indicator` +can serve as the first stage and anything `Indicator` can serve +as the second (or third, or fourth). Indicators that consume `Candle` / +`Tick` (`Atr`, `Adx`, `Stochastic`, `Mfi`, `Vwap`, `Psar`, `Keltner`, +`Donchian`, `Aroon`, `AwesomeOscillator`, `Obv`) cannot sit at the second +stage of a chain because their `Input` is not `f64`. They can still be used +as standalone indicators alongside a chain — wire them in your own loop. + +A multi-output indicator like `MacdIndicator` or `BollingerBands` can sit +last in a chain (its `Output` is a struct, but the chain only requires +`Input = f64` on the **second** stage). For example, +`Chain::new(Sma::new(20)?, MacdIndicator::classic())` is a valid type: +MACD-of-smoothed-prices. + +## Python and Node + +`Chain` is currently a Rust-only construct; the Python and Node bindings +expose individual indicators only. The straightforward equivalent in those +languages is a manual two-step loop: + +```python +import wickra as ta + +ema = ta.EMA(14) +rsi = ta.RSI(7) + +for price in prices: + smoothed = ema.update(price) + if smoothed is not None: + chained = rsi.update(smoothed) + if chained is not None: + ... +``` + +This is exactly what `Chain::update` does in Rust, transcribed to the +binding's `update` method. No information is lost. + +## See also + +- [Quickstart: Rust](Quickstart-Rust.md) — the `Chain` example in context. +- [Warmup Periods](Warmup-Periods.md) — the underlying stage formulas, and + the RSI `period + 1` off-by-one. +- [Streaming vs Batch](Streaming-vs-Batch.md) — `Chain` works with both + paths automatically, via `BatchExt`. +- Source: diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md new file mode 100644 index 00000000..f0b371a9 --- /dev/null +++ b/docs/wiki/Indicators-Overview.md @@ -0,0 +1,197 @@ +# Indicators Overview + +Wickra ships 25 indicators, organised in source under the four classical +families — trend, momentum, volatility, volume — that map directly to the +directory structure of `crates/wickra-core/src/indicators/`. The same family +labels are used here, plus a second-level grouping that reflects how the +indicators actually behave (which output range they live in, what data they +need, what question they answer). + +Every indicator is an O(1) state machine that consumes one input at a time +and produces either `Option` (Rust), `float | None` (Python), or +`number | null` (Node). Inputs are either a `f64` close price or an OHLCV +`Candle` (Rust) / dict-or-tuple (Python) / column arrays (Node). The full +trait surface and warmup-period semantics are covered in +[Quickstart: Rust](Quickstart-Rust.md) and [Warmup Periods](Warmup-Periods.md). + +The "Output range" column below is the value bounds an indicator emits once +warm. "unbounded" means it tracks the price scale of the input. The +"Warmup" column quotes `warmup_period()` as the indicator reports it; for +two indicators (`Hma`, `Kama`) the practical first-emission index can lag +the reported number because of stacked sub-indicator warmups — those +discrepancies are noted on the deep-dive pages. + +## Trend + +Trend indicators smooth the price series to surface direction. They are +all single-input, single-output (`f64 → f64`). + +### Simple averages + +Pure linear weighting. Mostly used as fast baselines or as comparison +benchmarks against fancier averages. + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive | +|-----------|-----------|-------|--------|-------|----------|--------|-----------| +| `Sma` | Equal-weighted rolling mean over `period` closes. | `f64` | `f64` | unbounded (price scale) | `period` (no default in core; Python defaults vary by binding) | `period` | [Indicator-Sma.md](indicators/trend/Indicator-Sma.md) | +| `Wma` | Linear weights `1, 2, …, period` so the newest bar matters most. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Wma.md](indicators/trend/Indicator-Wma.md) | + +### Exponential family + +Recursive smoothing with one or more chained EMAs. Lag reduction grows as +you stack more EMAs, but so does responsiveness to noise. + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive | +|-----------|-----------|-------|--------|-------|----------|--------|-----------| +| `Ema` | EMA with `α = 2 / (period + 1)`, seeded from the SMA of the first `period` inputs. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Ema.md](indicators/trend/Indicator-Ema.md) | +| `Dema` | Mulloy's `2·EMA − EMA(EMA)`; removes first-order EMA lag. | `f64` | `f64` | unbounded (price scale) | `period` | `2·period − 1` | [Indicator-Dema.md](indicators/trend/Indicator-Dema.md) | +| `Tema` | Mulloy's `3·EMA − 3·EMA(EMA) + EMA(EMA(EMA))`; removes more lag than DEMA. | `f64` | `f64` | unbounded (price scale) | `period` | `3·period − 2` | [Indicator-Tema.md](indicators/trend/Indicator-Tema.md) | +| `Trix` | `(EMA(EMA(EMA(price))).pct_change × 10000)`; oscillator built from a triple-smoothed EMA. | `f64` | `f64` | unbounded around zero | `period = 15` (Python) | `3·period − 1` | (see source `crates/wickra-core/src/indicators/trix.rs`) | + +### Adaptive & hybrid + +These two adjust their effective smoothing on the fly. They are the +"smart" trend filters; both also live in Trend by directory placement. + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive | +|-----------|-----------|-------|--------|-------|----------|--------|-----------| +| `Hma` | Hull's `WMA(2·WMA(n/2) − WMA(n), √n)`; near-zero lag with a built-in noise filter. | `f64` | `f64` | unbounded (price scale) | `period` | `period + round(√period) − 1` (see notes) | [Indicator-Hma.md](indicators/trend/Indicator-Hma.md) | +| `Kama` | Kaufman's adaptive average: efficiency ratio picks an α between a fast and slow EMA per bar. | `f64` | `f64` | unbounded (price scale) | `(er_period=10, fast=2, slow=30)` | `er_period + 1` (see notes) | [Indicator-Kama.md](indicators/trend/Indicator-Kama.md) | + +## Momentum + +Momentum indicators measure the *rate* of price change, not the level. +Several are bounded by construction (0–100 oscillators); others are +unbounded; one (`Adx`) is directional and bundles three values. + +### Bounded oscillators (0 – 100) + +These all share the "overbought above 70/80, oversold below 30/20" +mental model, though the exact thresholds differ in the literature. + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | +|--------------|-----------|-------|--------|-------|----------|--------| +| `Rsi` | Wilder's RSI; smoothed `gain / (gain + loss) × 100`. | `f64` | `f64` | `[0, 100]` | `period = 14` (Python) | `period + 1` | +| `Stochastic` | `%K = (close − low_n)/(high_n − low_n) × 100`, smoothed into `%D`. | `Candle` | `(k, d)` | each in `[0, 100]` | `(k_period=14, d_period=3)` (Python) | `k_period + d_period − 1` | +| `Mfi` | "Volume-weighted RSI": Wilder smoothing of money-flow ratios. | `Candle` | `f64` | `[0, 100]` | `period = 14` (Python) | `period` | +| `Aroon` | Bars-since-high and bars-since-low scaled to `[0, 100]`. | `Candle` | `(up, down)` | each in `[0, 100]` | `period = 14` (Python) | `period + 1` | + +### Unbounded oscillators + +Centered on zero or driven by raw price differences; no fixed cap. + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | +|---------------------|-----------|-------|--------|-------|----------|--------| +| `MacdIndicator` | `EMA(fast) − EMA(slow)` plus a signal-line EMA and the difference histogram. | `f64` | `(macd, signal, histogram)` | unbounded around zero | `(fast=12, slow=26, signal=9)` (Python) | `slow + signal − 1` | +| `Cci` | `(typical − SMA(typical)) / (0.015 · mean_dev)`; unbounded but typically `±100`. | `Candle` | `f64` | unbounded (typically `±100` to `±200`) | `period = 20` (Python) | `period` | +| `Roc` | `(price − price_n) / price_n × 100`; raw percentage change over `period` bars. | `f64` | `f64` | unbounded around zero | `period` | `period + 1` | +| `AwesomeOscillator` | `SMA(median, fast) − SMA(median, slow)`; Bill Williams' zero-line crossover oscillator. | `Candle` | `f64` | unbounded around zero | `(fast=5, slow=34)` (Python) | `slow_period` | +| `WilliamsR` | `−100 × (high_n − close) / (high_n − low_n)`; same family as Stochastic but inverted to `[−100, 0]`. | `Candle` | `f64` | `[−100, 0]` | `period = 14` (Python) | `period` | + +### Directional + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | +|-----------|-----------|-------|--------|-------|----------|--------| +| `Adx` | Wilder's directional system: `+DI`, `−DI` (each `[0, 100]`) and `ADX` trend-strength index. | `Candle` | `(plus_di, minus_di, adx)` | each in `[0, 100]` | `period = 14` (Python) | `2·period` | + +## Volatility + +Volatility indicators sit in two functional groups: those that draw an +envelope around price, and those that report a scalar dispersion/range. +PSAR is a special case — a trailing-stop tracker rather than a width +measure — that lives in the volatility module by source convention. + +### Envelopes + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | +|-------------------|-----------|-------|--------|-------|----------|--------| +| `BollingerBands` | SMA middle band with `±multiplier × population_stddev` upper/lower bands. | `f64` | `(upper, middle, lower, stddev)` | unbounded (price scale) | `(period=20, multiplier=2.0)` (Python) | `period` | +| `Keltner` | EMA middle band with `±multiplier × ATR` upper/lower bands. | `Candle` | `(upper, middle, lower)` | unbounded (price scale) | `(ema_period=20, atr_period=10, multiplier=2.0)` (Python) | `max(ema_period, atr_period)` | +| `Donchian` | Highest high and lowest low over `period` bars; middle = mean of the two. | `Candle` | `(upper, middle, lower)` | unbounded (price scale) | `period = 20` (Python) | `period` | + +### Range-average + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | +|-----------|-----------|-------|--------|-------|----------|--------| +| `Atr` | Wilder-smoothed True Range; per-bar absolute volatility. | `Candle` | `f64` | `[0, ∞)` (price scale) | `period = 14` (Python) | `period` | + +### Trailing stop + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | +|-----------|-----------|-------|--------|-------|----------|--------| +| `Psar` | Wilder's Parabolic Stop-and-Reverse; per-bar stop level that flips sides on price crossing. | `Candle` | `f64` | unbounded (price scale) | `(af_start=0.02, af_step=0.02, af_max=0.20)` (Python) | `2` | + +## Volume + +Volume indicators all take `Candle` input because they need `close` and +`volume` together (some also need `high`/`low`). + +### Cumulative + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | +|---------------|-----------|-------|--------|-------|----------|--------| +| `Obv` | On-Balance Volume: cumulative signed volume driven by close-vs-prior-close sign. | `Candle` | `f64` | unbounded (drifts with cumulative volume) | (no parameters) | `1` | +| `Vwap` | Cumulative volume-weighted average price from the start of the stream (intraday reset is your responsibility). | `Candle` | `f64` | unbounded (price scale) | (no parameters) | `1` | + +### Rolling + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | +|---------------|-----------|-------|--------|-------|----------|--------| +| `RollingVwap` | VWAP over a sliding window instead of since-start; useful for session-independent VWAP. | `Candle` | `f64` | unbounded (price scale) | `period` | `period` | + +## Pick the right indicator for… + +A short cheat-sheet of "I want X, which indicator?" answers, grounded in +what each indicator actually computes. + +- **Fast trend filter, minimal lag, single line.** `Hma` for smoothness + + responsiveness, `Tema` for further lag reduction at the cost of more + noise. If you want adaptiveness instead of fixed lag, `Kama`. +- **Slow trend filter, smooth as glass.** `Sma` is the simplest; `Ema` + responds slightly faster with the same smoothness budget. For long + trend filters either is appropriate; the difference is mostly aesthetic. +- **Trend-following crossovers.** Two-line crossovers (`Ema(fast)` vs + `Ema(slow)`, or any of the trend pairs) are the textbook entry signal; + `MacdIndicator` packages the same idea with a signal line and histogram. +- **Trend strength (is there a trend at all?).** `Adx` is the canonical + answer: `adx > 25` is "trending", `adx < 20` is "ranging". `Aroon` is + a softer alternative when you want directional confirmation. +- **Overbought / oversold reversal candidate.** `Rsi` is the default; + `Stochastic` for faster signals; `WilliamsR` for the same logic with an + inverted scale; `Mfi` if you have volume and want a volume-aware RSI. +- **Volatility expansion / contraction.** `BollingerBands` width + (`upper − lower`) for relative volatility; `Atr` for absolute per-bar + volatility in price units; `Keltner` to compare price against an + ATR-scaled envelope. +- **Breakout level.** `Donchian` upper/lower bands are the textbook + Turtle-style breakout trigger. +- **Trailing stop.** `Psar` gives you a per-bar stop level that flips + sides as the trend reverses. `Atr · k` (compute `Atr` yourself, multiply + by your preferred `k`) is the common alternative. +- **Volume confirmation.** `Obv` is the simplest; `Mfi` adds price into + the equation; `Vwap` / `RollingVwap` give you the volume-weighted + reference price. +- **Bill Williams setups.** `AwesomeOscillator` for the zero-line cross / + twin-peaks pattern from his suite. +- **Rate-of-change scalar.** `Roc` is the unsmoothed percentage change; + `Trix` is the same idea but on a triple-smoothed EMA. + +## Source-of-truth files + +Every claim above can be checked against the source in +`D:\Coding\Wickra\crates\wickra-core\src\indicators\` — one file per +indicator. The Rust unit tests inside each module are the ground truth +for sample values. Python defaults (the `period = 14` etc. in tables +above) come from the `#[pyo3(signature = …)]` attributes in +`D:\Coding\Wickra\bindings\python\src\lib.rs`; indicators not listed +with a Python default require an explicit `period` argument. + +## See also + +- [Warmup Periods](Warmup-Periods.md) — full verified table of every + indicator's `warmup_period()`. +- [Indicator Chaining](Indicator-Chaining.md) — combining indicators with + `Chain` and the stacked-warmup rule. +- [Quickstart: Rust](Quickstart-Rust.md), [Quickstart: Python](Quickstart-Python.md), + [Quickstart: Node](Quickstart-Node.md) — language-specific API surfaces. +- Source: diff --git a/docs/wiki/Quickstart-Node.md b/docs/wiki/Quickstart-Node.md new file mode 100644 index 00000000..174d9e11 --- /dev/null +++ b/docs/wiki/Quickstart-Node.md @@ -0,0 +1,173 @@ +# Quickstart: Node + +A five-minute tour of the Wickra Node.js binding. The binding is generated by +[napi-rs](https://napi.rs/), so it's a native addon — no WebAssembly, no +slow JS reimplementation. + +## Install + +```bash +npm install wickra +``` + +> **Windows install caveat (current, 0.1.4).** The platform-specific +> sub-package `wickra-win32-x64-msvc@0.1.4` is presently held back by npm's +> automated spam filter. On a Windows x64 machine `npm install wickra` +> succeeds, but `require('wickra')` then throws +> `Error: Cannot find module 'wickra-win32-x64-msvc'` because the loader +> falls through to `require('wickra-win32-x64-msvc')` when no local `.node` +> binary is found. Linux x64 and macOS (x64 + arm64) wheels are unaffected. +> If you are on Windows today, build the binding from source (see "Building +> from source" below) until the npm side is resolved. + +## A first run + +```javascript +const wickra = require('wickra'); + +console.log('wickra', wickra.version()); + +// Simple moving average over a fixed window. +const sma = new wickra.SMA(3); +console.log(sma.batch([2, 4, 6, 8, 10])); +// -> [ NaN, NaN, 4, 6, 8 ] +``` + +Two things to notice: + +1. The `batch` return type is a regular JavaScript `Array`. Warmup + slots are `NaN`, not `null` or `undefined`, so the array is + `Number.isFinite`-checkable in one pass. +2. `new wickra.SMA(0)` does **not** throw. Constructors in the Node binding + currently cannot raise errors from JS (a napi-rs 2.16 limitation), so + pathological values like `period = 0` are clamped to the smallest valid + window. This is exactly the behaviour pinned by + `bindings/node/__tests__/smoke.test.js` ("zero period is clamped to a + valid window"). + +## Streaming + +```javascript +const wickra = require('wickra'); + +const sma = new wickra.SMA(3); +for (const price of [2, 4, 6, 8, 10]) { + const value = sma.update(price); + console.log('update', price, '->', value); +} +``` + +Output: + +``` +update 2 -> null +update 4 -> null +update 6 -> 4 +update 8 -> 6 +update 10 -> 8 +``` + +`update` returns either a JavaScript `number` or `null` while the indicator +is still warming up. (Compare with `batch`, where warmup slots are `NaN`. +The asymmetry exists because the streaming API surfaces "no value yet" +through the JS type system, whereas the batch result is shaped as a numeric +array for downstream numeric code.) + +## MACD: streaming and batch + +`MACD` is the canonical multi-output indicator. The Node API surfaces this in +two shapes: + +- `update(price)` returns either `null` (during warmup) or + `{ macd, signal, histogram }`. +- `batch(prices)` returns a **flat** `Array` of length `prices.length * 3`, + laid out as `[macd_0, signal_0, hist_0, macd_1, signal_1, hist_1, ...]`, + with each warmup row written as three `NaN`s. + +Streaming form: + +```javascript +const wickra = require('wickra'); + +const macd = new wickra.MACD(12, 26, 9); +let last = null; +for (let i = 0; i < 40; i++) { + last = macd.update(100 + i * 0.5); +} +console.log(last); +// -> { macd: 3.5, signal: 3.500000000000001, histogram: -8.881784197001252e-16 } +``` + +Batch form (note the flat layout): + +```javascript +const wickra = require('wickra'); + +const prices = Array.from({ length: 40 }, (_, i) => 100 + i * 0.5); +const macd = new wickra.MACD(12, 26, 9); +const flat = macd.batch(prices); + +console.log('total values:', flat.length); // -> 120 (= 40 * 3) +console.log('row 33 :', flat.slice(99, 102)); // -> [ 3.5, 3.5, 0 ] +console.log('row 39 :', flat.slice(117, 120)); // -> [ 3.5, 3.5000000..., -8.88e-16 ] + +// Reshape into 3-tuples if you want: +const rows = []; +for (let i = 0; i < flat.length; i += 3) { + rows.push({ macd: flat[i], signal: flat[i + 1], histogram: flat[i + 2] }); +} +``` + +`MACD(12, 26, 9)` emits its first non-NaN row at index 33 because the +underlying Rust `warmup_period()` is `slow + signal − 1 = 34` (the first +ready row is `warmup_period - 1` in 0-indexed terms). + +## API surface + +The complete TypeScript definitions live at +`bindings/node/index.d.ts`. Every indicator class exposes some subset of: + +| Member | Notes | +|---------------------------|------------------------------------------------------------------------| +| `constructor(...)` | Pathological values are clamped, not thrown. | +| `update(...)` | Returns the indicator output or `null` during warmup. | +| `batch(...)` | Single-output: flat `Array` with `NaN` warmup.
Multi-output: flat interleaved `Array`. | +| `reset()` | Returns to a freshly-constructed state. | +| `isReady()` | `true` once the first value has been emitted. | +| `warmupPeriod()` | Present on single-output indicators (SMA, EMA, WMA, RSI, ...). Not exposed on every multi-output class — check `index.d.ts`. | + +A complete reference run lives in `bindings/node/__tests__/smoke.test.js`: + +```bash +cd bindings/node +npm install +npm run build # only needed if you cloned the repo (Windows: see above) +npm test +``` + +## Building from source (Windows workaround) + +If you are on Windows x64 and want to use Wickra today, build the binding +locally from the repository: + +```bash +git clone https://github.com/kingchenc/wickra +cd wickra/bindings/node +npm install +npm run build # requires a Rust toolchain (rustup) on PATH +npm test +``` + +`npm run build` produces `wickra.win32-x64-msvc.node` in the binding +directory; the platform loader in `index.js` then picks it up before falling +through to the npm sub-package and the install just works. + +## See also + +- [Quickstart: Python](Quickstart-Python.md) — sibling binding with NumPy + shapes. +- [Streaming vs Batch](Streaming-vs-Batch.md) — why `update` is the primary + entry point, not `batch`. +- [Warmup Periods](Warmup-Periods.md) — exact `warmup_period()` for every + indicator. +- Source: diff --git a/docs/wiki/Quickstart-Python.md b/docs/wiki/Quickstart-Python.md new file mode 100644 index 00000000..70a58fae --- /dev/null +++ b/docs/wiki/Quickstart-Python.md @@ -0,0 +1,181 @@ +# Quickstart: Python + +A five-minute tour of the Wickra Python binding. By the end you will have run +a batch RSI over a NumPy array, fed the same indicator one tick at a time, +and read a multi-column MACD result correctly during warmup. + +## Install + +```bash +pip install wickra +``` + +The published wheels target Python 3.9 – 3.12 on Linux x86_64, macOS +(Intel + Apple Silicon), and Windows x86_64. The only runtime dependency is +`numpy >= 1.22`. No system compiler, no C headers, no Rust toolchain are +needed to install — Wickra ships pre-built native wheels. + +Verify the install: + +```python +import wickra as ta +print(ta.__version__) +``` + +## Batch: RSI over a NumPy array + +`Indicator.batch(prices)` takes a 1-D `numpy.ndarray` of `float64` closes and +returns a 1-D `numpy.ndarray` of `float64` outputs. Warmup steps come back as +`NaN` so the result aligns 1:1 with your input prices and slots straight into +a pandas column or a NumPy mask. + +The first 15 prices below are the classic Wilder textbook example. RSI(14) +emits its first value at index 14 (the 15th input) because it needs 14 +diffs to seed Wilder's smoothing. + +```python +import numpy as np +import wickra as ta + +prices = np.array([ + 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, + 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, + 46.03, 46.41, 46.22, 45.64, +], dtype=float) + +rsi = ta.RSI(14) +values = rsi.batch(prices) + +print(values.dtype, values.shape) +print("warmup count:", int(np.isnan(values).sum())) +print("first value :", float(values[14])) +print("last value :", float(values[-1])) +``` + +Running this prints: + +``` +float64 (20,) +warmup count: 14 +first value : 70.46413502109705 +last value : 57.91502067008556 +``` + +The exact first value `70.464` matches Wilder's published table; this is the +same input/output pair the Rust test suite pins as `classic_wilder_textbook_values` +in `crates/wickra-core/src/indicators/rsi.rs`. + +## Streaming: feed one price at a time + +The same `RSI` instance can be driven tick-by-tick with `update()`. Each call +is O(1) and returns either a `float` or `None` while the indicator is still +warming up. + +```python +import wickra as ta + +rsi = ta.RSI(14) +prices = [ + 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, + 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, + 46.03, 46.41, +] + +for tick, price in enumerate(prices, start=1): + value = rsi.update(price) + if value is not None: + print(f"tick {tick:2d} close={price:.2f} rsi={value:.4f}") +``` + +Output: + +``` +tick 15 close=46.28 rsi=70.4641 +tick 16 close=46.00 rsi=66.2496 +tick 17 close=46.03 rsi=66.4809 +tick 18 close=46.41 rsi=69.3469 +``` + +Tick 15 is the first emission because `RSI(14).warmup_period() == 15`. Before +that, `update()` returns `None`. After warmup the indicator never goes back +to `None`: each subsequent tick produces a steady value. + +The full set of streaming-state methods is: + +| Method | Returns | Notes | +|-----------------------|----------------|--------------------------------------------| +| `update(price)` | `float`/`None` | O(1) state transition, `None` during warmup | +| `batch(prices)` | `np.ndarray` | replays `update`, `NaN` during warmup | +| `reset()` | `None` | returns to a freshly-constructed state | +| `is_ready()` | `bool` | `True` once the first value has been emitted | +| `warmup_period()` | `int` | inputs required before the first value | + +## MACD: a multi-column indicator and its warmup NaNs + +Some indicators emit several values at once. `MACD` returns three: the MACD +line, the signal line, and the histogram. The Python `batch` reflects that +shape directly — instead of a 1-D `float64` vector you get a 2-D +`(n_rows, n_columns)` array, and each warmup row is filled with `NaN` across +every column. (`Stochastic` follows the same pattern with two columns, +`Bollinger Bands` with four, `Keltner`/`Donchian`/`ADX` with three.) + +```python +import numpy as np +import wickra as ta + +prices = np.linspace(100.0, 120.0, 40) +macd = ta.MACD(12, 26, 9) +out = macd.batch(prices) + +print("shape :", out.shape) +print("warmup rows :", int(np.isnan(out[:, 0]).sum())) +print("first ready :", int(np.argmax(~np.isnan(out[:, 0])))) +print("row 33 :", out[33]) +print("row 39 :", out[39]) +``` + +Output: + +``` +shape : (40, 3) +warmup rows : 33 +first ready : 33 +row 33 : [ 3.58974359e+00 3.58974359e+00 -1.77635684e-15] +row 39 : [3.58974359e+00 3.58974359e+00 6.21724894e-15] +``` + +Two things to notice: + +1. `MACD(12, 26, 9).warmup_period()` is `slow + signal - 1 = 34`, and indeed + row `34 - 1 = 33` is the first row where every column is finite. Earlier + rows are entirely `NaN`; you should not slice a partial row out and use, + say, the `signal` column independently of the `macd` column. +2. Columns are positional — `out[:, 0]` is MACD, `out[:, 1]` is signal, + `out[:, 2]` is histogram. The streaming form returns the same triple as a + plain Python tuple: `(macd, signal, histogram)`. + +To filter a warmup-aware mask cleanly: + +```python +ready = ~np.isnan(out[:, 0]) +clean_rows = out[ready] +``` + +`ready` is a single boolean column you can apply to every column at once +because the warmup pattern is identical across all of them. + +## A deeper example + +`examples/python/backtest.py` in the repo runs a full panel of indicators +(RSI, EMA, Bollinger, MACD, ATR, ADX, OBV) over an OHLCV CSV and prints a +summary. It's a good template for "I have historical data on disk, give me a +table of indicator values" workflows; for live workflows, see +`examples/python/live_trading.py`. + +## See also + +- [Quickstart: Rust](Quickstart-Rust.md) — same API surface in Rust. +- [Streaming vs Batch](Streaming-vs-Batch.md) — why the streaming path is + the primary one, not a convenience. +- [Warmup Periods](Warmup-Periods.md) — the full table of warmup counts. +- Source: diff --git a/docs/wiki/Quickstart-Rust.md b/docs/wiki/Quickstart-Rust.md new file mode 100644 index 00000000..883f0c05 --- /dev/null +++ b/docs/wiki/Quickstart-Rust.md @@ -0,0 +1,142 @@ +# Quickstart: Rust + +A five-minute tour of the Wickra Rust crate. By the end you will have run a +batch SMA, fed an RSI tick by tick, and composed two indicators with `Chain`. + +## Install + +```bash +cargo add wickra +``` + +The default features pull in `parallel` (rayon-based `batch_parallel`); turn +them off with `cargo add wickra --no-default-features` if you want a leaner +build. The `wickra` crate is a thin façade that re-exports everything from +`wickra-core`; you can also depend on `wickra-core` directly if you want to +skip the façade. + +The published crate is at version `0.1.4` on +[crates.io](https://crates.io/crates/wickra). + +## The `Indicator` trait in 30 seconds + +Every indicator implements the same trait: + +```rust +pub trait Indicator { + type Input; + type Output; + fn update(&mut self, input: Self::Input) -> Option; + fn reset(&mut self); + fn warmup_period(&self) -> usize; + fn is_ready(&self) -> bool; + fn name(&self) -> &'static str; +} +``` + +`update` is O(1) in the input length. The companion trait `BatchExt` is a +blanket extension that adds a `batch(&[Self::Input])` method to every +indicator — its default implementation is literally a loop over `update`, so +batch and streaming results are bit-for-bit identical. + +## Batch and streaming side by side + +```rust +use wickra::{BatchExt, Indicator, Rsi, Sma}; + +fn main() -> Result<(), Box> { + // 1. Batch: SMA(3) over five prices. + let mut sma = Sma::new(3)?; + let out: Vec> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + println!("{:?}", out); + // -> [None, None, Some(2.0), Some(3.0), Some(4.0)] + + // 2. Streaming: feed Wilder's textbook example into RSI(14). + let mut rsi = Rsi::new(14)?; + let prices = [ + 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, + 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, 46.03, 46.41, + ]; + for (tick, price) in prices.iter().enumerate() { + if let Some(v) = rsi.update(*price) { + println!("tick {:2} close={:.2} rsi={:.4}", tick + 1, price, v); + } + } + Ok(()) +} +``` + +The streaming loop prints: + +``` +tick 15 close=46.28 rsi=70.4641 +tick 16 close=46.00 rsi=66.2496 +tick 17 close=46.03 rsi=66.4809 +tick 18 close=46.41 rsi=69.3469 +``` + +The first value lands on tick 15 because `Rsi::new(14)?.warmup_period() == 15` +(14 diffs to seed Wilder's smoothing, so the 15th input emits the first RSI). +The `70.4641` value matches the textbook value pinned by the unit test +`classic_wilder_textbook_values` in `crates/wickra-core/src/indicators/rsi.rs`. + +## Composing indicators with `Chain` + +`Chain` wires the output of `A` straight into the input of `B`, provided +both stages agree on `f64` as the bridging type. The chain itself is an +`Indicator`, so you can stack three stages with `.then(c)`, or four with +`.then(c).then(d)`. + +```rust +use wickra::{Chain, Ema, Indicator, Rsi}; + +fn main() -> Result<(), Box> { + // RSI(7) computed on the output of EMA(14). + let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?); + for i in 1..=22 { + if let Some(v) = chain.update(f64::from(i)) { + println!("chain emitted at input #{i}: {v}"); + } + } + println!("chain.warmup_period() = {}", chain.warmup_period()); + Ok(()) +} +``` + +Output: + +``` +chain emitted at input #21: 100 +chain emitted at input #22: 100 +chain.warmup_period() = 22 +``` + +`Ema::new(14)` needs 14 inputs to seed and `Rsi::new(7)` needs 8 more once +the EMA starts flowing, so the chain emits its first value at input 21. The +`warmup_period()` reported by `Chain` is a conservative `first + second` sum +(here `14 + 8 = 22`); see [Indicator Chaining](Indicator-Chaining.md) for the +exact contract. + +## A deeper example + +`crates/wickra/examples/backtest.rs` shipped with the workspace computes a +panel of indicators (RSI, EMA, Bollinger, MACD, ATR, ADX, OBV) over an OHLCV +CSV by way of `wickra-data`: + +```bash +cargo run --release --example backtest -- path/to/ohlcv.csv +``` + +For live-data work, `wickra-data` ships a streaming CSV reader, a +tick-to-candle aggregator, a candle resampler, and a Binance kline WebSocket +adapter under the `live-binance` feature. `crates/wickra-data/examples/live_binance.rs` +is the canonical example for the latter. + +## See also + +- [Quickstart: Python](Quickstart-Python.md) — same engine, NumPy-flavoured. +- [Streaming vs Batch](Streaming-vs-Batch.md) — the `batch == repeated update` + contract and the benchmark numbers it buys you. +- [Indicator Chaining](Indicator-Chaining.md) — three-stage chains and the + stacked-warmup rule. +- Source: diff --git a/docs/wiki/Streaming-vs-Batch.md b/docs/wiki/Streaming-vs-Batch.md new file mode 100644 index 00000000..a69655a8 --- /dev/null +++ b/docs/wiki/Streaming-vs-Batch.md @@ -0,0 +1,160 @@ +# Streaming vs Batch + +Wickra has one engine, not two. Every indicator is a state machine driven by +a single method, `Indicator::update`, and the batch API is a thin loop over +that method. This page is the concept doc for why that matters, and what +contracts you can rely on when you mix the two in real code. + +## The `update` contract + +`Indicator::update` is the only state transition. From `crates/wickra-core/src/traits.rs`: + +```rust +pub trait Indicator { + type Input; + type Output; + + /// Feed one new data point into the indicator and return the freshly computed + /// output, or `None` if the indicator is still warming up. + fn update(&mut self, input: Self::Input) -> Option; + + fn reset(&mut self); + fn warmup_period(&self) -> usize; + fn is_ready(&self) -> bool; + fn name(&self) -> &'static str; +} +``` + +Three properties hold by contract: + +1. **O(1) in the input length.** `update` may touch some pre-existing + buffered state, but it must never recompute over the entire history. The + `wickra-core` crate is `#![forbid(unsafe_code)]`, and the standard + indicator implementations all carry rolling sums, single recursive + accumulators, or fixed-size `VecDeque` windows. +2. **`None` during warmup, `Some` thereafter.** An indicator returns `None` + while it doesn't yet have enough data to produce a defined value. After + the first `Some`, it never goes back to `None` (short of a `reset()`). +3. **`reset()` restores construction-time state.** The state-machine is + fully encapsulated, so resetting and replaying produces bit-identical + results to a fresh instance. + +## The `BatchExt` blanket implementation + +The batch API is a blanket extension on top of every `Indicator`. The whole +implementation is six lines: + +```rust +pub trait BatchExt: Indicator { + fn batch(&mut self, inputs: &[Self::Input]) -> Vec> + where Self::Input: Clone, + { + let mut out = Vec::with_capacity(inputs.len()); + for x in inputs { + out.push(self.update(x.clone())); + } + out + } +} + +impl BatchExt for T {} +``` + +Two consequences: + +- **`batch == repeated update`, exactly.** There is no separate "vectorised" + code path that might disagree numerically with the streaming one. A unit + test pinning this invariant — `batch_equals_streaming` — lives in nearly + every `crates/wickra-core/src/indicators/.rs` file. You can rely on + the batch results in your backtest matching the streaming results that + your live bot will see. +- **Implementing one trait is enough.** Adding a new indicator means + implementing `Indicator` in Rust; every binding plus every batch helper + comes along for free. + +You can verify the equivalence yourself in Python: + +```python +import numpy as np +import wickra as ta + +np.random.seed(0) +prices = np.cumsum(np.random.randn(100)) + 100.0 + +# Batch path. +batch_out = ta.RSI(14).batch(prices) + +# Streaming path: same inputs, fresh indicator, fed one at a time. +rsi = ta.RSI(14) +stream_out = np.array( + [np.nan if (v := rsi.update(p)) is None else v for p in prices] +) + +b_nan = np.isnan(batch_out) +s_nan = np.isnan(stream_out) +assert np.array_equal(b_nan, s_nan) +assert np.array_equal(batch_out[~b_nan], stream_out[~s_nan]) +``` + +This passes; the last three values of both arrays are +`[69.64533252, 70.00767057, 71.18111330]`. + +## Why batch-only libraries fall behind live + +Suppose a strategy looks at RSI(14) on each new minute-bar of a market. A +classical batch-only library (TA-Lib, pandas-ta, finta, ...) gives you a +single function `rsi(prices)` that recomputes the indicator over the entire +input array. To use it inside a streaming loop, you concatenate each new +tick onto your history and call `rsi(history)` again. That's +`O(n)` work for every new bar, and the gap widens linearly as `n` grows. + +Wickra's `update` is the opposite: each new bar is O(1) because the +recursive smoothing state is already inside the indicator. You never carry +history just to recompute it. + +The numbers below are reproduced from the project README, where +`python -m benchmarks.compare_libraries` is the source script. + +### Batch — single full pass over a 5 000-bar series + +| Indicator | Wickra | finta | talipp | +|---------------------|---------------------|------------------------|------------------------------| +| SMA(20) | **26.0 µs** | 295.3 µs (11.4× slower) | 1 812.8 µs (69.7× slower) | +| EMA(20) | **16.8 µs** | 205.5 µs (12.2× slower) | 2 534.4 µs (150.9× slower) | +| RSI(14) | **31.2 µs** | 714.1 µs (22.9× slower) | 3 751.7 µs (120.2× slower) | +| MACD(12, 26, 9) | **30.8 µs** | 359.5 µs (11.7× slower) | 11 642.2 µs (378.0× slower) | +| Bollinger(20, 2.0) | **26.7 µs** | 690.6 µs (25.9× slower) | 27 482.4 µs (1 030.1× slower) | +| ATR(14) | **40.6 µs** | 1 120.3 µs (27.6× slower) | 3 760.2 µs (92.7× slower) | + +### Streaming — per-tick latency after seeding with 2 000 historical bars + +| Indicator | Wickra (per tick) | talipp (per tick) | +|-----------|---------------------|---------------------------| +| RSI(14) | **0.07 µs** | 1.16 µs (17.5× slower) | + +The streaming gap widens linearly with how much history a batch-only library +has to recompute on every new tick; the table above is the gap at a modest +2 000-bar seed. + +## Practical consequences + +- **Mix freely.** A common pattern is "warm up the indicator on historical + bars in one `batch` call, then drive it tick-by-tick with `update` for + live data". This is correct because the two paths share state. +- **`is_ready()` is the safe gate.** Don't use a `len(prices) > warmup_period` + check; trust the indicator's `is_ready()` method, which is `true` exactly + when at least one `Some` value has been emitted. +- **Multi-output indicators NaN/None together.** Every column of a MACD or + Bollinger batch transitions from `NaN` to a real value on the same row. + Use `~np.isnan(out[:, 0])` (Python) or `Number.isFinite(row[0])` + (Node) as a single mask across all columns. + +## See also + +- [Quickstart: Python](Quickstart-Python.md) — concrete Python usage of both + paths. +- [Quickstart: Rust](Quickstart-Rust.md) — the `BatchExt` trait and `?` + error handling. +- [Warmup Periods](Warmup-Periods.md) — the exact `warmup_period()` for + every indicator. +- Source: diff --git a/docs/wiki/Warmup-Periods.md b/docs/wiki/Warmup-Periods.md new file mode 100644 index 00000000..21739659 --- /dev/null +++ b/docs/wiki/Warmup-Periods.md @@ -0,0 +1,120 @@ +# Warmup Periods + +Every Wickra indicator returns `None` (Rust), `None` (Python), or `null` +(Node) for its first few inputs while it gathers enough data to produce a +defined value. The number of inputs an indicator needs before it emits its +first non-empty value is its **warmup period**, surfaced everywhere as +`warmup_period()` / `warmupPeriod()`. + +After the first emission, the indicator never goes back to a "no value yet" +state — it has rolled its state forward and will produce a steady value on +every subsequent `update()`. Calling `reset()` returns to the warming-up +state, equivalent to a freshly constructed instance. + +## How to read the formula column + +The formulas below are taken verbatim from the `warmup_period()` methods in +`crates/wickra-core/src/indicators/.rs`. The "Inputs at first +emission" column says, in 1-indexed terms, which `update()` call returns the +first `Some`/non-`NaN` value. They are the same number; "first emission +index" in 0-indexed terms is `warmup_period − 1`. + +## Single-output indicators + +| Indicator | Constructor | Formula | `warmup_period()` for shown args | Inputs at first emission | +|-----------------|----------------------------------------------|----------------------------------|----------------------------------|--------------------------| +| `Sma` | `Sma::new(14)` | `period` | 14 | 14th | +| `Ema` | `Ema::new(14)` | `period` | 14 | 14th | +| `Wma` | `Wma::new(14)` | `period` | 14 | 14th | +| `Dema` | `Dema::new(14)` | `2 * period - 1` | 27 | 27th | +| `Tema` | `Tema::new(14)` | `3 * period - 2` | 40 | 40th | +| `Hma` | `Hma::new(14)` | `period + round(sqrt(period)).max(1) - 1` | 17 | 17th | +| `Kama` | `Kama::new(10, 2, 30)` | `er_period + 1` | 11 | 11th | +| `Rsi` | `Rsi::new(14)` | `period + 1` | 15 | 15th | +| `Cci` | `Cci::new(20)` | `period` | 20 | 20th | +| `Roc` | `Roc::new(12)` | `period + 1` | 13 | 13th | +| `WilliamsR` | `WilliamsR::new(14)` | `period` | 14 | 14th | +| `Mfi` | `Mfi::new(14)` | `period` | 14 | 14th | +| `Trix` | `Trix::new(15)` | `3 * period - 1` | 44 | 44th | +| `AwesomeOscillator` | `AwesomeOscillator::new(5, 34)` | `slow_period` | 34 | 34th | +| `Atr` | `Atr::new(14)` | `period` | 14 | 14th | +| `Psar` | `Psar::new(0.02, 0.20)` | constant `2` | 2 | 2nd | +| `Obv` | `Obv::new()` | constant `1` | 1 | 1st | +| `Vwap` | `Vwap::new()` | constant `1` | 1 | 1st | +| `RollingVwap` | `RollingVwap::new(20)` | `period` | 20 | 20th | + +## Multi-output indicators + +These indicators emit several values at once (a struct in Rust, a tuple in +Python, an object in Node) and every column / field transitions from "not +ready" to "ready" together — there are no rows that have a `signal` but no +`macd`, for example. + +| Indicator | Constructor | Formula | `warmup_period()` for shown args | Inputs at first emission | Outputs | +|-------------------|--------------------------------------|------------------------------------------|----------------------------------|--------------------------|--------------------------------------------------------| +| `MacdIndicator` | `MacdIndicator::new(12, 26, 9)` | `slow + signal - 1` | 34 | 34th | `macd`, `signal`, `histogram` | +| `BollingerBands` | `BollingerBands::new(20, 2.0)` | `period` | 20 | 20th | `upper`, `middle`, `lower`, `stddev` | +| `Stochastic` | `Stochastic::new(14, 3)` | `k_period + d_period - 1` | 16 | 16th | `k`, `d` | +| `Adx` | `Adx::new(14)` | `2 * period` | 28 | 28th | `plus_di`, `minus_di`, `adx` | +| `Aroon` | `Aroon::new(14)` | `period + 1` | 15 | 15th | `up`, `down` | +| `Keltner` | `Keltner::new(20, 10, 2.0)` | `ema_period.max(atr_period)` | 20 | 20th | `upper`, `middle`, `lower` | +| `Donchian` | `Donchian::new(20)` | `period` | 20 | 20th | `upper`, `middle`, `lower` | + +## "Off-by-one" cases worth memorising + +A few indicators look like they should warm up at `period` but in fact need +`period + 1` inputs. The reason is always the same — they consume *diffs* +or *previous-close* differences, not the prices themselves, and the very +first input has nothing to diff against. + +- **`Rsi::new(period)` warmup is `period + 1`.** RSI is based on Wilder's + smoothing over per-tick gains and losses. With 14 prices you only have 13 + diffs; you need 15 prices to compute 14 diffs and seed `avg_gain` / + `avg_loss`. The Rust unit test that pins this is + `warmup_period_is_period_plus_one`: + ```rust + let rsi = Rsi::new(14).unwrap(); + assert_eq!(rsi.warmup_period(), 15); + ``` +- **`Roc::new(period)` warmup is `period + 1`.** ROC compares the current + price to the price `period` bars ago; that comparison only makes sense + starting at input `period + 1`. +- **`Aroon::new(period)` warmup is `period + 1`.** Aroon scans a `period + 1`-bar + window to find the bars-since-high and bars-since-low. +- **`Kama::new(er_period, ...)` warmup is `er_period + 1`.** Kaufman's + efficiency ratio needs `er_period` differences, which costs one extra + bar. + +## Cross-checking from your own code + +The cleanest way to verify any of these from your application code is the +indicator's own `warmup_period()`: + +```rust +use wickra::{Indicator, MacdIndicator}; +let macd = MacdIndicator::classic(); // (12, 26, 9) +assert_eq!(macd.warmup_period(), 34); +``` + +```python +import wickra as ta +assert ta.MACD(12, 26, 9).warmup_period() == 34 +``` + +```javascript +const wickra = require('wickra'); +const sma = new wickra.SMA(20); +console.log(sma.warmupPeriod()); // -> 20 +``` + +(Note: as of `wickra@0.1.4`, `warmupPeriod()` is exposed on the Node +single-output classes but not on every multi-output class — consult +`bindings/node/index.d.ts` for the authoritative surface.) + +## See also + +- [Streaming vs Batch](Streaming-vs-Batch.md) — the `is_ready()` gate, and + why a `len(prices) > warmup_period` check is the wrong abstraction. +- [Indicator Chaining](Indicator-Chaining.md) — how warmups stack inside a + `Chain`. +- Source: diff --git a/docs/wiki/indicators/momentum/Indicator-Adx.md b/docs/wiki/indicators/momentum/Indicator-Adx.md new file mode 100644 index 00000000..a7cbd432 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Adx.md @@ -0,0 +1,245 @@ +# ADX + +> Wilder's Average Directional Index — the smoothed strength of a trend, +> plus the two directional components (`+DI`, `−DI`) that say which +> direction the trend is going. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum (directional) | +| Sub-category | directional / trend-strength | +| Input type | `Candle` | +| Output type | `AdxOutput { plus_di, minus_di, adx }` | +| Output range | each field in `[0, 100]` | +| Default parameters | `period = 14` (Python) | +| Warmup period | `2 · period` (28 for `period = 14`) | +| Interpretation | `adx > 25` means a meaningful trend; the dominant DI gives its direction | + +## Formula + +For each new candle at time `t` (with previous candle `t-1`): + +``` ++DM_t = high_t − high_{t-1} if (high_t − high_{t-1}) > (low_{t-1} − low_t) + and (high_t − high_{t-1}) > 0 + = 0 otherwise + +−DM_t = low_{t-1} − low_t if (low_{t-1} − low_t) > (high_t − high_{t-1}) + and (low_{t-1} − low_t) > 0 + = 0 otherwise + +TR_t = max(high_t − low_t, + |high_t − close_{t-1}|, + |low_t − close_{t-1}|) +``` + +Wilder's smoothing is applied to all three series. Seeding is a simple +sum over the first `period` post-prev candles; after seeding the update +rule for any of these is + +``` +S_t = S_{t-1} − S_{t-1} / period + X_t +``` + +where `X_t` is `TR_t`, `+DM_t`, or `−DM_t`. The directional indicators +and DX then are + +``` ++DI_t = 100 · (+DM smoothed)_t / (TR smoothed)_t +−DI_t = 100 · (−DM smoothed)_t / (TR smoothed)_t +DX_t = 100 · |+DI_t − −DI_t| / (+DI_t + −DI_t) +``` + +`ADX_t` is itself a Wilder-smoothed `DX` series, seeded as the mean of +the first `period` `DX` values, and then updated with `α = 1/period`: + +``` +ADX_t = (ADX_{t-1} · (period − 1) + DX_t) / period +``` + +When `+DI + −DI == 0`, `DX` is `0`; when `TR == 0`, both DI lines are +`0`. These are the divide-by-zero guards in `Adx::update`. + +## Parameters + +| Name | Type | Default (Python) | Valid range | Description | +|------|------|------------------|-------------|-------------| +| `period` | `usize` | `14` | `>= 1` | Wilder smoothing length shared by `+DM`, `−DM`, `TR`, and `ADX`. | + +`Adx::new(0)` returns `Error::PeriodZero`. + +## Inputs / Outputs + +From `impl Indicator for Adx`: + +```rust +type Input = Candle; +type Output = AdxOutput; +fn update(&mut self, candle: Candle) -> Option; +``` + +`AdxOutput`: + +| Field | Description | +|-------|-------------| +| `plus_di` | Plus Directional Indicator (`+DI`) — strength of upward movement. | +| `minus_di` | Minus Directional Indicator (`−DI`) — strength of downward movement. | +| `adx` | Average Directional Index — smoothed `|DX|`, a directionless trend-strength measure. | + +Python's `ADX.batch(high, low, close)` returns a `(n, 3)` `float64` array +with columns `[plus_di, minus_di, adx]`; warmup rows are entirely `NaN`. +The streaming `update(candle)` returns a `(plus_di, minus_di, adx)` +tuple or `None`. + +Node's `ADX.batch(high, low, close)` returns a flat `number[]` of length +`n * 3`, interleaved `[plus_di_0, minus_di_0, adx_0, plus_di_1, …]`. +Only `batch` is exposed on the Node binding — no `update`. + +## Warmup + +`warmup_period()` returns `2 · period`. The first candle just provides a +"previous" reference (no DM/TR can be computed yet); the next `period` +candles seed the smoothed `+DM`, `−DM`, and `TR` sums; the next `period` +candles after that produce `DX` values that seed `ADX`. For `period = +14` that's `1 + 14 + 13 = 28` candles before the first full +`AdxOutput`, which matches `2 · 14 = 28`. + +## Edge cases + +- **Strong unidirectional trend.** If every candle is strictly higher + than the last (with `+DM` always positive, `−DM` always zero), `+DI` + saturates at `100`, `−DI` at `0`, and `ADX` climbs toward `100`. The + example below produces exactly that. +- **Flat market (no high/low movement).** Every `TR`, `+DM`, `−DM` is + zero, so the divide-by-zero guards return `+DI = −DI = 0` and `DX = + 0`; `ADX` then sits at `0` indefinitely. +- **Reset.** `reset()` clears `prev`, all seed sums and counts, all + smoothed values, the DX buffer, and `adx_value`. + +## Examples + +### Rust + +```rust +use wickra::{Adx, BatchExt, Candle, Indicator}; + +let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64 * 2.0; + Candle::new(base + 0.5, base + 1.0, base - 0.5, base + 0.5, 1.0, 0).unwrap() + }) + .collect(); +let mut adx = Adx::new(14)?; +let out = adx.batch(&candles); +let v = out[27].unwrap(); +println!("row 27 +DI={} -DI={} ADX={}", v.plus_di, v.minus_di, v.adx); +let v = out[39].unwrap(); +println!("row 39 +DI={} -DI={} ADX={}", v.plus_di, v.minus_di, v.adx); +# Ok::<(), wickra::Error>(()) +``` + +Verified output: + +``` +row 27 +DI=80 -DI=0 ADX=100 +row 39 +DI=80 -DI=0 ADX=100 +``` + +### Python + +```python +import numpy as np +import wickra as ta + +n = 40 +i = np.arange(n, dtype=float) +base = 100.0 + i * 2.0 +high = base + 1.0 +low = base - 0.5 +close = base + 0.5 +adx = ta.ADX(14) +out = adx.batch(high, low, close) +print('warmup:', adx.warmup_period()) +print('shape :', out.shape) +print('row 27:', out[27]) +print('row 39:', out[39]) +``` + +Verified output: + +``` +warmup: 28 +shape : (40, 3) +row 27: [ 80. 0. 100.] +row 39: [ 80. 0. 100.] +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const n = 40; +const high = [], low = [], close = []; +for (let i = 0; i < n; i++) { + const b = 100 + i * 2; + high.push(b + 1); + low.push(b - 0.5); + close.push(b + 0.5); +} +const adx = new wickra.ADX(14); +const out = adx.batch(high, low, close); +console.log('len :', out.length); +console.log('row 27:', { plusDi: out[27 * 3], minusDi: out[27 * 3 + 1], adx: out[27 * 3 + 2] }); +console.log('row 39:', { plusDi: out[39 * 3], minusDi: out[39 * 3 + 1], adx: out[39 * 3 + 2] }); +``` + +Verified output: + +``` +len : 120 +row 27: { plusDi: 80, minusDi: 0, adx: 100 } +row 39: { plusDi: 80, minusDi: 0, adx: 100 } +``` + +## Interpretation + +- **Trend-strength bands.** `ADX < 20` is typically read as a ranging + market; `ADX > 25` as a "real" trend; `ADX > 40` as a strong trend. + ADX itself is direction-agnostic — you need `+DI` vs `−DI` to know + which way the trend points. +- **DI crossover.** `+DI` crossing above `−DI` is a bullish directional + signal; the mirror is bearish. Many traders only act on a crossover + when `ADX > 25` to filter out crossovers in a ranging market. +- **ADX peaks.** A rising ADX confirms trend continuation; a falling + ADX from a high level suggests the current trend is exhausting (even + if `+DI` still dominates `−DI`). + +## Common pitfalls + +- **Long warmup.** ADX needs `2 · period` candles before the first + emission — twice as many as most other Wilder indicators. A common + bug is reusing an "RSI fits in `period + 1` bars" mental model and + reading garbage during the ADX warmup; check `is_ready()` or test + for `NaN` on the `adx` column. +- **Plotted on the same axis as DI.** `+DI`, `−DI`, and `ADX` all live + in `[0, 100]` and are typically overlaid. The crossover signal is + between `+DI` and `−DI` only — `ADX` does not cross either of them + for any directional meaning. + +## References + +- J. Welles Wilder, *New Concepts in Technical Trading Systems*, Trend + Research, 1978 — the original publication of `+DI`, `−DI`, `DX`, + `ADX`, and the smoothing scheme they share with RSI and ATR. + +## See also + +- [Indicator: Rsi](Indicator-Rsi.md) — shares Wilder smoothing. +- [Indicator: Aroon](Indicator-Aroon.md) — alternative trend-strength + measure, range-based. +- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — trend-following + momentum, useful as a confirmation against `+DI` / `−DI`. +- [Warmup Periods](../../Warmup-Periods.md) — the `2 · period` ADX entry. diff --git a/docs/wiki/indicators/momentum/Indicator-Aroon.md b/docs/wiki/indicators/momentum/Indicator-Aroon.md new file mode 100644 index 00000000..6071ce3b --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Aroon.md @@ -0,0 +1,206 @@ +# Aroon + +> Tushar Chande's Aroon indicator — tracks the bars-since-highest-high +> and bars-since-lowest-low inside a `period + 1`-bar window, reported +> as percentages. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum (directional / trend-strength) | +| Sub-category | bounded directional pair | +| Input type | `Candle` | +| Output type | `AroonOutput { up, down }` | +| Output range | `up, down ∈ [0, 100]` | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period + 1` (15 for `period = 14`) | +| Interpretation | `up > 70 && down < 30` strong uptrend (mirror for downtrend); crossovers as turn signals | + +## Formula + +Scan the rolling `period + 1`-bar window for the position of the highest +high and the position of the lowest low (with `0 = oldest`, +`period = newest`): + +``` +hh_idx_t = argmax_{i in 0..period} high_{t-period+i} +ll_idx_t = argmin_{i in 0..period} low_{t-period+i} + +up_t = 100 · hh_idx_t / period +down_t = 100 · ll_idx_t / period +``` + +When the highest high lands on the most-recent bar (`hh_idx == period`), +`up == 100`; when it lands on the oldest bar in the window, `up == 0`. +The same holds for `down`. + +In Wickra's implementation the scan uses `>=` / `<=`, so ties go to the +*latest* matching bar — which is why a perfectly flat window produces +`up = down = 100` rather than `0` (the latest bar is always tied with +the oldest). + +## Parameters + +| Name | Type | Default (Python) | Valid range | Description | +|------|------|------------------|-------------|-------------| +| `period` | `usize` | `14` | `>= 1` | Lookback length. The internal window holds `period + 1` candles. | + +`Aroon::new(0)` returns `Error::PeriodZero`. + +## Inputs / Outputs + +From `impl Indicator for Aroon`: + +```rust +type Input = Candle; +type Output = AroonOutput; +fn update(&mut self, candle: Candle) -> Option; +``` + +`AroonOutput`: + +| Field | Description | +|-------|-------------| +| `up` | `100 · bars_since_oldest_HH / period`, in `[0, 100]`. High = recent new high. | +| `down` | `100 · bars_since_oldest_LL / period`, in `[0, 100]`. High = recent new low. | + +Python's `Aroon.batch(high, low)` returns a `(n, 2)` `float64` array +with columns `[up, down]`; warmup rows are `[NaN, NaN]`. Streaming +`update(candle)` returns a `(up, down)` tuple or `None`. + +Node's `Aroon.batch(high, low)` returns a flat `number[]` of length +`n * 2`, interleaved `[up_0, down_0, up_1, down_1, …]`. Only `batch` +is exposed on the Node binding. + +## Warmup + +`warmup_period()` returns `period + 1`. Aroon scans `period + 1` bars +to find "bars since highest high" (which ranges over `0..period`), so +the indicator is not ready until exactly `period + 1` candles have +arrived. This is the same off-by-one as RSI and ROC, but for a +window-position reason rather than a diff reason. + +## Edge cases + +- **Pure uptrend.** Every new candle is a new high — `hh_idx` is always + the latest position, `up == 100`. The lowest low is the oldest + candle in the window, `down == 0`. Tests `pure_uptrend_aroon_up_100` + pin this. +- **Constant input.** Every candle's high is equal to every other + candle's high. The `>=` tiebreak in the scan means the most-recent + candle always wins both the HH and LL positions — both `up` and + `down` end up at `100`. (Be careful: this is *not* a neutral + reading; it is an artefact of the tiebreak rule.) +- **Reset.** `reset()` clears the candle buffer; the next `period + 1` + updates return `None`. + +## Examples + +### Rust + +```rust +use wickra::{Aroon, BatchExt, Candle, Indicator}; + +let candles: Vec = (1..=15) + .map(|i| Candle::new(i as f64, i as f64 + 1.0, i as f64 - 1.0, i as f64, 1.0, 0).unwrap()) + .collect(); +let mut aroon = Aroon::new(14)?; +let out = aroon.batch(&candles); +let v = out[14].unwrap(); +println!("uptrend row 14 up={} down={}", v.up, v.down); +# Ok::<(), wickra::Error>(()) +``` + +Verified output: + +``` +uptrend row 14 up=100 down=0 +``` + +### Python + +```python +import numpy as np +import wickra as ta + +i = np.arange(1, 16, dtype=float) +high = i + 1.0 +low = i - 1.0 +aroon = ta.Aroon(14) +out = aroon.batch(high, low) +print('warmup:', aroon.warmup_period()) +print('shape :', out.shape) +print('row 14:', out[14]) +``` + +Verified output: + +``` +warmup: 15 +shape : (15, 2) +row 14: [100. 0.] +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const high = [], low = []; +for (let i = 1; i <= 15; i++) { + high.push(i + 1); + low.push(i - 1); +} +const a = new wickra.Aroon(14); +const out = a.batch(high, low); +console.log('len :', out.length); +console.log('row 14:', { up: out[14 * 2], down: out[14 * 2 + 1] }); +``` + +Verified output: + +``` +len : 30 +row 14: { up: 100, down: 0 } +``` + +## Interpretation + +- **Strong trend bands.** `up > 70` with `down < 30` indicates a strong + uptrend (new highs are recent, new lows are old); mirror for a + downtrend. +- **Crossover.** `up` crossing above `down` is a bullish trend-shift + signal; the mirror is bearish. Crossovers near `50/50` are weak + (the window has no clear leader); crossovers from `0`/`100` extremes + are strong. +- **Consolidation.** Both lines wandering near `50` means neither + recent highs nor recent lows are dominating — typical of a + range-bound market. + +## Common pitfalls + +- **Constant input gives `up == down == 100`, not `0` or `50`.** The + `>=` / `<=` tiebreak in the scan rewards the most-recent candle. + Treat constant or near-constant windows as a degenerate case; a + reading of `(100, 100)` is *not* a strong trend in both directions + — it is "no information". +- **`period + 1` warmup, not `period`.** Same off-by-one trap as RSI: + the indicator looks at a window of size `period + 1` so that + `bars_since_high` can range from `0` to `period`. Indexing your + output array as if it were ready at the `period`-th input gives you + one `NaN` / `None` row at the start you didn't expect. + +## References + +- Tushar Chande, "A New Tool for Technical Traders: The Aroon + Indicator", *Technical Analysis of Stocks & Commodities*, September + 1995 — the original publication. + +## See also + +- [Indicator: Adx](Indicator-Adx.md) — alternative trend-strength + measure with explicit `+DI` / `−DI` direction. +- [Indicator: Stochastic](Indicator-Stochastic.md) — also range-window + based, but reports close position rather than extremum age. +- [Warmup Periods](../../Warmup-Periods.md) — the `period + 1` family. diff --git a/docs/wiki/indicators/momentum/Indicator-AwesomeOscillator.md b/docs/wiki/indicators/momentum/Indicator-AwesomeOscillator.md new file mode 100644 index 00000000..f3f6e56f --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-AwesomeOscillator.md @@ -0,0 +1,196 @@ +# AwesomeOscillator + +> Bill Williams' Awesome Oscillator — the difference of two simple moving +> averages computed on the bar's median price `(high + low) / 2`. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | unbounded oscillator (zero-centred) | +| Input type | `Candle` | +| Output type | `f64` | +| Output range | unbounded (centred on 0; in price-difference units) | +| Default parameters | `fast = 5`, `slow = 34` (`AwesomeOscillator::classic()`, Python default) | +| Warmup period | `slow_period` (34 for the classic configuration) | +| Interpretation | zero-line cross; "saucer" and "twin-peaks" Bill Williams patterns | + +## Formula + +For each new candle, compute the median price: + +``` +median_t = (high_t + low_t) / 2 +``` + +Then AO is the difference of two SMAs of that series: + +``` +AO_t = SMA_fast(median)_t − SMA_slow(median)_t +``` + +There is no smoothing on top — the output is in the same units as the +input prices (a number, not a percent). + +## Parameters + +| Name | Type | Default (Python) | Valid range | Description | +|------|------|------------------|-------------|-------------| +| `fast` | `usize` | `5` | `>= 1` and `< slow` | Fast SMA period over median price. | +| `slow` | `usize` | `34` | `>= 1` and `> fast` | Slow SMA period over median price. | + +`AwesomeOscillator::new` returns `Error::PeriodZero` if either period is +zero and `Error::InvalidPeriod` if `fast >= slow`. + +## Inputs / Outputs + +From `impl Indicator for AwesomeOscillator`: + +```rust +type Input = Candle; +type Output = f64; +fn update(&mut self, candle: Candle) -> Option; +``` + +The `close` and `volume` fields on the input candle are ignored — only +`high` and `low` matter, via `Candle::median_price()`. + +Python's `AwesomeOscillator.batch(high, low)` returns a 1-D `float64` +`np.ndarray`. Node's `AwesomeOscillator.batch(high, low)` returns a +flat `number[]`. Both produce `NaN` during warmup; only Python exposes +a streaming `update(candle)` method. + +## Warmup + +`warmup_period()` returns `slow_period`. The slow SMA is the slower of +the two SMAs, and because both consume the same median-price stream the +first time both have valid output is exactly the `slow_period`-th input. +For the classic `(5, 34)` configuration this is `34` — verified above. + +## Edge cases + +- **Constant input.** Both SMAs converge to the constant median price, + so `AO == 0` (test `constant_series_yields_zero`). +- **Reset.** `reset()` resets both SMAs; the next `slow_period` updates + return `None`. + +## Examples + +### Rust + +```rust +use wickra::{AwesomeOscillator, BatchExt, Candle, Indicator}; + +let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + i as f64; + Candle::new(m, m + 1.0, m - 1.0, m, 1.0, 0).unwrap() + }) + .collect(); +let mut ao = AwesomeOscillator::classic(); +let out = ao.batch(&candles); +println!("row 33 = {}", out[33].unwrap()); +println!("row 39 = {}", out[39].unwrap()); +``` + +Verified output: + +``` +row 33 = 14.5 +row 39 = 14.5 +``` + +(`SMA(5) − SMA(34)` on a unit-slope ramp converges to a constant offset +that depends only on the difference between the two windows' centres, +which is why both rows print the same number.) + +### Python + +```python +import numpy as np +import wickra as ta + +n = 40 +i = np.arange(n, dtype=float) +m = 100.0 + i +high = m + 1.0 +low = m - 1.0 +ao = ta.AwesomeOscillator(5, 34) +out = ao.batch(high, low) +print('warmup:', ao.warmup_period()) +print('row 33:', out[33]) +print('row 39:', out[39]) +``` + +Verified output: + +``` +warmup: 34 +row 33: 14.5 +row 39: 14.5 +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const n = 40; +const high = [], low = []; +for (let i = 0; i < n; i++) { + const m = 100 + i; + high.push(m + 1); + low.push(m - 1); +} +const ao = new wickra.AwesomeOscillator(5, 34); +const out = ao.batch(high, low); +console.log('row 33:', out[33]); +console.log('row 39:', out[39]); +``` + +Verified output: + +``` +row 33: 14.5 +row 39: 14.5 +``` + +## Interpretation + +- **Zero-line cross.** AO crossing zero from below is a bullish + momentum signal — the fast SMA of median price has overtaken the + slow SMA. The mirror cross is bearish. +- **Saucer.** A short sequence of bars where AO turns from negative to + positive momentum without crossing zero (two declining-magnitude + bars on the same side of zero followed by a turn) is Bill Williams' + "saucer" pattern. +- **Twin peaks.** Two AO peaks on the same side of the zero line, with + the second peak lower (or shallower) than the first while price + pushes further, is Williams' divergence-style "twin peaks" pattern. + +## Common pitfalls + +- **Median-price input, not close.** AO ignores `close` entirely. If + your data source reports an "average" price or only closes, you must + reconstruct `high` and `low` or pick a different oscillator (e.g. + MACD on closes). +- **Output magnitude depends on the asset.** Because AO is in raw + price units, an AO of `14.5` on a price ramp through `100..140` + means something completely different than `14.5` on a price stream + near `0.00012`. Always interpret AO relative to a per-asset baseline + or normalise by ATR. + +## References + +- Bill Williams, *Trading Chaos: Applying Expert Techniques to + Maximize Your Profits*, Wiley, 1995 — introduces the Awesome + Oscillator alongside the rest of the Profitunity tool set. + +## See also + +- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — sister + oscillator on closes (with an extra signal line on top). +- [Indicator: Trix](Indicator-Trix.md) — momentum oscillator on a + triple-smoothed series. +- [Warmup Periods](../../Warmup-Periods.md) — bare `slow_period`. diff --git a/docs/wiki/indicators/momentum/Indicator-Cci.md b/docs/wiki/indicators/momentum/Indicator-Cci.md new file mode 100644 index 00000000..7ae3120f --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Cci.md @@ -0,0 +1,199 @@ +# CCI + +> Commodity Channel Index — measures how far the current typical price +> deviates from its rolling mean, in units of mean absolute deviation +> scaled by Lambert's constant. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | unbounded oscillator | +| Input type | `Candle` | +| Output type | `f64` | +| Output range | unbounded (typically `[−200, +200]` thanks to the 0.015 factor) | +| Default parameters | `period = 20` (Python) | +| Warmup period | `period` (20 for `period = 20`) | +| Interpretation | `> +100` overbought, `< −100` oversold (Lambert) | + +## Formula + +For each candle, compute the typical price `TP = (high + low + close) / 3`, +then over the rolling `period`-bar window: + +``` +SMA_TP_t = (TP_{t-period+1} + … + TP_t) / period +MAD_t = (1 / period) · Σ |TP_i − SMA_TP_t| for i = t-period+1 … t + +CCI_t = (TP_t − SMA_TP_t) / (factor · MAD_t) +``` + +The default `factor` is Lambert's `0.015`, chosen empirically so that +roughly 70–80 % of values fall inside `[−100, +100]`. The implementation +exposes the factor through `Cci::with_factor(period, factor)` if you want +to retune it for an asset with very different volatility characteristics. + +When `MAD == 0` (a perfectly flat window), the implementation returns `0` +rather than dividing by zero. + +## Parameters + +| Name | Type | Default (Python) | Valid range | Description | +|------|------|------------------|-------------|-------------| +| `period` | `usize` | `20` | `>= 1` | Rolling window length for both the SMA of typical price and the MAD. | +| `factor` | `f64` | `0.015` (`Cci::new`) | `> 0`, finite | Lambert's scaling constant; configurable via `Cci::with_factor`. | + +`Cci::new(0)` returns `Error::PeriodZero`. `Cci::with_factor(_, factor)` +returns `Error::NonPositiveMultiplier` when `factor <= 0` or non-finite. + +## Inputs / Outputs + +From `impl Indicator for Cci`: + +```rust +type Input = Candle; +type Output = f64; +fn update(&mut self, candle: Candle) -> Option; +``` + +Python's `CCI.batch(high, low, close)` returns a 1-D `float64` `np.ndarray` +with `NaN` during warmup. Node's `CCI.batch(high, low, close)` returns a +flat `number[]` (also `NaN` during warmup); the Node binding does not +expose a streaming `update()` (`bindings/node/index.d.ts` lists only +`constructor` and `batch`). + +## Warmup + +`warmup_period()` returns exactly `period`. CCI does not consume diffs — +it only needs `period` typical-price samples to populate its rolling +window before it can compute an SMA and MAD. In streaming terms, calls +`1..period` return `None`; the `period`-th call returns the first value. + +## Edge cases + +- **Flat input.** Every `TP` is the SMA, so `MAD == 0` and the + implementation returns `0.0` (test `flat_candles_yield_zero`). This + avoids the divide-by-zero that would otherwise produce `NaN` / + `±∞`. +- **Custom factor.** `Cci::with_factor(period, factor)` lets you replace + Lambert's `0.015`. Picking a smaller factor widens the typical range + of CCI values; picking a larger one compresses them. +- **Reset.** `reset()` clears the rolling window and the running sum, + returning the indicator to the freshly-constructed state. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Cci, Indicator}; + +let candles: Vec = (0..25) + .map(|i| { + let m = 50.0 + i as f64; + Candle::new(m, m + 1.0, m - 1.0, m, 1.0, 0).unwrap() + }) + .collect(); +let mut cci = Cci::new(20)?; +let out = cci.batch(&candles); +println!("row 19 = {}", out[19].unwrap()); +println!("row 24 = {}", out[24].unwrap()); +# Ok::<(), wickra::Error>(()) +``` + +Verified output: + +``` +row 19 = 126.66666666666667 +row 24 = 126.66666666666667 +``` + +### Python + +```python +import numpy as np +import wickra as ta + +i = np.arange(25, dtype=float) +m = 50.0 + i +high = m + 1.0 +low = m - 1.0 +close = m +cci = ta.CCI(20) +out = cci.batch(high, low, close) +print('row 19:', out[19]) +print('row 24:', out[24]) +``` + +Verified output: + +``` +row 19: 126.66666666666667 +row 24: 126.66666666666667 +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const n = 25; +const high = [], low = [], close = []; +for (let i = 0; i < n; i++) { + const m = 50 + i; + high.push(m + 1); + low.push(m - 1); + close.push(m); +} +const cci = new wickra.CCI(20); +const out = cci.batch(high, low, close); +console.log('row 19:', out[19]); +console.log('row 24:', out[24]); +``` + +Verified output: + +``` +row 19: 126.66666666666667 +row 24: 126.66666666666667 +``` + +## Interpretation + +- **±100 threshold.** Lambert's published convention is to treat values + above `+100` as overbought and below `−100` as oversold. The choice + of `0.015` for the divisor is what makes the threshold meaningful; + changing the factor changes the threshold. +- **Zero-line cross.** `CCI` crossing zero says the typical price has + moved through its `period`-bar mean — sometimes used as a + trend-direction filter. +- **Divergence.** As with RSI/Stochastic, a price making a new high + while CCI makes a lower high is a classic bearish divergence. + +## Common pitfalls + +- **CCI is unbounded.** Unlike RSI or Stochastic, CCI can spike well + outside `±100` in volatile markets. Threshold-based rules should be + paired with a maximum-absolute-value guard, or you will mis-classify + legitimate breakouts as "extreme overbought". +- **The 0.015 factor is empirical, not derived.** It was chosen by + Lambert in 1980 for commodity futures markets. Modern equities and + crypto have wider distributions; if your `|CCI|` distribution sits + almost entirely outside `±100`, retune via `Cci::with_factor` rather + than rewriting downstream thresholds. + +## References + +- Donald Lambert, "Commodity Channel Index: Tools for Trading Cyclical + Trends", *Commodities Magazine*, October 1980 — the original + publication, including the empirical choice of `0.015`. + +## See also + +- [Indicator: Rsi](Indicator-Rsi.md) — bounded sibling for comparison. +- [Indicator: WilliamsR](Indicator-WilliamsR.md) — another candle-input + oscillator, range-based rather than deviation-based. +- [Indicator: Mfi](Indicator-Mfi.md) — volume-weighted RSI; useful as a + confirmation alongside CCI. +- [Warmup Periods](../../Warmup-Periods.md) — `period` (no off-by-one). diff --git a/docs/wiki/indicators/momentum/Indicator-MacdIndicator.md b/docs/wiki/indicators/momentum/Indicator-MacdIndicator.md new file mode 100644 index 00000000..9a3c3ad0 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-MacdIndicator.md @@ -0,0 +1,216 @@ +# MacdIndicator + +> Moving Average Convergence Divergence — the difference of two EMAs, with +> a third EMA on top as the signal line. + +The Rust struct is `MacdIndicator` (since `Macd` would collide with the +output struct on case-insensitive file systems and several existing trait +imports). The Python and Node bindings expose the same engine under the +shorter, conventional name `MACD`. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | unbounded oscillator (trend-following) | +| Input type | `f64` (close) | +| Output type | `MacdOutput { macd, signal, histogram }` | +| Output range | unbounded (centred on 0) | +| Default parameters | `fast = 12`, `slow = 26`, `signal = 9` (`MacdIndicator::classic()`) | +| Warmup period | `slow + signal − 1` (34 for the classic configuration) | +| Interpretation | crossovers of `macd` and `signal`; zero-line crosses; histogram momentum | + +## Formula + +``` +EMA_n(x) = exponential moving average of x over n periods + (Wickra's EMA seeds from a simple average of the first n inputs) + +macd_t = EMA_fast(close)_t − EMA_slow(close)_t +signal_t = EMA_signal(macd)_t +hist_t = macd_t − signal_t +``` + +The signal EMA does not start consuming inputs until `macd_t` becomes +defined (i.e. until both the fast and slow EMAs have seeded), which is +why the overall warmup is `slow + signal − 1` rather than +`max(slow, signal)`. + +## Parameters + +| Name | Type | Default (Python) | Valid range | Description | +|------|------|------------------|-------------|-------------| +| `fast` | `usize` | `12` | `>= 1` and `< slow` | Fast EMA period. | +| `slow` | `usize` | `26` | `>= 1` and `> fast` | Slow EMA period. | +| `signal` | `usize` | `9` | `>= 1` | EMA period applied to the raw MACD line. | + +`MacdIndicator::new` returns `Error::PeriodZero` if any period is zero and +`Error::InvalidPeriod` if `fast >= slow`. + +## Inputs / Outputs + +From `impl Indicator for MacdIndicator`: + +```rust +type Input = f64; +type Output = MacdOutput; +fn update(&mut self, input: f64) -> Option; +``` + +`MacdOutput` carries three fields: + +| Field | Description | +|-------|-------------| +| `macd` | `EMA(fast) − EMA(slow)` of the input series. | +| `signal` | `EMA(signal)` of `macd`. | +| `histogram` | `macd − signal`. | + +Python's `MACD.batch(prices)` returns a `(n, 3)` `float64` array with +columns `[macd, signal, histogram]`; warmup rows are entirely `NaN`. + +Node's `MACD.batch(prices)` returns a flat `number[]` of length `n * 3` +in the same interleaved order: index `i*3 + 0` is `macd`, `i*3 + 1` is +`signal`, `i*3 + 2` is `histogram`. The streaming `update(value)` returns +a `{ macd, signal, histogram }` object (or `null` during warmup). + +## Warmup + +`warmup_period()` returns `slow + signal − 1`. The slow EMA seeds at +input `slow`; from that point onward the signal EMA starts receiving +`macd` values, and needs `signal − 1` further inputs to seed itself. +For the classic `(12, 26, 9)` configuration this gives `26 + 9 − 1 = 34` +inputs before the first complete `MacdOutput` is emitted, as pinned by +the unit test `first_emission_matches_warmup_period`. + +## Edge cases + +- **Constant input.** Both EMAs converge to the constant value, so `macd` + approaches `0`; with no movement in `macd`, the signal EMA also + approaches `0`, and so does the histogram. The Rust test + `constant_series_yields_zero_macd_eventually` pins this. +- **Non-finite input.** `update(NaN)` or `update(±∞)` returns the + previously emitted `MacdOutput` without advancing any internal EMA. +- **Reset.** `reset()` resets all three EMAs and clears `last`. The next + `warmup_period()` calls return `None` again. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, MacdIndicator}; + +let prices: Vec = (0..40).map(|i| 100.0 + i as f64 * (20.0 / 39.0)).collect(); +let mut macd = MacdIndicator::classic(); +let out = macd.batch(&prices); +let v = out[33].unwrap(); +println!("row 33 macd={} signal={} hist={}", v.macd, v.signal, v.histogram); +let v = out[39].unwrap(); +println!("row 39 macd={} signal={} hist={}", v.macd, v.signal, v.histogram); +``` + +Verified output: + +``` +row 33 macd=3.589743589743577 signal=3.5897435897435788 hist=-0.0000000000000017763568394002505 +row 39 macd=3.589743589743591 signal=3.589743589743585 hist=0.000000000000006217248937900877 +``` + +### Python + +```python +import numpy as np +import wickra as ta + +prices = np.linspace(100.0, 120.0, 40) +macd = ta.MACD(12, 26, 9) +out = macd.batch(prices) +print('shape :', out.shape) +print('warmup:', macd.warmup_period()) +print('row 33:', out[33]) +print('row 39:', out[39]) +``` + +Verified output: + +``` +shape : (40, 3) +warmup: 34 +row 33: [ 3.58974359e+00 3.58974359e+00 -1.77635684e-15] +row 39: [3.58974359e+00 3.58974359e+00 6.21724894e-15] +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const macd = new wickra.MACD(12, 26, 9); +const prices = Array.from({ length: 40 }, (_, i) => 100 + i * 20 / 39); +const flat = macd.batch(prices); +console.log('flat length:', flat.length); +console.log('row 33 macd :', flat[33 * 3]); +console.log('row 33 signal:', flat[33 * 3 + 1]); +console.log('row 33 hist :', flat[33 * 3 + 2]); +console.log('row 39 macd :', flat[39 * 3]); +console.log('row 39 signal:', flat[39 * 3 + 1]); +console.log('row 39 hist :', flat[39 * 3 + 2]); +``` + +Verified output: + +``` +flat length: 120 +row 33 macd : 3.589743589743577 +row 33 signal: 3.5897435897435788 +row 33 hist : -1.7763568394002505e-15 +row 39 macd : 3.589743589743591 +row 39 signal: 3.589743589743585 +row 39 hist : 6.217248937900877e-15 +``` + +## Interpretation + +- **Signal-line crossover.** `macd` crossing above `signal` is the canonical + bullish signal; the symmetric crossover below is bearish. The + `histogram` makes this explicit — it crosses zero on the same bar. +- **Zero-line crossover.** `macd` crossing above zero says the fast EMA + has overtaken the slow EMA; a longer-term trend confirmation, weaker + than the signal-line cross. +- **Histogram momentum.** Rising histogram bars (even while negative) + indicate that bearish momentum is fading, and vice versa. Traders use + this to anticipate signal-line crosses. + +## Common pitfalls + +- **The signal line lags the MACD line by `signal_period` bars.** A + crossover signal therefore arrives one full EMA-cycle after the + underlying momentum turn, which is why MACD is a *confirmation* + indicator, not a leading one. +- **`fast >= slow` is rejected.** A common bug when reading + parameters from a config file is swapping the two — the constructor + returns `Error::InvalidPeriod` rather than silently producing an + inverted MACD line. +- **Don't slice a single column out of a warmup row.** During the first + `slow + signal − 1` inputs every field is `NaN` (Python) or absent + (`None` in Rust / `null` in Node). Filter by checking `macd` for + finiteness before reading `signal` or `histogram`. + +## References + +- Gerald Appel, *Technical Analysis: Power Tools for Active Investors*, + Financial Times Prentice Hall, 2005 — the canonical modern treatment + of the MACD line/signal-line/histogram trio Appel popularised in the + late 1970s. + +## See also + +- [Indicator: Rsi](Indicator-Rsi.md) — bounded sibling oscillator, useful + as a confirmation filter on top of MACD signals. +- [Indicator: Trix](Indicator-Trix.md) — another EMA-based momentum + oscillator (triple-smoothed rate of change). +- [Warmup Periods](../../Warmup-Periods.md) — table including the `slow + + signal − 1` rule. +- [Quickstart: Python](../../Quickstart-Python.md) — MACD multi-column NaN + pattern explained. diff --git a/docs/wiki/indicators/momentum/Indicator-Mfi.md b/docs/wiki/indicators/momentum/Indicator-Mfi.md new file mode 100644 index 00000000..097d921a --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Mfi.md @@ -0,0 +1,204 @@ +# MFI + +> Money Flow Index — a volume-weighted RSI built on typical price times +> volume. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | bounded oscillator (volume-driven) | +| Input type | `Candle` (volume needed) | +| Output type | `f64` | +| Output range | `[0, 100]` | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period` (14 for `period = 14`) | +| Interpretation | overbought above 80, oversold below 20 | + +## Formula + +For each new candle: + +``` +TP_t = (high_t + low_t + close_t) / 3 (typical price) +MF_t = TP_t · volume_t (money flow) + +positive MF = MF_t if TP_t > TP_{t-1}, else 0 +negative MF = MF_t if TP_t < TP_{t-1}, else 0 + (both zero when TP_t == TP_{t-1}) +``` + +Maintain rolling sums of positive and negative money flow over the last +`period` bars. Then: + +``` +MR_t = positive_sum / negative_sum +MFI_t = 100 − 100 / (1 + MR_t) +``` + +The implementation guards both special cases: when both rolling sums are +zero, MFI returns `50` (neutral); when only `negative_sum == 0`, MFI +returns `100`; otherwise the standard formula. + +## Parameters + +| Name | Type | Default (Python) | Valid range | Description | +|------|------|------------------|-------------|-------------| +| `period` | `usize` | `14` | `>= 1` | Rolling window length for the positive/negative money-flow sums. | + +`Mfi::new(0)` returns `Error::PeriodZero`. + +## Inputs / Outputs + +From `impl Indicator for Mfi`: + +```rust +type Input = Candle; +type Output = f64; +fn update(&mut self, candle: Candle) -> Option; +``` + +Volume is consumed via `candle.volume` — it is not optional. Calling +the indicator with a zero-volume candle is legal (every money flow on +that bar is zero), but mass zero-volume bars will dilute the sums. + +Python's `MFI.batch(high, low, close, volume)` returns a 1-D `float64` +`np.ndarray` (warmup → `NaN`). Node's `MFI.batch(high, low, close, +volume)` returns a flat `number[]` (warmup → `NaN`); only `batch` is +exposed on the Node binding. + +## Warmup + +`warmup_period()` returns `period`. The first candle has no previous +`TP` to compare against, so its money flow is classified as neither +positive nor negative — it sits in the window as a `0 / 0` slot but +still counts toward filling the window. The first `Some` is therefore +emitted at the `period`-th `update`, exactly when the rolling positive +and negative sums first contain `period − 1` real comparisons. + +## Edge cases + +- **Pure uptrend.** Every `TP_t > TP_{t-1}`, so `negative_sum == 0` and + the implementation returns `100` directly (test + `pure_uptrend_yields_high_mfi`). Pure downtrend mirrors at `0` (test + `pure_downtrend_yields_low_mfi`). +- **Flat input (all `TP` equal).** Both sums stay at zero; the + implementation returns `50` (the same neutral convention as RSI on + flat input). +- **Zero-volume candle.** Money flow on that bar is zero. The window + still advances; the indicator just gets one less data point of + influence. +- **Reset.** `reset()` clears `prev_tp`, both rolling windows, and both + sums. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, Mfi}; + +let candles: Vec = (1..=20) + .map(|i| Candle::new(i as f64, i as f64, i as f64, i as f64, 100.0, 0).unwrap()) + .collect(); +let mut mfi = Mfi::new(14)?; +let out = mfi.batch(&candles); +println!("row 13 = {}", out[13].unwrap()); +println!("row 19 = {}", out[19].unwrap()); +# Ok::<(), wickra::Error>(()) +``` + +Verified output: + +``` +row 13 = 100 +row 19 = 100 +``` + +### Python + +```python +import numpy as np +import wickra as ta + +n = 20 +i = np.arange(1, n + 1, dtype=float) +high = low = close = i +volume = np.full(n, 100.0) +mfi = ta.MFI(14) +out = mfi.batch(high, low, close, volume) +print('warmup:', mfi.warmup_period()) +print('row 13:', out[13]) +print('row 19:', out[19]) +``` + +Verified output: + +``` +warmup: 14 +row 13: 100.0 +row 19: 100.0 +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const n = 20; +const high = [], low = [], close = [], vol = []; +for (let i = 1; i <= n; i++) { + high.push(i); low.push(i); close.push(i); vol.push(100); +} +const m = new wickra.MFI(14); +const out = m.batch(high, low, close, vol); +console.log('row 13:', out[13]); +console.log('row 19:', out[19]); +``` + +Verified output: + +``` +row 13: 100 +row 19: 100 +``` + +## Interpretation + +- **Overbought / oversold.** The conventional MFI thresholds are + `80 / 20` — tighter than RSI's `70 / 30` because the volume weighting + amplifies sustained one-way moves. +- **Divergence.** MFI divergences are read like RSI divergences: a new + price high without a confirming MFI high is bearish, and vice versa. + Because volume is in the mix, MFI divergences are often interpreted + as "the move is happening on weak participation" — i.e. structurally + more meaningful than a pure-price divergence. +- **Compare with OBV.** OBV (the unsmoothed cumulative volume) tells + you accumulated participation; MFI tells you participation pressure + over a fixed horizon. The two often diverge interestingly near + trend exhaustion. + +## Common pitfalls + +- **MFI requires volume.** Unlike RSI (close only) or Stochastic + (high/low/close), MFI's per-bar money flow is `TP × volume`. Passing + a candle stream with `volume == 0` throughout will collapse MFI to + `50` regardless of price action. Validate your data source before + reaching for MFI. +- **Same flat-input convention as RSI.** A perfectly flat window yields + `50` (not `NaN`, not "no value"). Treat the value as informational + only until the underlying TP series starts moving. + +## References + +- Gene Quong and Avrum Soudack, "Volume-Weighted RSI: Money Flow", + *Technical Analysis of Stocks & Commodities*, March 1989 — the + original publication of the MFI as a volume-weighted RSI variant. + +## See also + +- [Indicator: Rsi](Indicator-Rsi.md) — the price-only ancestor. +- [Indicator: Adx](Indicator-Adx.md) — directional/trend strength to + pair with MFI's overbought/oversold reading. +- [Warmup Periods](../../Warmup-Periods.md) — bare `period` (no off-by-one). diff --git a/docs/wiki/indicators/momentum/Indicator-Roc.md b/docs/wiki/indicators/momentum/Indicator-Roc.md new file mode 100644 index 00000000..0727f491 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Roc.md @@ -0,0 +1,173 @@ +# ROC + +> Rate of Change — the percent change between the current close and the +> close `period` bars ago. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | unbounded oscillator | +| Input type | `f64` (close) | +| Output type | `f64` | +| Output range | unbounded (centred on 0; expressed as a percent) | +| Default parameters | none — `period` is required in every binding | +| Warmup period | `period + 1` (13 for `period = 12`) | +| Interpretation | sign and magnitude of momentum; zero-line crossover for direction changes | + +## Formula + +``` +ROC_t = (close_t − close_{t − period}) / close_{t − period} · 100 +``` + +When `close_{t − period}` is exactly zero, the implementation returns +`0.0` rather than dividing by zero. The unit test `known_value` pins the +basic case: with `period = 3`, inputs `[100, 105, 108, 110]` produce +ROC `= 10` at index 3 (because `(110 − 100) / 100 · 100 = 10`). + +## Parameters + +| Name | Type | Default | Valid range | Description | +|------|------|---------|-------------|-------------| +| `period` | `usize` | required | `>= 1` | Lookback distance for the comparison close. | + +`Roc::new(0)` returns `Error::PeriodZero`. The Python and Node bindings +do **not** assign a default for `period`; you must pass it explicitly. + +## Inputs / Outputs + +From `impl Indicator for Roc`: + +```rust +type Input = f64; +type Output = f64; +fn update(&mut self, input: f64) -> Option; +``` + +Python's `ROC.batch(prices)` returns a 1-D `float64` `np.ndarray`. Node's +`ROC.batch(prices)` returns a flat `number[]`. Streaming `update(price)` +returns a scalar (`float` / `number`) or `None` / `null` during warmup. + +## Warmup + +`warmup_period()` returns `period + 1`. The reason is the same off-by-one +as RSI: ROC compares against the close `period` bars ago, so at the +`period`-th input we still have nothing to look back at — the `(period + +1)`-th input is the first one for which `close_{t − period}` exists. +Internally the rolling buffer is sized `period + 1`. + +## Edge cases + +- **Constant input.** Every diff is zero, so `ROC == 0` for every emitted + value (test `constant_series_yields_zero`). +- **Reference close of zero.** Treated as `0.0` rather than producing + `NaN`/`±∞` — see the `prev == 0.0` early return in `update`. This + matters for assets quoted with zero as a legitimate value (rare for + prices, but possible for, e.g., yield spreads). +- **Non-finite input.** `update(NaN)` or `update(±∞)` returns `None` + without advancing the rolling buffer. +- **Reset.** `reset()` clears the rolling buffer; the next `period + 1` + updates return `None`. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Roc}; + +let mut roc = Roc::new(3)?; +let out = roc.batch(&[100.0, 105.0, 108.0, 110.0]); +println!("ROC(3) at idx 3 = {}", out[3].unwrap()); +# Ok::<(), wickra::Error>(()) +``` + +Verified output: + +``` +ROC(3) at idx 3 = 10 +``` + +### Python + +```python +import wickra as ta + +roc = ta.ROC(3) +print('warmup:', roc.warmup_period()) +for p in [100.0, 105.0, 108.0, 110.0]: + print(p, '->', roc.update(p)) +``` + +Verified output: + +``` +warmup: 4 +100.0 -> None +105.0 -> None +108.0 -> None +110.0 -> 10.0 +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const roc = new wickra.ROC(3); +console.log('warmup:', roc.warmupPeriod()); +for (const p of [100, 105, 108, 110]) { + console.log(p, '->', roc.update(p)); +} +``` + +Verified output: + +``` +warmup: 4 +100 -> null +105 -> null +108 -> null +110 -> 10 +``` + +## Interpretation + +- **Sign.** Positive ROC means price is higher than `period` bars ago; + negative means lower. The magnitude is the percent move. +- **Zero-line crossover.** A move through zero signals a regime change + in the `period`-bar horizon. Combined with a longer-period ROC, this + gives you a poor-man's trend filter. +- **Divergence.** A new price high paired with a lower ROC high is the + same bearish-divergence pattern as RSI/Stochastic, with the + unbounded-oscillator caveat that "lower high" is unambiguous (no + saturation against a `100` ceiling). + +## Common pitfalls + +- **ROC is unbounded.** A 10× price spike over `period` bars produces + `ROC = 900`. Don't pipe ROC directly into rule sets designed for + bounded oscillators (RSI, %K, %R) without an explicit clamp or a + log-return transformation upstream. +- **Off-by-one on the warmup.** The first non-`None` value lands at the + `(period + 1)`-th input, not the `period`-th. A common bug is sizing + an output array as `len(prices) - period` and getting an off-by-one + empty row at the end. + +## References + +- Robert Colby, *The Encyclopedia of Technical Market Indicators*, + 2nd ed., McGraw-Hill, 2002 — Chapter on Rate of Change / Momentum, + covering the canonical percent and ratio formulations. + +## See also + +- [Indicator: Rsi](Indicator-Rsi.md) — same `period + 1` warmup, but + bounded. +- [Indicator: Trix](Indicator-Trix.md) — also a rate of change, but on + a triple-smoothed EMA. +- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — momentum + cousin operating on EMA differences instead of raw close differences. +- [Warmup Periods](../../Warmup-Periods.md) — the `period + 1` family. diff --git a/docs/wiki/indicators/momentum/Indicator-Rsi.md b/docs/wiki/indicators/momentum/Indicator-Rsi.md new file mode 100644 index 00000000..b167a7d2 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Rsi.md @@ -0,0 +1,214 @@ +# RSI + +> Relative Strength Index — Wilder's bounded momentum oscillator that maps +> the ratio of average gains to average losses onto the `[0, 100]` range. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | bounded oscillator | +| Input type | `f64` (close) | +| Output type | `f64` | +| Output range | `[0, 100]` | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period + 1` (15 for `period = 14`) | +| Interpretation | overbought above 70, oversold below 30 (Wilder's thresholds) | + +## Formula + +``` +diff_t = close_t − close_{t-1} +gain_t = max(diff_t, 0) +loss_t = max(−diff_t, 0) + +Seed (Wilder, at t = period): + avg_gain_p = (gain_1 + … + gain_p) / p + avg_loss_p = (loss_1 + … + loss_p) / p + +Recursive smoothing (t > period), with α = 1 / period: + avg_gain_t = (avg_gain_{t-1} · (period − 1) + gain_t) / period + avg_loss_t = (avg_loss_{t-1} · (period − 1) + loss_t) / period + +RS_t = avg_gain_t / avg_loss_t +RSI_t = 100 − 100 / (1 + RS_t) +``` + +When `avg_loss_t == 0` and `avg_gain_t > 0`, RSI is `100` directly; when both +are zero (a perfectly flat series) the implementation returns the standard +`50` convention. + +## Parameters + +| Name | Type | Default (Python) | Valid range | Description | +|------|------|------------------|-------------|-------------| +| `period` | `usize` | `14` | `>= 1` | Wilder smoothing length. `Rsi::new(0)` returns `Error::PeriodZero`. | + +## Inputs / Outputs + +From `impl Indicator for Rsi` in `crates/wickra-core/src/indicators/rsi.rs`: + +```rust +type Input = f64; +type Output = f64; +fn update(&mut self, input: f64) -> Option; +``` + +The output is a scalar in `[0, 100]`. In Python `batch(prices)` returns a +1-D `np.ndarray` of `float64`, with `NaN` in the warmup positions. In Node +`batch(prices)` returns a flat `number[]`, also `NaN` during warmup. + +## Warmup + +`warmup_period()` returns `period + 1`. The reason is that RSI consumes +*diffs*, not prices: with `period` prices you only have `period − 1` diffs, +so you need exactly one extra price before Wilder's seed average is well +defined. The Rust test `warmup_period_is_period_plus_one` pins this: + +```rust +let rsi = Rsi::new(14).unwrap(); +assert_eq!(rsi.warmup_period(), 15); +``` + +In streaming terms, the first `period` calls to `update()` return `None`; +the `(period + 1)`-th call returns the first `Some(value)`. + +## Edge cases + +- **Flat input.** When every input price is identical, every `gain` and + every `loss` is zero, so `avg_loss == avg_gain == 0`. The implementation + returns `50.0` by convention (see `Rsi::rsi_from_avgs`). The unit test + `flat_series_yields_rsi_50` pins this behaviour. +- **Pure uptrend / pure downtrend.** `avg_loss == 0` with `avg_gain > 0` + short-circuits to `100`; the mirror case returns `0`. Tests + `pure_uptrend_yields_rsi_100` and `pure_downtrend_yields_rsi_0` cover + this. +- **Non-finite input.** `update()` returns the previously emitted value + (or `None` if no value has been emitted yet) when the input is `NaN` or + infinite — the internal state is *not* advanced. +- **Reset.** `reset()` returns the indicator to the freshly-constructed + state: `prev_close`, both seed buffers, both averages, and `last_value` + are cleared. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Rsi}; + +let prices = [ + 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, + 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, + 46.03, 46.41, 46.22, 45.64, +]; +let mut rsi = Rsi::new(14)?; +let out = rsi.batch(&prices); +println!("first = {}", out[14].unwrap()); +println!("last = {}", out[19].unwrap()); +# Ok::<(), wickra::Error>(()) +``` + +Verified output: + +``` +first = 70.46413502109705 +last = 57.91502067008556 +``` + +### Python + +```python +import numpy as np +import wickra as ta + +prices = np.array([ + 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, + 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, + 46.03, 46.41, 46.22, 45.64, +], dtype=float) +rsi = ta.RSI(14) +v = rsi.batch(prices) +print("warmup:", rsi.warmup_period()) +print("first :", float(v[14])) +print("last :", float(v[-1])) +``` + +Verified output: + +``` +warmup: 15 +first : 70.46413502109705 +last : 57.91502067008556 +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const rsi = new wickra.RSI(14); +const prices = [ + 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, + 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, + 46.03, 46.41, 46.22, 45.64, +]; +const v = rsi.batch(prices); +console.log('warmup:', rsi.warmupPeriod()); +console.log('first :', v[14]); +console.log('last :', v[19]); +``` + +Verified output: + +``` +warmup: 15 +first : 70.46413502109705 +last : 57.91502067008556 +``` + +## Interpretation + +- **Overbought / oversold zones.** Wilder's classic thresholds are `70` + (overbought) and `30` (oversold). Many crypto and FX desks tighten them + to `80 / 20` for trending markets and loosen to `60 / 40` for + range-bound markets. +- **Midline cross.** A move through `50` is sometimes used as a directional + signal; above 50 means average gains exceed average losses over the + smoothing window. +- **Divergence.** A higher price high paired with a lower RSI high (bearish + divergence) is a classic Wilder signal; the symmetric pattern at lows is + bullish. + +## Common pitfalls + +- **RSI on flat input is `50`, not undefined.** The implementation returns + `50.0` when both averages are zero. Do not interpret this as a neutral + signal — it is a placeholder that means "the indicator has no opinion + yet". Pair RSI with a volatility filter (e.g. ATR) if your strategy is + sensitive to ranging markets. +- **`period + 1` warmup, not `period`.** A common bug is sizing the result + array against `period` and indexing into the warmup region. The first + `Some` arrives at the *(period + 1)*-th `update`; in batch form, indices + `0..period` are `None`/`NaN`. See [Warmup Periods](../../Warmup-Periods.md). +- **Non-finite inputs are absorbed silently.** `update(f64::NAN)` does not + advance the state and returns the previous value. If you depend on a 1:1 + input-to-output mapping, pre-validate your data before feeding it in. + +## References + +- J. Welles Wilder, *New Concepts in Technical Trading Systems*, Trend + Research, 1978. The original publication that defines both RSI and the + Wilder smoothing scheme used internally. + +## See also + +- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — also momentum, + but trend-following and unbounded. +- [Indicator: Stochastic](Indicator-Stochastic.md) — sibling bounded + oscillator, faster and noisier than RSI. +- [Warmup Periods](../../Warmup-Periods.md) — the canonical `period + 1` + off-by-one explained. +- [Quickstart: Python](../../Quickstart-Python.md) — full RSI batch / streaming + walk-through. diff --git a/docs/wiki/indicators/momentum/Indicator-Stochastic.md b/docs/wiki/indicators/momentum/Indicator-Stochastic.md new file mode 100644 index 00000000..f6a82189 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Stochastic.md @@ -0,0 +1,220 @@ +# Stochastic + +> The fast Stochastic Oscillator — `%K` measures where the current close +> sits inside the high/low range of the last `k_period` bars, and `%D` is +> a short SMA on top of `%K`. + +Wickra ships a single **fast** variant (`%K` is the raw oscillator value, +`%D` is its SMA). The "slow stochastic" wraps an additional SMA on `%K`; +that variant is not built in — if you need it, smooth `%K` yourself via +a `Chain` with `Sma::new(slow_period)`. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | bounded oscillator | +| Input type | `Candle` | +| Output type | `StochasticOutput { k, d }` | +| Output range | `k, d ∈ [0, 100]` | +| Default parameters | `k_period = 14`, `d_period = 3` (`Stochastic::classic()`) | +| Warmup period | `k_period + d_period − 1` (16 for the classic configuration) | +| Interpretation | overbought above 80, oversold below 20; %K / %D crossovers | + +## Formula + +For each new candle at time `t`, let `HH` and `LL` be the highest high +and lowest low over the last `k_period` candles: + +``` +HH_t = max(high_{t-k_period+1}, …, high_t) +LL_t = min(low_{t-k_period+1}, …, low_t) + +%K_t = 100 · (close_t − LL_t) / (HH_t − LL_t) when HH ≠ LL +%K_t = 50 when HH == LL (flat range) + +%D_t = SMA_{d_period}(%K)_t +``` + +The implementation maintains `HH` and `LL` with two monotonic deques so +each update is amortized O(1). + +## Parameters + +| Name | Type | Default (Python) | Valid range | Description | +|------|------|------------------|-------------|-------------| +| `k_period` | `usize` | `14` | `>= 1` | Lookback window for the `%K` extrema. | +| `d_period` | `usize` | `3` | `>= 1` | SMA period for `%D` over the `%K` stream. | + +Either period being zero returns `Error::PeriodZero`. + +## Inputs / Outputs + +From `impl Indicator for Stochastic`: + +```rust +type Input = Candle; +type Output = StochasticOutput; +fn update(&mut self, candle: Candle) -> Option; +``` + +`StochasticOutput`: + +| Field | Description | +|-------|-------------| +| `k` | Raw `%K` (where `close` sits inside the window's H–L range). | +| `d` | `SMA(d_period)` of the `%K` series — the slower "signal" line. | + +Python's `Stochastic.batch(high, low, close)` returns a `(n, 2)` array +with columns `[k, d]`; warmup rows are `[NaN, NaN]`. + +Node's `Stochastic.batch(high, low, close)` returns a flat `number[]` +of length `n * 2`, interleaved as `[k_0, d_0, k_1, d_1, …]`. There is +no streaming `update()` on the Node binding — only `batch` is exposed. + +## Warmup + +`warmup_period()` returns `k_period + d_period − 1`. The `%K` series itself +becomes available at input `k_period`; the `%D` SMA then needs `d_period` +of those `%K` values to seed, producing its first output at input +`k_period + d_period − 1`. For the classic `(14, 3)` configuration this is +`16` — verified above. + +## Edge cases + +- **Flat range (`HH == LL`).** The implementation returns `%K = 50` by + convention (mirroring RSI's flat-input behaviour). The unit test + `flat_range_yields_k_50` pins this; with a constant input both `%K` and + `%D` collapse to `50`. +- **Close at the window high.** `%K = 100` exactly; close at the window + low gives `%K = 0` exactly (tests `close_at_high_yields_k_100` and + `close_at_low_yields_k_0`). +- **Reset.** `reset()` clears the candle buffer, both monotonic deques, + the SMA, and `last_k` — the indicator returns to a freshly-constructed + state. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, Stochastic}; + +let candles: Vec = (0..20) + .map(|i| { + let m = 10.0 + (i as f64 * 0.5).sin() * 2.0; + Candle::new(m, m + 1.0, m - 1.0, m, 1.0, 0).unwrap() + }) + .collect(); +let mut s = Stochastic::new(14, 3)?; +let out = s.batch(&candles); +let v = out[15].unwrap(); +println!("row 15 k={} d={}", v.k, v.d); +let v = out[19].unwrap(); +println!("row 19 k={} d={}", v.k, v.d); +# Ok::<(), wickra::Error>(()) +``` + +Verified output: + +``` +row 15 k=81.19360374383255 d=69.94559370965067 +row 19 k=47.26766986190959 d=62.55762656278284 +``` + +### Python + +```python +import numpy as np +import wickra as ta + +n = 20 +i = np.arange(n, dtype=float) +m = 10.0 + np.sin(i * 0.5) * 2.0 +high = m + 1.0 +low = m - 1.0 +close = m +stoch = ta.Stochastic(14, 3) +out = stoch.batch(high, low, close) +print('shape :', out.shape) +print('warmup:', stoch.warmup_period()) +print('row 15:', out[15]) +print('row 19:', out[19]) +``` + +Verified output: + +``` +shape : (20, 2) +warmup: 16 +row 15: [81.19360374 69.94559371] +row 19: [47.26766986 62.55762656] +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const n = 20; +const high = [], low = [], close = []; +for (let i = 0; i < n; i++) { + const m = 10.0 + Math.sin(i * 0.5) * 2.0; + high.push(m + 1.0); + low.push(m - 1.0); + close.push(m); +} +const s = new wickra.Stochastic(14, 3); +const out = s.batch(high, low, close); +console.log('len :', out.length); +console.log('row 15 :', { k: out[15 * 2], d: out[15 * 2 + 1] }); +console.log('row 19 :', { k: out[19 * 2], d: out[19 * 2 + 1] }); +``` + +Verified output: + +``` +len : 40 +row 15 : { k: 81.19360374383255, d: 69.94559370965067 } +row 19 : { k: 47.26766986190959, d: 62.55762656278284 } +``` + +## Interpretation + +- **Overbought / oversold zones.** The canonical Lane thresholds are + `80` and `20`. Crossings back from outside these bands are typically + used as reversal-confirmation signals, not entries on their own. +- **`%K` / `%D` crossover.** `%K` crossing above `%D` from below is a + short-horizon bullish signal; the mirror cross is bearish. +- **Divergence.** A price making a new high but `%K` failing to confirm + is a classic bearish divergence — same logic as RSI divergence but on + a faster, range-based oscillator. + +## Common pitfalls + +- **`%K` on a flat candle window is `50`, not undefined.** During a + quiet drift where `HH == LL`, the convention used here is `50.0` and + `%D` therefore also converges to `50.0`. Do not interpret a sequence + of `50`s as a real oversold/overbought cycle — it is the silent-market + fallback path. +- **Wickra exposes only the fast variant.** "Slow stochastic" is `%K = + SMA(raw_%K, slow_k)` with `%D = SMA(%K, d_period)` on top. The + built-in `Stochastic` skips the first SMA; to reproduce the slow + variant, drive the raw `%K` (taken from `stoch.update(candle).k`) + through your own `Sma`. + +## References + +- George C. Lane, *Investment Educators* seminars and articles + (late 1950s, popularised through the 1980s) — the original + formulation of `%K` and `%D` as a fast oscillator. + +## See also + +- [Indicator: Rsi](Indicator-Rsi.md) — sister bounded oscillator, slower + and smoother than `%K`. +- [Indicator: WilliamsR](Indicator-WilliamsR.md) — the negated mirror of + fast `%K`, plotted on `[−100, 0]`. +- [Warmup Periods](../../Warmup-Periods.md) — `k_period + d_period − 1` rule + in context. diff --git a/docs/wiki/indicators/momentum/Indicator-Trix.md b/docs/wiki/indicators/momentum/Indicator-Trix.md new file mode 100644 index 00000000..5e1d2cd5 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Trix.md @@ -0,0 +1,187 @@ +# TRIX + +> Triple-EMA percent rate of change — applies three EMAs in sequence to +> smooth out short-term noise, then reports the one-bar percent change +> of the resulting series. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | unbounded oscillator (zero-centred) | +| Input type | `f64` (close) | +| Output type | `f64` | +| Output range | unbounded (typically a few percent, centred on 0) | +| Default parameters | none — `period` is required in every binding | +| Warmup period | `3 · period − 1` (44 for `period = 15`) | +| Interpretation | zero-line crossings as trend-change cues; magnitude as momentum | + +## Formula + +Let `EMA_n(·)` denote Wickra's EMA over `n` periods (seeded from the +simple mean of the first `n` inputs, then recursive with `α = 2/(n+1)`). +For each input close, build a triple-smoothed series: + +``` +TR_t = EMA_period( EMA_period( EMA_period( close ) ) )_t +``` + +Then TRIX is the one-bar percent rate of change of `TR`: + +``` +TRIX_t = 100 · (TR_t − TR_{t-1}) / TR_{t-1} +``` + +When `TR_{t-1} == 0` exactly, the implementation returns `0.0` rather +than dividing by zero. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|------|------|---------|-------------|-------------| +| `period` | `usize` | required | `>= 1` | Period shared by all three EMAs. | + +`Trix::new(0)` returns `Error::PeriodZero` (via the inner `Ema::new`). +The Python and Node bindings expose no default for `period`; you must +pass it explicitly. + +## Inputs / Outputs + +From `impl Indicator for Trix`: + +```rust +type Input = f64; +type Output = f64; +fn update(&mut self, input: f64) -> Option; +``` + +Python's `TRIX.batch(prices)` returns a 1-D `float64` `np.ndarray` +(warmup → `NaN`). Node's `TRIX.batch(prices)` returns a flat +`number[]` (warmup → `NaN`). Both also expose streaming `update(price)`. + +## Warmup + +`warmup_period()` returns `3 · period − 1`. Three stacked EMAs of the +same period seed at input `3 · period − 2`; once `TR` exists, TRIX +itself needs one more input to form the `TR_t − TR_{t-1}` difference, +which lands at input `3 · period − 1`. For `period = 15` this is +`3 · 15 − 1 = 44`, verified above. + +## Edge cases + +- **Constant input.** All three EMAs converge to the constant value, so + `TR_t − TR_{t-1} == 0` and TRIX returns `0` (test + `constant_series_yields_zero_trix`). +- **`TR_{t-1} == 0`.** The implementation returns `0` rather than + producing `NaN` / `±∞`. This is the `Some(_)` branch with `prev != + 0.0`-failed in `Trix::update`. +- **Reset.** `reset()` resets all three EMAs and clears `prev_tr`. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Trix}; + +let prices: Vec = (1..=50).map(|i| i as f64).collect(); +let mut trix = Trix::new(15)?; +let out = trix.batch(&prices); +println!("row 43 = {}", out[43].unwrap()); +println!("row 49 = {}", out[49].unwrap()); +# Ok::<(), wickra::Error>(()) +``` + +Verified output: + +``` +row 43 = 4.545454545454546 +row 49 = 3.5714285714285716 +``` + +(The series decays toward zero as a ramp gets longer because the +percent change of an arithmetic ramp shrinks as the level grows.) + +### Python + +```python +import wickra as ta + +trix = ta.TRIX(15) +print('warmup:', trix.warmup_period()) +vals = [] +for i in range(1, 51): + vals.append(trix.update(float(i))) +print('vals[43]:', vals[43]) +print('vals[49]:', vals[49]) +``` + +Verified output: + +``` +warmup: 44 +vals[43]: 4.545454545454546 +vals[49]: 3.5714285714285716 +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const trix = new wickra.TRIX(15); +console.log('warmup:', trix.warmupPeriod()); +const vals = []; +for (let i = 1; i <= 50; i++) vals.push(trix.update(i)); +console.log('vals[43]:', vals[43]); +console.log('vals[49]:', vals[49]); +``` + +Verified output: + +``` +warmup: 44 +vals[43]: 4.545454545454546 +vals[49]: 3.5714285714285716 +``` + +## Interpretation + +- **Zero-line cross.** TRIX crossing above zero suggests the + triple-smoothed trend is turning up; crossing below, turning down. + Because of the triple smoothing, these crosses are deliberately + late and deliberately stable. +- **Magnitude.** A larger absolute TRIX value means the smoothed series + is changing faster per bar. There is no canonical "overbought" band + — TRIX is interpreted by its sign and slope, not by threshold. +- **Compare to MACD.** Both are EMA-based momentum oscillators on a + zero-centred scale. MACD reacts faster (two EMAs, one diff); TRIX + reacts slower (three EMAs, one rate of change), making it a + cleaner long-horizon trend filter. + +## Common pitfalls + +- **Long warmup.** `3 · period − 1` is one of the largest warmups in + the library (44 for the canonical `period = 15`). Sizing your input + buffer to `period` and expecting values immediately will hand you + `None` / `NaN` for a full 44 bars. +- **Triple smoothing kills small wiggles.** TRIX deliberately ignores + short-term noise. Do not use it for entry-timing inside a fast + oscillator strategy; use it as a long-term trend filter on top of a + faster signal. + +## References + +- Jack Hutson, "Good TRIX", *Technical Analysis of Stocks & + Commodities*, July 1983 — the original publication popularising the + triple-EMA rate-of-change oscillator. + +## See also + +- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — faster + EMA-based momentum oscillator, useful as a confirmation against + TRIX zero-line crosses. +- [Indicator: Roc](Indicator-Roc.md) — the raw, one-stage rate of + change TRIX is built on top of. +- [Warmup Periods](../../Warmup-Periods.md) — `3 · period − 1` entry. diff --git a/docs/wiki/indicators/momentum/Indicator-WilliamsR.md b/docs/wiki/indicators/momentum/Indicator-WilliamsR.md new file mode 100644 index 00000000..28602130 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-WilliamsR.md @@ -0,0 +1,184 @@ +# WilliamsR + +> Williams %R — Larry Williams' negated mirror of fast Stochastic %K, +> plotted on `[−100, 0]` instead of `[0, 100]`. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | bounded oscillator | +| Input type | `Candle` | +| Output type | `f64` | +| Output range | `[−100, 0]` | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period` (14 for `period = 14`) | +| Interpretation | overbought above `−20`, oversold below `−80` | + +## Formula + +For each new candle, let `HH` and `LL` be the highest high and lowest +low over the last `period` candles: + +``` +HH_t = max(high_{t-period+1}, …, high_t) +LL_t = min(low_{t-period+1}, …, low_t) + +%R_t = −100 · (HH_t − close_t) / (HH_t − LL_t) when HH ≠ LL +%R_t = −50 when HH == LL (flat range) +``` + +This is the negation of fast Stochastic `%K` measured from the *top* of +the window: when the close sits at the window high, `%R = 0`; when it +sits at the window low, `%R = −100`. + +## Parameters + +| Name | Type | Default (Python) | Valid range | Description | +|------|------|------------------|-------------|-------------| +| `period` | `usize` | `14` | `>= 1` | Lookback window for the `HH` / `LL` extrema. | + +`WilliamsR::new(0)` returns `Error::PeriodZero`. + +## Inputs / Outputs + +From `impl Indicator for WilliamsR`: + +```rust +type Input = Candle; +type Output = f64; +fn update(&mut self, candle: Candle) -> Option; +``` + +Python's `WilliamsR.batch(high, low, close)` returns a 1-D `float64` +`np.ndarray` (warmup → `NaN`). Node's `WilliamsR.batch(high, low, close)` +returns a flat `number[]` (warmup → `NaN`); only `batch` is exposed on +the Node binding. + +## Warmup + +`warmup_period()` returns `period`. Williams %R works on a rolling +range, not a rolling diff, so once `period` candles have arrived the +indicator is ready — there is no off-by-one. The first `period − 1` +calls to `update()` return `None`; the `period`-th call returns the +first `Some(value)`. + +## Edge cases + +- **Close at the window high.** `%R == 0` exactly. The unit test + `close_at_high_yields_zero` pins this case (with H, L = 8, 10, 12 and + closes ending at 12, the result is `0`). Note that floating-point + zero can print as `-0` when scaled by `-100`; both compare equal to + `0`. +- **Close at the window low.** `%R == −100` exactly (test + `close_at_low_yields_minus_100`). +- **Flat range.** When `HH == LL`, the implementation returns `−50` as + the neutral convention. +- **Reset.** `reset()` clears the candle buffer; the next `period` + updates return `None`. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, WilliamsR}; + +let candles = vec![ + Candle::new(9.0, 10.0, 8.0, 9.0, 1.0, 0).unwrap(), + Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap(), + Candle::new(12.0, 12.0, 10.0, 12.0, 1.0, 0).unwrap(), // close == HH +]; +let mut w = WilliamsR::new(3)?; +let out = w.batch(&candles); +println!("Williams %R(3) at idx 2 = {}", out[2].unwrap()); +# Ok::<(), wickra::Error>(()) +``` + +Verified output: + +``` +Williams %R(3) at idx 2 = -0 +``` + +(`-0.0` is bit-equal to `0.0` in IEEE-754; the negative sign is just a +side effect of multiplying `+0.0` by `-100.0`.) + +### Python + +```python +import numpy as np +import wickra as ta + +high = np.array([10.0, 11.0, 12.0]) +low = np.array([8.0, 9.0, 10.0]) +close = np.array([9.0, 10.0, 12.0]) +w = ta.WilliamsR(3) +out = w.batch(high, low, close) +print('warmup:', w.warmup_period()) +print('row 2 :', out[2]) +``` + +Verified output: + +``` +warmup: 3 +row 2 : -0.0 +``` + +### Node + +```javascript +const wickra = require('wickra'); + +const high = [10.0, 11.0, 12.0]; +const low = [8.0, 9.0, 10.0]; +const close = [9.0, 10.0, 12.0]; +const w = new wickra.WilliamsR(3); +const out = w.batch(high, low, close); +console.log('row 2:', out[2]); +``` + +Verified output: + +``` +row 2: -0 +``` + +## Interpretation + +- **Larry Williams' thresholds.** `%R > −20` is overbought; `%R < −80` + is oversold. Because the scale runs from `−100` (oversold) to `0` + (overbought), the inequalities feel inverted to anyone used to + Stochastic — but the *positions* of the bands are identical. +- **Failure swings.** A `%R` value that pokes into overbought, retreats, + then fails to reach overbought on the next rally is the classic + Williams "failure swing" — interpreted as bearish exhaustion. +- **Use alongside trend.** %R is a pure range oscillator; in a strong + trend it can stay pinned at `0` or `−100` for many bars. Pair with + ADX or a moving-average filter before reading it as a reversal cue. + +## Common pitfalls + +- **Sign inversion.** Williams %R lives in `[−100, 0]`, not `[0, 100]`. + Code that assumes "higher value = more bullish" will work; code that + assumes a positive range will silently mis-classify every value. +- **Mirror of fast %K, not slow.** Williams %R has no built-in + smoothing; it tracks raw `%K` (with a sign flip and a shift). If you + need a smoothed version, drive `%R` through your own `Sma` or `Ema` + via a `Chain`. + +## References + +- Larry Williams, *How I Made One Million Dollars … Last Year … + Trading Commodities*, Windsor Books, 1973 — the original %R + publication. + +## See also + +- [Indicator: Stochastic](Indicator-Stochastic.md) — the positive-axis + sibling; `%R` and `%K` are linked by `%R = %K − 100`. +- [Indicator: Rsi](Indicator-Rsi.md) — slower bounded oscillator, + better behaved in trending markets. +- [Warmup Periods](../../Warmup-Periods.md) — bare `period` (no off-by-one). diff --git a/docs/wiki/indicators/trend/Indicator-Dema.md b/docs/wiki/indicators/trend/Indicator-Dema.md new file mode 100644 index 00000000..f74e6c12 --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Dema.md @@ -0,0 +1,214 @@ +# DEMA + +> Double Exponential Moving Average — Patrick Mulloy's `2·EMA − EMA(EMA)`, +> a single-line trend filter that removes the first-order lag of a plain +> EMA. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Exponential family | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required (no default in either binding) | +| Warmup period | `2·period − 1` | +| Interpretation | EMA-style smoothing with less lag; sits ahead of `Ema` on a sustained trend. | + +## Formula + +Let `EMA1 = EMA(price, period)` and `EMA2 = EMA(EMA1, period)`. Then: + +``` +DEMA_t = 2 * EMA1_t - EMA2_t +``` + +Both inner EMAs use the same `period`, hence the same +`α = 2 / (period + 1)`. The subtraction is a finite-difference +approximation of "remove the lag introduced by single EMA smoothing": +if EMA lags the true series by `L`, then EMA(EMA) lags by roughly `2L`, +so `2·EMA − EMA(EMA)` cancels most of the first-order error. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Period shared by both internal EMAs. `period = 0` errors with `Error::PeriodZero`. | + +(Python class `wickra.DEMA(period)` has no `#[pyo3(signature)]` default; +pass `period` explicitly.) + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/dema.rs`: + +```rust +impl Indicator for Dema { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +Python `update` returns `float | None`, `batch` returns a 1-D +`numpy.ndarray` (`float64`, `NaN` for warmup). Node `update` returns +`number | null`, `batch` returns `Array` with `NaN` placeholders. + +## Warmup + +`Dema::new(period).warmup_period() == 2 * period - 1`. The comment in +the source explains it cleanly: + +> EMA1 seeds at `period`, then EMA2 needs another `period − 1` values to +> seed. + +`Ema::new(period)` only starts producing output once it has seen +`period` inputs. So `ema1` emits its first value at input `period`. From +that point on, `ema2` starts receiving inputs (the outputs of `ema1`) +and itself needs `period` of them to seed — first emission at "input +`period` of `ema1`" = input `2·period − 1` of `Dema`. For +`Dema::new(14)` this gives `27`, matching the table in +[Warmup Periods](../../Warmup-Periods.md). + +The implementation uses the `?` operator to short-circuit: +`let e1 = self.ema1.update(input)?; let e2 = self.ema2.update(e1)?;`, +so `ema2` is only fed once `ema1` actually emits — which is exactly +what the warmup arithmetic above models. + +## Edge cases + +- **Constant series.** Feeding `[100.0; n]` eventually produces + `Some(100.0)`: once both EMAs converge to `100.0`, the output is + `2 · 100 − 100 = 100`. The unit test `constant_series_yields_constant_dema` + pins this with `Dema::new(5)` over 60 constants. +- **NaN / infinity inputs.** Inherited from the inner `Ema`: non-finite + inputs are silently dropped and the previously emitted value (if any) + is preserved. Inputs that fail to pass `is_finite()` never reach the + `2·EMA1 − EMA2` arithmetic. +- **Reset.** `dema.reset()` resets both internal EMAs. The next `update` + starts a full `2·period − 1` warmup countdown. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Dema, Indicator}; + +fn main() -> Result<(), Box> { + let mut dema = Dema::new(5)?; + let prices: Vec = (1..=20).map(f64::from).collect(); + let out: Vec> = dema.batch(&prices); + println!("warmup_period = {}", dema.warmup_period()); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +warmup_period = 9 +[None, None, None, None, None, None, None, None, Some(9.0), Some(10.0), Some(11.0), Some(12.0), Some(13.000000000000002), Some(14.000000000000002), Some(15.000000000000002), Some(16.000000000000004), Some(17.0), Some(18.0), Some(19.0), Some(20.0)] +``` + +The first `Some` arrives at index 8 (the 9th input), exactly as +predicted by `2·5 − 1 = 9`. On a linear ramp `1, 2, …, 20`, DEMA tracks +the input ramp almost perfectly because the lag has been cancelled to +first order — the floating-point tail of `13.000000000000002` is +ordinary IEEE-754 drift. The unit test +`linear_uptrend_dema_above_ema_eventually` pins the property that +`Dema` exceeds `Ema` of the same period on a sustained uptrend. + +### Python + +```python +import numpy as np +import wickra as ta + +dema = ta.DEMA(5) +out = dema.batch(np.arange(1.0, 21.0)) +print("warmup_period =", dema.warmup_period()) +print(out) +``` + +Output: + +``` +warmup_period = 9 +[nan nan nan nan nan nan nan nan 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. + 19. 20.] +``` + +### Node + +```javascript +const ta = require('D:/Coding/Wickra/bindings/node'); +const dema = new ta.DEMA(5); +const prices = Array.from({ length: 20 }, (_, i) => i + 1); +console.log(dema.batch(prices)); +console.log('warmupPeriod:', dema.warmupPeriod()); +``` + +Output: + +``` +[ + NaN, NaN, + NaN, NaN, + NaN, NaN, + NaN, NaN, + 9, 10, + 11, 12, + 13.000000000000002, 14.000000000000002, + 15.000000000000002, 16.000000000000004, + 17, 18, + 19, 20 +] +warmupPeriod: 9 +``` + +## Interpretation + +`Dema` is the canonical "I want EMA, but with less lag" answer. On a +sustained directional trend the DEMA line sits ahead of an `Ema` of the +same period (the unit test pins this). The same signals you use for +`Ema` — price-vs-MA crossover, fast-vs-slow MA crossover — apply, and +they fire earlier. In return for the lower lag you accept more +sensitivity to noise: on choppy data DEMA will whipsaw earlier than EMA +of the same period. + +Prefer `Dema` over `Ema` when you want a faster trend filter without +moving to a smaller `period` (which would also amplify noise). Prefer +`Tema` for *even* less lag at the cost of further noise sensitivity, or +`Hma` if you want lag reduction *plus* an inherent smoothing step. + +## Common pitfalls + +- **Picking a `period` that's too short for a noisy market.** Because + `Dema` removes lag rather than adding smoothing, on choppy series it + amplifies high-frequency oscillations. If you reach for `Dema(5)` on + a tick-by-tick feed and get a jittery line, the fix is to *raise* + `period` — `Dema(20)` is often a better compromise than `Dema(5)`. +- **Assuming the first `Dema` value lines up with the first `Ema` + value at the same period.** `Ema(14)` first emits at input 14; + `Dema(14)` first emits at input 27. If you align a DEMA series to an + EMA series in a backtest, account for the offset or use the + `~np.isnan(...)` mask (Python) / `is_some()` filter (Rust) to drop the + warmup rows. + +## References + +Patrick G. Mulloy, *"Smoothing Data with Faster Moving Averages"*, +**Technical Analysis of Stocks & Commodities**, January 1994 (DEMA), and +*"Smoothing Data with Less Lag"*, **Technical Analysis of Stocks & +Commodities**, February 1994 (TEMA). + +## See also + +- [Indicator-Ema.md](Indicator-Ema.md) — the building block. +- [Indicator-Tema.md](Indicator-Tema.md) — three-EMA version, less lag still. +- [Indicator-Hma.md](Indicator-Hma.md) — same lag-reduction goal, built on WMAs. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/trend/Indicator-Ema.md b/docs/wiki/indicators/trend/Indicator-Ema.md new file mode 100644 index 00000000..16d68196 --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Ema.md @@ -0,0 +1,201 @@ +# EMA + +> Exponential Moving Average with smoothing factor `α = 2 / (period + 1)`, +> seeded from the SMA of the first `period` inputs (the TA-Lib convention). + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Exponential family | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required; or `Ema::with_alpha(α)` for a custom smoothing factor | +| Warmup period | `period` | +| Interpretation | Smoother, less laggy than `Sma` of the same length. | + +## Formula + +For `t >= period` (after warmup): + +``` +α = 2 / (period + 1) +seed = (1 / period) * Σ_{i=0}^{period-1} price_i // SMA of first `period` inputs +EMA_t = α * price_t + (1 - α) * EMA_{t-1} // recursive update +``` + +The first emitted value (at input `period`) is the seed itself, identical +to `Sma::new(period)` on the same prefix. From input `period + 1` onward +the recursive formula takes over. (`Ema::with_alpha(α)` skips the seed and +uses the very first input as the initial state, so `warmup_period() == 1` +in that mode — see the `with_alpha` method for details.) + +Wilder's smoothing (used by `Rsi`/`Atr`/`Adx`) uses `α = 1/period` +instead; that is a different smoothing constant and a different +indicator family. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|----------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Window length used to derive `α`. `period = 0` errors with `Error::PeriodZero`. | +| `α` (alternative constructor `Ema::with_alpha`) | `f64` | none | `(0.0, 1.0]` and finite | Custom smoothing factor; bypasses the period-derived α. Reported `period` is 1, `warmup_period() == 1`. Invalid `α` errors with `Error::InvalidPeriod`. | + +(The Python class `wickra.EMA(period)` does not set a `#[pyo3(signature)]` +default; the period must be passed explicitly. `with_alpha` is Rust-only.) + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/ema.rs`: + +```rust +impl Indicator for Ema { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +Python streams as `float | None`, batches as a 1-D `numpy.ndarray` +(`NaN` for warmup). Node streams as `number | null`, batches as +`Array` with `NaN` placeholders. + +## Warmup + +`Ema::new(period).warmup_period() == period`. The first non-empty value +is the SMA of the first `period` inputs (the "seed"); from there each +new input contributes `α * input + (1 − α) * previous`. The unit test +`warmup_returns_none_until_seed` and the test +`first_value_equals_sma_seed` pin this contract. + +This is the same warmup count as `Sma::new(period)` because the seed +itself is an SMA — `Ema` is "no slower to start emitting than `Sma`, just +more reactive afterwards". + +## Edge cases + +- **Constant series.** Feeding `[42.0; n]` returns `Some(42.0)` from input + `period` onward; the seed is `42.0`, and `α · 42 + (1 − α) · 42 = 42`. + The unit test `constant_series_converges_to_constant` pins this. +- **NaN / infinity inputs.** The first line of `update` is + `if !input.is_finite() { return self.state; }`. Non-finite inputs are + silently dropped: they do not advance warmup, do not corrupt the + state, and the previously emitted value (if any) is returned. The unit + test `ignores_non_finite_input` pins this. +- **Reset.** `ema.reset()` clears both the smoothed state and the warmup + buffer; the next `update` starts a new warmup countdown. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Ema, Indicator}; + +fn main() -> Result<(), Box> { + let mut ema = Ema::new(3)?; + let out: Vec> = ema.batch(&[1.0, 2.0, 3.0, 10.0]); + println!("{:?}", out); + println!("alpha = {}", ema.alpha()); + Ok(()) +} +``` + +Output: + +``` +[None, None, Some(2.0), Some(6.0)] +alpha = 0.5 +``` + +`period = 3` gives `α = 2 / 4 = 0.5`. The seed at input 3 is the SMA of +`[1, 2, 3] = 2.0`; the next step is `0.5 · 10 + 0.5 · 2 = 6.0`. This +matches the `step_after_seed_uses_alpha_formula` unit test. + +### Python + +```python +import wickra as ta + +ema = ta.EMA(3) +for x in [1.0, 2.0, 3.0, 10.0]: + print(x, '->', ema.update(x)) +print('alpha:', ema.alpha) +print('warmup_period:', ema.warmup_period()) +``` + +Output: + +``` +1.0 -> None +2.0 -> None +3.0 -> 2.0 +10.0 -> 6.0 +alpha: 0.5 +warmup_period: 3 +``` + +### Node + +```javascript +const ta = require('D:/Coding/Wickra/bindings/node'); +const ema = new ta.EMA(3); +for (const x of [1, 2, 3, 10]) { + console.log(x, '->', ema.update(x)); +} +``` + +Output: + +``` +1 -> null +2 -> null +3 -> 2 +10 -> 6 +``` + +## Interpretation + +`Ema` is the "default" smoothed trend filter for most practitioners. The +two main signals are price-vs-EMA and EMA-fast-vs-EMA-slow crossovers +(the latter is the basis of `MacdIndicator`). Compared with `Sma` at the +same period, `Ema` reacts faster to direction changes at the cost of +slightly noisier output — useful when you care about the inflection +point, not the long-run level. + +Prefer `Ema` over `Sma` when you want a single-line trend filter with +moderate lag. Prefer `Dema` / `Tema` when the EMA lag is too much for +your timeframe. Prefer `Hma` when you want lag reduction *and* a built-in +noise filter (a triple WMA chain rather than a triple EMA chain). + +## Common pitfalls + +- **Confusing `α = 2/(n+1)` with Wilder's `α = 1/n`.** Wickra's `Ema` + uses the TA-Lib convention `α = 2/(n+1)`. The same numerical period + passed to `Rsi(14)` or `Atr(14)` uses `α = 1/14 ≈ 0.0714`, not + `α = 2/15 ≈ 0.1333`. They are different smoothing schemes; comparing + an EMA(14) line directly to the RSI/ATR's internal smoothing will not + match. If you want a Wilder-style EMA, build it on top of `Ema` with + the custom factor: `Ema::with_alpha(1.0 / 14.0)`. +- **Assuming the first emitted EMA is "the EMA".** The first value is + the SMA seed, not a recursively-smoothed EMA. The series only starts + behaving like an EMA from input `period + 1` onward. For short series, + this means the first emission tracks `Sma::new(period)` exactly — that + is the intended behaviour, not a bug. + +## References + +The TA-Lib seeding convention used here ("EMA is seeded with an SMA") +is documented in the TA-Lib source and replicated by virtually every +commercial charting platform. The recursive form +`EMA_t = α · price + (1 − α) · EMA_{t-1}` is the standard exponential +smoothing identity attributed to Robert Brown (1956). + +## See also + +- [Indicator-Sma.md](Indicator-Sma.md) — equal weights, identical seed. +- [Indicator-Dema.md](Indicator-Dema.md) — `2·EMA − EMA(EMA)`. +- [Indicator-Tema.md](Indicator-Tema.md) — `3·EMA − 3·EMA(EMA) + EMA(EMA(EMA))`. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/trend/Indicator-Hma.md b/docs/wiki/indicators/trend/Indicator-Hma.md new file mode 100644 index 00000000..99101a9d --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Hma.md @@ -0,0 +1,242 @@ +# HMA + +> Hull Moving Average — Alan Hull's +> `WMA(2·WMA(n/2) − WMA(n), √n)`, a near-lag-free trend filter that +> combines a fast `Wma(n/2)`, a slow `Wma(n)`, and a final smoothing pass. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Adaptive & hybrid | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required (no default in either binding) | +| Warmup period (`warmup_period()`) | `period + round(√period).max(1) − 1` — see below; the practical first-emission index can lag this number | +| Interpretation | Near-zero-lag trend line with an inherent smoothing step. | + +## Formula + +``` +half = max(period / 2, 1) // integer division +smooth = max(round(sqrt(period)), 1) // nearest integer, floor at 1 + +raw_t = 2 * WMA(price, half)_t - WMA(price, period)_t +HMA_t = WMA(raw, smooth)_t +``` + +The "magic" is the `2·WMA(n/2) − WMA(n)` step: the fast WMA leads the +slow WMA on a trend, so doubling the fast and subtracting the slow +produces a series that is *ahead* of the input by roughly the WMA lag. +The final `WMA(…, √n)` then smooths the resulting overshoot back down +to a clean line. For `period = 9` this gives `half = 4`, `smooth = 3`; +for `period = 14`, `half = 7`, `smooth = 4`. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Top-level lookback. The inner WMA periods are derived from it. `period = 0` errors with `Error::PeriodZero`. | + +(Python class `wickra.HMA(period)` has no `#[pyo3(signature)]` default; +pass `period` explicitly.) + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/hma.rs`: + +```rust +impl Indicator for Hma { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +Python returns `float | None` (streaming) / `numpy.ndarray` (batch, +`NaN` for warmup). Node returns `number | null` / `Array` with +`NaN`. + +## Warmup + +This is the one case in the trend family where the reported +`warmup_period()` is a **lower bound**, not the exact first-emission +index. + +The `warmup_period()` method returns: + +``` +period + round(sqrt(period)).max(1) - 1 +``` + +which gives `11` for `Hma::new(9)`, `17` for `Hma::new(14)`, +`19` for `Hma::new(16)`. This number assumes the three inner WMAs +warm up *in parallel*: the slow `WMA(period)` would emit at input +`period`, and the smoothing `WMA(√period)` would then need `√period − 1` +more inputs. + +In practice the implementation uses the `?` short-circuit: + +```rust +fn update(&mut self, input: f64) -> Option { + let h = self.half_wma.update(input)?; // returns early if None + let f = self.full_wma.update(input)?; // ONLY called when half emits + let diff = 2.0 * h - f; + self.smooth_wma.update(diff) +} +``` + +`self.full_wma.update(input)` is only reached after `self.half_wma` +starts emitting (i.e. from input `half = period/2` onward). So +`full_wma` does not see input until iteration `half`, and then needs +`period` of its own inputs — it emits first at iteration +`half + period − 1`. The diff then flows into `smooth_wma`, which needs +`smooth` of those — first emission at iteration +`half + period - 1 + smooth - 1` = `half + period + smooth − 2`. + +For the three example periods this gives: + +| `period` | `half` | `smooth` | `warmup_period()` (reported) | Actual first emission | +|----------|--------|----------|------------------------------|------------------------| +| 9 | 4 | 3 | 11 | 14 | +| 14 | 7 | 4 | 17 | 23 | +| 16 | 8 | 4 | 19 | 26 | + +The numbers in the "Actual first emission" column are verified by +streaming `Hma::new(period).update(...)` over a linear ramp and noting +the first call that returns `Some`. The discrepancy is a known +implementation quirk: the reported value is the theoretical floor; the +streaming order pushes the practical emission later. If you need the +exact first-non-`None` index for chaining or array alignment, prefer +checking `is_ready()` or filtering on `~np.isnan(...)` after the fact. + +## Edge cases + +- **Constant series.** Feeding `[10.0; n]` produces `Some(10.0)` once + the chain is warm. All three WMAs converge to `10`, so + `raw = 2·10 − 10 = 10`, then `WMA(10, smooth) = 10`. The unit test + `constant_series_yields_constant_hma` pins this with `Hma::new(9)` + over 80 constants. +- **NaN / infinity inputs.** Inherited from the inner `Wma`: non-finite + inputs are silently dropped at the half/full WMA boundary and never + reach the `2·h − f` arithmetic. +- **Reset.** `hma.reset()` resets all three internal WMAs; the next + `update` starts a full warmup countdown. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Hma, Indicator}; + +fn main() -> Result<(), Box> { + let mut hma = Hma::new(9)?; + let prices: Vec = (1..=20).map(f64::from).collect(); + let out: Vec> = hma.batch(&prices); + println!("warmup_period (reported) = {}", hma.warmup_period()); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +warmup_period (reported) = 11 +[None, None, None, None, None, None, None, None, None, None, None, None, None, Some(14.0), Some(15.0), Some(16.0), Some(17.0), Some(18.0), Some(19.0), Some(20.0)] +``` + +The reported warmup says `11`, but the first `Some` lands at index 13 +(the 14th input) for the reason given in the [Warmup](#warmup) section. +On the linear ramp `1, 2, …, 20`, HMA tracks price exactly with no +visible lag. + +### Python + +```python +import numpy as np +import wickra as ta + +hma = ta.HMA(9) +out = hma.batch(np.arange(1.0, 21.0)) +print("warmup_period (reported) =", hma.warmup_period()) +print(out) +``` + +Output: + +``` +warmup_period (reported) = 11 +[nan nan nan nan nan nan nan nan nan nan nan nan nan 14. 15. 16. 17. 18. + 19. 20.] +``` + +### Node + +```javascript +const ta = require('D:/Coding/Wickra/bindings/node'); +const hma = new ta.HMA(9); +const prices = Array.from({ length: 20 }, (_, i) => i + 1); +console.log(hma.batch(prices)); +console.log('warmupPeriod (reported):', hma.warmupPeriod()); +``` + +Output: + +``` +[ + NaN, NaN, NaN, NaN, NaN, NaN, + NaN, NaN, NaN, NaN, NaN, NaN, + NaN, 14, 15, 16, 17, 18, + 19, 20 +] +warmupPeriod (reported): 11 +``` + +## Interpretation + +`Hma` is the lag-reduction trend filter that does *not* require you to +choose between responsiveness and noise: the final `WMA(√period)` pass +is a built-in smoothing step that prevents the kind of whipsaw a `Tema` +of the same period would produce on noisy data. On clean trending data +it sits effectively on top of price; on choppy data the smoothing pass +keeps the line readable. + +The textbook signal is colour-coded slope: HMA turning up = uptrend, +turning down = downtrend. Crossover patterns (`Hma(9)` vs `Hma(20)`) +also work and tend to be cleaner than the equivalent EMA pair. + +Prefer `Hma` over `Dema` / `Tema` when your data is noisy enough that +the lag-reduction in those would manifest as whipsaws. Prefer `Tema` / +`Dema` on cleaner data where you want one fewer smoothing step. + +## Common pitfalls + +- **Trusting `warmup_period()` for chaining or array alignment.** As + the table above shows, `Hma::new(9).warmup_period() == 11` but the + first actual emission is at the 14th input. If you use HMA as the + first stage of a `Chain`, the chain's overall warmup will lag what + `Chain::warmup_period()` reports. Filter on `is_some()` / + `~np.isnan(...)` after the fact, or precompute the actual index by + streaming a small ramp once. +- **Picking `period = 2` or `3`.** The inner `half = period / 2` is an + integer division floored at 1. For `period = 2`, `half = 1`, + `smooth = 1`, and you essentially end up with `Wma(2·price − WMA(2))` + which is a sharp, noisy line. HMA is designed for `period >= 9` or so; + for shorter lookbacks reach for `Ema(period)` or `Wma(period)` instead. + +## References + +Alan Hull, *"How to Reduce Lag in a Moving Average"*, 2005 — the +original HMA derivation, hosted on Hull's site at +. + +## See also + +- [Indicator-Wma.md](Indicator-Wma.md) — the building block. +- [Indicator-Tema.md](Indicator-Tema.md) — same lag-reduction goal, EMA-based. +- [Indicator-Kama.md](Indicator-Kama.md) — adaptive smoothing instead of fixed. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/trend/Indicator-Kama.md b/docs/wiki/indicators/trend/Indicator-Kama.md new file mode 100644 index 00000000..dc6697c0 --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Kama.md @@ -0,0 +1,251 @@ +# KAMA + +> Kaufman's Adaptive Moving Average — picks its own smoothing constant +> on every bar from a fast/slow EMA pair, weighted by an efficiency +> ratio that measures how trending the recent price action has been. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Adaptive & hybrid | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | Python: `(er_period=10, fast=2, slow=30)`; Rust: `Kama::classic()` returns the same triple | +| Warmup period (`warmup_period()`) | `er_period + 1` — see below; the *first* emission lands at this index, but on a fresh KAMA that emission equals the seed (the input itself) | +| Interpretation | Fast in trending markets, slow in choppy markets — by construction. | + +## Formula + +For each new input `price_t` (with `n = er_period`): + +``` +direction_t = | price_t - price_{t-n} | +volatility_t = Σ_{i=1}^{n} | price_{t-i+1} - price_{t-i} | +ER_t = direction_t / volatility_t // 0 = pure chop, 1 = pure trend; 0 if volatility = 0 + +fast_sc = 2 / (fast + 1) // fast EMA smoothing constant +slow_sc = 2 / (slow + 1) // slow EMA smoothing constant +SC_t = (ER_t * (fast_sc - slow_sc) + slow_sc) ^ 2 + +KAMA_t = KAMA_{t-1} + SC_t * (price_t - KAMA_{t-1}) +``` + +The squared `SC_t` is Kaufman's choice (he found that squaring widens +the dynamic range between "act like a fast EMA" and "act like a slow +EMA"). On the very first emission `KAMA_{t-1}` is seeded with the +oldest price in the window (`window.front()`), which is the convention +in the source. + +## Parameters + +| Name | Type | Default (Python `KAMA(...)`) | Valid range | Description | +|-------------|---------|-------------------------------|-------------|-------------| +| `er_period` | `usize` | `10` | `>= 1` | Lookback for the efficiency ratio. Larger → smoother ER, slower adaptation. | +| `fast` | `usize` | `2` | `>= 1`, strictly `< slow` | Fast EMA period; sets the lower bound on responsiveness. | +| `slow` | `usize` | `30` | `>= 1`, strictly `> fast` | Slow EMA period; sets the upper bound on smoothness. | + +Any of `er_period`, `fast`, `slow` being `0` errors with +`Error::PeriodZero`; `fast >= slow` errors with `Error::InvalidPeriod`. +The Python defaults come from +`#[pyo3(signature = (er_period=10, fast=2, slow=30))]` in +`bindings/python/src/lib.rs`; the Rust convenience constructor +`Kama::classic()` returns the same triple. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/kama.rs`: + +```rust +impl Indicator for Kama { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +Python returns `float | None` (streaming) / `numpy.ndarray` (batch, +`NaN` for warmup). Node returns `number | null` (streaming) / +`Array` with `NaN` (batch). `warmup_period()` is exposed in +Rust and Python but **not** on the Node `KAMA` class (consult +`bindings/node/index.d.ts` for the surface). + +## Warmup + +`Kama::new(er_period, fast, slow).warmup_period() == er_period + 1`. +The "off-by-one" is because the efficiency ratio compares `price_t` to +`price_{t-er_period}` and sums `er_period` consecutive absolute diffs; +that requires `er_period + 1` prices in the window. For +`Kama::classic()` (`er_period = 10`) the first emission lands on input +11, matching the table in [Warmup Periods](../../Warmup-Periods.md). + +The implementation uses a `VecDeque` of capacity `er_period + 1`. Once +full, every subsequent `update` pops the front and pushes the new +input — `update` is O(`er_period`) in principle (the volatility sum is +re-computed) but O(1) in `period`/`fast`/`slow` since the EMA-style +recursion has no window. + +Note: on the *first* emission, `prev = window.front()` (the oldest +price), and the output is +`prev + SC · (input − prev)`. On a perfectly trending series +(`ER ≈ 1`, `SC ≈ fast_sc² ≈ 0.444`) this means the first KAMA value +is materially below the latest price; on `[1, 2, …, 20]` for instance, +KAMA's first emission at input 11 is `5.444…`, not `11`. See the +example output below. + +## Edge cases + +- **Constant series.** Feeding `[100.0; n]` produces `Some(100.0)`: + both `direction` and `volatility` are zero, the source branches + `if volatility == 0.0 { 0.0 } else { ... }` so `ER = 0`, + `SC = slow_sc² ≈ 0.00416`, and + `100 + 0.00416 · (100 − 100) = 100`. The unit test + `constant_series_yields_constant_kama` pins this with + `Kama::classic()`. +- **NaN / infinity inputs.** The first line of `update` is + `if !input.is_finite() { return self.state; }`. Non-finite inputs are + silently dropped; the window is not advanced, the previously emitted + value is preserved. +- **Reset.** `kama.reset()` clears both the window and the smoothed + state. The next `update` starts a fresh `er_period + 1` warmup + countdown. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Kama}; + +fn main() -> Result<(), Box> { + let mut kama = Kama::classic(); // (10, 2, 30) + let prices: Vec = (1..=20).map(f64::from).collect(); + let out: Vec> = kama.batch(&prices); + println!("warmup_period = {}", kama.warmup_period()); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +warmup_period = 11 +[None, None, None, None, None, None, None, None, None, None, Some(5.444444444444443), Some(8.358024691358022), Some(10.421124828532234), Some(12.011736015851241), Some(13.339853342139579), Some(14.522140745633099), Some(15.62341152535172), Some(16.679673069639843), Some(17.710929483133246), Some(18.728294157296247)] +``` + +`Kama::classic().periods()` returns `(10, 0.6666666666666666, 0.06451612903225806)` +— the second and third numbers are `fast_sc = 2/3` and `slow_sc = 2/31`, +not the integer `fast`/`slow` periods themselves. On the linear ramp +`1, 2, …, 20` the efficiency ratio is `1.0` (every step moves direction +the same as volatility), so `SC = fast_sc² ≈ 0.4444`. The first +emission `5.444…` is `1 + 0.4444 · (11 − 1)` and each subsequent value +follows the same recursion. + +### Python + +```python +import numpy as np +import wickra as ta + +kama = ta.KAMA() # defaults: er_period=10, fast=2, slow=30 +out = kama.batch(np.arange(1.0, 21.0)) +print("warmup_period =", kama.warmup_period()) +print(out) +``` + +Output: + +``` +warmup_period = 11 +[ nan nan nan nan nan nan + nan nan nan nan 5.44444444 8.35802469 + 10.42112483 12.01173602 13.33985334 14.52214075 15.62341153 16.67967307 + 17.71092948 18.72829416] +``` + +### Node + +```javascript +const ta = require('D:/Coding/Wickra/bindings/node'); +const kama = new ta.KAMA(10, 2, 30); // no default constructor; pass the triple +const prices = Array.from({ length: 20 }, (_, i) => i + 1); +console.log(kama.batch(prices)); +``` + +Output: + +``` +[ + NaN, NaN, + NaN, NaN, + NaN, NaN, + NaN, NaN, + NaN, NaN, + 5.444444444444443, 8.358024691358022, + 10.421124828532234, 12.011736015851241, + 13.339853342139579, 14.522140745633099, + 15.62341152535172, 16.679673069639843, + 17.710929483133246, 18.728294157296247 +] +``` + +(The Node `KAMA` class does not expose `warmupPeriod()`; use the Rust +or Python binding if you need that getter from your application.) + +## Interpretation + +KAMA's defining property is that it **changes its own behaviour with the +market**. In a clean trend the efficiency ratio approaches `1`, `SC` +approaches `fast_sc²`, and KAMA behaves like a fast EMA — it tracks +price closely. In a choppy sideways market the efficiency ratio +collapses toward `0`, `SC` approaches `slow_sc²`, and KAMA effectively +freezes — its line goes nearly flat regardless of how violently price +oscillates around it. This is by design: Kaufman's argument is that you +should not chase noise. + +The two usable signals are slope (positive = uptrend; flat = ranging; +negative = downtrend) and price-vs-KAMA crossover. Because KAMA can +sit nearly flat for long stretches in a range, "price crossed KAMA" +generates fewer false signals than the same test against an EMA of +similar period. + +Prefer `Kama` over a static EMA/SMA when the market regime varies +materially (trending → ranging → trending). Prefer a fixed `Ema` / +`Hma` when you want a predictable smoothing profile that is independent +of price action. + +## Common pitfalls + +- **Assuming `warmup_period()` is when the line is "good".** The first + emission lands at input 11 (for the default `er_period = 10`), but + the seed `KAMA_{t-1} = window.front()` is the *oldest* price in the + window, so the very first emitted value is biased toward the + 10-bars-ago price. On a strong trend this means the first 3–5 + emissions are noticeably below (or above, depending on direction) the + current price. If that matters, drop the first `er_period` post-warmup + emissions, not just the warmup itself. +- **Tuning `fast` and `slow` independently of `er_period`.** Kaufman's + derivation assumes `slow >> fast` so that the per-bar SC has room to + move. Picking, say, `(10, 5, 6)` gives `fast_sc ≈ 0.333` and + `slow_sc ≈ 0.286`, so SC barely changes regardless of the efficiency + ratio — KAMA degenerates into "an EMA somewhere around period 6". + Keep `slow` at least `5×` `fast` if you want the adaptive behaviour + to actually matter. + +## References + +Perry J. Kaufman, *Smarter Trading*, McGraw-Hill, 1995 (book-length +introduction); reprinted in Kaufman's *Trading Systems and Methods* +across multiple editions, where the squared-SC choice is justified +empirically. + +## See also + +- [Indicator-Ema.md](Indicator-Ema.md) — the two endpoints (`fast` and + `slow`) KAMA interpolates between. +- [Indicator-Hma.md](Indicator-Hma.md) — the other "smart" trend filter in + Wickra. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/trend/Indicator-Sma.md b/docs/wiki/indicators/trend/Indicator-Sma.md new file mode 100644 index 00000000..bab9a9d6 --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Sma.md @@ -0,0 +1,184 @@ +# SMA + +> Simple Moving Average — the equal-weighted rolling mean of the last +> `period` closes, maintained as an O(1) rolling-sum state machine. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Simple averages | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required (no default in either binding) | +| Warmup period | `period` | +| Interpretation | Smoothed price level; price-vs-SMA crossings flag direction changes. | + +## Formula + +``` +SMA_t = (1 / n) * Σ_{i=0}^{n-1} price_{t-i} +``` + +where `n = period`. Maintained incrementally as `sum -= window.pop_front(); +sum += new_price; out = sum / n`, so `update` is O(1) regardless of +`period`. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|----------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Length of the rolling window. `period = 0` errors with `Error::PeriodZero`. `period = 1` is a pass-through. | + +(There is no Python `#[pyo3(signature = …)]` default for `SMA`, so +`wickra.SMA(period)` requires the period explicitly.) + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/sma.rs`: + +```rust +impl Indicator for Sma { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. The Python binding maps +this to `float | None` (streaming) or a `numpy.ndarray` of dtype +`float64` with `NaN` for warmup rows (batch). The Node binding maps it to +`number | null` / `Array` with `NaN` for warmup. + +## Warmup + +`Sma::new(period).warmup_period() == period`. The first non-empty value +is emitted on the `period`-th `update()` call, because the window needs to +hold exactly `period` values before the mean is defined. There is no +seeding step beyond filling the window — `Sma` only ever stores its +running sum and the `VecDeque` of values, so its readiness condition is +literally `window.len() == period`. + +## Edge cases + +- **Constant series.** Feeding `[7.0; n]` returns `Some(7.0)` from input + `period` onward; the running-sum bookkeeping is exact for constants + (the unit test `constant_series_yields_constant_sma` pins this). +- **NaN / infinity inputs.** The first line of `update` is + `if !input.is_finite() { return self.value(); }`. Non-finite inputs are + **silently dropped** — they do not advance the window, do not corrupt + the sum, and the previous valid value (if any) is returned. The unit + test `ignores_non_finite_input_but_keeps_state` pins this behaviour. +- **Reset.** `sma.reset()` clears the window and the sum, returning the + indicator to a fresh `is_ready() == false` state. The next `update` + starts a new warmup countdown. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Sma}; + +fn main() -> Result<(), Box> { + let mut sma = Sma::new(3)?; + let out: Vec> = sma.batch(&[2.0, 4.0, 6.0, 8.0, 10.0]); + println!("{:?}", out); + println!("warmup_period = {}", sma.warmup_period()); + Ok(()) +} +``` + +Output: + +``` +[None, None, Some(4.0), Some(6.0), Some(8.0)] +warmup_period = 3 +``` + +The first two inputs return `None` while the window fills; the third +emits `(2 + 4 + 6) / 3 = 4.0` and every subsequent input slides the +window by one. This matches the `known_reference_values` test in +`crates/wickra-core/src/indicators/sma.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +sma = ta.SMA(3) +print(sma.batch(np.array([2.0, 4.0, 6.0, 8.0, 10.0]))) +print("warmup_period =", sma.warmup_period()) +``` + +Output: + +``` +[nan nan 4. 6. 8.] +warmup_period = 3 +``` + +Warmup rows come back as `NaN` so the result aligns 1:1 with the input +array. + +### Node + +```javascript +const ta = require('D:/Coding/Wickra/bindings/node'); +const sma = new ta.SMA(3); +console.log(sma.batch([2, 4, 6, 8, 10])); +console.log('warmupPeriod:', sma.warmupPeriod()); +``` + +Output: + +``` +[ NaN, NaN, 4, 6, 8 ] +warmupPeriod: 3 +``` + +## Interpretation + +`Sma` is a smoothed price level. The two canonical signals are: + +1. **Price–SMA crossover.** Close above the SMA suggests an uptrend, close + below suggests a downtrend. The longer the SMA, the slower (and more + trustworthy) the signal. +2. **Two-SMA crossover.** A fast SMA crossing above a slow SMA is the + classic "golden cross"; below is the "death cross". Either of `Ema` + or `Hma` will give earlier (but noisier) signals at the same period. + +Prefer `Sma` when you want the simplest possible reference price — for +example, as the middle band of [`BollingerBands`](../../Indicators-Overview.md), +which uses an SMA by construction. Prefer `Ema` if you want the same +smoothness profile but slightly less lag on direction changes. + +## Common pitfalls + +- **Treating `period = 0` as "use a default".** `Sma::new(0)` returns + `Err(Error::PeriodZero)` in Rust and a `ValueError` in Python; there is + no implicit default. Pass an explicit period. +- **Slicing batch results with `> warmup_period` instead of + `~np.isnan(...)`.** In Python the batch output has `NaN` for warmup + rows; in Rust it has `None`. Use the warmup-aware mask to filter — see + the [Quickstart: Python](../../Quickstart-Python.md#macd-a-multi-column-indicator-and-its-warmup-nans) + pattern. Slicing by `prices.size - warmup_period` works for a single + indicator but breaks the moment you compose two of them via `Chain`. + +## References + +The simple moving average predates technical analysis as a discipline. +The implementation here follows the standard "rolling sum, slide on each +update" formulation; the matching reference implementations are TA-Lib +and pandas (`rolling(period).mean()`). + +## See also + +- [Indicator-Ema.md](Indicator-Ema.md) — same smoothness budget, less lag. +- [Indicator-Wma.md](Indicator-Wma.md) — linear weights instead of equal. +- [Indicator-Hma.md](Indicator-Hma.md) — built on three WMAs for near-zero + lag. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/trend/Indicator-Tema.md b/docs/wiki/indicators/trend/Indicator-Tema.md new file mode 100644 index 00000000..52676b4a --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Tema.md @@ -0,0 +1,208 @@ +# TEMA + +> Triple Exponential Moving Average — Mulloy's +> `3·EMA1 − 3·EMA2 + EMA3` (where each EMA is fed from the previous one), +> the second-order lag-reduction sibling of DEMA. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Exponential family | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required (no default in either binding) | +| Warmup period | `3·period − 2` | +| Interpretation | Even less lag than `Dema`, at the cost of more noise sensitivity. | + +## Formula + +Let `EMA1 = EMA(price, period)`, `EMA2 = EMA(EMA1, period)`, +`EMA3 = EMA(EMA2, period)`. Then: + +``` +TEMA_t = 3 * EMA1_t - 3 * EMA2_t + EMA3_t +``` + +All three EMAs share the same `period`, hence the same +`α = 2 / (period + 1)`. The coefficients `(3, −3, 1)` are the +second-order finite-difference correction that removes both the +first-order and second-order EMA lag terms — they come from expanding +`(1 − L)^{-3}` where `L` is the lag operator. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Period shared by all three internal EMAs. `period = 0` errors with `Error::PeriodZero`. | + +(Python class `wickra.TEMA(period)` has no `#[pyo3(signature)]` default; +pass `period` explicitly.) + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/tema.rs`: + +```rust +impl Indicator for Tema { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +Python `update` returns `float | None`, `batch` returns a 1-D +`numpy.ndarray` (`float64`, `NaN` for warmup). Node `update` returns +`number | null`, `batch` returns `Array` with `NaN` +placeholders. + +## Warmup + +`Tema::new(period).warmup_period() == 3 * period - 2`. Each stacked EMA +adds `period − 1` more inputs to the warmup count: + +- `ema1` emits first at input `period`. +- `ema2`, fed from `ema1`, emits first at input `period + (period − 1) = 2·period − 1`. +- `ema3`, fed from `ema2`, emits first at input `(2·period − 1) + (period − 1) = 3·period − 2`. + +For `Tema::new(14)` this gives `40` (matches the table in +[Warmup Periods](../../Warmup-Periods.md)); for `Tema::new(5)` (the example +below) it gives `13`. The implementation uses `?` short-circuit on every +stage, so each inner EMA is only fed once the previous one emits. + +## Edge cases + +- **Constant series.** Feeding `[42.0; n]` produces `Some(42.0)` once all + three EMAs have converged: `3·42 − 3·42 + 42 = 42`. The unit test + `constant_series_yields_constant_tema` pins this with `Tema::new(5)` + over 80 constants. +- **NaN / infinity inputs.** Inherited from the inner `Ema`: non-finite + inputs are silently dropped at the `ema1` boundary and never reach the + `3·EMA1 − 3·EMA2 + EMA3` arithmetic. +- **Reset.** `tema.reset()` resets all three internal EMAs; the next + `update` starts a full `3·period − 2` warmup countdown. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Tema}; + +fn main() -> Result<(), Box> { + let mut tema = Tema::new(5)?; + let prices: Vec = (1..=20).map(f64::from).collect(); + let out: Vec> = tema.batch(&prices); + println!("warmup_period = {}", tema.warmup_period()); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +warmup_period = 13 +[None, None, None, None, None, None, None, None, None, None, None, None, Some(13.0), Some(14.0), Some(15.000000000000002), Some(16.000000000000004), Some(17.000000000000007), Some(18.000000000000007), Some(19.000000000000007), Some(20.0)] +``` + +The first `Some` lands at index 12 (the 13th input), matching +`3·5 − 2 = 13`. On the linear ramp `1, 2, …, 20`, TEMA tracks the input +ramp essentially exactly because both first- and second-order lag have +been cancelled; the floating-point tail +(`15.000000000000002`, `16.000000000000004`, …) is ordinary IEEE-754 +drift from the recursive subtractions. + +### Python + +```python +import numpy as np +import wickra as ta + +tema = ta.TEMA(5) +out = tema.batch(np.arange(1.0, 21.0)) +print("warmup_period =", tema.warmup_period()) +print(out) +``` + +Output: + +``` +warmup_period = 13 +[nan nan nan nan nan nan nan nan nan nan nan nan 13. 14. 15. 16. 17. 18. + 19. 20.] +``` + +### Node + +```javascript +const ta = require('D:/Coding/Wickra/bindings/node'); +const tema = new ta.TEMA(5); +const prices = Array.from({ length: 20 }, (_, i) => i + 1); +console.log(tema.batch(prices)); +console.log('warmupPeriod:', tema.warmupPeriod()); +``` + +Output: + +``` +[ + NaN, NaN, + NaN, NaN, + NaN, NaN, + NaN, NaN, + NaN, NaN, + NaN, NaN, + 13, 14, + 15.000000000000002, 16.000000000000004, + 17.000000000000007, 18.000000000000007, + 19.000000000000007, 20 +] +warmupPeriod: 13 +``` + +## Interpretation + +`Tema` removes more lag than `Dema` and noticeably more than `Ema`. +On a clean trending series the line stays glued to price; on a noisy +or sideways series the same lag-cancellation amplifies the noise — TEMA +overshoots and reverses faster than DEMA, and very much faster than EMA. + +The signals are the same crossover patterns: price-vs-TEMA and +fast-TEMA-vs-slow-TEMA. The `(3, −3, 1)` coefficient pattern is also +what makes `Trix` (also in this family) work — `Trix` is the percentage +change of `EMA3`, the triple-smoothed series. + +Prefer `Tema` when `Dema` still feels too laggy and your data is clean +enough to tolerate the extra noise sensitivity. Prefer `Hma` if you want +a similar lag profile but with a built-in smoothing step (WMA chain +instead of EMA chain), which behaves more gracefully on noisy data. + +## Common pitfalls + +- **Forgetting the `3·period − 2` warmup.** `Tema::new(50)` will not + emit until input 148. That is a significant chunk of any short-term + backtest. If you are running a side-by-side panel of indicators with + different warmups, filter rows on `~np.isnan(...)` (Python) / + `is_some()` (Rust) per indicator rather than picking one global + warmup cutoff. +- **Using TEMA for noisy intraday data without a smoothing step.** The + same lag-cancellation that makes TEMA attractive on clean data turns + into whipsaws on tick-by-tick feeds. Either raise `period` materially + or switch to `Hma`, which has a final WMA smoothing pass built in. + +## References + +Patrick G. Mulloy, *"Smoothing Data with Less Lag"*, **Technical Analysis +of Stocks & Commodities**, February 1994 (TEMA). The coefficient pattern +`(3, −3, 1)` for cancelling first- and second-order EMA lag is derived +in the same article. + +## See also + +- [Indicator-Ema.md](Indicator-Ema.md) — the building block. +- [Indicator-Dema.md](Indicator-Dema.md) — second-order's sibling. +- [Indicator-Hma.md](Indicator-Hma.md) — similar lag profile, built on WMAs. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/trend/Indicator-Wma.md b/docs/wiki/indicators/trend/Indicator-Wma.md new file mode 100644 index 00000000..0ba24068 --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Wma.md @@ -0,0 +1,186 @@ +# WMA + +> Weighted Moving Average with linear weights `1, 2, …, period`, so the +> most recent bar carries the most weight. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Simple averages | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required (no default in either binding) | +| Warmup period | `period` | +| Interpretation | Front-weighted trend filter; faster than `Sma`, smoother than `Ema`. | + +## Formula + +``` +weights = [1, 2, ..., n] // n = period +W = n * (n + 1) / 2 // sum of weights +WMA_t = (1 / W) * Σ_{i=0}^{n-1} (n - i) * price_{t-i} + = (1 / W) * (n * price_t + (n-1) * price_{t-1} + ... + 1 * price_{t-n+1}) +``` + +Maintained in O(1) using the identity that, when sliding the window by +one, every retained element's weight drops by exactly one and the +newcomer enters at weight `n`: + +``` +new_weight_sum = old_weight_sum - old_value_sum + n * new_input +new_value_sum = old_value_sum - oldest_value + new_input +``` + +This is the bookkeeping in the steady-state branch of `update`; during +warmup the full `Σ weight·value` is computed once when the window first +fills. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Length of the rolling window. `period = 0` errors with `Error::PeriodZero`. `period = 1` is a pass-through. | + +(The Python class `wickra.WMA(period)` does not set a `#[pyo3(signature)]` +default; pass the period explicitly.) + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/wma.rs`: + +```rust +impl Indicator for Wma { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +Python returns `float | None` from `update` and a `numpy.ndarray` +(`float64`, `NaN` for warmup) from `batch`. Node returns `number | null` +and `Array` (with `NaN` placeholders) respectively. + +## Warmup + +`Wma::new(period).warmup_period() == period`. Like `Sma`, the first +emission lands on the `period`-th `update()` call: the window needs +exactly `period` values for the weighted sum to be defined. There is no +seeding step beyond filling the window. + +## Edge cases + +- **Constant series.** For `[c; n]`, every element contributes `c · weight_i` + and the result is `c · ΣW / ΣW = c`. The proptest + `proptest_matches_naive` exercises this implicitly across many random + inputs; the textbook `period = 4` test confirms `WMA(4)` of + `[1, 2, 3, 4]` is exactly `(1·1 + 2·2 + 3·3 + 4·4) / 10 = 30 / 10 = 3.0`. +- **NaN / infinity inputs.** The first line of `update` is + `if !input.is_finite() { return self.value(); }`. Non-finite inputs are + silently dropped — they do not advance warmup, do not corrupt the + rolling sums, and the previously emitted value (if any) is returned. +- **Reset.** `wma.reset()` clears the window and both rolling sums; the + next `update` starts a new warmup countdown. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Wma}; + +fn main() -> Result<(), Box> { + let mut wma = Wma::new(4)?; + let out: Vec> = wma.batch(&[1.0, 2.0, 3.0, 4.0]); + println!("{:?}", out); + println!("warmup_period = {}", wma.warmup_period()); + Ok(()) +} +``` + +Output: + +``` +[None, None, None, Some(3.0)] +warmup_period = 4 +``` + +The fourth input emits `(1·1 + 2·2 + 3·3 + 4·4) / (1+2+3+4) = 30 / 10 = 3.0`. +This matches the `known_values_period_4` unit test in +`crates/wickra-core/src/indicators/wma.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +wma = ta.WMA(4) +print(wma.batch(np.array([1.0, 2.0, 3.0, 4.0]))) +print("warmup_period =", wma.warmup_period()) +``` + +Output: + +``` +[nan nan nan 3.] +warmup_period = 4 +``` + +### Node + +```javascript +const ta = require('D:/Coding/Wickra/bindings/node'); +const wma = new ta.WMA(4); +console.log(wma.batch([1, 2, 3, 4])); +console.log('warmupPeriod:', wma.warmupPeriod()); +``` + +Output: + +``` +[ NaN, NaN, NaN, 3 ] +warmupPeriod: 4 +``` + +## Interpretation + +`Wma` sits between `Sma` and `Ema` on the lag/responsiveness spectrum: +because the most recent bar carries weight `n` (vs `1` for the oldest), +direction changes propagate faster than in `Sma`, but the smooth linear +decay produces less of the "exponential tail" overshoot you sometimes +see with `Ema`. The same two crossover signals (price-vs-WMA and +fast-WMA-vs-slow-WMA) apply. + +The most important downstream use of `Wma` inside Wickra is `Hma`: +`Hma` is built entirely from three `Wma` instances (see +[Indicator-Hma.md](Indicator-Hma.md)). + +## Common pitfalls + +- **Mistaking linear weights for exponential ones.** A `Wma(20)` is *not* + an `Ema(20)`; the weights decay linearly `(20, 19, 18, …, 1)` rather + than geometrically, so very old bars still contribute (weight 1) where + in an EMA they would have decayed to near zero. If you want the + exponential decay, use `Ema`. +- **Comparing `Wma(period)` to a "WMA" from a different library and + finding the seed off.** Wickra's `Wma` has no separate seeding step — + it simply returns `None` until the window is full and then returns the + exact weighted mean from input `period` onward. Some libraries + pre-seed with a partial-window value; that is a different convention + and will produce different first-few-bar values. + +## References + +The linearly-weighted moving average is older than most named indicators +and has no single canonical citation; TA-Lib's `WMA` is the standard +reference implementation and matches Wickra's output bit-for-bit. + +## See also + +- [Indicator-Sma.md](Indicator-Sma.md) — equal weights instead of linear. +- [Indicator-Ema.md](Indicator-Ema.md) — exponential decay instead of linear. +- [Indicator-Hma.md](Indicator-Hma.md) — Hull MA, built from three WMAs. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volatility/Indicator-Atr.md b/docs/wiki/indicators/volatility/Indicator-Atr.md new file mode 100644 index 00000000..660431ea --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-Atr.md @@ -0,0 +1,226 @@ +# ATR (Average True Range) + +> Wilder's volatility benchmark: an exponentially-smoothed average of the +> per-bar true range that absorbs overnight gaps and is dimensioned in price +> units. + +## Quick reference + +| Item | Value | +|---------------------|--------------------------------------------------------------------------------------| +| Family | Volatility | +| Sub-category | range-average | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `f64` | +| Output range | unbounded `≥ 0` | +| Default parameters | `period = 14` (Wilder) | +| Warmup period | `period` (14 for defaults) | +| Interpretation | dollar-denominated volatility scale; rises in chop and expansion | + +## Formula + +For each candle, the **true range** is +`TR_t = max(H_t - L_t, |H_t - C_{t-1}|, |L_t - C_{t-1}|)` when a previous +close is available, otherwise `TR_t = H_t - L_t` (see `Candle::true_range` +in `crates/wickra-core/src/ohlcv.rs`). + +ATR is then Wilder-smoothed: + +``` +seed_ATR_period = (TR_1 + TR_2 + … + TR_period) / period +ATR_t = ((period - 1) * ATR_{t-1} + TR_t) / period for t > period +``` + +This is mathematically the same recursion as an EMA with `alpha = 1/period` +(Wilder smoothing), seeded with a simple mean of the first `period` true +ranges (`crates/wickra-core/src/indicators/atr.rs:58-69`). + +## Parameters + +| Name | Type | Default | Constraint | Source | +|----------|---------|---------|------------|-------------------------------------| +| `period` | `usize` | `14` | `> 0` | `Atr::new` (`atr.rs:26`) | + +Python default from `#[pyo3(signature = (period=14))]` in +`bindings/python/src/lib.rs`. `period == 0` returns `Error::PeriodZero`. + +## Inputs / Outputs + +```rust +impl Indicator for Atr { + type Input = Candle; + type Output = f64; + fn update(&mut self, candle: Candle) -> Option; + fn warmup_period(&self) -> usize { self.period } +} +``` + +- **Rust input.** A full `Candle` struct; only `high`, `low`, and `close` + are read (`prev_close` is cached internally between calls). +- **Python streaming.** Accepts either a 6-tuple + `(open, high, low, close, volume, timestamp)` or a dict with keys + `open`, `high`, `low`, `close`, `volume`, and optional `timestamp`. +- **Python batch.** `ATR.batch(high, low, close)` takes three equal-length + `numpy.ndarray` columns and returns a 1-D `np.ndarray` with `NaN` for + every warmup row. +- **Node streaming.** `atr.update(high, low, close)` returns `number | null`. +- **Node batch.** `atr.batch(high, low, close)` returns `Array` of + the same length, `NaN` during warmup. + +## Warmup + +`warmup_period() == period`. The first `period - 1` candles return `None` +(or `NaN`/`null` in batch); the `period`-th candle returns the seed value +`(TR_1 + … + TR_period) / period`. Each subsequent candle applies the +Wilder recursion. + +Verified for `period = 3`: the first non-`None` output is at index `2` +(the 3rd candle). + +## Edge cases + +- **First candle.** `Candle::true_range(None)` falls back to `high - low` + because there is no previous close yet. The first TR is the bar range. +- **Gaps.** With a previous close at `5.0` and a candle of `H=10, L=9`, + `TR = max(1, 5, 4) = 5` — i.e. `|H - prev_close|` dominates. The + pinned test `gap_up_uses_high_minus_prev_close` covers exactly this. +- **Constant input.** A series of identical candles (no gaps, fixed range) + yields a constant ATR equal to the bar range, even before the seed is + complete — the smoothing has nothing to smooth. +- **Non-negativity.** ATR is always `≥ 0`. The Rust test `never_negative` + pins this property across a sinusoidal price series. +- **NaN / infinity.** `Candle::new` rejects non-finite `open`/`high`/ + `low`/`close`/`volume`; constructing the candle returns + `Error::InvalidCandle` before it can ever reach ATR. +- **Reset.** `reset()` clears `prev_close`, the seed buffer, and the + running average; the next call behaves as if the indicator were + freshly constructed. + +## Examples + +### Rust + +```rust +use wickra::{Atr, BatchExt, Candle, Indicator}; + +fn main() -> Result<(), Box> { + let candles = vec![ + Candle::new(10.0, 11.0, 9.0, 10.5, 1.0, 0)?, + Candle::new(10.5, 12.0, 10.0, 11.5, 1.0, 0)?, + Candle::new(11.5, 13.0, 11.0, 12.5, 1.0, 0)?, + Candle::new(12.5, 14.0, 12.0, 13.5, 1.0, 0)?, + Candle::new(13.5, 15.0, 13.0, 14.5, 1.0, 0)?, + ]; + let mut atr = Atr::new(3)?; + println!("{:?}", atr.batch(&candles)); + Ok(()) +} +``` + +Output: + +``` +[None, None, Some(2.0), Some(2.0), Some(2.0)] +``` + +Every bar has range `2.0` and no gap-driven TR component, so both the +seed `(2 + 2 + 2) / 3 = 2.0` and every subsequent Wilder update stay at +`2.0`. + +### Python + +```python +import numpy as np +import wickra as ta + +atr = ta.ATR(3) +high = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) +low = np.array([ 9.0, 10.0, 11.0, 12.0, 13.0]) +close = np.array([10.5, 11.5, 12.5, 13.5, 14.5]) +print(atr.batch(high, low, close)) +``` + +Output: + +``` +[nan nan 2. 2. 2.] +``` + +### Node + +```js +const w = require('wickra'); + +const atr = new w.ATR(3); +console.log(atr.batch( + [11, 12, 13, 14, 15], + [ 9, 10, 11, 12, 13], + [10.5, 11.5, 12.5, 13.5, 14.5], +)); +``` + +Output: + +``` +[ NaN, NaN, 2, 2, 2 ] +``` + +Streaming form (`atr.update(high, low, close)`): + +```js +const w = require('wickra'); + +const atr = new w.ATR(3); +console.log(atr.update(11, 9, 10.5)); +console.log(atr.update(12, 10, 11.5)); +console.log(atr.update(13, 11, 12.5)); +console.log(atr.update(14, 12, 13.5)); +``` + +Output: + +``` +null +null +2 +2 +``` + +## Interpretation + +- **Stop sizing.** A common pattern is "place a stop `k * ATR` away from + entry," with `k` typically in `[1.5, 3.0]` depending on the timeframe. + ATR's units are price, so the stop distance is directly tradable. +- **Position sizing.** `risk_per_trade / ATR` gives a quantity that + normalises risk across assets of very different price levels. +- **Regime detection.** Persistently rising ATR signals an expansion + regime; persistently low ATR signals consolidation, often preceding + expansion (the volatility-of-volatility argument). + +## Common pitfalls + +- **Wilder smoothing vs EMA.** Wilder's smoothing factor is `1/period`, + not the EMA's `2/(period+1)`. They look similar but produce different + numbers; a 14-period Wilder ATR is **not** the same as a 14-period + EMA of true range. Wickra uses the Wilder recursion explicitly. +- **Off-by-one seeding.** ATR(14) emits its first value on the 14th + candle, not the 15th — unlike RSI(14) which needs 15 candles for 14 + diffs. The difference is that ATR's seed uses `period` true ranges + directly (and `TR_1` is well-defined even without a previous close), + while RSI(14) needs 14 *differences* between consecutive closes. + +## References + +- J. Welles Wilder Jr., *New Concepts in Technical Trading Systems*, + Trend Research, 1978. Chapter on the Average True Range and the + Wilder smoothing constant. + +## See also + +- [Bollinger Bands](Indicator-BollingerBands.md) — stddev-based volatility + envelope around an SMA. +- [Keltner Channels](Indicator-Keltner.md) — directly composes EMA + ATR. +- [Donchian Channels](Indicator-Donchian.md) — rolling high/low without + any smoothing. +- [PSAR](Indicator-Psar.md) — uses ATR-like volatility tracking implicitly + through its acceleration factor. diff --git a/docs/wiki/indicators/volatility/Indicator-BollingerBands.md b/docs/wiki/indicators/volatility/Indicator-BollingerBands.md new file mode 100644 index 00000000..8922e2c5 --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-BollingerBands.md @@ -0,0 +1,258 @@ +# Bollinger Bands + +> An SMA centerline wrapped in symmetric standard-deviation envelopes; the +> classical reading is that price persistently outside a band signals a +> volatility-driven trend, not a reversal. + +## Quick reference + +| Item | Value | +|---------------------|--------------------------------------------------------------------------------| +| Family | Volatility | +| Sub-category | envelope | +| Input type | `f64` (typically the close price) | +| Output type | `BollingerOutput { upper: f64, middle: f64, lower: f64, stddev: f64 }` | +| Output range | unbounded; `lower ≤ middle ≤ upper`, `stddev ≥ 0` | +| Default parameters | `period = 20`, `multiplier = 2.0` | +| Warmup period | `period` (20 for defaults) | +| Interpretation | width tracks recent volatility; price tags band on momentum | + +## Formula + +Each step uses the trailing window of the last `period` inputs: + +``` +mean = (1/n) * Σ x_i +var = (1/n) * Σ (x_i - mean)^2 (population variance, denominator = n) +stddev = sqrt(var) +upper = mean + multiplier * stddev +middle = mean +lower = mean - multiplier * stddev +``` + +Wickra computes `var` from the streaming sums `Σ x` and `Σ x²` as +`Σx²/n - (Σx/n)²` and clamps to `0.0` to absorb catastrophic cancellation on +near-constant inputs (`crates/wickra-core/src/indicators/bollinger.rs:82`). + +## Parameters + +| Name | Type | Default | Constraint | Source | +|--------------|---------|---------|----------------------|----------------------------------------------------------| +| `period` | `usize` | `20` | `> 0` | `BollingerBands::new` (`bollinger.rs:43`) | +| `multiplier` | `f64` | `2.0` | finite and `> 0.0` | `BollingerBands::new` (`bollinger.rs:47`) | + +Python defaults come from `#[pyo3(signature = (period=20, multiplier=2.0))]` +in `bindings/python/src/lib.rs`. Invalid inputs raise `ValueError` in Python +and return `Error::PeriodZero` / `Error::NonPositiveMultiplier` in Rust. + +## Inputs / Outputs + +Rust signature: + +```rust +impl Indicator for BollingerBands { + type Input = f64; + type Output = BollingerOutput; + fn update(&mut self, input: f64) -> Option; + fn warmup_period(&self) -> usize { self.period } +} +``` + +`BollingerOutput` fields: `upper`, `middle`, `lower`, `stddev`. + +- **Python streaming** (`update`) returns the 4-tuple `(upper, middle, lower, stddev)` + or `None` during warmup. +- **Python batch** (`batch`) returns a 2-D `numpy.ndarray` of shape `(n, 4)` with + columns `[upper, middle, lower, stddev]`; warmup rows are entirely `NaN`. +- **Node streaming** (`update`) returns a `{ upper, middle, lower, stddev }` + object or `null` during warmup. +- **Node batch** (`batch`) returns a flat `Array` of length `n * 4` + interleaved per row: `[u0, m0, l0, s0, u1, m1, l1, s1, …]`. Warmup rows + are four consecutive `NaN`s. + +## Warmup + +`warmup_period() == period`. The first `period - 1` inputs return `None`; the +`period`-th input emits the first `BollingerOutput`. Verified for `period = 5`: +the first non-`None` value appears on the 5th input (index 4). + +## Edge cases + +- **Constant input.** With a flat series the population stddev collapses to + exactly `0.0`, so `upper == middle == lower == mean`. The library guards + against tiny negative floating-point values from catastrophic cancellation + by clamping the variance with `.max(0.0)`. +- **Flat range / squeeze.** Real markets never give exactly `0.0`, but very + low-volatility windows produce visibly narrow bands; the upper and lower + bands collapse onto the middle band (the "Bollinger squeeze"). +- **NaN / infinity input.** The implementation skips non-finite inputs: + `if !input.is_finite() { return self.current(); }`. The window is not + advanced and the previous `BollingerOutput` (or `None`) is returned. +- **Multiplier validation.** `multiplier <= 0` or non-finite returns + `Error::NonPositiveMultiplier`. `period == 0` returns `Error::PeriodZero`. +- **Reset.** `reset()` clears the window and both running sums, returning the + indicator to a freshly-constructed state. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, BollingerBands, Indicator}; + +fn main() -> Result<(), Box> { + let mut bb = BollingerBands::new(5, 2.0)?; + let out = bb.batch(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]); + for (i, v) in out.into_iter().enumerate() { + println!("i={i} -> {:?}", v); + } + Ok(()) +} +``` + +Output: + +``` +i=0 -> None +i=1 -> None +i=2 -> None +i=3 -> None +i=4 -> Some(BollingerOutput { upper: 5.759591794226543, middle: 3.8, lower: 1.8404082057734565, stddev: 0.9797958971132716 }) +i=5 -> Some(BollingerOutput { upper: 5.379795897113269, middle: 4.4, lower: 3.420204102886732, stddev: 0.48989794855663404 }) +i=6 -> Some(BollingerOutput { upper: 7.190890230020663, middle: 5.0, lower: 2.809109769979336, stddev: 1.095445115010332 }) +i=7 -> Some(BollingerOutput { upper: 9.577708763999665, middle: 6.0, lower: 2.422291236000335, stddev: 1.7888543819998326 }) +``` + +The first emission at `i=4` uses the window `[2, 4, 4, 4, 5]` with mean +`3.8` and population stddev `sqrt(0.96) ≈ 0.9797959`. + +### Python + +```python +import numpy as np +import wickra as ta + +bb = ta.BollingerBands(5, 2.0) +prices = np.array([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], dtype=float) +out = bb.batch(prices) +print("shape:", out.shape) +print("row 4:", out[4]) +print("row 7:", out[7]) +``` + +Output: + +``` +shape: (8, 4) +row 4: [5.75959179 3.8 1.84040821 0.9797959 ] +row 7: [9.57770876 6. 2.42229124 1.78885438] +``` + +Streaming variant returns a 4-tuple `(upper, middle, lower, stddev)` per +tick or `None` during warmup: + +```python +import wickra as ta + +bb = ta.BollingerBands(5, 2.0) +for p in [2.0, 4.0, 4.0, 4.0, 5.0, 9.0]: + print(p, "->", bb.update(p)) +``` + +Output: + +``` +2.0 -> None +4.0 -> None +4.0 -> None +4.0 -> None +5.0 -> (5.759591794226543, 3.8, 1.8404082057734565, 0.9797958971132716) +9.0 -> (9.078143885933063, 5.2, 1.321856114066938, 1.939071942966531) +``` + +### Node + +```js +const w = require('wickra'); + +const bb = new w.BollingerBands(5, 2.0); +const flat = bb.batch([2, 4, 4, 4, 5, 5, 7, 9]); +console.log('length:', flat.length); +console.log('row 4 [upper, middle, lower, stddev]:', flat.slice(16, 20)); +console.log('row 7 [upper, middle, lower, stddev]:', flat.slice(28, 32)); +``` + +Output: + +``` +length: 32 +row 4 [upper, middle, lower, stddev]: [ 5.759591794226543, 3.8, 1.8404082057734565, 0.9797958971132716 ] +row 7 [upper, middle, lower, stddev]: [ 9.577708763999665, 6, 2.422291236000335, 1.7888543819998326 ] +``` + +Streaming returns the named object `{ upper, middle, lower, stddev }`: + +```js +const w = require('wickra'); + +const bb = new w.BollingerBands(5, 2.0); +[2, 4, 4, 4, 5].forEach(p => console.log(p, '->', bb.update(p))); +``` + +Output: + +``` +2 -> null +4 -> null +4 -> null +4 -> null +5 -> { + upper: 5.759591794226543, + middle: 3.8, + lower: 1.8404082057734565, + stddev: 0.9797958971132716 +} +``` + +## Interpretation + +- **Bandwidth as volatility.** `(upper - lower) / middle` is the Bollinger + bandwidth; a multi-month low in bandwidth is the classic "squeeze" that + often precedes an expansion move. +- **Tags vs breakouts.** A single touch of the upper band is not a sell + signal in Bollinger's own framework; persistent closes outside the band + ("walking the band") signal trend continuation, not exhaustion. +- **%b position.** `(price - lower) / (upper - lower)` normalises position + inside the channel and is useful as a feature for cross-asset comparison. + +## Common pitfalls + +- **Stddev convention.** Wickra uses **population** standard deviation + (denominator `n`, not `n - 1`). This matches Bollinger's original + formulation and every reference implementation (TA-Lib, pandas-ta); + switching to the sample variant would mis-align bands by a factor of + `sqrt(n / (n - 1))` and break parity with other tools. +- **Partial rows.** In the Python 2-D batch result, do not slice an + individual column out and use it for analysis without checking for + `NaN` — every warmup row is `NaN` across all four columns. Filter with + `mask = ~np.isnan(out[:, 0])` before reading any single column. +- **Flat batch length in Node.** The Node `batch` returns `n * 4` numbers + interleaved per row, not four parallel arrays. Reshape with + `Array.from({ length: n }, (_, i) => flat.slice(i * 4, i * 4 + 4))` + if you want per-row records. + +## References + +- John Bollinger, *Bollinger on Bollinger Bands*, McGraw-Hill, 2001 (the + original publication of the indicator dates to the early 1980s). +- Wilder's *New Concepts in Technical Trading Systems* (1978) for the + surrounding family of volatility envelopes. + +## See also + +- [Keltner Channels](Indicator-Keltner.md) — same envelope shape but band + width is driven by ATR instead of stddev. +- [Donchian Channels](Indicator-Donchian.md) — rolling high/low envelope + with no smoothing. +- [ATR](Indicator-Atr.md) — the volatility scale most commonly used to + size Bollinger-style stops. diff --git a/docs/wiki/indicators/volatility/Indicator-Donchian.md b/docs/wiki/indicators/volatility/Indicator-Donchian.md new file mode 100644 index 00000000..8b822f23 --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-Donchian.md @@ -0,0 +1,214 @@ +# Donchian Channels + +> The unsmoothed price-extreme envelope: highest high and lowest low over a +> rolling window, with the mid-band defined as their average. Breakouts of +> the Donchian channel are the foundation of the Turtle trading rules. + +## Quick reference + +| Item | Value | +|---------------------|--------------------------------------------------------------------| +| Family | Volatility | +| Sub-category | envelope (rolling extrema) | +| Input type | `Candle` (uses `high` and `low`) | +| Output type | `DonchianOutput { upper: f64, middle: f64, lower: f64 }` | +| Output range | unbounded; `lower ≤ middle ≤ upper` | +| Default parameters | `period = 20` | +| Warmup period | `period` (20 for defaults) | +| Interpretation | breakout boundary; channel touches are tradable events | + +## Formula + +For a lookback of `period` candles: + +``` +upper_t = max( high_t, high_{t-1}, …, high_{t-period+1} ) +lower_t = min( low_t, low_{t-1}, …, low_{t-period+1} ) +middle_t = (upper_t + lower_t) / 2 +``` + +`crates/wickra-core/src/indicators/donchian.rs:58-72` computes both +extrema by folding over the in-window candles each tick; this is O(n) +per update in the period size and O(1) in the data length. + +## Parameters + +| Name | Type | Default | Constraint | Source | +|----------|---------|---------|------------|-----------------------------------------| +| `period` | `usize` | `20` | `> 0` | `Donchian::new` (`donchian.rs:30`) | + +Python default from `#[pyo3(signature = (period=20))]` in +`bindings/python/src/lib.rs`. `period == 0` returns `Error::PeriodZero`. + +## Inputs / Outputs + +```rust +impl Indicator for Donchian { + type Input = Candle; + type Output = DonchianOutput; + fn update(&mut self, candle: Candle) -> Option; +} + +pub struct DonchianOutput { pub upper: f64, pub middle: f64, pub lower: f64 } +``` + +- **Python streaming.** Returns `(upper, middle, lower)` tuple or `None`. +- **Python batch.** `Donchian.batch(high, low)` returns a 2-D + `np.ndarray` of shape `(n, 3)` with columns `[upper, middle, lower]`; + warmup rows are `NaN` across all three columns. (`close` is not + required.) +- **Node streaming.** Not exposed — the Node binding ships only the + `batch` form for `Donchian`. +- **Node batch.** `donchian.batch(high, low)` returns a flat + `Array` of length `n * 3` interleaved per row: + `[u0, m0, l0, u1, m1, l1, …]`. + +## Warmup + +`warmup_period() == period`. The first `period - 1` candles return +`None`; the `period`-th candle emits the first envelope. Verified for +`period = 3`: the first non-`None` output is at index `2` (the 3rd +candle). + +## Edge cases + +- **Flat market (HH == LL).** When every candle in the window has + identical highs and identical lows, `upper == lower` (and therefore + `middle == upper == lower`). The pinned test + `flat_market_yields_equal_bands` covers this. +- **Single extreme candle.** A lone wick at the edge of the window sets + the boundary until it scrolls out. Donchian therefore reacts in + step-functions, not smoothly — a new all-time high inside the window + immediately moves the upper band; a single bar later, that high + remains the boundary unless an even higher print occurs. +- **NaN / infinity.** `Candle::new` rejects non-finite OHLC values + before they can reach Donchian. +- **Reset.** `reset()` clears the candle window; the configured + `period` is preserved. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Donchian, Indicator}; + +fn main() -> Result<(), Box> { + let candles = vec![ + Candle::new(10.0, 11.0, 9.0, 10.5, 1.0, 0)?, + Candle::new(10.5, 12.0, 10.0, 11.5, 1.0, 0)?, + Candle::new(11.5, 13.0, 11.0, 12.5, 1.0, 0)?, + Candle::new(12.5, 14.0, 12.0, 13.5, 1.0, 0)?, + Candle::new(13.5, 15.0, 13.0, 14.5, 1.0, 0)?, + ]; + let mut d = Donchian::new(3)?; + for (i, v) in d.batch(&candles).into_iter().enumerate() { + println!("i={i} -> {:?}", v); + } + Ok(()) +} +``` + +Output: + +``` +i=0 -> None +i=1 -> None +i=2 -> Some(DonchianOutput { upper: 13.0, middle: 11.0, lower: 9.0 }) +i=3 -> Some(DonchianOutput { upper: 14.0, middle: 12.0, lower: 10.0 }) +i=4 -> Some(DonchianOutput { upper: 15.0, middle: 13.0, lower: 11.0 }) +``` + +At `i = 2` the window contains highs `[11, 12, 13]` and lows `[9, 10, 11]`, +so `upper = 13`, `lower = 9`, `middle = 11`. + +### Python + +```python +import numpy as np +import wickra as ta + +d = ta.Donchian(3) +h = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) +l = np.array([ 9.0, 10.0, 11.0, 12.0, 13.0]) +print(d.batch(h, l)) +``` + +Output: + +``` +[[nan nan nan] + [nan nan nan] + [13. 11. 9.] + [14. 12. 10.] + [15. 13. 11.]] +``` + +### Node + +```js +const w = require('wickra'); + +const d = new w.Donchian(3); +const flat = d.batch( + [11, 12, 13, 14, 15], + [ 9, 10, 11, 12, 13], +); +console.log('length:', flat.length); +console.log('row 2 [upper, middle, lower]:', flat.slice(6, 9)); +console.log('row 4 [upper, middle, lower]:', flat.slice(12, 15)); +``` + +Output: + +``` +length: 15 +row 2 [upper, middle, lower]: [ 13, 11, 9 ] +row 4 [upper, middle, lower]: [ 15, 13, 11 ] +``` + +## Interpretation + +- **Breakouts.** The original Turtle Trading rules (Dennis / Eckhardt, + early 1980s) buy on a 20-day Donchian upper-band breach and sell on + a 10-day lower-band breach. The modern descendant is the "channel + breakout" family of trend-following systems. +- **Mean reversion.** A small minority of systems take the bands as + fade levels; this works on range-bound assets and fails dramatically + in trends — the inverse of breakout systems. +- **Volatility proxy.** Channel width `upper - lower` is a simple + volatility proxy that requires no smoothing and no parameter tuning + beyond the lookback length. + +## Common pitfalls + +- **Stale extreme.** A single shock high from `period` candles ago + keeps the upper band elevated even when current prices have fallen + back to normal. Watch for the "channel drop" event when that high + scrolls out of the window — the upper band will step down sharply + in a single bar. +- **No close required.** Donchian only uses high/low. Feeding it a + close-only series (with high = low = close) collapses it into an + envelope of close extremes, which is a much noisier signal than + the canonical high/low form. The Python `batch` accepts only + `(high, low)` for exactly this reason. +- **Flat range collapse.** On a truly flat instrument the channel + collapses to a line (`upper == middle == lower`); downstream code + that divides by `upper - lower` (e.g. computing channel position) + must handle this division-by-zero case explicitly. + +## References + +- Richard Donchian published the 4-week channel rule in the early + 1960s as part of his broader trend-following work. +- Curtis Faith, *Way of the Turtle*, McGraw-Hill, 2007, documents + the 20/10-day Donchian variant that defined the Turtle program. + +## See also + +- [Bollinger Bands](Indicator-BollingerBands.md) — envelope shaped by + stddev rather than rolling extrema. +- [Keltner Channels](Indicator-Keltner.md) — envelope shaped by ATR + around an EMA centerline. +- [PSAR](Indicator-Psar.md) — alternative trailing-stop construction + for breakout systems. diff --git a/docs/wiki/indicators/volatility/Indicator-Keltner.md b/docs/wiki/indicators/volatility/Indicator-Keltner.md new file mode 100644 index 00000000..764c4b4f --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-Keltner.md @@ -0,0 +1,220 @@ +# Keltner Channels + +> A pure composition of [EMA](../trend/Indicator-Ema.md) on typical price plus +> ATR-scaled envelopes. The middle line is the trend filter, the bands are +> the volatility cone. + +## Quick reference + +| Item | Value | +|---------------------|------------------------------------------------------------------------------------| +| Family | Volatility | +| Sub-category | envelope (composed: EMA + ATR) | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `KeltnerOutput { upper: f64, middle: f64, lower: f64 }` | +| Output range | unbounded; `lower ≤ middle ≤ upper` | +| Default parameters | `ema_period = 20`, `atr_period = 10`, `multiplier = 2.0` | +| Warmup period | `max(ema_period, atr_period)` (`20` for defaults) — see Warmup notes | +| Interpretation | trend-following envelope; tags signal momentum, not exhaustion | + +## Formula + +``` +middle_t = EMA_{ema_period}( typical_price_t ) // tp = (H+L+C)/3 +upper_t = middle_t + multiplier * ATR_{atr_period}_t +lower_t = middle_t - multiplier * ATR_{atr_period}_t +``` + +The middle line is an EMA of **typical price**, not of close +(`crates/wickra-core/src/indicators/keltner.rs:62`, +`candle.typical_price()`). + +## Parameters + +| Name | Type | Default | Constraint | Source | +|--------------|---------|---------|-------------------------|----------------------------------------------| +| `ema_period` | `usize` | `20` | `> 0` | `Keltner::new` (`keltner.rs:33`) | +| `atr_period` | `usize` | `10` | `> 0` | `Keltner::new` (`keltner.rs:33`) | +| `multiplier` | `f64` | `2.0` | finite and `> 0.0` | `Keltner::new` (`keltner.rs:34-36`) | + +Python defaults from +`#[pyo3(signature = (ema_period=20, atr_period=10, multiplier=2.0))]` in +`bindings/python/src/lib.rs`. `Keltner::classic()` returns the same +configuration. + +## Inputs / Outputs + +```rust +impl Indicator for Keltner { + type Input = Candle; + type Output = KeltnerOutput; + fn update(&mut self, candle: Candle) -> Option; +} + +pub struct KeltnerOutput { pub upper: f64, pub middle: f64, pub lower: f64 } +``` + +- **Python streaming.** Returns `(upper, middle, lower)` tuple or `None`. +- **Python batch.** `Keltner.batch(high, low, close)` returns a 2-D + `np.ndarray` of shape `(n, 3)` with columns `[upper, middle, lower]`; + warmup rows are `NaN` across all three columns. +- **Node streaming.** Returns a `{ upper, middle, lower }` object or + `null`. +- **Node batch.** `keltner.batch(high, low, close)` returns a flat + `Array` of length `n * 3` interleaved per row: + `[u0, m0, l0, u1, m1, l1, …]`. + +## Warmup + +`warmup_period()` reports `max(ema_period, atr_period)` — for the +default `(20, 10, 2.0)` that is `20`. + +**Important caveat verified empirically.** Because `Keltner::update` +calls `self.ema.update(...)?` *before* `self.atr.update(...)?`, the ATR +sub-indicator only receives an input on candles where the EMA already +has a value. The actual first emission therefore occurs after roughly +`ema_period + atr_period - 1` candles, not `max(ema_period, atr_period)`. +With the classic `(20, 10, 2.0)` configuration the first non-`None` +output is the 29th candle (index `28`), not the 20th. Code reference: +`keltner.rs:61-69`. Plan your data prefix accordingly. + +## Edge cases + +- **Flat market.** A constant-OHLC series produces `upper == middle == lower` + because ATR collapses to `0`. The pinned test + `flat_market_collapses_bands` covers this. +- **Trending market.** When ATR rises, both bands widen symmetrically + around the EMA centerline. +- **Reset.** `reset()` resets both the underlying EMA and ATR; the + configured periods/multiplier are preserved. +- **NaN / infinity.** `Candle::new` rejects non-finite OHLC values up + front; the indicator never receives them. +- **Invalid params.** `ema_period == 0`, `atr_period == 0`, or non-positive + `multiplier` returns an error from `Keltner::new`. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, Keltner}; + +fn main() -> Result<(), Box> { + let candles = vec![ + Candle::new(10.0, 11.0, 9.0, 10.5, 1.0, 0)?, + Candle::new(10.5, 12.0, 10.0, 11.5, 1.0, 0)?, + Candle::new(11.5, 13.0, 11.0, 12.5, 1.0, 0)?, + Candle::new(12.5, 14.0, 12.0, 13.5, 1.0, 0)?, + Candle::new(13.5, 15.0, 13.0, 14.5, 1.0, 0)?, + ]; + let mut k = Keltner::new(3, 3, 2.0)?; + for (i, v) in k.batch(&candles).into_iter().enumerate() { + println!("i={i} -> {:?}", v); + } + Ok(()) +} +``` + +Output: + +``` +i=0 -> None +i=1 -> None +i=2 -> None +i=3 -> None +i=4 -> Some(KeltnerOutput { upper: 17.166666666666664, middle: 13.166666666666666, lower: 9.166666666666666 }) +``` + +Notice the first emission is at `i = 4` (the 5th candle), not `i = 2`, +even though `max(ema=3, atr=3) = 3`. This is the EMA-gates-ATR effect +documented under **Warmup**. + +### Python + +```python +import numpy as np +import wickra as ta + +k = ta.Keltner(3, 3, 2.0) +h = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) +l = np.array([ 9.0, 10.0, 11.0, 12.0, 13.0]) +c = np.array([10.5, 11.5, 12.5, 13.5, 14.5]) +print(k.batch(h, l, c)) +``` + +Output: + +``` +[[ nan nan nan] + [ nan nan nan] + [ nan nan nan] + [ nan nan nan] + [17.16666667 13.16666667 9.16666667]] +``` + +### Node + +```js +const w = require('wickra'); + +const k = new w.Keltner(3, 3, 2.0); +const flat = k.batch( + [11, 12, 13, 14, 15], + [ 9, 10, 11, 12, 13], + [10.5, 11.5, 12.5, 13.5, 14.5], +); +console.log('length:', flat.length); +console.log('row 4 [upper, middle, lower]:', flat.slice(12, 15)); +``` + +Output: + +``` +length: 15 +row 4 [upper, middle, lower]: [ 17.166666666666664, 13.166666666666666, 9.166666666666666 ] +``` + +## Interpretation + +- **Trend filter.** Persistent closes above the upper band signal + trend continuation, much like Bollinger's "walking the band" pattern; + Keltner is generally tighter than Bollinger on noisy series because + ATR responds more smoothly than a rolling stddev. +- **Squeeze cross-over.** A common "squeeze" setup compares Bollinger + bandwidth to Keltner channel width: when Bollinger fits *inside* + Keltner, a volatility expansion is statistically more likely. +- **Pullback entries.** In a defined uptrend, pullbacks to the middle + EMA line are a classic continuation entry; the lower band acts as + the disaster stop. + +## Common pitfalls + +- **Reported warmup understates the true warmup.** `warmup_period()` + reports `max(ema_period, atr_period)`, but because the EMA is + evaluated first and short-circuits the ATR update via `?`, the + indicator only emits after roughly `ema_period + atr_period - 1` + candles. For the classic `(20, 10, 2.0)` you need 29 candles, not + 20, before the first valid `KeltnerOutput`. Inspecting + `is_ready()` is the safest gate. +- **Typical price ≠ close.** The middle EMA runs on + `(H + L + C) / 3`, not on close. A pre-computed "EMA of close" + panel will not equal the Keltner middle line and trying to align + them at floating-point precision will fail. + +## References + +- Chester W. Keltner, *How to Make Money in Commodities*, 1960. The + original construction used a 10-day SMA of typical price with an + envelope sized by the 10-day average range. The modern variant + (EMA centerline + ATR envelope) is the form Wickra implements. +- Linda Bradford Raschke popularised the EMA + ATR rephrasing in the + 1990s; this is the version most TA libraries ship today. + +## See also + +- [EMA](../trend/Indicator-Ema.md) — the centerline component. +- [ATR](Indicator-Atr.md) — the envelope width component. +- [Bollinger Bands](Indicator-BollingerBands.md) — envelope using stddev + rather than ATR; useful side-by-side comparison. +- [Donchian Channels](Indicator-Donchian.md) — envelope using rolling + extrema with no smoothing. diff --git a/docs/wiki/indicators/volatility/Indicator-Psar.md b/docs/wiki/indicators/volatility/Indicator-Psar.md new file mode 100644 index 00000000..66d8e817 --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-Psar.md @@ -0,0 +1,247 @@ +# PSAR (Parabolic SAR) + +> Wilder's parabolic Stop-And-Reverse: a state-machine trailing stop that +> accelerates toward price as a trend extends and flips sides on a +> penetration of the SAR line. + +## Quick reference + +| Item | Value | +|---------------------|------------------------------------------------------------------------------------| +| Family | Volatility | +| Sub-category | trailing-stop (state machine) | +| Input type | `Candle` (uses `high`, `low`) | +| Output type | `f64` | +| Output range | unbounded; bracketed by the prior two highs/lows | +| Default parameters | `af_start = 0.02`, `af_step = 0.02`, `af_max = 0.20` (Wilder) | +| Warmup period | `2` (state machine seeds on the 2nd candle) | +| Interpretation | trailing stop that "flips" sides on penetration; never tied to a fixed bar count | + +## Formula + +PSAR is a two-state machine — `Up` (long bias) and `Down` (short bias). +Each bar updates three pieces of state: + +``` +EP_t = extreme price reached so far in the current trend (max high in Up, + min low in Down) +AF_t = acceleration factor, bumped by af_step each time EP makes a new + extreme, capped at af_max +SAR_t = stop-and-reverse level +``` + +The transition is: + +``` +SAR_t = SAR_{t-1} + AF_{t-1} * (EP_{t-1} - SAR_{t-1}) + +# Wilder rule: SAR cannot penetrate today's or yesterday's range +if Up: SAR_t = min(SAR_t, low_{t-1}, low_t) +if Down: SAR_t = max(SAR_t, high_{t-1}, high_t) + +# Reversal test +if Up and low_t <= SAR_t: flip to Down, SAR_t = EP_{t-1}, reset AF +if Down and high_t >= SAR_t: flip to Up, SAR_t = EP_{t-1}, reset AF +``` + +The exact step-by-step is `crates/wickra-core/src/indicators/psar.rs:75-141`. + +## Parameters + +| Name | Type | Default | Constraint | Source | +|------------|-------|---------|-------------------------------------------|---------------------------------------| +| `af_start` | `f64` | `0.02` | finite, `> 0`, `≤ af_max` | `Psar::new` (`psar.rs:39-50`) | +| `af_step` | `f64` | `0.02` | finite, `> 0` | `Psar::new` (`psar.rs:39-50`) | +| `af_max` | `f64` | `0.20` | finite, `> 0` | `Psar::new` (`psar.rs:39-50`) | + +Python defaults from +`#[pyo3(signature = (af_start=0.02, af_step=0.02, af_max=0.20))]` in +`bindings/python/src/lib.rs`. `Psar::classic()` returns the same triple. + +Validation errors: +- non-finite or non-positive AF parameter → `Error::NonPositiveMultiplier` +- `af_start > af_max` → `Error::InvalidPeriod { message: "af_start must be <= af_max" }` + +## Inputs / Outputs + +```rust +impl Indicator for Psar { + type Input = Candle; + type Output = f64; + fn update(&mut self, candle: Candle) -> Option; + fn warmup_period(&self) -> usize { 2 } +} +``` + +- **Python streaming.** Returns `float | None`. +- **Python batch.** `PSAR.batch(high, low, close)` returns a 1-D + `np.ndarray`; the first row is `NaN` (warmup) and every subsequent + row holds the SAR level for that bar. +- **Node streaming.** Not exposed in the Node binding. +- **Node batch.** `psar.batch(high, low, close)` returns + `Array` with `NaN` for the first row. + +## Warmup + +`warmup_period() == 2`. The very first candle seeds internal state +(`prev_high`, `prev_low`, `sar = low`, `ep = high`, `trend = Up`, +`af = af_start`) and returns `None`. The second candle produces the +first SAR value. + +The seed trend is **always** `Up` (`psar.rs:83`); the indicator will +reverse to `Down` on the first qualifying penetration. There is no +look-ahead at the second candle's close — the seed is purely structural. + +## Edge cases + +- **First bar.** Always returns `None`; downstream code must tolerate + the first row being absent without crashing. +- **Pure uptrend.** With monotonically rising highs and lows, the SAR + remains below the lows and accelerates toward price as the EP makes + successive new highs. The pinned test `pure_uptrend_sar_below_lows` + asserts `SAR ≤ low` on every emitted bar of a 40-bar ramp. +- **Pure downtrend.** Symmetrically, with monotonically falling highs, + the SAR sits above the highs after the trend establishes. + `pure_downtrend_sar_above_highs` covers this. +- **Reversal mechanics.** When the trend flips, `SAR` is set to the + previous EP (not the calculated parabola value), AF is reset to + `af_start`, and the new EP is the current bar's high (Down→Up) or + low (Up→Down). +- **Choppy regime.** Frequent reversals cause many AF resets; SAR + becomes a poor stop in mean-reverting regimes and whipsaws. +- **NaN / infinity.** `Candle::new` rejects non-finite OHLC values. + `Psar::new` rejects non-finite AF parameters. +- **Reset.** `reset()` clears the initialised flag and resets `af` to + `af_start`, `sar` to `0.0`, `ep` to `0.0`; the next `update` re-seeds. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, Psar}; + +fn main() -> Result<(), Box> { + let candles: Vec = (0..8) + .map(|i| { + let base = 100.0 + f64::from(i); + Candle::new(base, base + 0.5, base - 0.5, base + 0.25, 1.0, 0).unwrap() + }) + .collect(); + let mut p = Psar::classic(); // (0.02, 0.02, 0.20) + for (i, v) in p.batch(&candles).into_iter().enumerate() { + println!("i={i} -> {:?}", v); + } + Ok(()) +} +``` + +Output: + +``` +i=0 -> None +i=1 -> Some(99.5) +i=2 -> Some(99.58) +i=3 -> Some(99.7552) +i=4 -> Some(100.054784) +i=5 -> Some(100.4993056) +i=6 -> Some(101.099388928) +i=7 -> Some(101.85547447808) +``` + +The SAR starts at `99.5` (the first candle's low) and accelerates +upward toward price as the EP makes new highs on every bar. + +### Python + +```python +import numpy as np +import wickra as ta + +p = ta.PSAR() # defaults (0.02, 0.02, 0.20) +h = np.array([100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5, 107.5]) +l = np.array([ 99.5, 100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5]) +cl = np.array([100.25, 101.25, 102.25, 103.25, 104.25, 105.25, 106.25, 107.25]) +print(p.batch(h, l, cl)) +``` + +Output: + +``` +[ nan 99.5 99.58 99.7552 100.054784 + 100.4993056 101.09938893 101.85547448] +``` + +### Node + +```js +const w = require('wickra'); + +const p = new w.PSAR(0.02, 0.02, 0.20); +console.log(p.batch( + [100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5, 107.5], + [ 99.5, 100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5], + [100.25, 101.25, 102.25, 103.25, 104.25, 105.25, 106.25, 107.25], +)); +``` + +Output: + +``` +[ + NaN, + 99.5, + 99.58, + 99.7552, + 100.054784, + 100.4993056, + 101.099388928, + 101.85547447808 +] +``` + +## Interpretation + +- **Stop & reverse.** PSAR is a *trailing stop*, not a signal generator + in isolation: a long is exited (and a short is initiated) the bar + that price penetrates the SAR line. +- **Acceleration.** The further a trend extends without making new + extremes, the slower the SAR rises (or falls). When EP makes a new + extreme, AF bumps by `af_step` and the SAR closes the distance to + price more aggressively. +- **Whipsaw risk.** In sideways markets PSAR flips repeatedly; pair it + with a trend filter (ADX, slope of EMA) to skip trades when the + underlying isn't actually trending. + +## Common pitfalls + +- **The first bar always returns `None`.** Code that pre-allocates a + vector and does `out[i] = psar.update(c).unwrap()` will panic on + the very first input. Use `if let Some(...)` or skip the first + row explicitly. +- **Initial trend is hard-coded to `Up`.** The seed bar always sets + `trend = Up`, regardless of whether the data is in a downtrend. + Expect a near-immediate reversal to `Down` if you feed PSAR a + decisively bearish series — the first emitted SAR may look + "wrong" because it is the prior EP from the artificial `Up` + seed, not from a real bullish run. +- **Acceleration cap matters.** `af_max = 0.20` is Wilder's choice; + raising it produces an extremely tight stop near tops/bottoms but + exits good trends prematurely. Lowering it produces a forgiving + stop that gives back more open profit. Always re-validate strategy + PnL when you change `af_max`. + +## References + +- J. Welles Wilder Jr., *New Concepts in Technical Trading Systems*, + Trend Research, 1978. Chapter on the Parabolic SAR introduces the + state-machine recursion and the default `(0.02, 0.02, 0.20)` + parameters. + +## See also + +- [ATR](Indicator-Atr.md) — sister indicator from the same Wilder text. +- [Donchian Channels](Indicator-Donchian.md) — alternative breakout-style + trailing stop based on rolling extrema. +- [Keltner Channels](Indicator-Keltner.md) — envelope you can use as a + smoother stop boundary than PSAR in choppy regimes. diff --git a/docs/wiki/indicators/volume/Indicator-Obv.md b/docs/wiki/indicators/volume/Indicator-Obv.md new file mode 100644 index 00000000..fe25fceb --- /dev/null +++ b/docs/wiki/indicators/volume/Indicator-Obv.md @@ -0,0 +1,190 @@ +# OBV (On-Balance Volume) + +> A cumulative signed-volume series: each candle adds its volume on an up +> close, subtracts on a down close, and leaves the running total unchanged +> on a flat close. The shape of the OBV curve, not its absolute level, is +> what carries information. + +## Quick reference + +| Item | Value | +|---------------------|--------------------------------------------------------------| +| Family | Volume | +| Sub-category | cumulative | +| Input type | `Candle` (uses `close` and `volume`) | +| Output type | `f64` | +| Output range | unbounded (signed, integer-of-volume in spirit) | +| Default parameters | none | +| Warmup period | `1` | +| Interpretation | divergence vs price signals accumulation / distribution | + +## Formula + +For each candle `t > 0` (after the seed): + +``` +if close_t > close_{t-1}: OBV_t = OBV_{t-1} + volume_t +if close_t < close_{t-1}: OBV_t = OBV_{t-1} - volume_t +if close_t == close_{t-1}: OBV_t = OBV_{t-1} +``` + +The first candle initialises the running total to `0.0` and emits +that value (`crates/wickra-core/src/indicators/obv.rs:42-55`). + +## Parameters + +`Obv::new()` takes no parameters. Python: `wickra.OBV()`. Node: +`new w.OBV()`. + +## Inputs / Outputs + +```rust +impl Indicator for Obv { + type Input = Candle; + type Output = f64; + fn update(&mut self, candle: Candle) -> Option; + fn warmup_period(&self) -> usize { 1 } +} +``` + +- **Python streaming.** Accepts a 6-tuple or dict candle; returns + `float | None`. +- **Python batch.** `OBV.batch(close, volume)` takes two equal-length + 1-D `numpy.ndarray` columns and returns a 1-D `np.ndarray`. The + first value is `0.0`, never `NaN`. +- **Node streaming.** Not exposed; the Node binding ships only + `batch` for `OBV`. +- **Node batch.** `obv.batch(close, volume)` returns `Array` + of the same length. + +## Warmup + +`warmup_period() == 1`. The very first candle emits `0.0` by +convention (the "baseline" — there is no prior close to compare +against, so the indicator starts the running total at zero). Every +subsequent candle emits the updated cumulative total. + +## Edge cases + +- **First bar.** Always emits `0.0` (pinned test + `first_candle_baseline_zero`). This is the canonical OBV convention + used by Granville's original formulation. +- **Equal closes.** A candle with `close_t == close_{t-1}` does not + change the running total — the volume is discarded. (`obv.rs:46-50`). +- **Down close.** Subtracts the bar's volume, so OBV can go strongly + negative on a sustained downtrend; that is expected and meaningful. +- **Zero volume.** A zero-volume bar adds or subtracts `0`, so OBV + is unchanged regardless of close direction. +- **NaN / infinity.** `Candle::new` rejects non-finite OHLCV values + before they reach OBV. +- **Reset.** `reset()` zeroes the running total and clears the + `has_emitted` / `prev_close` state. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, Obv}; + +fn main() -> Result<(), Box> { + let candles = vec![ + Candle::new(10.0, 10.0, 10.0, 10.0, 100.0, 0)?, // baseline -> 0 + Candle::new(10.0, 11.0, 10.0, 11.0, 20.0, 0)?, // up -> +20 + Candle::new(11.0, 11.0, 10.5, 10.5, 30.0, 0)?, // down -> -30 + Candle::new(10.5, 10.5, 10.5, 10.5, 40.0, 0)?, // flat -> 0 + Candle::new(10.5, 12.0, 10.5, 12.0, 10.0, 0)?, // up -> +10 + ]; + let mut obv = Obv::new(); + println!("{:?}", obv.batch(&candles)); + Ok(()) +} +``` + +Output: + +``` +[Some(0.0), Some(20.0), Some(-10.0), Some(-10.0), Some(0.0)] +``` + +Hand check: baseline `0`, then `0 + 20 = 20`, then `20 - 30 = -10`, +then `-10` (flat close discards the 40), then `-10 + 10 = 0`. + +### Python + +```python +import numpy as np +import wickra as ta + +obv = ta.OBV() +c = np.array([10.0, 11.0, 10.5, 10.5, 12.0]) +v = np.array([100.0, 20.0, 30.0, 40.0, 10.0]) +print(obv.batch(c, v)) +``` + +Output: + +``` +[ 0. 20. -10. -10. 0.] +``` + +### Node + +```js +const w = require('wickra'); + +const obv = new w.OBV(); +console.log(obv.batch( + [10, 11, 10.5, 10.5, 12], + [100, 20, 30, 40, 10], +)); +``` + +Output: + +``` +[ 0, 20, -10, -10, 0 ] +``` + +## Interpretation + +- **Divergence is the signal.** OBV's absolute level depends entirely + on where the series started and is therefore meaningless on its + own. The interpretable signal is the *shape* of OBV relative to + price: a new price high without a new OBV high (bearish divergence) + suggests the rally is not being confirmed by accumulating buy + volume, and vice versa. +- **Trend confirmation.** A rising OBV that tracks a rising price is + confirmation of the trend; a flattening OBV under a still-rising + price is the canonical warning of distribution. +- **Smoothing.** Many traders apply an SMA or EMA to OBV (e.g. 20-period + SMA) and treat crossings of that smoothed line as buy/sell triggers. + +## Common pitfalls + +- **Absolute value is arbitrary.** Comparing OBV values across + different start times or different instruments is meaningless — + only slopes, divergences, and crossings of derived smoothers carry + signal. +- **Flat closes discard volume.** A candle that closes exactly at the + previous close contributes nothing to OBV no matter how heavy its + volume. Some practitioners prefer A/D-style alternatives (e.g. + Chaikin Money Flow) that distribute the volume according to where + in the bar's range the close landed, precisely to avoid this + discontinuity. + +## References + +- Joseph Granville, *Granville's New Strategy of Daily Stock Market + Timing for Maximum Profit*, Prentice-Hall, 1976. The OBV + construction was first popularised in Granville's earlier 1963 + work and refined in his subsequent books. + +## See also + +- [VWAP](Indicator-Vwap.md) — volume-weighted price benchmark; OBV and + VWAP are the two canonical volume-aware indicators in the panel. +- [MFI](../momentum/Indicator-Mfi.md) — money-flow index, an oscillator blending + typical price with volume. +- [SMA](../trend/Indicator-Sma.md) / [EMA](../trend/Indicator-Ema.md) — the smoothers + most commonly layered on top of OBV to define trade triggers. diff --git a/docs/wiki/indicators/volume/Indicator-Vwap.md b/docs/wiki/indicators/volume/Indicator-Vwap.md new file mode 100644 index 00000000..835e6fe6 --- /dev/null +++ b/docs/wiki/indicators/volume/Indicator-Vwap.md @@ -0,0 +1,290 @@ +# VWAP (Volume-Weighted Average Price) + +> The volume-weighted mean of typical price; the institutional benchmark for +> "fair" intraday execution. Wickra ships both the unbounded cumulative +> session VWAP and a finite-window `RollingVwap`. + +## Quick reference + +| Item | Value | +|---------------------|----------------------------------------------------------------| +| Family | Volume | +| Sub-category | cumulative (`Vwap`) / rolling (`RollingVwap`) | +| Input type | `Candle` (uses `high`, `low`, `close`, `volume`) | +| Output type | `f64` | +| Output range | unbounded (price-units) | +| Default parameters | none for `Vwap`; `period` required for `RollingVwap` | +| Warmup period | `1` for `Vwap`, `period` for `RollingVwap` | +| Interpretation | intraday fair-price benchmark for execution | + +## Formula + +Both variants use the typical price `tp_t = (H_t + L_t + C_t) / 3` +(see `Candle::typical_price` in `crates/wickra-core/src/ohlcv.rs:104-108`). + +Cumulative VWAP: + +``` +VWAP_t = ( Σ_{i=1..t} tp_i * v_i ) / ( Σ_{i=1..t} v_i ) +``` + +Rolling VWAP over the last `period` candles: + +``` +RollingVWAP_t = ( Σ_{i=t-period+1..t} tp_i * v_i ) / ( Σ_{i=t-period+1..t} v_i ) +``` + +Both forms gate their output: when the relevant volume sum is `0.0`, no +value is emitted (`vwap.rs:50, 121`). + +--- + +## `Vwap` (cumulative) + +The session VWAP. State grows forever; call `reset()` at session +boundaries (e.g. the start of the trading day) to restart accumulation. + +### Parameters + +`Vwap::new()` takes no parameters. Python: `wickra.VWAP()`. Node: +`new w.VWAP()`. + +### Inputs / Outputs + +```rust +impl Indicator for Vwap { + type Input = Candle; + type Output = f64; + fn update(&mut self, candle: Candle) -> Option; + fn warmup_period(&self) -> usize { 1 } +} +``` + +- **Rust input.** A full `Candle`; the indicator multiplies + `typical_price() * volume` and accumulates. +- **Python batch.** `VWAP.batch(high, low, close, volume)` returns a 1-D + `np.ndarray` with `NaN` for any prefix where the cumulative volume is + still `0`. +- **Node batch.** `vwap.batch(high, low, close, volume)` returns + `Array` with `NaN` for the same prefix. + +### Warmup + +`warmup_period() == 1`. Provided the first candle has positive volume, +the indicator emits on tick 1. If the first `k` candles all have +`volume == 0`, no output is emitted until the first candle with +non-zero volume — `RollingVwap`'s warmup gating is independent of +this volume-gating logic and applies on top of it. + +### Edge cases + +- **Zero-volume bar.** A candle with `volume == 0` does not advance the + running sums in any visible way and (if it is the *first* such bar + the indicator has seen) keeps the output at `None`. The implementation + short-circuits with `if self.sum_v == 0.0 { return None; }` + (`vwap.rs:50`). +- **Constant input.** Identical candles produce a flat VWAP equal to + their typical price. +- **Session boundaries.** There is no automatic reset; the caller is + responsible for invoking `reset()` at the start of each new session. +- **NaN / infinity.** `Candle::new` rejects non-finite OHLCV values + before they can reach the indicator. +- **Reset.** `reset()` zeroes both running sums and unsets the `has_emitted` + flag. + +### Examples + +#### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, Vwap}; + +fn main() -> Result<(), Box> { + let candles = vec![ + Candle::new(10.0, 10.0, 10.0, 10.0, 1.0, 0)?, // tp = 10 + Candle::new(20.0, 20.0, 20.0, 20.0, 3.0, 0)?, // tp = 20 + Candle::new(30.0, 30.0, 30.0, 30.0, 1.0, 0)?, // tp = 30 + Candle::new(40.0, 40.0, 40.0, 40.0, 2.0, 0)?, // tp = 40 + ]; + let mut v = Vwap::new(); + println!("{:?}", v.batch(&candles)); + Ok(()) +} +``` + +Output: + +``` +[Some(10.0), Some(17.5), Some(20.0), Some(25.714285714285715)] +``` + +Hand check at `t = 2`: `(10*1 + 20*3) / (1+3) = 70/4 = 17.5`. +At `t = 4`: `(10*1 + 20*3 + 30*1 + 40*2) / (1+3+1+2) = 180/7 ≈ 25.7142857`. + +#### Python + +```python +import numpy as np +import wickra as ta + +vw = ta.VWAP() +h = np.array([10.0, 20.0, 30.0, 40.0]) +l = np.array([10.0, 20.0, 30.0, 40.0]) +c = np.array([10.0, 20.0, 30.0, 40.0]) +v = np.array([ 1.0, 3.0, 1.0, 2.0]) +print(vw.batch(h, l, c, v)) +``` + +Output: + +``` +[10. 17.5 20. 25.71428571] +``` + +#### Node + +```js +const w = require('wickra'); + +const vw = new w.VWAP(); +console.log(vw.batch( + [10, 20, 30, 40], + [10, 20, 30, 40], + [10, 20, 30, 40], + [ 1, 3, 1, 2], +)); +``` + +Output: + +``` +[ 10, 17.5, 20, 25.714285714285715 ] +``` + +--- + +## `RollingVwap` (finite window) + +A rolling-window variant for streaming bots that want a finite-memory +fair-price benchmark instead of an unbounded session aggregate. + +### Parameters + +| Name | Type | Default | Constraint | Source | +|----------|---------|--------------|------------|----------------------------------------------| +| `period` | `usize` | (no default) | `> 0` | `RollingVwap::new` (`vwap.rs:89`) | + +`period == 0` returns `Error::PeriodZero`. `RollingVwap` is exposed in +Rust only — Python's `VWAP` / Node's `VWAP` correspond to the cumulative +form. + +### Inputs / Outputs + +```rust +impl Indicator for RollingVwap { + type Input = Candle; + type Output = f64; + fn update(&mut self, candle: Candle) -> Option; + fn warmup_period(&self) -> usize { self.period } +} +``` + +The window stores `(typical_price * volume, volume)` pairs and runs +incremental `sum_pv` / `sum_v` aggregates, so each `update` is O(1). + +### Warmup + +`warmup_period() == period`. The first `period - 1` candles return +`None`; the `period`-th candle emits the first value provided the rolling +volume sum is positive. If the entire window has `volume == 0`, the +indicator stays at `None`. + +### Edge cases + +- **Window slides.** Once `window.len() == period`, the oldest + `(pv, v)` pair is subtracted from the running sums before the new + pair is added. +- **Zero-volume window.** If every candle in the window has zero + volume, `sum_v == 0` and the indicator suppresses output until a + positive-volume candle is in scope. +- **Reset.** `reset()` clears the window and both running sums. +- **`is_ready()`.** Returns `true` only when the window is full **and** + `sum_v > 0` (`vwap.rs:138`). + +### Examples + +#### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, RollingVwap}; + +fn main() -> Result<(), Box> { + let candles = vec![ + Candle::new(10.0, 10.0, 10.0, 10.0, 1.0, 0)?, + Candle::new(20.0, 20.0, 20.0, 20.0, 3.0, 0)?, + Candle::new(30.0, 30.0, 30.0, 30.0, 1.0, 0)?, + Candle::new(40.0, 40.0, 40.0, 40.0, 2.0, 0)?, + ]; + let mut rv = RollingVwap::new(3)?; + println!("{:?}", rv.batch(&candles)); + Ok(()) +} +``` + +Output: + +``` +[None, None, Some(20.0), Some(28.333333333333332)] +``` + +Hand check at `t = 3` with window `[10@1, 20@3, 30@1]`: +`(10 + 60 + 30) / (1+3+1) = 100/5 = 20.0`. +At `t = 4` with window `[20@3, 30@1, 40@2]`: +`(60 + 30 + 80) / (3+1+2) = 170/6 ≈ 28.333`. + +(`RollingVwap` is currently exposed only in the Rust API; the Python +`VWAP` and Node `VWAP` classes correspond to the cumulative form.) + +## Interpretation + +- **Execution benchmark.** "Beat VWAP" is the canonical buy-side + execution mandate: an aggressive algo that ends up paying *below* + VWAP on the day is considered to have earned alpha relative to a + passive participation strategy. +- **Mean reversion.** Intraday strategies often fade extensions away + from VWAP, treating the VWAP line as a magnet. +- **Trend filter.** Some systems trade only longs above VWAP and only + shorts below it; the line acts as a session-aware bias toggle. + +## Common pitfalls + +- **Forgetting to reset.** Call `reset()` at session start (or on each + new trading day) — otherwise you average yesterday's tape into + today's signal and the line drifts permanently behind current + price action. +- **Zero-volume warmup.** Several common data sources include + pre-session candles with `volume = 0` for "no print this minute". + Cumulative VWAP returns `None` until at least one positive-volume + candle has been seen; downstream code should treat `None` / + `NaN` / `null` as "not yet ready," not as "VWAP is zero." +- **Typical price vs close.** Wickra uses typical price + `(H + L + C) / 3`, not close. A naive implementation that uses + close will produce noticeably different numbers on bars with wide + intraday ranges. + +## References + +- The VWAP construct emerged in institutional execution literature in + the late 1980s and early 1990s; it has no single attributed + inventor. The textbook reference for its role as an execution + benchmark is Bertsimas & Lo, "Optimal control of execution costs," + *Journal of Financial Markets*, 1998. + +## See also + +- [OBV](Indicator-Obv.md) — cumulative signed-volume measure that pairs + well with VWAP as a divergence flag. +- [MFI](../momentum/Indicator-Mfi.md) — money-flow oscillator that also blends + typical price with volume. +- [Bollinger Bands](../volatility/Indicator-BollingerBands.md) — non-volume volatility + envelope, often layered alongside VWAP on intraday charts.