DocumentationTrader Alerts

Trader alerts

AlertController is part of the base openalgo-charts package. It evaluates conditions and manages their lifecycle. Your application handles notification, sound or webhook delivery. An alert never sends an order.

Create an alert

Provide a container with id="chart". Set the instrument context before creating alerts so each record belongs to the correct symbol, exchange and interval.

import { createChart, AlertController, type AlertTriggeredPayload } from 'openalgo-charts';
 
const chart = createChart(document.getElementById('chart')!);
chart.setDataContext({ symbol: 'SAMPLE', exchange: 'DEMO', interval: '1m' });
const series = chart.addSeries('candlestick');
series.setData([
  { time: 60, open: 98, high: 99, low: 97, close: 98 },
  { time: 120, open: 98, high: 100, low: 98, close: 99 },
]);
const alerts = new AlertController(chart);
const off = chart.on('alert:triggered', payload => {
  const event = payload as AlertTriggeredPayload;
  console.info(event.title, event.time, event.price);
});
const level = alerts.add({
  title: 'Confirmed breakout',
  source: { kind: 'price', price: 100 },
  condition: 'crossingUp',
  policy: 'onBarClose',
  repeat: 'once',
});
 
series.update({ time: 120, open: 98, high: 103, low: 98, close: 102 });
series.update({ time: 180, open: 102, high: 103, low: 101, close: 102 });
// The new bar confirms the preceding close and triggers the alert once.

Create one controller per chart. chart.destroy() destroys its controller; alerts.destroy() can release that owner earlier. Call off() when disposing the example’s listener. For a widget, use widget.alerts and widget.openAlerts(); the widget already owns a controller.

Sources and timing

SourceConfiguration
Instrument price{ kind: 'price', price: 100 }; add upperPrice for a range.
Study plot{ kind: 'indicator', instanceId: study.id, plotKey: 'rsi', value: 70 }; add upperValue for a range.
Drawing level{ kind: 'drawing', drawingId, level? }; supply the drawing provider when constructing the controller.
Named bar condition{ kind: 'barCondition', id: 'bullish' }, with condition matches.

Study sources use a particular instance and declared plot key, in that plot’s units. They do not compare an oscillator against price wicks. Missing study readings are unavailable rather than zero. Pass an existing DrawingController as new AlertController(chart, { drawings: draw }) to resolve drawing anchors. A drawing on a study pane also needs input: { instanceId, plotKey } identifying the study whose readings should be compared with that level.

Numeric sources support crossing, crossingUp, crossingDown, greaterThan, lessThan, enteringRange and leavingRange. Ranges need finite ordered bounds; equality is allowed and boundaries count as inside. Built-in bar predicates are bullish, bearish, inside, outside, gap-up and gap-down. registerBarCondition adds a custom predicate before its records are restored. Keep predicates free of delivery effects; handle those through events.

onBarClose and once are the defaults. A new live primary bar confirms the previous bar. Loading or prepending history seeds observations without firing past conditions. With onTouch, newly observed extrema and price movement can trigger before close, so a signal may disappear from the final candle. At most one match is consumed per alert and bar, including matches suppressed by cooldownSeconds.

From 2.6.0, an update that appends several bars at once is judged bar by bar, in order, as the same number of single appends would be. That happens when one source bar completes two or more Renko bricks on a transformed chart (the other transforms add at most one element per source bar), or when a custom AlertChartHost writes two bars before it emits data:update. Every bar the update closes is confirmed, and each match reports its own bar in time and index. The newest bar may still be forming, so onBarClose judges it only once a newer bar follows it; a forming Kagi vertex or range bar that moves to a newer time is still that bar. Repeats, cooldowns and expiry work as they would for separate appends at the same moment: a once alert fires at the first matching bar and stops. A reload, a page of older history, a late correction of an older bar, or a symbol or interval change still seeds silently, and a corrected bar is never judged.

Use repeat: 'everyTime' to keep a triggered condition armed. expiresAt is an absolute UTC timestamp in seconds; expiration takes priority over a new trigger. Programmatic alerts have no expiry unless you supply one. The widget editor defaults a new expiry to two chart-calendar months, clamped at month end, and keeps the timezone captured when the editor opened.

Edit, pause and inspect

