E5: update the warmup docs to the post-A5 behavior
A5 changed Keltner and HMA to feed their sibling sub-indicators unconditionally, so warmup_period() is now the exact first-emission index for every indicator. The wiki still described the old ?-starvation behavior as correct. - Indicator-Keltner.md: the Warmup section, the worked example output (first emission now at i=2, not i=4), the summary table row, and the "reported warmup understates" pitfall now state that warmup_period() is exact. Example output regenerated by running the code. - Indicator-Hma.md: the Warmup section, all three language examples (first Some at index 10, not 13), the table row, and the chaining pitfall corrected. Outputs regenerated. - Indicators-Overview.md: dropped the claim that Hma and Kama lag their reported warmup — both were verified exact.
This commit is contained in:
@@ -16,10 +16,10 @@ trait surface and warmup-period semantics are covered in
|
||||
|
||||
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.
|
||||
"Warmup" column quotes `warmup_period()` as the indicator reports it; this
|
||||
is the **exact** first-emission index for every indicator — the first
|
||||
non-`None` output lands on input `warmup_period()` (index
|
||||
`warmup_period() - 1`).
|
||||
|
||||
## Trend
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| 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 |
|
||||
| Warmup period (`warmup_period()`) | `period + round(√period).max(1) − 1` — exact first-emission index |
|
||||
| Interpretation | Near-zero-lag trend line with an inherent smoothing step. |
|
||||
|
||||
## Formula
|
||||
@@ -61,56 +61,46 @@ Python returns `float | None` (streaming) / `numpy.ndarray` (batch,
|
||||
|
||||
## 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:
|
||||
`warmup_period()` 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.
|
||||
`19` for `Hma::new(16)`. This figure is **exact**: the first non-`None`
|
||||
output lands on input `warmup_period()` (index `warmup_period() - 1`).
|
||||
|
||||
In practice the implementation uses the `?` short-circuit:
|
||||
The number reflects how the three inner WMAs warm up *in parallel*: the
|
||||
slow `WMA(period)` emits at input `period`, then the smoothing
|
||||
`WMA(√period)` needs `√period − 1` more inputs on top.
|
||||
|
||||
```rust
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
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)
|
||||
// Both raw WMAs are fed unconditionally so neither delays the other.
|
||||
let h = self.half_wma.update(input);
|
||||
let f = self.full_wma.update(input);
|
||||
match (h, f) {
|
||||
(Some(h), Some(f)) => self.smooth_wma.update(2.0 * h - f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`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`.
|
||||
`half_wma` and `full_wma` receive every input, so `full_wma` emits at
|
||||
input `period` (not later). The `2·half − full` diff then flows into
|
||||
`smooth_wma`, which needs `round(√period)` of those — giving a first
|
||||
emission at exactly `period + round(√period) − 1`.
|
||||
|
||||
For the three example periods this gives:
|
||||
| `period` | `round(√period)` | `warmup_period()` | First emission (input #) |
|
||||
|----------|------------------|-------------------|--------------------------|
|
||||
| 9 | 3 | 11 | 11 |
|
||||
| 14 | 4 | 17 | 17 |
|
||||
| 16 | 4 | 19 | 19 |
|
||||
|
||||
| `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.
|
||||
This is pinned by the `first_emission_matches_warmup_period` test in
|
||||
`hma.rs`: the first call that returns `Some` is exactly at
|
||||
`warmup_period() - 1` (0-indexed).
|
||||
|
||||
## Edge cases
|
||||
|
||||
@@ -136,7 +126,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut hma = Hma::new(9)?;
|
||||
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
|
||||
let out: Vec<Option<f64>> = hma.batch(&prices);
|
||||
println!("warmup_period (reported) = {}", hma.warmup_period());
|
||||
println!("warmup_period = {}", hma.warmup_period());
|
||||
println!("{:?}", out);
|
||||
Ok(())
|
||||
}
|
||||
@@ -145,14 +135,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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)]
|
||||
warmup_period = 11
|
||||
[None, None, None, None, None, None, None, None, None, None, Some(11.0), Some(12.0), Some(13.0), 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.
|
||||
The first `Some` lands at index 10 (the 11th input) — exactly
|
||||
`warmup_period() - 1`, as the [Warmup](#warmup) section explains. On the
|
||||
linear ramp `1, 2, …, 20`, HMA tracks price exactly with no visible lag.
|
||||
|
||||
### Python
|
||||
|
||||
@@ -162,15 +151,15 @@ 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("warmup_period =", 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.
|
||||
warmup_period = 11
|
||||
[nan nan nan nan nan nan nan nan nan nan 11. 12. 13. 14. 15. 16. 17. 18.
|
||||
19. 20.]
|
||||
```
|
||||
|
||||
@@ -181,7 +170,7 @@ const ta = require('wickra');
|
||||
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());
|
||||
console.log('warmupPeriod:', hma.warmupPeriod());
|
||||
```
|
||||
|
||||
Output:
|
||||
@@ -189,11 +178,11 @@ Output:
|
||||
```
|
||||
[
|
||||
NaN, NaN, NaN, NaN, NaN, NaN,
|
||||
NaN, NaN, NaN, NaN, NaN, NaN,
|
||||
NaN, 14, 15, 16, 17, 18,
|
||||
NaN, NaN, NaN, NaN, 11, 12,
|
||||
13, 14, 15, 16, 17, 18,
|
||||
19, 20
|
||||
]
|
||||
warmupPeriod (reported): 11
|
||||
warmupPeriod: 11
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
@@ -215,13 +204,11 @@ the lag-reduction in those would manifest as whipsaws. Prefer `Tema` /
|
||||
|
||||
## 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.
|
||||
- **Mis-reading the warmup as a lag.** `warmup_period()` is the exact
|
||||
first-emission index (`Hma::new(9).warmup_period() == 11`, first
|
||||
`Some` at the 11th input), so it can be used directly for `Chain`
|
||||
alignment. The leading `None`/`NaN` values are warmup, not lag — once
|
||||
HMA emits it tracks price with near-zero lag.
|
||||
- **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))`
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| 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 |
|
||||
| Warmup period | `max(ema_period, atr_period)` (`20` for defaults) — exact first-emission index |
|
||||
| Interpretation | trend-following envelope; tags signal momentum, not exhaustion |
|
||||
|
||||
## Formula
|
||||
@@ -67,16 +67,17 @@ pub struct KeltnerOutput { pub upper: f64, pub middle: f64, pub lower: f64 }
|
||||
## Warmup
|
||||
|
||||
`warmup_period()` reports `max(ema_period, atr_period)` — for the
|
||||
default `(20, 10, 2.0)` that is `20`.
|
||||
default `(20, 10, 2.0)` that is `20` — and that figure is **exact**: the
|
||||
first non-`None` output lands on candle `warmup_period()` (index
|
||||
`warmup_period() - 1`).
|
||||
|
||||
**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.
|
||||
`Keltner::update` feeds the EMA and ATR sub-indicators *unconditionally*
|
||||
on every candle, then emits once both are ready. The two sub-indicators
|
||||
warm up in parallel over the same candle window, so the slower of the
|
||||
two (`max(ema_period, atr_period)`) governs the first emission. With the
|
||||
classic `(20, 10, 2.0)` configuration the first valid `KeltnerOutput` is
|
||||
the 20th candle (index `19`). This is pinned by the
|
||||
`first_emission_matches_warmup_period` test in `keltner.rs`.
|
||||
|
||||
## Edge cases
|
||||
|
||||
@@ -120,14 +121,15 @@ Output:
|
||||
```
|
||||
i=0 -> None
|
||||
i=1 -> None
|
||||
i=2 -> None
|
||||
i=3 -> None
|
||||
i=2 -> Some(KeltnerOutput { upper: 15.166666666666666, middle: 11.166666666666666, lower: 7.166666666666666 })
|
||||
i=3 -> Some(KeltnerOutput { upper: 16.166666666666664, middle: 12.166666666666666, lower: 8.166666666666666 })
|
||||
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**.
|
||||
The first emission is at `i = 2` (the 3rd candle), exactly
|
||||
`max(ema=3, atr=3) = 3` — the value `warmup_period()` reports. The EMA
|
||||
and ATR sub-indicators are fed in parallel, so neither delays the
|
||||
other.
|
||||
|
||||
### Python
|
||||
|
||||
@@ -189,13 +191,6 @@ row 4 [upper, middle, lower]: [ 17.166666666666664, 13.166666666666666, 9.166666
|
||||
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user