Build a full trading terminal on openalgo-charts - symbol search, interval switcher, chart-type picker, indicator menu, drawing rail with clipboard, live OpenAlgo REST plus WebSocket data behind a warm-load bar cache, on-chart order lines with drag-to-modify, market depth, a settings dialog and context menu, market replay, symbol comparison, a linked split view, and layout persistence. Use when the user asks for a trading terminal, a charting workstation, on-chart trading, a linked chart grid, or to wire orders onto a chart.
Build a full trading terminal on openalgo-charts - symbol search, interval switcher, chart-type picker, indicator menu, drawing rail with clipboard, live OpenAlgo REST plus WebSocket data behind a warm-load bar cache, on-chart order lines with drag-to-modify, market depth, a settings dialog and context menu, market replay, symbol comparison, a linked split view, and layout persistence. Use when the user asks for a trading terminal, a charting workstation, on-chart trading, a linked chart grid, or to wire orders onto a chart.
argument-hint
[feature]
allowed-tools
Read, Write, Edit, Bash, Glob, Grep
Build or extend a terminal. This is the largest thing you can build with this library, so build it in the order below and get each layer working before starting the next.
Two working references exist. Read the one closer to the user's stack before writing code:
examples/yfinance/index.html in the openalgo-charts repo - a complete single-file terminal shell, no framework.
frontend/src/components/trading/ChartPane.tsx and frontend/src/lib/trading/terminal.ts in the OpenAlgo application repo - a production React terminal. Its central lesson: keep chart orchestration in a plain TypeScript module and let React own only the DOM shell.
Load history, then subscribe, seeding the builder from the last historical bar so the live candle continues its bucket instead of starting a fresh one. See feeds-and-live.
Wrap the feed in withBarCache before anything else touches it. A terminal reloads the same series constantly (symbol flicking, interval pills, a page refresh), and the wrapper turns those into warm reads. It never stores the forming bar, so what comes back is short by at most the bar the live subscription is about to send you anyway.
Two things a terminal has to do itself. Show the verdict: a status line saying "warm 1 ms" against "fetched 320 ms" is the only way anyone can tell the cache is working, and feed.stats() gives you the hits. And cap to at the newest bar that can exist when the venue is shut: a hit past the entry's coverage is only allowed while the next bar is still forming, and the cache cannot know a market is closed, so out of hours every load is cold without the cap. You already have a session table for the clock; reuse it.
2. Symbol and interval switching
Switching either one is a full data reload, not a chart rebuild. Tear down the old subscription first, then setData, then resubscribe. Do not create a second chart.
Every code on the interval pills must resolve.s/m/h/d/w are built in; anything else (monthly, quarterly, a 500-tick bar) is a registerInterval call carrying a Bucketing rule, and an unregistered code now throws UnknownIntervalError at subscribe time rather than quietly drawing minute bars. Validate with isKnownInterval when the code can come from the user, and bucket calendar codes with bucketStartOf, never with a seconds-per-month constant: a month is 28 to 31 days and a New York month is not a Mumbai month.
Switch the clock with the symbol.chart.setTimezone(zoneForExchange(exchange)) takes an IANA name (default Asia/Kolkata) and moves the axis labels, the crosshair tag and every session-anchored study, so a US symbol stops restarting its VWAP in the middle of the afternoon. Hold the chosen zone in your own state if anything in the terminal rebuilds the chart, and offer it as a control: a user watching a US symbol from Mumbai may want either clock. The settings schema ships that control (time.timezone, step 10), so mirror the zone rather than building a second picker. It throws on a name the runtime does not know, so guard user input with isValidTimezone. See data-and-time.
3. History paging
chart.setHistoryLoader(fn) fires when the user pans past the left edge. Fetch older bars, series.prependData(older), then chart.historyLoadComplete(). Prepending shifts logical indices but the visible window is preserved - do not re-fit.
4. Chart type and transforms
The picker maps a label to a SeriesType. Renko, Range, Line Break and Heikin Ashi transform the bars you pass to an ordinary series; Point and Figure and Kagi need import 'openalgo-charts/transform' for their renderers. See transforms.
5. Indicators
import 'openalgo-charts/indicators', then a menu built from registeredIndicators(). The gear on a pane legend emits indicatorSettings - open your own dialog generated from the descriptor. See indicators.
The controller is headless. Build the rail from registeredDrawingTools() and show the chord from drawingShortcuts() beside each name. The library installs no key listener - call matchDrawingShortcut(event) from your own handler, gated on the chart having focus and no dialog being open. See drawing-tools.
Wire Ctrl+C / Ctrl+X / Ctrl+V to draw.copy(), draw.cut() and draw.paste() in the same handler, and show the chords in the drawing's right-click menu. All three are async: await the cut, because it deletes only after the clipboard write succeeds and its boolean is the difference between "gone" and "still there". A paste of foreign clipboard content resolves to an empty array rather than throwing, so treat that as nothing to paste, not as an error to report.
7. On-chart trading
Two distinct layers. Get this right or you will build the wrong one.
Need
Layer
Draw positions, working orders, brackets, fills; let the user drag them
chart.trading in the base bundle
Actually place, modify and cancel with a broker
OrderEngine from openalgo-charts/trade
The loop is: push exchange state into chart.trading, the user drags a line, the chart emits a trading:* event, your handler calls the broker, the broker's response comes back as new exchange state which you push in again. The chart is never the source of truth. See trading and trade-tier.
Ship analyzer/sandbox mode first and default to it. Nothing should reach a live broker until the user explicitly arms it.
8. Market depth
DomLadder from the trade tier, fed by subscribeDepth on the feed. Levels range from 5 to 200 depending on what the broker streams.
One payload covers viewport, grid, panes, price scales, indicator instances, drawings and the settings block (canvas, status line, trading colours, event filters). Indicators whose tier is not loaded are skipped on restore rather than throwing, so restore after your tier imports. See events-and-state.
Render the tabs from the schema rather than hardcoding controls: the inputs are the IndicatorInput union your indicator settings form already handles plus one extra kind, colorPair, and every control maps to a real option. Five tabs: Price, Readout, Axes, Appearance, Trading. See settings-and-menus.
Build the colorPair widget before anything else in the dialog. It is a bullish/bearish pair on one labelled row (switch, up swatch, down swatch), and it is what keeps a dense tab fitting without a scrollbar. Its enabled half is optional: a candle Body has no visibility flag, so that row renders with two swatches and an empty switch slot rather than a checkbox that does nothing.
The timezone is a schema control now, time.timezone on the Axes tab. Drive it through applyChartSettings like every other key and let Cancel restore it with the rest; do not add a second zone row of your own.
A menu raised on a price axis is the other half of step 10. It is a branch of the same handler, not a second listener:
chart.on('contextmenu', (e) => {
const ev = e asContextMenuEvent;
if (ev.target.kind !== 'price-scale') return;
ev.preventDefault();
renderAxisMenu(ev.point, chart.priceAxisState(ev.paneIndex, ev.target.scaleId ?? 'right'));
});
Every row it draws is readable back from priceAxisState and actionable through setPriceAxisOptions, setPriceAxisAutoFit, setPriceAxisLockRatio and movePriceAxis, so no row is drawn with a checkmark and nothing behind it. Reference levels (previous close, session high and low) belong in the same menu, driven by a PriceLevels primitive: each level's plot line and axis tag are two flags in one group, and a level whose available(kind) is false is a disabled row, not a missing one.
Hold your chrome to the UI standard in themes-and-styling: styled scrollbars on every dark surface, colour inputs as small squares, paired colours on one row, themed checkboxes and selects, tab glyphs, and no control with nothing behind it.
Both are headless: draw the transport bar from replay.state() plus the replay:* events, and the symbol chips from list(). Detach the live feed while replaying, and pass every series on the timeline (volume included) to the replay controller. See replay-and-compare.
12. Split view and linked charts
A second chart is the one place rule 1 does not apply: a split view is genuinely two Chart instances, each with its own symbol picker, interval pills and feed subscription, joined by a link group.
const group = createLinkGroup({ crosshair: true, viewport: true, symbol: false });
group.add(chartA, { symbol: symA, onSymbol: (s, c) =>load(s, c) });
group.add(chartB, { symbol: symB, onSymbol: (s, c) =>load(s, c) });
Do not write your own sync. Copying one chart's getVisibleLogicalRange() onto the other is the bug this feature exists to prevent: the logical index belongs to that chart's own bars, so it is a different instant on a chart with a different interval or a different history depth. The group converts through time on both sides.
Three switches in the link menu, one per channel, plus the nearest/hide choice for an instant the follower has no bar for. Symbol sync needs a per-member onSymbol that loads bars, or the switch does nothing; a member without one is your "pin this chart" affordance rather than an omission.
Suspend viewport sync while a member loads its first dataset. Letting a fresh follower's fitContent broadcast throws the leader off the window the user was on (fitContent emits zoom/pan like a gesture does since 1.4.0), and adopting the leader's window instead can leave a month of hourly bars as a sliver. Turn it off for the load, turn it back on, and let the two converge on the first pan. See chart-linking.
Destroying a split pane is chart.destroy(): the group notices the 'destroy' event and prunes the member itself, so there is no bookkeeping to forget.
Rules
One chart instance for the terminal's life. Symbol, interval, theme and chart-type changes all mutate it in place. A deliberate split view (step 12) is the exception, and its second chart is a full peer with its own feed and its own teardown.
Chart orchestration belongs outside the UI framework. A plain module holding the chart, the feed and the subscriptions; the framework renders the shell and calls into it.
Tear down every subscription on symbol change and on unmount. A leaked WebSocket handler will keep pushing bars into a destroyed chart.
API keys never live in committed client source. Use the project's env mechanism.
Analyzer mode by default.
No emojis or icons in the UI, code, or logs.
Verify
Typecheck, then drive the real thing: switch symbol, switch interval, pan left until history loads, add an indicator, place a drawing, copy it and paste it, reload the page and confirm the layout came back and the second load reported warm. If you built the split view, open it on a different interval from the first chart and check that the linked crosshair lands on the bar at the same clock time, not on the same bar number. Report which of these you actually exercised and which you could not.