list() returns records with states armed, triggered, disabled or expired. Use update(id, patch), enable(id), disable(id) and remove(id) for lifecycle changes. Updates reject invalid configuration before changing the record. An expired record needs a future or cleared expiry before it can remain armed again.

availability(id) reports whether the source can currently evaluate, with a reason when it cannot. This is separate from lifecycle state: a disabled alert can still have an available source.

setPaused(true) suppresses evaluation and threshold edits during a host loading or replay-selection transition. Resume with setPaused(false) after the correct source is ready. The controller also observes chart replay start/stop events. Context mismatches and replay suppress delivery; resuming does not deliver a backlog. The host still owns pausing live series writes while replay is active.

Switching timeframes

From 2.5.0, a fixed price alert stays visible on every timeframe for the same symbol and exchange. Its label shows the original timeframe when you view another one. An armed alert shows Paused there and cannot be dragged. Triggered, disabled and expired price alerts keep their lifecycle badges.

Visibility does not change evaluation. An alert created on 5m still needs 5m bars, including when its policy is onTouch. Switching to 1m does not turn a five-minute close condition into a one-minute close condition. availability(id) explains which timeframe to return to. Study and drawing levels remain visible only on their original timeframe because their values and geometry can change with the bars. The alert list retains every record.

When changing data context, clear the old source bars, call setDataContext, then load the new bars. Returning to the original timeframe seeds observations silently and resumes with fresh live updates. Bars from another timeframe do not advance that alert’s evaluated-bar checkpoint. Saved alerts use the same rules after a reload; no document migration is needed.

This chart-owned controller only evaluates the chart data it receives. To keep alerts running on an unseen timeframe, your application needs a separate evaluator receiving that timeframe’s data, and one owner for delivery.

Data variants

An alert also belongs to the data variant the chart showed when it was set: extended hours, raw prices, a quote currency or a unit. alert.scope.variant records it, and an alert set on the provider’s default series has no variant field at all, so its scope is the same as before variants existed. It evaluates only on that series, the way it evaluates only on its own timeframe, and availability(id) asks you to switch back to it.

A fixed price stays visible, paused, on another session or adjustment of the same instrument, because the price means the same there. In another currency or unit it is a different price, so the level is not drawn. A saved document naming a variant this build cannot read is refused by parseAlertsDocument.

Finished alert lines

From 2.5.1, custom hosts can hide triggered and expired lines without deleting the alerts:

const alerts = new AlertController(chart, { spentLines: 'hide' });

The default is 'show'. Hidden alerts stay in list() and saved state; a once-only alert cannot fire again just because the chart reloads. Armed, repeating and disabled alerts keep their normal lines. Re-arm with enable(id) to restore a line. Clear or extend an elapsed expiresAt before re-arming an expired record. A drawing-anchored alert never deletes its source drawing.

This setting belongs to the controller, so pass it again after recreating the controller. It is not stored in alert documents. Keep the saved runtime; removing a spent alert merely to hide its line loses its once-only protection. The packaged widget and yfinance example keep the default display policy.

