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

FieldWhat it is
from, toThe resolved endpoints
change{ absolute, percent }, close to close over the spanned bars
bars, upBars, downBars, flatBarsBar counts
spanMs, calendarDaysElapsed time
annualized{ return, basis }, or null under minAnnualizeDays
high, lowThe 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
warningsWhy 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.42 means +20.42%, not 0.2042.
  • Anything the data cannot support is null, never 0 or NaN, with the reason in warnings. An annualized return is omitted for spans under minAnnualizeDays rather than extrapolated from a week. An annualized volatility is omitted for intraday bars unless you supply periodsPerYear.

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:

FieldMeasures
barsToTroughThe decline, from the peak to the bottom
barsToRecoveryThe recovery, from the bottom back to the old peak
barsUnderwaterThe 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' })
FunctionReturns
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