ox-charts

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/ui that 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-* tokens

One 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.
nemesis REST + WS IDatafeed getHistory / subscribe ChartCell load + onBar series.update(bar) — DataStore append/replace Indicator engine — fold step Layers controller — VWAP, profile, TPO Trading controller — last price to P&L renderer.invalidate() one rAF canvas pixels

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.

StateOwnerHow React sees it
Bars, series, scales, viewportThe engine (ChartImpl, Pane, TimeScale)Through events such as subscribeCrosshairMove and subscribePaneLayoutChange
Bar dataDataStore: parallel Float64Array columns that double in size as they growNever copied into React
Replay, trading UI, fullscreenSmall 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 handlerPassed 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 OxScriptsThat 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 drawingsOne DrawingsController per cell (ShellDrawingToolbar), stored per symbol × intervalThe rail and style bar act on the focused cell's controller
Orders and positionsBroker adapter, behind the TradingControllerController events feed the trading store
Strategy instances, the audit log, confirm cards, the kill switchTradingController (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 inside setCellSymbol / setCellResolution; turning one on applies the focused cell's value everywhere. Crosshair and time switch the halves of the one core/sync group, 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:v1 holds the cells (with their indicators), layout, sync toggles, maximised cell and dragged split sizes per layout. On mount each CellIndicators restores its list once (scripts are recompiled from source under their saved id); a cell that never recorded any gets defaultIndicators. New cells clone the focused cell, indicators included.
  • Layouts are unit-square rectangles; splitTreeOf turns one into a tree of full-length splits and cellRects places 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 labels

Extensions 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 indicator

Definition. Each indicator declares:

  • params
  • plots
  • fills: shaded areas between two plots or two hlines
  • hlines: fixed levels, such as RSI's 70/30
  • fourColor: 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.
LayerWhat it does
FootprintReplaces the candle body with bid × ask volume at each price. Clicking a bar opens the trade summary, which can be sorted.
VWAPA chart-level line from computeAnchoredVwap. It survives changes of chart type.
Volume profileA histogram of the visible range, with POC and value area. A series-attached primitive at zOrder bottom.
TPOLetters showing time at each price, per session.
BubblesLarge trades, sized by quantity.
Depth heatmapOrder-book liquidity over time, from a depth datafeed.
Volume / CVDEach in its own pane. CVD candles come from deriveCvdCandles.
series-attached chart-level re-anchor bars LayersController anchor series: profile, TPO, bubbles, heatmap VWAP line, Volume pane, CVD pane chart type swap

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 chart

Scripts 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

applyThemeVars chartColorsOf OxTheme object in theme.ts --ox-* CSS vars on shell root ox-ui.css bridge: --popover = var(--ox-surface) shadcn + ox: Tailwind classes chart.applyOptions(colors) for canvas

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.

On this page