live
Rendering live chart…
Synthetic intrabar updates. Trigger alerts to hide the once-only line, restore the saved records, or re-arm it. Repeating alerts keep their lines. Showing finished lines changes only their display.
View example code
function stockBars(startTime, count, intervalSec, startPrice, seed, volatility = 0.004, baseVolume = 2000) {
  let state = seed >>> 0 || 1;
  const random = () => {
    state ^= state << 13; state >>>= 0;
    state ^= state >>> 17;
    state ^= state << 5; state >>>= 0;
    return state / 4294967296;
  };
  const round = value => Math.round(value * 100) / 100;
  const bars = [];
  const trends = [0.5, -0.35, 0.2, 0.85, -0.7, 0.15, -0.4];
  let close = round(startPrice);
  let regimeEnd = 0;
  let trend = 0;
  for (let i = 0; i < count; i++) {
    if (i >= regimeEnd) {
      trend = trends[Math.floor(random() * trends.length)] * volatility;
      regimeEnd = i + 14 + Math.floor(random() * 18);
    }
    const gap = i > 0 && i % 37 === 0 ? (random() - 0.5) * volatility * 6 : 0;
    const open = round(Math.max(0.01, close * (1 + gap)));
    const noise = (random() + random() + random() - 1.5) * volatility * 2;
    const move = trend + noise;
    close = round(Math.max(0.01, open * (1 + move)));
    const upperWick = Math.max(0.01, round(open * volatility * (0.12 + random() * 0.7)));
    const lowerWick = Math.max(0.01, round(open * volatility * (0.12 + random() * 0.7)));
    const volume = Math.round(baseVolume * (0.65 + random() * 0.8
      + Math.abs(move) / volatility * 0.18 + (i % 31 === 0 ? 1.5 : 0)));
    bars.push({ time: startTime + i * intervalSec, open,
      high: round(Math.max(open, close) + upperWick),
      low: round(Math.max(0.01, Math.min(open, close) - lowerWick)),
      close, volume });
  }
  return bars;
}
el.style.cssText = 'display:flex;flex-direction:column';
const controls = document.createElement('div');
controls.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;padding:8px 64px 8px 8px';
const output = document.createElement('output');
output.dataset.alertLifecycleState = '';
output.setAttribute('role', 'status');
output.style.cssText = 'padding:0 10px 8px;font:12px/1.6 system-ui';
const stage = document.createElement('div');
stage.style.cssText = 'flex:1;min-height:240px;position:relative';
el.append(controls, output, stage);
const chart = lib.createChart(stage, { timeNavigator: false, priceAxisWidth: 64 });
chart.setDataContext({ symbol: 'SYNTHETIC', exchange: 'DEMO', interval: '1m' });
const series = chart.addSeries('candlestick');
const bars = stockBars(1735689600, 40, 60, 99, 46, 0.003, 1200);
const previousClose = bars[38].close;
bars[39] = { ...bars[39], open: previousClose, close: 100,
  high: Math.max(previousClose, 100) + 0.45, low: Math.min(previousClose, 100) - 0.4 };
series.setData(bars);
chart.setVisibleLogicalRange({ from: -2, to: 44 });
chart.panes()[0].priceScale.setFixedRange({ min: 90, max: 118 });
let spentLines = 'hide';
let alerts = new lib.AlertController(chart, { spentLines, now: () => 1000 });
let deliveries = 0;
const listeners = new AbortController();
chart.on('alert:triggered', () => { deliveries++; });
function show() {
  const records = alerts.list();
  const once = records.find(record => record.id === 'once');
  output.textContent = 'Finished lines: ' + spentLines + '. Once alert: ' + once.state
    + '. Saved records: ' + records.length + '. Deliveries: ' + deliveries + '.';
  output.dataset.policy = spentLines;
  output.dataset.state = once.state;
  output.dataset.records = String(records.length);
  output.dataset.deliveries = String(deliveries);
}
function restore() {
  const saved = alerts.toJSON();
  alerts.destroy();
  alerts = new lib.AlertController(chart, { spentLines, now: () => 1000 });
  alerts.fromJSON(saved);
  show();
}
function reset() {
  series.setData(bars);
  for (const alert of alerts.list()) alerts.remove(alert.id);
  alerts.add({ id: 'once', title: 'Once threshold', source: { kind: 'price', price: 105 }, policy: 'onTouch', condition: 'crossingUp' });
  alerts.add({ id: 'repeat', title: 'Repeating threshold', source: { kind: 'price', price: 108 }, policy: 'onTouch', condition: 'crossingUp', repeat: 'everyTime' });
  alerts.add({ id: 'armed', title: 'Watching threshold', source: { kind: 'price', price: 114 } });
  alerts.add({ id: 'expired', title: 'Expired threshold', source: { kind: 'price', price: 94 }, expiresAt: 999 });
  deliveries = 0;
  show();
}
function button(label, action, key) {
  const node = document.createElement('button');
  node.type = 'button';
  node.textContent = label;
  node.dataset.alertLifecycleAction = key;
  node.style.cssText = 'min-height:34px;padding:5px 9px;border:1px solid var(--oac-card-border);border-radius:5px;background:var(--oac-card);color:inherit;font:12px system-ui;cursor:pointer';
  node.addEventListener('click', action, { signal: listeners.signal });
  controls.appendChild(node);
  return node;
}
button('Trigger alerts', () => {
  series.update({ ...bars[bars.length - 1], high: 110, close: 110 });
  show();
}, 'trigger');
button('Restore records', restore, 'restore');
button('Re-arm once alert', () => { alerts.enable('once'); show(); }, 'rearm');
const policy = button('Show finished lines', () => {
  spentLines = spentLines === 'hide' ? 'show' : 'hide';
  policy.textContent = spentLines === 'hide' ? 'Show finished lines' : 'Hide finished lines';
  restore();
}, 'policy');
button('Reset', reset, 'reset');
reset();
el.dataset.alertLifecycleReady = 'true';
return { destroy() { delete el.dataset.alertLifecycleReady; listeners.abort(); alerts.destroy(); chart.destroy(); } };

