Architecture
How ox-charts is built: the canvas engine, the React shell, one bar from the wire to the pixel, layers, trading and OxScript.
An in-house charting library with three parts:
- a canvas engine with no dependencies, in
src/core - a React shell in
src/uithat turns the engine into a trading terminal - datafeeds that pour bars into the engine
This document follows one bar from the wire to the pixel. For colours and themes, see theming.md.
Map
The two halves are strictly separated. core knows nothing about React, and uses no DOM beyond a canvas. ui knows nothing about pixels. They talk through a single API object, IChartApi.
src/
├── core/ # the engine — plain TS, no React
│ ├── chart.ts # IChartApi: panes, series, options, events
│ ├── pane.ts # one horizontal strip: series + price scale + primitives
│ ├── time-scale.ts # bar index ⇄ x, zoom, pan, right-edge cap
│ ├── price-scale.ts # price ⇄ y, autoscale, log / %
│ ├── series/ # DataStore (Float64Array columns) + one renderer per chart type
│ ├── render/ # Renderer: rAF loop, layout, axes, axis pills
│ ├── indicators/ # fold engine + ~68 built-ins + fills/hlines decorations
│ ├── orderflow/ # footprint, volume profile, TPO, bubbles, depth heatmap
│ ├── drawings/ # trendlines, fibs… as interactive primitives
│ ├── trading/ # order/position lines, brackets, brokers, controller
│ ├── script/ # OxScript: lexer → parser → analyze → codegen → runtime
│ ├── transforms/ # Heikin-Ashi, Renko, Kagi, P&F, line-break, range
│ ├── replay/ sync/ # bar replay; crosshair/time sync across charts
│ └── interaction/ # gestures: drag, pinch, wheel, kinetic pan
├── datafeed/ # nemesis-datafeed: REST history + WS stream
├── react/ # <OxChart> + useOxChart: thin engine binding
└── ui/ # the terminal shell (React 18 + shadcn/Base UI)
├── shell/ # ChartShell → ChartGrid → ChartCell, toolbar, layers, settings
├── indicators/ orderflow/ trading-ui/ drawing-toolbar/ scripts/ symbol-search/
├── shadcn/ # generated shadcn components (Mira preset)
└── styles/ # ox-ui.css: shadcn vars bridged onto --ox-* tokensOne bar, wire to pixel
A datafeed (IDatafeed) implements two calls:
getHistory(), called once.subscribe(), which emits a full bar snapshot per tick. A bar with the same timestamp updates the live bar; a newer timestamp appends a bar.
Every update ends in invalidate(). The renderer merges any number of invalidations into a single requestAnimationFrame, and draws nothing when nothing has changed. Crosshair moves use the cheaper invalidateCursor(), which repaints only the overlay canvas.
Where state lives
There is no global store library. Each kind of state has exactly one owner, and React only reads it.
| State | Owner | How React sees it |
|---|---|---|
| Bars, series, scales, viewport | The engine (ChartImpl, Pane, TimeScale) | Through events such as subscribeCrosshairMove and subscribePaneLayoutChange |
| Bar data | DataStore: parallel Float64Array columns that double in size as they grow | Never copied into React |
| Replay, trading UI, fullscreen | Small external stores (replay-controller, trading-store) | useSyncExternalStore |
| Shell UI (menus, focused cell, per-cell symbol / interval / chart type / layers, grid sync, maximised cell, split sizes, settings, theme) | ChartShell React state; every grid change is a pure transition in shell/grid-state.ts, committed through one commit that persists (under storageKey) and fires onActiveCellChange / onCellSymbolChange in the same handler | Passed down through ShellContext (useShell()), with the host's features resolved (shell/features.ts) and the adapter's capabilities. Each shell has its own context, so two terminals on one page don't collide |
| A cell's indicators and OxScripts | That cell's own useIndicatorManager (one per cell, in CellIndicators); mirrored into ShellCell.indicators as PersistedIndicator[] (scripts carry their source) | The manager's active list; onChange records it in shell state, which persists it |
| A cell's drawings | One DrawingsController per cell (ShellDrawingToolbar), stored per symbol × interval | The rail and style bar act on the focused cell's controller |
| Orders and positions | Broker adapter, behind the TradingController | Controller events feed the trading store |
| Strategy instances, the audit log, confirm cards, the kill switch | TradingController (one per adapter, held in a ref by ChartShell) | useSyncExternalStore on controller.getSnapshot() |
House rule: derive state, don't sync it. Compute values during render or in event handlers. Use useEffect only to connect to the imperative engine.
<ChartShell> // owns shell state, ShellContext, theme vars on root
<TopToolbar> // focused cell's symbol, interval, type, Indicators, Layers; grid sync + layout
<ChartGrid> // 1..8+ cells, absolutely placed; divider layer (GridSplits, lazy)
<CellHeader> // multi-cell only: the cell's symbol, interval, maximise, actions menu
<ChartCell> // creates IChartApi, loads datafeed, owns layers controller
<ShellIndicators> // one <CellIndicators> per cell: manager, legend, settings dialog
<ShellOrderflow/> <ShellTrading/> // focused cell only: footprint window, buy/sell, ticket, lines
<ShellDrawingToolbar> <ShellReplay> <StatusBar>The grid: per-chart state
Every cell is its own chart, as in TradingView. A cell owns its symbol, interval, chart type, order-flow view, layers, indicators, OxScripts and drawings, and keeps them when focus moves: ChartGrid places cells as absolutely positioned siblings keyed by cell id, so a layout change, a divider drag or a maximise only moves a box. No chart, indicator manager or script worker is remounted, and nothing is recomputed on a focus switch.
- Focus. Clicking a cell, its header or its legend focuses it. The top toolbar, the Indicators and Layers menus, the OxScript panel (
acceptScripts), the drawing rail and the trading chrome (quote row, ticket, order lines) all act on the focused cell only. Each cell's legend edits and removes its own items; a row's copy button adds that indicator or script to every other cell. - Sync (
GridSync, the link menu in the toolbar, persisted with the layout). Symbol and interval sync run insidesetCellSymbol/setCellResolution; turning one on applies the focused cell's value everywhere. Crosshair and time switch the halves of the onecore/syncgroup, which now maps between charts by time rather than bar index. Drawings sync mirrors a cell's drawing change live to the other cells on the same symbol, re-projected by time (drawings-sync.ts). - Persistence.
ox-shell:v1holds the cells (with theirindicators), layout, sync toggles, maximised cell and dragged split sizes per layout. On mount eachCellIndicatorsrestores its list once (scripts are recompiled from source under their saved id); a cell that never recorded any getsdefaultIndicators. New cells clone the focused cell, indicators included. - Layouts are unit-square rectangles;
splitTreeOfturns one into a tree of full-length splits andcellRectsplaces the cells for the current sizes. The divider layer is a transparent stack of shadcn Resizable groups over the cells; only its hairline handles take input.
How a frame is drawn
Each pane has two canvases: the scene, and a cursor overlay on top of it. Both are sized for the device pixel ratio.
A frame walks the panes from top to bottom and paints them in a fixed order:
render frame
layout panes, axes, separators
for each pane
clear, grid, session breaks
primitives zOrder "bottom" # fills, profiles, heatmap
series renderers # candles, lines, histograms…
primitives zOrder "normal" # drawings, order lines
last-price line
primitives zOrder "top" # labels, markers
price axis + axis pills # last price, indicator values
time axis
cursor overlay (separate canvas)
crosshair + its axis labelsExtensions never touch this loop. Instead they register primitives, the single extension point for anything drawn on the chart:
interface ISeriesPrimitive {
attached?({ requestUpdate }) // call to invalidate
paneViews(): IPrimitiveView[]
hitTest?(e): boolean // for hover / drag
}
interface IPrimitiveView {
zOrder: "bottom" | "normal" | "top"
draw({ ctx, width, height, timeScale, priceScale })
}All of these are primitives:
- footprint, volume profile, TPO and volume bubbles
- drawings, order lines and the ghost ticket line
- indicator fills and horizontal levels
A primitive converts bars and prices to coordinates with timeScale.indexToX and priceScale.priceToY. That is why every layer pans and zooms along with the chart without extra code.
How indicators are calculated
Every built-in indicator is a fold: a pure step function applied to one bar at a time. It never rescans the bar array, so a live tick costs one step rather than a full recompute.
IndicatorFold<S>
init() → S
update(state, bar, index) → { state, outputs: { basis, upper, lower } }
engine.onBar(bar)
if bar.time == last.time # live bar changed
state = preLastState # one-step snapshot kept by the engine
preLastState = state
{ state, outputs } = fold.update(state, bar, i)
write outputs into Float64Array columns # NaN = still warming up
push columns → line/histogram series of that indicatorDefinition. Each indicator declares:
paramsplotsfills: shaded areas between two plots or two hlineshlines: fixed levels, such as RSI's 70/30fourColor: four-colour histograms
The built-ins live in core/indicators/builtins.
Sources and chaining. An indicator's source is either a base field (close, hl2, ohlc4…) or another indicator's plot, for example an EMA of RSI. The engine resolves these dependencies in order.
Rendering. Each plot becomes a series, drawn on the price chart or in the indicator's own pane. decorations-primitive paints fills and levels underneath the plots. Fills are clipped to the visible range and broken at gaps.
Settings. The dialog has an Inputs tab and a Style tab.
- Inputs holds the params. Changing a param re-runs the fold from bar 0.
- Style holds the per-plot style: colour, opacity, width, dash and plot type. Changing a style only restyles the series.
How layers work
Layers are order-flow studies that sit on any chart type.
- Each chart cell has a
createLayersController(ui/shell/layers.ts) that owns its layers. - The Layers menu turns a layer on or off. The eye in the legend hides it without removing it.
- Layers accept plain OHLC bars or footprint bars. Footprint bars carry exact bid/ask deltas instead of an estimate derived from OHLC.
| Layer | What it does |
|---|---|
| Footprint | Replaces the candle body with bid × ask volume at each price. Clicking a bar opens the trade summary, which can be sorted. |
| VWAP | A chart-level line from computeAnchoredVwap. It survives changes of chart type. |
| Volume profile | A histogram of the visible range, with POC and value area. A series-attached primitive at zOrder bottom. |
| TPO | Letters showing time at each price, per session. |
| Bubbles | Large trades, sized by quantity. |
| Depth heatmap | Order-book liquidity over time, from a depth datafeed. |
| Volume / CVD | Each in its own pane. CVD candles come from deriveCvdCandles. |
Trading on the chart
host adapter (paper / mock / the integrator's own) # ONE account: every call names its symbol; orders + positions streams
└─ TradingController (core/trading/order-controller) # the single entry for EVERY order, account-wide gate
│ submit() → risk gate → audit log → adapter; kill switch; confirm queue; strategy instances; marks per symbol
├─ attachTrading (per cell) # that cell's symbol's lines, fed by the controller (it is an ITradingAdapter too)
│ ├─ order-line-primitive # draggable limit/stop lines → modify
│ ├─ position-line-primitive # avg price, live P&L, close
│ ├─ brackets # TP/SL pairs, hollow while pending
│ ├─ ticket-ghost-primitive # preview line while the ticket is open
│ └─ execution-markers # fills on bars
└─ risk-gate (pure) # limits: lots, position, notional, rate, hours, band, daily loss…
UI: BuySellButtons · OrderTicket · "+" price-axis menu · TradingPanel (Orders / Positions / Closed / Stats /
Automation / Strategy tester) · confirm cards · toasts · Settings → Automation (limits)Quantities are entered in lots and multiplied by the symbol's own lotSize (from SymbolInfo, via resolveSymbol) before they reach the broker; a symbol whose spec is not resolved is not tradable. Colours come from the theme's trading palette. Text on a coloured background uses buyInk/sellInk, which have at least 4.5:1 contrast. Armed strategies use the amber --ox-armed token, in the legend row, the cell header and the panel strip at once.
The order pipeline
ChartShell wraps the host's adapter in a TradingController and hands that to every trading surface, so a click on Buy, a dragged order line, a panel Close and a script's strategy.entry() end in the same submit(): gate, audit row (with the surface it came from and the symbol), then the adapter. The gate is a pure function over the request, the limits in force and the account's counters; the controller owns the counters (orders per minute and day, daily P&L, rejects, cool-down) and the kill switch for the whole account, and checks position limits per symbol. ShellTrading mounts one trading layer per grid cell (its symbol's lines, axis tags, fills and confirm strip) and keeps the ticket, quote row and toasts on the focused cell; one cell per symbol feeds that symbol's bars to the controller and to a paper-broker host. The host guide is docs/host-integration.md.
worker session TradingController adapter
────────────── ───────────────── ───────
strategy.entry(...) reportRun(intents, backtest)
→ OrderIntent[] on the reply ──▶ drop forming-bar intents (executeOn close)
history never emits (live) dedupe by content key (LRU)
bar close → onClose: true planActions: reverse, pyramiding, exit legs → brackets
evaluateGate → AuditEntry
Test → nothing (the worker's own paper broker did it)
Paper → paper broker ──────────────────▶ fills
Confirm → card (Send / Skip / expires) ──▶ placeOrder(clientOrderId = key, tag)
Auto → straight through ─────────────▶ placeOrder
◀── configure({ account }) ◀── subscribeOrders / subscribePositions, the strategy's symbol only (before the next bar)Modes are per script instance and never persisted; pausing (kill switch, rejects, missed bars, worker kill, tab sleep) stops intents at the door, and resuming reconciles the instance's resting orders by tag before re-arming. docs/trading-integration.md is the host's guide: the adapter fields, the option-contract resolver, limits and modes.
OxScript (Pine-like)
source ─ lexer ─ parser ─ AST ─ analyze (types, scopes, ta call-sites)
─ codegen ─ new Function(...) ─ runtime: runs once per bar
series history x[n], var/varip, ta state per call-site
─ plots → indicator series on the chartScripts run in a pooled Web Worker sandbox (core/script/worker.ts); the page only sends source, inputs, context and bars, and receives columns, drawings, alerts and — for a strategy() script — order intents. A strategy's strategy.* calls never reach a broker from the worker: in Test mode the session runs a private paper broker (core/script/backtester.ts) and replies with the ledger; in the live modes the intents cross to the trading controller (see "The order pipeline") and the broker's position comes back as the account the strategy.* getters read.
One theme, two targets
Canvas can't read CSS variables, so the same OxTheme object also feeds the engine directly.
To add a theme, add one OxTheme object; the chrome and the canvas both follow it. A host passes its own as the theme prop (built with themeFromTokens from its design tokens) and the shell follows every change live.
Tailwind's preflight is off and its classes carry the ox: prefix, so apps that embed the chart are unaffected.