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.
This commit is contained in:
@@ -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<f64>;
|
||||
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<number>` 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<dyn std::error::Error>> {
|
||||
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.
|
||||
@@ -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<BollingerOutput>;
|
||||
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<number>` 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<dyn std::error::Error>> {
|
||||
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.
|
||||
@@ -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<DonchianOutput>;
|
||||
}
|
||||
|
||||
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<number>` 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<dyn std::error::Error>> {
|
||||
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.
|
||||
@@ -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<KeltnerOutput>;
|
||||
}
|
||||
|
||||
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<number>` 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<dyn std::error::Error>> {
|
||||
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.
|
||||
@@ -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<f64>;
|
||||
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<number>` 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<dyn std::error::Error>> {
|
||||
let candles: Vec<Candle> = (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.
|
||||
Reference in New Issue
Block a user