Drag a threshold

live
Rendering live chart…
Synthetic candles and study readings. Drag the price level, either price-band boundary, or the study level in the lower pane. Price previews snap to a 0.25 tick. The saved-value readout changes once on release; Escape cancels. No live feed or external delivery is connected.
View example code
function stockBars(startTime, count, intervalSec, startPrice, seed, volatility = 0.004, baseVolume = 2000) {
  let state = seed >>> 0 || 1;
  const random = () => {
    state ^= state << 13; state >>>= 0;
    state ^= state >>> 17;
    state ^= state << 5; state >>>= 0;
    return state / 4294967296;
  };
  const round = value => Math.round(value * 100) / 100;
  const bars = [];
  const trends = [0.5, -0.35, 0.2, 0.85, -0.7, 0.15, -0.4];
  let close = round(startPrice);
  let regimeEnd = 0;
  let trend = 0;
  for (let i = 0; i < count; i++) {
    if (i >= regimeEnd) {
      trend = trends[Math.floor(random() * trends.length)] * volatility;
      regimeEnd = i + 14 + Math.floor(random() * 18);
    }
    const gap = i > 0 && i % 37 === 0 ? (random() - 0.5) * volatility * 6 : 0;
    const open = round(Math.max(0.01, close * (1 + gap)));
    const noise = (random() + random() + random() - 1.5) * volatility * 2;
    const move = trend + noise;
    close = round(Math.max(0.01, open * (1 + move)));
    const upperWick = Math.max(0.01, round(open * volatility * (0.12 + random() * 0.7)));
    const lowerWick = Math.max(0.01, round(open * volatility * (0.12 + random() * 0.7)));
    const volume = Math.round(baseVolume * (0.65 + random() * 0.8
      + Math.abs(move) / volatility * 0.18 + (i % 31 === 0 ? 1.5 : 0)));
    bars.push({ time: startTime + i * intervalSec, open,
      high: round(Math.max(open, close) + upperWick),
      low: round(Math.max(0.01, Math.min(open, close) - lowerWick)),
      close, volume });
  }
  return bars;
}
el.style.display = 'flex';
el.style.flexDirection = 'column';
const controls = document.createElement('div');
controls.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;padding:8px;flex-shrink:0';
const values = document.createElement('output');
values.dataset.alertDragValues = '';
values.style.cssText = 'display:block;padding:0 10px;font:12px/1.7 ui-monospace,monospace;flex-shrink:0';
const status = document.createElement('p');
status.dataset.alertDragStatus = '';
status.setAttribute('role', 'status');
status.style.cssText = 'margin:0;padding:4px 10px 8px;font:12px/1.5 system-ui;flex-shrink:0';
const stage = document.createElement('div');
stage.dataset.alertDragChart = '';
stage.style.cssText = 'flex:1;min-height:260px;position:relative';
el.append(controls, values, status, stage);

lib.registerIndicator({
  id: 'website-alert-drag-study', name: 'Sample study', placement: 'pane', inputs: [],
  plots: [{ key: 'value', type: 'line', title: 'Study units', style: { color: '#a78bfa' } }],
  calc: bars => ({ value: bars.map((bar, i) => {
    if (i < 14) return 50;
    let gains = 0;
    let losses = 0;
    for (let j = i - 13; j <= i; j++) {
      const change = bars[j].close - bars[j - 1].close;
      gains += Math.max(0, change);
      losses += Math.max(0, -change);
    }
    return losses === 0 ? 100 : gains === 0 ? 0 : 100 - 100 / (1 + gains / losses);
  }) }),
});
const chart = lib.createChart(stage, { timeNavigator: false, priceAxisWidth: 72 });
chart.setDataContext({ symbol: 'SYNTHETIC', exchange: 'DEMO', interval: '5m' });
const bars = stockBars(1735689600, 72, 300, 100, 138, 0.006, 1800);
chart.addSeries('candlestick').setData(bars);
let study = chart.addIndicator('website-alert-drag-study');
chart.setVisibleLogicalRange({ from: -2, to: 76 });
chart.panes()[0].priceScale.setOptions({ minMove: 0.25 });
chart.panes()[0].priceScale.setFixedRange({ min: 80, max: 130 });
chart.panes()[study.paneIndex].priceScale.setFixedRange({ min: 0, max: 100 });
const alerts = new lib.AlertController(chart);
const listeners = new AbortController();
let commits = 0;
let paused = false;
let dragging = false;
let commitsBeforeDrag = 0;

