Vertical Horizontal Filter (Adam White, 1991) — net movement over total
movement: how much of the distance the price walked actually took it
somewhere.
${output} = (highest − lowest of column over period) /Σ|column[i] − column[i−1]| over period
Appends one column, in 0..1. High is trending, low is choppy — the
opposite polarity to choppinessIndex, which measures the reciprocal
idea on a log scale. A market that went one way in a straight line has a
net range equal to its path length and reads near 1; one that shuffled
back and forth reads near period's reciprocal. White's use of it was as a
regime filter: run a trend-following rule when it is high and an
oscillator when it is low, rather than reading it directionally.
Definition — pinned, because no vendor arbitrates it
TA-Lib has no VHF, so the oracle is a pandas replication with the
analytic first-valid bar asserted and a measured separation from the
plausible wrong turn — the same ratio over a period − 1-long sum of
changes, which is the off-by-one this study's window alignment invites (see
below).
Highest and lowest of the same column the changes are taken over.
White's statement is in terms of closes and this study generalises it to
any column, one option rather than three, because both halves must read
the same field or the ratio is not a ratio of anything. (Some vendors use
the bar's high and low for the range and the close for the changes;
that mixes two fields and is a different, larger number. The single
column shape is what makes this study composable over another study's
output, which is the zScore / rollingStdev shape.)
A fraction, not a percent.0.34, not 34 — the historicalVolatility convention: only percentChange and the
studies whose names say percent multiply by 100.
28 is White's own default. Both halves take the one period.
Warm-up: period rows, not period − 1 — the alignment matters
The two halves do not have the same reach. period bars give
period − 1 changes, so a period-long sum of changes needs
period + 1 closes: it first exists on bar period, one row later
than the range's period − 1. The column therefore first lands on bar
period — bar 28 at the default — and the range on that bar covers rows
[1 … period] while the changes cover the transitions [0 → 1] … [period − 1 → period], i.e. one transition into the range's window. That
asymmetry is inherent to the definition (a net range is over bars, a path
length is over moves) and is why the oracle separates this study from the
version whose sum is one term shorter.
Edges
Scale-invariant, and shift-invariant. Numerator and denominator are
both homogeneous of degree one in price and both are built from
differences. Pinned by property tests.
Bounded 0 < vhf ≤ 1, and the upper bound is the interesting one.
The period − 1 transitions inside the range's window already trace a
path from the window's low to its high, so their absolute values sum to
at least the range; the sum has one further term on top of them, so the
ratio can never exceed 1, and 1 means "no retracement at all in the
window". There is no useful lower bound: the extra term is the move
into the window and is not bounded by the window's own range, so a
single large gap on that bar can drive the reading arbitrarily close to
zero. Pinned as (0, 1] rather than as the 1/period floor a
symmetric-looking argument would suggest.
A window with no movement is 0/0, and needs no guard. If the
changes sum to zero then every close in the window is the same one, which
forces the range to zero too — so the ratio is JavaScript's own NaN and
the study reports undefined on a halted instrument without a branch.
There is no input that puts a non-zero numerator over a zero denominator,
because both halves read the same column: an explicit guard here would be
dead code that no test could kill (the commodityChannelIndex
finding, applied rather than copied). Contrast choppinessIndex,
whose two halves read different columns and which therefore does need
one.
A leading gap shifts the start; an interior gap blanks the change
on that bar and the next, then every sum over those, after which the
study recovers. As in choppinessIndex, the two halves treat the
gap differently — core's max/min skip a missing cell,
rollingMeanValues does not — so the path-length half is what sets
the mask.
A misnamed column throws rather than reading empty: max / min
fall through to core's sweep, which rejects the name. As in
ulcerIndex, that is a reducer-level split — the same
rollingValues door with stdev reads all-missing instead — and
both behaviours are pinned by tests rather than reconciled here.
Vertical Horizontal Filter (Adam White, 1991) — net movement over total movement: how much of the distance the price walked actually took it somewhere.
Appends one column, in
0..1. High is trending, low is choppy — the opposite polarity to choppinessIndex, which measures the reciprocal idea on a log scale. A market that went one way in a straight line has a net range equal to its path length and reads near1; one that shuffled back and forth reads nearperiod's reciprocal. White's use of it was as a regime filter: run a trend-following rule when it is high and an oscillator when it is low, rather than reading it directionally.Definition — pinned, because no vendor arbitrates it
TA-Lib has no VHF, so the oracle is a pandas replication with the analytic first-valid bar asserted and a measured separation from the plausible wrong turn — the same ratio over a
period − 1-long sum of changes, which is the off-by-one this study's window alignment invites (see below).column, one option rather than three, because both halves must read the same field or the ratio is not a ratio of anything. (Some vendors use the bar'shighandlowfor the range and the close for the changes; that mixes two fields and is a different, larger number. The singlecolumnshape is what makes this study composable over another study's output, which is the zScore / rollingStdev shape.)0.34, not34— the historicalVolatility convention: only percentChange and the studies whose names say percent multiply by 100.period.Warm-up:
periodrows, notperiod − 1— the alignment mattersThe two halves do not have the same reach.
periodbars giveperiod − 1changes, so aperiod-long sum of changes needsperiod + 1closes: it first exists on barperiod, one row later than the range'speriod − 1. The column therefore first lands on barperiod— bar 28 at the default — and the range on that bar covers rows[1 … period]while the changes cover the transitions[0 → 1] … [period − 1 → period], i.e. one transition into the range's window. That asymmetry is inherent to the definition (a net range is over bars, a path length is over moves) and is why the oracle separates this study from the version whose sum is one term shorter.Edges
0 < vhf ≤ 1, and the upper bound is the interesting one. Theperiod − 1transitions inside the range's window already trace a path from the window's low to its high, so their absolute values sum to at least the range; the sum has one further term on top of them, so the ratio can never exceed1, and1means "no retracement at all in the window". There is no useful lower bound: the extra term is the move into the window and is not bounded by the window's own range, so a single large gap on that bar can drive the reading arbitrarily close to zero. Pinned as(0, 1]rather than as the1/periodfloor a symmetric-looking argument would suggest.0/0, and needs no guard. If the changes sum to zero then every close in the window is the same one, which forces the range to zero too — so the ratio is JavaScript's ownNaNand the study reportsundefinedon a halted instrument without a branch. There is no input that puts a non-zero numerator over a zero denominator, because both halves read the same column: an explicit guard here would be dead code that no test could kill (the commodityChannelIndex finding, applied rather than copied). Contrast choppinessIndex, whose two halves read different columns and which therefore does need one.max/minskip a missing cell, rollingMeanValues does not — so the path-length half is what sets the mask.columnthrows rather than reading empty:max/minfall through to core's sweep, which rejects the name. As in ulcerIndex, that is a reducer-level split — the same rollingValues door withstdevreads all-missing instead — and both behaviours are pinned by tests rather than reconciled here.