Comparison Mode

Only series[0] is the chart's instrument. Extra series entries are treated as indicators, so a second symbol goes on through comparison mode, which puts it on a secondary y-axis and normalizes it against the primary.

chart.addComparison({ name: 'AGG', data: aggCloses, color: '#8b5cf6' })
chart.setComparisonMode('percent')

data is either [{ x, y }] closes or full OHLC rows. A comparison instrument may not take the primary series' name, or the reserved name __primary__.

MethodDoes
addComparison({ name, data, color? })Adds an instrument
removeComparison(name)Removes one
clearComparisons()Removes all
getComparisons()Lists them
setComparisonMode(mode) / getComparisonMode()The normalization
setComparisonOptions(opts) / getComparisonOptions()Alignment and baseline policy
setComparisonBenchmark(name) / getComparisonBenchmark()The benchmark role
getComparisonStats({ from, to })The leaderboard

Everything is aligned first

Every instrument, the primary included, is put onto one shared x grid before anything is normalized. Without that step a newer listing, a different holiday calendar, or one missing day skews the comparison, and the skew is invisible.

chart.setComparisonOptions({ join: 'union', fill: 'hold' })

join picks the grid:

ValueGrid
'union'Every x any instrument has. The default
'primary'The primary's bars; everything else is resampled onto them
'intersection'Only the x values every instrument has

fill picks the missing-data policy:

ValueBehavior
'hold'Carry the last observation forward. The default, and the finance convention
'gap'Leave a hole
'drop'Drop the row

A carried-forward value is used for the math but is not plotted, so a line never shows a bar its instrument does not have. resample: true plots the filled grid instead, and is the default for join: 'primary', which exists in order to resample. getComparisonStats() reports how many points were filled, under coverage.

A point before an instrument's first observation is always null. It is never backfilled, whatever fill says.

Baselines

baseline decides where 0% is:

ValueRebases at
'common'The first x where every instrument has data. The default
'own'Each instrument's own first point
'visible'The left edge of the visible window, following the zoom
a numberThat x value

'common' is the default because the alternative quietly overstates the newest listing: with 'own', each line starts at 0% on a different date. History earlier than the common baseline still plots, as a negative percent, rather than being hidden.

baseline in the returned options and in the comparisonChange payload reports the policy actually applied, which can fall back to 'own' when no single x has data for every instrument.

Modes

chart.setComparisonMode('indexed')
ModeShows
'percent'Percent change from the baseline. The default
'indexed'An index where the baseline reads indexBase, default 100
'absolute'Raw prices
'relative'percentChange(asset) - percentChange(benchmark), in percentage points. Zero means "kept pace"
'ratio'asset / benchmark, rebased to indexBase

The benchmark is a role, not a ticker

chart.setComparisonBenchmark('SPY')
chart.setComparisonBenchmark('__primary__')   // the chart's own instrument, the default

In relative and ratio mode the benchmark's own line becomes the flat reference, so what everything is being measured against is visible on the chart rather than implied. Removing that instrument hands the role back to the primary. No symbol is hard-coded anywhere in the library.

The leaderboard

getComparisonStats() returns the table ready to render, one row per instrument with the primary included:

const rows = chart.getComparisonStats()   // ranked by percent change; [] when none added

for (const row of rows) {
  row.name
  row.rank
  row.change.percent
  row.relative              // excess return vs the benchmark
  row.high, row.low
  row.volatility, row.drawdown
  row.coverage              // { bars, filled, firstX, lastX }
  row.primary, row.benchmark // which role this row holds
}

The window runs from the baseline to the last observation, so with baseline: 'visible' the rows follow the zoom. Rows are close-based for every instrument, per source; getRangeStats() is the OHLC-aware path for the primary.

Volatility and drawdown are measured on each instrument's own observations rather than on the filled grid, so a weekly line sitting on a daily grid is not annualized as though it had 252 bars a year.

Events

chart.on('comparisonChange', ({ reason, mode, benchmark, baseline, instruments, stats, warnings }) => {
  // reason: 'add' | 'remove' | 'clear' | 'mode' | 'benchmark' | 'options' | 'visible'
})

The payload carries the recomputed leaderboard, and the whole computation is skipped when nothing is subscribed.

Declarative defaults

const chart = new ApexStock(el, {
  analysis: {
    comparison: {
      mode: 'indexed',
      benchmark: 'SPY',
      join: 'union',
      fill: 'hold',
      baseline: 'common',
      indexBase: 100,
      source: 'close',
    },
  },
  // ...
})

A benchmark named here is remembered until its instrument is added, with the primary filling the role, and saying so in warnings, until then. An unknown option value is warned about and ignored rather than silently changing what the numbers mean.

Restoring a saved comparison

getState() captures the mode, the benchmark, the alignment policy, and each instrument's name and color. It deliberately does not capture instrument data: your app fetches it, it runs to thousands of bars per instrument, and it would be stale the moment it was written to storage.

So setState() restores the setup, keeps any instrument whose data is still loaded, and emits comparisonRestoreNeeded for the rest:

chart.on('comparisonRestoreNeeded', async ({ names }) => {
  for (const name of names) {
    chart.addComparison({ name, data: await fetchCloses(name) })  // color is reapplied
  }
})

A benchmark whose instrument has not come back yet is remembered by name, with the primary filling the role until it does.

See also