Indicators-Overview.md referenced the absolute author-machine paths
D:\Coding\Wickra\crates\... and D:\Coding\Wickra\bindings\... in its
"Source-of-truth files" section, and seven trend-indicator pages had
Node examples that did require('D:/Coding/Wickra/bindings/node').
Replace the overview paths with repo-relative GitHub links and change
the Node examples to require('wickra'), the published npm package name
a reader would actually use. No D:/Coding path remains anywhere in docs.
8.5 KiB
HMA
Hull Moving Average — Alan Hull's
WMA(2·WMA(n/2) − WMA(n), √n), a near-lag-free trend filter that combines a fastWma(n/2), a slowWma(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:
impl Indicator for Hma {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
Python returns float | None (streaming) / numpy.ndarray (batch,
NaN for warmup). Node returns number | null / Array<number> 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:
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)
}
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]producesSome(10.0)once the chain is warm. All three WMAs converge to10, soraw = 2·10 − 10 = 10, thenWMA(10, smooth) = 10. The unit testconstant_series_yields_constant_hmapins this withHma::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 the2·h − farithmetic. - Reset.
hma.reset()resets all three internal WMAs; the nextupdatestarts a full warmup countdown.
Examples
Rust
use wickra::{BatchExt, Hma, Indicator};
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!("{:?}", 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 section.
On the linear ramp 1, 2, …, 20, HMA tracks price exactly with no
visible lag.
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
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());
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() == 11but the first actual emission is at the 14th input. If you use HMA as the first stage of aChain, the chain's overall warmup will lag whatChain::warmup_period()reports. Filter onis_some()/~np.isnan(...)after the fact, or precompute the actual index by streaming a small ramp once. - Picking
period = 2or3. The innerhalf = period / 2is an integer division floored at 1. Forperiod = 2,half = 1,smooth = 1, and you essentially end up withWma(2·price − WMA(2))which is a sharp, noisy line. HMA is designed forperiod >= 9or so; for shorter lookbacks reach forEma(period)orWma(period)instead.
References
Alan Hull, "How to Reduce Lag in a Moving Average", 2005 — the original HMA derivation, hosted on Hull's site at https://alanhull.com/hull-moving-average.
See also
- Indicator-Wma.md — the building block.
- Indicator-Tema.md — same lag-reduction goal, EMA-based.
- Indicator-Kama.md — adaptive smoothing instead of fixed.
- Indicators-Overview.md — the full taxonomy.