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:
kingchenc
2026-05-22 16:30:56 +02:00
parent 71e46a1ea6
commit 87b3f383d6
3 changed files with 65 additions and 83 deletions
+4 -4
View File
@@ -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 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 warm. "unbounded" means it tracks the price scale of the input. The
"Warmup" column quotes `warmup_period()` as the indicator reports it; for "Warmup" column quotes `warmup_period()` as the indicator reports it; this
two indicators (`Hma`, `Kama`) the practical first-emission index can lag is the **exact** first-emission index for every indicator — the first
the reported number because of stacked sub-indicator warmups — those non-`None` output lands on input `warmup_period()` (index
discrepancies are noted on the deep-dive pages. `warmup_period() - 1`).
## Trend ## Trend
+44 -57
View File
@@ -14,7 +14,7 @@
| Output type | `f64` | | Output type | `f64` |
| Output range | unbounded; tracks the input price scale | | Output range | unbounded; tracks the input price scale |
| Default parameters | `period` is required (no default in either binding) | | 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. | | Interpretation | Near-zero-lag trend line with an inherent smoothing step. |
## Formula ## Formula
@@ -61,56 +61,46 @@ Python returns `float | None` (streaming) / `numpy.ndarray` (batch,
## Warmup ## Warmup
This is the one case in the trend family where the reported `warmup_period()` returns:
`warmup_period()` is a **lower bound**, not the exact first-emission
index.
The `warmup_period()` method returns:
``` ```
period + round(sqrt(period)).max(1) - 1 period + round(sqrt(period)).max(1) - 1
``` ```
which gives `11` for `Hma::new(9)`, `17` for `Hma::new(14)`, which gives `11` for `Hma::new(9)`, `17` for `Hma::new(14)`,
`19` for `Hma::new(16)`. This number assumes the three inner WMAs `19` for `Hma::new(16)`. This figure is **exact**: the first non-`None`
warm up *in parallel*: the slow `WMA(period)` would emit at input output lands on input `warmup_period()` (index `warmup_period() - 1`).
`period`, and the smoothing `WMA(√period)` would then need `√period 1`
more inputs.
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 ```rust
fn update(&mut self, input: f64) -> Option<f64> { fn update(&mut self, input: f64) -> Option<f64> {
let h = self.half_wma.update(input)?; // returns early if None // Both raw WMAs are fed unconditionally so neither delays the other.
let f = self.full_wma.update(input)?; // ONLY called when half emits let h = self.half_wma.update(input);
let diff = 2.0 * h - f; let f = self.full_wma.update(input);
self.smooth_wma.update(diff) 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` `half_wma` and `full_wma` receive every input, so `full_wma` emits at
starts emitting (i.e. from input `half = period/2` onward). So input `period` (not later). The `half full` diff then flows into
`full_wma` does not see input until iteration `half`, and then needs `smooth_wma`, which needs `round(√period)` of those — giving a first
`period` of its own inputs — it emits first at iteration emission at exactly `period + round(√period) 1`.
`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` | `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 | This is pinned by the `first_emission_matches_warmup_period` test in
|----------|--------|----------|------------------------------|------------------------| `hma.rs`: the first call that returns `Some` is exactly at
| 9 | 4 | 3 | 11 | 14 | `warmup_period() - 1` (0-indexed).
| 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 ## Edge cases
@@ -136,7 +126,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut hma = Hma::new(9)?; let mut hma = Hma::new(9)?;
let prices: Vec<f64> = (1..=20).map(f64::from).collect(); let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let out: Vec<Option<f64>> = hma.batch(&prices); let out: Vec<Option<f64>> = hma.batch(&prices);
println!("warmup_period (reported) = {}", hma.warmup_period()); println!("warmup_period = {}", hma.warmup_period());
println!("{:?}", out); println!("{:?}", out);
Ok(()) Ok(())
} }
@@ -145,14 +135,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Output: Output:
``` ```
warmup_period (reported) = 11 warmup_period = 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)] [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 first `Some` lands at index 10 (the 11th input) — exactly
(the 14th input) for the reason given in the [Warmup](#warmup) section. `warmup_period() - 1`, as the [Warmup](#warmup) section explains. On the
On the linear ramp `1, 2, …, 20`, HMA tracks price exactly with no linear ramp `1, 2, …, 20`, HMA tracks price exactly with no visible lag.
visible lag.
### Python ### Python
@@ -162,15 +151,15 @@ import wickra as ta
hma = ta.HMA(9) hma = ta.HMA(9)
out = hma.batch(np.arange(1.0, 21.0)) out = hma.batch(np.arange(1.0, 21.0))
print("warmup_period (reported) =", hma.warmup_period()) print("warmup_period =", hma.warmup_period())
print(out) print(out)
``` ```
Output: Output:
``` ```
warmup_period (reported) = 11 warmup_period = 11
[nan nan nan nan nan nan nan nan nan nan nan nan nan 14. 15. 16. 17. 18. [nan nan nan nan nan nan nan nan nan nan 11. 12. 13. 14. 15. 16. 17. 18.
19. 20.] 19. 20.]
``` ```
@@ -181,7 +170,7 @@ const ta = require('wickra');
const hma = new ta.HMA(9); const hma = new ta.HMA(9);
const prices = Array.from({ length: 20 }, (_, i) => i + 1); const prices = Array.from({ length: 20 }, (_, i) => i + 1);
console.log(hma.batch(prices)); console.log(hma.batch(prices));
console.log('warmupPeriod (reported):', hma.warmupPeriod()); console.log('warmupPeriod:', hma.warmupPeriod());
``` ```
Output: Output:
@@ -189,11 +178,11 @@ Output:
``` ```
[ [
NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN,
NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, 11, 12,
NaN, 14, 15, 16, 17, 18, 13, 14, 15, 16, 17, 18,
19, 20 19, 20
] ]
warmupPeriod (reported): 11 warmupPeriod: 11
``` ```
## Interpretation ## Interpretation
@@ -215,13 +204,11 @@ the lag-reduction in those would manifest as whipsaws. Prefer `Tema` /
## Common pitfalls ## Common pitfalls
- **Trusting `warmup_period()` for chaining or array alignment.** As - **Mis-reading the warmup as a lag.** `warmup_period()` is the exact
the table above shows, `Hma::new(9).warmup_period() == 11` but the first-emission index (`Hma::new(9).warmup_period() == 11`, first
first actual emission is at the 14th input. If you use HMA as the `Some` at the 11th input), so it can be used directly for `Chain`
first stage of a `Chain`, the chain's overall warmup will lag what alignment. The leading `None`/`NaN` values are warmup, not lag — once
`Chain::warmup_period()` reports. Filter on `is_some()` / HMA emits it tracks price with near-zero lag.
`~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 - **Picking `period = 2` or `3`.** The inner `half = period / 2` is an
integer division floored at 1. For `period = 2`, `half = 1`, integer division floored at 1. For `period = 2`, `half = 1`,
`smooth = 1`, and you essentially end up with `Wma(2·price WMA(2))` `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 type | `KeltnerOutput { upper: f64, middle: f64, lower: f64 }` |
| Output range | unbounded; `lower ≤ middle ≤ upper` | | Output range | unbounded; `lower ≤ middle ≤ upper` |
| Default parameters | `ema_period = 20`, `atr_period = 10`, `multiplier = 2.0` | | 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 | | Interpretation | trend-following envelope; tags signal momentum, not exhaustion |
## Formula ## Formula
@@ -67,16 +67,17 @@ pub struct KeltnerOutput { pub upper: f64, pub middle: f64, pub lower: f64 }
## Warmup ## Warmup
`warmup_period()` reports `max(ema_period, atr_period)` — for the `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` `Keltner::update` feeds the EMA and ATR sub-indicators *unconditionally*
calls `self.ema.update(...)?` *before* `self.atr.update(...)?`, the ATR on every candle, then emits once both are ready. The two sub-indicators
sub-indicator only receives an input on candles where the EMA already warm up in parallel over the same candle window, so the slower of the
has a value. The actual first emission therefore occurs after roughly two (`max(ema_period, atr_period)`) governs the first emission. With the
`ema_period + atr_period - 1` candles, not `max(ema_period, atr_period)`. classic `(20, 10, 2.0)` configuration the first valid `KeltnerOutput` is
With the classic `(20, 10, 2.0)` configuration the first non-`None` the 20th candle (index `19`). This is pinned by the
output is the 29th candle (index `28`), not the 20th. Code reference: `first_emission_matches_warmup_period` test in `keltner.rs`.
`keltner.rs:61-69`. Plan your data prefix accordingly.
## Edge cases ## Edge cases
@@ -120,14 +121,15 @@ Output:
``` ```
i=0 -> None i=0 -> None
i=1 -> None i=1 -> None
i=2 -> None i=2 -> Some(KeltnerOutput { upper: 15.166666666666666, middle: 11.166666666666666, lower: 7.166666666666666 })
i=3 -> None 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 }) 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`, The first emission is at `i = 2` (the 3rd candle), exactly
even though `max(ema=3, atr=3) = 3`. This is the EMA-gates-ATR effect `max(ema=3, atr=3) = 3` — the value `warmup_period()` reports. The EMA
documented under **Warmup**. and ATR sub-indicators are fed in parallel, so neither delays the
other.
### Python ### Python
@@ -189,13 +191,6 @@ row 4 [upper, middle, lower]: [ 17.166666666666664, 13.166666666666666, 9.166666
## Common pitfalls ## 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 - **Typical price ≠ close.** The middle EMA runs on
`(H + L + C) / 3`, not on close. A pre-computed "EMA of close" `(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 panel will not equal the Keltner middle line and trying to align