function positions() {
  const records = alerts.list();
  stage.dataset.dragX = String(chart.timeToCoordinate(bars[48].time));
  for (const [key, id, field, delta, pane] of [
    ['price', 'demo-price', 'price', 5, 0],
    ['lower', 'demo-range', 'price', 5, 0],
    ['upper', 'demo-range', 'upperPrice', -5, 0],
    ['study', 'demo-study', 'value', 10, study.paneIndex],
  ]) {
    const value = records.find(alert => alert.id === id)?.source[field];
    stage.dataset[key + 'Y'] = Number.isFinite(value) ? String(chart.priceToCoordinate(value, pane)) : '';
    stage.dataset[key + 'TargetY'] = Number.isFinite(value) ? String(chart.priceToCoordinate(value + delta, pane)) : '';
  }
}
function show(message) {
  const records = alerts.list();
  const price = records.find(alert => alert.id === 'demo-price').source;
  const range = records.find(alert => alert.id === 'demo-range').source;
  const indicator = records.find(alert => alert.id === 'demo-study')?.source;
  values.textContent = 'Saved price ' + price.price.toFixed(2)
    + ' | Range ' + range.price.toFixed(2) + ' to ' + range.upperPrice.toFixed(2)
    + ' | Study ' + (indicator ? indicator.value.toFixed(2) : 'unavailable') + ' | Commits ' + commits;
  values.dataset.price = String(price.price);
  values.dataset.lower = String(range.price);
  values.dataset.upper = String(range.upperPrice);
  values.dataset.study = indicator ? String(indicator.value) : '';
  values.dataset.commits = String(commits);
  status.textContent = message;
  positions();
}
function reset() {
  for (const record of alerts.list()) alerts.remove(record.id);
  if (!chart.indicators().some(item => item.id === study.id)) {
    study = chart.addIndicator('website-alert-drag-study');
    chart.panes()[study.paneIndex].priceScale.setFixedRange({ min: 0, max: 100 });
  }
  alerts.add({ id: 'demo-price', title: 'Price level', source: { kind: 'price', price: 110 } });
  alerts.add({ id: 'demo-range', title: 'Price band', condition: 'enteringRange',
    source: { kind: 'price', price: 90, upperPrice: 120 } });
  alerts.add({ id: 'demo-study', title: 'Study level',
    source: { kind: 'indicator', instanceId: study.id, plotKey: 'value', value: 30 } });
  commits = 0;
  show(paused ? 'Alerts paused. Resume to drag a level.' : 'Drag a dashed line or its Alert badge. Press Escape before release to cancel.');
}
function button(label, action, key) {
  const node = document.createElement('button');
  node.type = 'button';
  node.textContent = label;
  node.dataset.alertDragAction = key;
  node.style.cssText = 'min-height:36px;padding:5px 9px;border:1px solid var(--oac-card-border);border-radius:5px;background:var(--oac-card);color:inherit;font:12px system-ui;cursor:pointer';
  node.addEventListener('click', action, { signal: listeners.signal });
  controls.appendChild(node);
  return node;
}
button('Reset thresholds', reset, 'reset');
const pause = button('Pause alerts', () => {
  paused = !paused;
  alerts.setPaused(paused);
  pause.textContent = paused ? 'Resume alerts' : 'Pause alerts';
  pause.setAttribute('aria-pressed', String(paused));
  show(paused ? 'Alerts paused. Dragging is unavailable.' : 'Alerts resumed. Drag a level to preview a change.');
}, 'pause');
pause.setAttribute('aria-pressed', 'false');
reset();
chart.on('drag', () => {
  if (!dragging) { dragging = true; commitsBeforeDrag = commits; }
  show('Preview only. Saved levels and the commit count stay unchanged until release.');
});
chart.on('alert:updated', () => { commits++; show('Threshold committed on release.'); });
chart.on('drag:end', () => {
  dragging = false;
  show(commits > commitsBeforeDrag ? 'Threshold committed on release.' : 'No threshold change.');
});
chart.on('drag:cancel', () => { dragging = false; show('Drag cancelled. Saved levels are unchanged.'); });
chart.on('resize', positions);
el.dataset.alertDragReady = 'true';
return { destroy() { delete el.dataset.alertDragReady; listeners.abort(); alerts.destroy(); chart.destroy(); } };

