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__.
| Method | Does |
|---|---|
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:
| Value | Grid |
|---|---|
'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:
| Value | Behavior |
|---|---|
'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:
| Value | Rebases 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 number | That 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')
| Mode | Shows |
|---|---|
'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
- Range Statistics and Drawdown for
ApexStock.stats.align, the primitive underneath this - Price Scale Modes for percent and indexed axes, which are a different thing
- State Persistence for the full save and restore contract