Files
wickra/docs/wiki/Indicator-Chaining.md
T
kingchenc 278b6afaa4 E4: commit the documentation sources
The 33 Markdown files under docs/wiki/ were never tracked. Commit them
into the repository so the documentation is versioned alongside the
code: 8 top-level pages plus 25 per-indicator deep dives under
indicators/{momentum,trend,volatility,volume}/.

The pages are kept in-repo (not pushed to a flat GitHub Wiki), so the
relative indicators/<family>/... links in Home.md resolve correctly
when rendered on GitHub.
2026-05-22 16:18:04 +02:00

5.8 KiB

Indicator Chaining

Chain<A, B> 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

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<Chain<Ema, Ema>, Rsi>. You can keep chaining indefinitely; the Chain<A, B> produced at each step also implements Indicator<Input = f64, Output = ...>, 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:

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:

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:

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 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":

if chain.is_ready() {
    if let Some(v) = chain.update(price) {
        // ...
    }
}

State, reset, and Send

Chain<A, B> propagates reset() to both stages:

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:

use wickra::{BatchExt, Chain, Ema, Rsi};

let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
let out: Vec<Option<f64>> = chain.batch(&prices);

Stages from different families

The bridging type is f64, so anything Indicator<Input = f64, Output = f64> can serve as the first stage and anything Indicator<Input = f64> 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:

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