An armed price or study line previews its value during a drag. list(), saved state and market evaluation keep the committed value until a changed, valid release. That release emits one alert:updated, followed by alerts:changed with { id, reason: 'dragged' }.

Clicking without movement, Escape, pointer cancellation or pinch cancellation does not commit. Pause, replay, source/context changes and alert lifecycle changes discard an unfinished draft. Triggered, disabled and expired lines do not drag. A range bound stops at its opposite bound without swapping them. Study lines use their own scale; a line on a different scale omits the right-axis price tag to avoid displaying the wrong units. Drawing-owned levels follow the drawing instead of intercepting its drag.

Save and restore

const snapshot = alerts.toJSON();
const serialized = JSON.stringify(snapshot);
alerts.fromJSON(serialized);

The versioned document validates completely before replacement. Restore seeds evaluation from the current bars and retains consumed-bar guards, cooldowns and once-triggered state. It does not replay historical notifications. A runtime payload can be opaque, but persistence requires finite JSON data; functions, cycles and class instances are rejected.

chart.getState() includes the alert document. For complete chart restoration, load the required study descriptors, create the primary series with the correct data/context, and attach the drawing/controller owners before calling chart.restoreState(saved). Drawing restoration precedes alert restoration. Stable study instance IDs preserve anchors. When an available provider reports a missing study, plot or drawing, restore drops that record and emits alert:removed with indicator-missing, plot-missing or drawing-missing. If the provider itself is absent, the record stays unavailable. Reusable study templates create fresh identities and cannot claim old alert anchors.

Persist lifecycle events (alert:created, alert:updated, alert:removed, alert:triggered, alert:expired, alerts:restored) and alerts:checkpoint when consumed-bar guards advance. A checkpoint requests a save, not a notification. Only alert:triggered is the trader-alert delivery event. Descriptor-authored indicator:alert events remain a separate study contract.

See chart state, workspaces and the widget alert controls for host integration.

Tick snapping and keyboard removal

From 2.5.0, preview and release both snap alert thresholds to their source scale’s tick: the primary series for price alerts, the chosen plot for study alerts. Left and independent scales keep their own units. A range bound stops at a valid tick inside the opposite bound. No declared tick means no rounding. Manually entered thresholds are preserved until you move them.

An instrument whose tick depends on the price snaps by band. With a tick schedule on the chart (chart.setTickSchedule(schedule), or Instrument.applyTo with tickBands), a dragged price alert lands on the tick of the band it is dropped in: with 0.01 below 100 and 0.25 from 100, a drop at 105.87 stores 105.75, where the axis grid alone would have kept 105.87. A range bound stops a whole band tick inside an off-tick opposite bound. Without a schedule nothing changes.

Chart.snapPrice(paneIndex, price) uses that pane’s right-axis tick, and the band of the schedule on the price pane. For another series scale, use series.priceScale().snapToTick(price). A custom AlertChartHost may expose primarySeries() for the owning scale, or use the optional snapPrice fallback when it has no series handle. Without scale tick metadata, a callback that rounds the opposite bound outside the range cancels the move and keeps the saved threshold. Expose the source scale for exact inner tick snapping.

alerts.hovered() returns the alert id under the pointer, or undefined. The widget and yfinance host bind Delete and Backspace to it only when a selected or hovered drawing, an active drawing tool, or text editing does not own the key. Custom hosts should keep the same priority and clear their saved record on alert:removed. The controller itself never installs keyboard input.