Range Statistics and Drawdown
Everything a reader wants to know about a region of the chart is a number, not a picture: how much it moved, over how long, how volatile it was, and how far underwater it went. getRangeStats(from, to) returns all of it for any window.
const stats = chart.getRangeStats('2024-01-01', '2024-03-01')
stats.change.percent // 20.42 (percent units, unrounded)
stats.bars // 42
stats.volatility.annualized
stats.drawdown.max // the deepest decline inside the range
Endpoints
from and to may be given in either order, as a bar index, an epoch-millisecond x, a Date, or a date string. A bare number is read as an index when it is a valid one, and as an x value otherwise. Force the reading with by:
chart.getRangeStats(0, 41) // bar indices
chart.getRangeStats(1704067200000, 1709251200000) // epoch ms
chart.getRangeStats(new Date('2024-01-01'), new Date()) // Dates
chart.getRangeStats(20240101, 20240301, { by: 'x' }) // force x
What comes back
| Field | What it is |
|---|---|
from, to | The resolved endpoints |
change | { absolute, percent }, close to close over the spanned bars |
bars, upBars, downBars, flatBars | Bar counts |
spanMs, calendarDays | Elapsed time |
annualized | { return, basis }, or null under minAnnualizeDays |
high, low | The true high and low, from the OHLC, not from the closes |
average | { close, volume } |
total | { volume } |
volatility | { stdev, annualized, periodsPerYear, inferred }, or null |
drawdown | { max, peak, trough, recovery, barsToTrough, barsToRecovery, barsUnderwater, recovered } |
basis | { source, drawdown }, the conventions the numbers were computed under |
warnings | Why anything came back null |
Three contracts hold everywhere in the engine, and they are worth reading once:
- Values are unrounded. The consumer formats. A statistic that arrived pre-rounded could not be summed or re-based correctly.
- Every percent-like value is in percent units.
20.42means +20.42%, not 0.2042. - Anything the data cannot support is
null, never0orNaN, with the reason inwarnings. An annualized return is omitted for spans underminAnnualizeDaysrather than extrapolated from a week. An annualized volatility is omitted for intraday bars unless you supplyperiodsPerYear.
bars is a count of bars, not of trading sessions. ApexStock owns no market calendar, so it does not claim to know which days a market was open.
Three durations, not one
A drawdown has three separate lengths, and collapsing them into one loses the question most people are actually asking:
| Field | Measures |
|---|---|
barsToTrough | The decline, from the peak to the bottom |
barsToRecovery | The recovery, from the bottom back to the old peak |
barsUnderwater | The whole episode, peak to recovery |
recovered is false when the episode is still open at the end of the range.
Drawdown as a series
getDrawdown() returns per-bar drawdown as something you can plot, plus every episode it found:
const dd = chart.getDrawdown()
dd.values // percent at or below zero, aligned to the series
dd.points // [{ x, y }], ready to plot
dd.max // the deepest
dd.current // the open episode, if there is one
dd.episodes // [{ peak, trough, recovery, depth, ...durations, ongoing }]
By default each bar's close is measured against the running peak of closes. basis: 'intrabar' measures each bar's low against the running high instead, which is the conservative reading: it reports the worst price actually printed rather than the worst close.
chart.getDrawdown({ basis: 'intrabar' })
The drawdown pane
The same measurement, on the chart, under the price:
chart.updateIndicator('drawdown')
It is an ordinary oscillator-registry entry, so it inherits the whole pane mechanism: it stacks with other panes, shares the x-axis and the zoom, survives a theme or chart-type switch, reports through getDataAt, round-trips through getState, and appears in the indicators dropdown under an Analysis heading rather than mixed in with the technical indicators.
Zero is pinned to the top of the pane's axis, because a drawdown is never positive, and the deepest point is labelled on the pane itself.
It streams. Its state is the running peak, so each appendData step is O(1) with no warm-up period, and the pane's maximum label is re-asserted after each append so a new deeper trough moves it rather than leaving a stale line behind.
The pane measures on the chart's analysis.drawdownBasis, so the pane, getRangeStats() and getDrawdown() cannot disagree with each other.
Pane heights
Panes divide the indicator area in proportion to a relative heightRatio, not evenly. Two panes at 1 and 2 split it one third and two thirds. Every indicator defaults to 1 except the drawdown pane, which is cumulative and takes 1.4.
const chart = new ApexStock(el, {
panes: { drawdown: { heightRatio: 2 } },
// ...
})
chart.setPaneHeightRatio('rsi', 1.5)
chart.setPaneHeightRatio('rsi', null) // back to the pane's default
chart.getPaneHeightRatios()
The heights always sum to the container exactly. The ratios you set are captured by getState() under panes; which panes exist is derived from the active indicators, so it is not stored twice.
Setting the conventions once
analysis at construction sets the defaults for every call, and each method takes the same keys as a per-call override:
const chart = new ApexStock(el, {
analysis: {
source: 'close', // which price the statistics read
drawdownBasis: 'close', // or 'intrabar'
periodsPerYear: 252, // needed to annualize intraday volatility
minAnnualizeDays: 30, // below this, `annualized` is omitted
by: 'x', // how a bare number endpoint is read
},
// ...
})
Without a chart
ApexStock.stats is the whole engine with no chart and no DOM, for server-side reports, tests, and workers:
import ApexStock from 'apexstock'
const stats = ApexStock.stats.rangeStats(series, from, to)
const dd = ApexStock.stats.drawdown(series)
const r = ApexStock.stats.returns(series, { mode: 'log' })
| Function | Returns |
|---|---|
rangeStats(series, from, to, opts?) | The same object getRangeStats returns |
drawdown(series, opts?) | Per-bar drawdown plus episodes |
drawdownRange(series, from, to, opts?) | Drawdown within a window |
worstDrawdown(series, from, to, opts?) | Just the deepest episode |
returns(series, { mode, source }) | { mode, source, values, points } in percent; index 0 is null |
volatility(series, from, to, opts?) | { stdev, annualized, periodsPerYear, inferred } |
annualize(totalPercent, calendarDays, opts?) | An annualized figure |
inferPeriodsPerYear(series) | The bar cadence, inferred |
resolveIndex(series, ref, opts?) | An endpoint reference resolved to a bar index |
Volatility is the sample standard deviation of log returns, annualized, measured inside the range rather than on the whole series.
Comparing instruments that do not share a calendar
Four primitives sit under comparison mode and are usable on their own:
const aligned = ApexStock.stats.align([spy, agg, gld], { join: 'union', fill: 'hold' })
const base = ApexStock.stats.baseline(aligned, 'common')
const pct = ApexStock.stats.rebase(aligned, { mode: 'percent', baseline: base })
const spread = ApexStock.stats.relative(aligned, 'GLD', 'SPY', { mode: 'spread' })
align puts every instrument on one x grid: join picks the grid ('union', 'primary', 'intersection') and fill picks the missing-data policy ('hold', 'gap', 'drop'). A point before an instrument's first observation is always null and is never backfilled, whatever fill says, because inventing history is not a fill policy. fill: 'drop' over a union grid is the same thing as an intersection.
baseline resolves a rebasing policy: 'common' (the first x where every instrument has data), 'own', 'visible', or an explicit x value. rebase turns aligned columns into percent-change or indexed columns. relative gives a benchmark spread in percentage points or a ratio, with the benchmark as a role filled by any named instrument.
See also
- Measuring a Region for the interactive version of the same numbers
- Comparison Mode for the multi-instrument leaderboard
- Technical Indicators for the indicator panes the drawdown pane stacks with
- State Persistence for what survives a reload