ox-charts

Host integration

Embedding ox-charts in your app: symbol search and instrument specs, the trading adapter (including brokers that return no order id), ChartShell props, the trading dock, host theme, storage, and installing the package or linking its source.

This is the guide for a company embedding the terminal: Phoenix first, others later. It covers the datafeed calls the chart needs per symbol (search, names, instrument specs), the one function surface you implement to trade (the trading adapter), the ChartShell props that wire your app to the chart, feature switches, the trading dock, live theming, storage namespacing and how to install the package, or consume it from source while you work on both.

The library is in development. Interfaces change outright; there is no compatibility layer. Everything here is what packages/ox-charts/src/ does today.

1. The datafeed: search, names and instrument specs

Beside history and the live stream, two optional IDatafeed calls decide how well the chart knows your instruments.

import type { IDatafeed, SymbolInfo, SymbolSearchFilter, SymbolType } from "@option-x-tech/ox-charts";

interface IDatafeed {
  getHistory(req: HistoryRequest): Promise<OhlcBar[]>;
  subscribe(symbol: string, resolution: Resolution, onBar: (bar: OhlcBar) => void): () => void;
  searchSymbols?(query: string, filter?: SymbolSearchFilter): Promise<SymbolInfo[]>;
  resolveSymbol?(symbol: string): Promise<SymbolInfo | null>;
  readonly exchanges?: readonly string[];      // the search dialog's exchange row
  readonly symbolTypes?: readonly SymbolType[]; // its category tabs; default index, stock, future, option
}

type SymbolType = "index" | "stock" | "future" | "option" | "crypto" | "forex" | "commodity" | "etf";

interface SymbolSearchFilter {
  type?: SymbolType;   // the selected category tab; absent on "All"
  exchange?: string;   // the selected exchange; absent on "All", only sent when you list `exchanges`
}

interface SymbolInfo {
  symbol: string;      // the id: getHistory, subscribe and the adapter receive it
  name: string;        // what every surface shows, e.g. "BANKNIFTY 26 SEP 52500 CE"
  exchange: string;
  type: SymbolType;
  tickSize: number;    // > 0; prices snap to it
  decimals?: number;   // price decimals; default: what tickSize implies (0.05 → 2, 0.0025 → 4)
  lotSize?: number;    // units per lot (NIFTY 75); default 1
  description?: string;
}

Search filters server-side. The dialog's category tabs and exchange row travel as filter, so query per type on your server. The dialog shows exactly what you return (it does not filter again), so an empty query with a category selected should return that category's defaults: the Options tab lists options, not an empty slice of the all-category popular list. While a request is in flight the list says "Searching…", never "No symbols found". The chosen tab and exchange are remembered between openings; a remembered choice your feed no longer offers falls back to "All", so it can never hide results.

Names are readable everywhere. The toolbar, cell headers, the legend, the search dialog's bold label, the trading panel's symbol columns, the ticket and the confirm cards show SymbolInfo.name, never the raw id (the id stays in the tooltip). A symbol picked from search shows its name at once; any other symbol shows its raw id until resolveSymbol answers.

Specs are per symbol. Tick size, decimals and lot size come from resolveSymbol, per symbol, cached once per symbol and datafeed object (instrumentsFor(datafeed)). The price axis, the OHLC legend, the quote row, the ticket, the order and position lines, the risk gate's lot limits and the lots → units conversion all use the spec of the symbol they are about: a 2×2 grid can show NIFTY (0.05, lots of 75) beside USD/INR (0.0025, lots of 1,000). There is no shell-wide tick size, decimals or lot size.

Until a symbol's spec is known, it is not tradable: the quote row and the replay bar's buttons are disabled with the reason on hover, the "+" menu offers no orders, the ticket does not send, and the controller refuses any placement or reversal on it (no_instrument in the audit) instead of guessing a lot size of 1. Closing a position and cancelling orders always work. A feed without resolveSymbol, a null answer, a non-positive tick size or a lot size that is not a positive whole number all leave the symbol untradable, with the reason in the ticket.

2. The trading adapter (the contract)

One adapter serves one account and every symbol in it. The chart never asks "which instrument does this adapter trade": every call names its symbol, every Order and Position carries one, and the subscriptions publish the whole account. Each grid cell filters to the symbol it shows.

import type {
  ITradingAdapter, PlaceOrderRequest, PlaceOrderResult, PlaceOrderAck, ModifyOrderRequest,
  Order, Position, TradingCapabilities, HostMeta,
} from "@option-x-tech/ox-charts";

interface ITradingAdapter {
  readonly capabilities?: TradingCapabilities;

  placeOrder(request: PlaceOrderRequest): Promise<PlaceOrderResult>;   // Order | PlaceOrderAck
  modifyOrder(id: string, changes: ModifyOrderRequest): Promise<Order>;
  cancelOrder(id: string): Promise<void>;

  closePosition(symbol: string): Promise<void>;
  reversePosition?(symbol: string): Promise<void>;          // optional
  modifyPositionBrackets(symbol: string, tpPrice?: number, slPrice?: number): Promise<void>;

  subscribeOrders(cb: (orders: Order[]) => void): () => void;         // all symbols, emits synchronously on subscribe
  subscribePositions(cb: (positions: Position[]) => void): () => void; // all open positions; [] when flat
  getOrders(): Order[];
  getPositions(): Position[];
}

interface PlaceOrderRequest {
  symbol: string;                 // the cell's symbol, or a resolved option contract
  side: "buy" | "sell";
  type: "market" | "limit" | "stop";
  qty: number;                    // UNITS (lots × the symbol's lot size), never lots
  price: number;                  // limit/stop price; for market, the last price the chart saw
  tpPrice?: number;               // optional brackets (see capabilities)
  slPrice?: number;
  clientOrderId: string;          // always set by the controller; reject duplicates, ECHO IT
  tag?: string;                   // "ox:<instance>:<pineId>:<role>" on script orders; echo it
  source: "ticket" | "quote" | "axis-plus" | "line-drag" | "ladder" | "panel" | "script" | "host";
  meta?: HostMeta;                // your fields: Readonly<Record<string, string | number | boolean>>
}

/** What placeOrder may answer when the broker has not assigned an id yet. */
interface PlaceOrderAck {
  clientOrderId: string;          // the request's, echoed
  status: "pending-new";
}
type PlaceOrderResult = Order | PlaceOrderAck;

interface ModifyOrderRequest { price?: number; qty?: number; tpPrice?: number; slPrice?: number; meta?: HostMeta }

interface TradingCapabilities {
  supportsBrackets?: boolean;          // TP/SL on orders and positions (default true)
  bracketsRequireStopLoss?: boolean;   // a TP needs an SL with it (default false)
  canModifyRestingBrackets?: boolean;  // a resting order's TP/SL can be modified (default true)
  supportsMarketOrders?: boolean;      // type: "market" (default true)
  supportsStop?: boolean;              // type: "stop" (default true)
  supportsReverse?: boolean;           // defaults to `reversePosition !== undefined`
  supportsModifyQty?: boolean;         // default true
  supportsModifyPrice?: boolean;       // the draggable order line (default true)
}

Order carries id, symbol, side, type, price, qty, status, tpPrice?, slPrice?, createdAt, filledPrice?, filledAt?, cancelReason?, bracketOf?, clientOrderId?, tag?, source?, meta?. Position carries symbol, qty (signed), avgPrice, tpPrice?, slPrice?, openedAt?, initialStop?; openedAt is optional, and a position without it shows "—" in the panel's Held column. A flat symbol has no entry in the positions list. status is one of open | pending-new | pending-modify | pending-cancel | filled | cancelled; a pending-* status renders the line as "sending" and a rejection (a rejected promise) snaps it back to the last snapshot, so publish your snapshots truthfully and let the promise carry the error message (Error.message is what the toast and the ticket show).

Optional: implement ITradingLedger (subscribeAccount, subscribeClosedTrades) and the panel gains the account figures, the Closed trades tab and Stats. Without it the panel still shows Orders, Positions (open P&L from the chart's marks), Automation and the tester.

Brokers whose place endpoint returns no order id

Phoenix's POST /order answers without an order id; the order appears later on the orders stream. The contract covers this:

  1. placeOrder resolves with { clientOrderId, status: "pending-new" } (the request's clientOrderId) instead of an Order.
  2. The controller shows a placeholder order with id pending:<clientOrderId> and status pending-new: a "sending" line on the chart, an "Awaiting broker" row in the panel, a "sent · waiting for the broker" toast. A placeholder cannot be dragged, modified or cancelled (there is no broker id yet), and it counts against the position limit.
  3. When subscribeOrders publishes an Order whose clientOrderId equals the request's, that order replaces the placeholder in the same emission: one line, one row, no ghost. The audit row that sent it is linked to the broker's order id, and a later fill is recorded on it. If the broker published the order before placeOrder resolved, the controller links it directly and never shows a placeholder.
  4. A placeholder not matched within tradingOptions.orderConfirmTimeoutMs (default 15 s) leaves the chart and becomes an "order status unknown — check the order book" row: a red row at the top of the Orders tab with Dismiss, a badge on the panel strip and the status-bar toggle, a toast, and the audit decision unknown. It keeps counting against the position limit until the broker publishes it (it is then reconciled, "confirmed by the broker after the timeout") or the user dismisses it.

What the host must echo: clientOrderId on every Order it publishes (and tag on script orders). An order published without it can never be matched to its placeholder, so both show until the placeholder times out.

controller.subscribeOrderEvents(cb) reports { kind: "confirmed", order, late } and { kind: "unknown", order }; controller.getSnapshot().unknownOrders lists what is unknown; controller.dismissUnknownOrder(clientOrderId) clears a row.

Capabilities: hide what the broker cannot do

The UI hides or disables unsupported actions up front instead of failing at call time, and the controller refuses them before calling you:

FlagWhat changes
supportsBrackets: falseNo TP/SL rows in the ticket, no TP/SL handles on lines
bracketsRequireStopLoss: trueThe ticket asks for an SL before it sends a TP; the position line offers TP only once an SL is set, and the SL cannot be cleared while a TP remains
canModifyRestingBrackets: falseA resting order's TP/SL legs are drawn but cannot be dragged
supportsMarketOrders: falseThe ticket offers Limit/Stop only; the quote buttons (and one-click) open a limit ticket at the last price
supportsStop: falseNo Stop in the ticket or the "+" menu
supportsReverse: falseNo ↕ on the position chip
supportsModifyPrice: falseOrder lines cannot be dragged to a new price (✕ still cancels)

What the chart does with it

Every chart-originated action reaches your adapter only through the trading controller (createTradingController, built by ChartShell around your adapter): the ticket, the quote row, the price-axis "+", a dragged order line or bracket, a line's ✕, the panel's Close, and every script intent. The controller assigns clientOrderId, checks the capabilities and the symbol's spec, runs the risk gate (account-wide kill switch, cool-down, order-rate counters and daily P&L; position limits per symbol, in that symbol's lots), records an audit row with the source and symbol, then calls you. Confirm mode queues a card first.

Pseudo-code for a host:

const adapter: ITradingAdapter = {
  capabilities: { supportsReverse: false, supportsModifyQty: false, bracketsRequireStopLoss: true },
  async placeOrder(r) {
    await api.post("/order", {
      symbol: r.symbol, side: r.side, type: r.type, qty: r.qty, price: r.price,
      tp: r.tpPrice, sl: r.slPrice, clientId: r.clientOrderId, tag: r.tag,
      product: r.meta?.product ?? "MIS",
    });
    // No id yet: the order arrives on the stream, carrying clientId.
    return { clientOrderId: r.clientOrderId, status: "pending-new" };
  },
  modifyOrder: (id, c) => api.patch(`/orders/${id}`, c).then(toOrder),
  cancelOrder: (id) => api.delete(`/orders/${id}`),
  closePosition: (symbol) => api.post(`/positions/${symbol}/close`),
  modifyPositionBrackets: (symbol, tp, sl) => api.post(`/positions/${symbol}/brackets`, { tp, sl }),
  subscribeOrders(cb) { cb(cache.orders); return socket.on("orders", (rows) => cb(rows.map(toOrder))); },
  subscribePositions(cb) { cb(cache.positions); return socket.on("positions", (rows) => cb(rows.map(toPosition))); },
  getOrders: () => cache.orders,
  getPositions: () => cache.positions,
};

// toOrder MUST map the broker's client id back: { ..., clientOrderId: row.clientId, tag: row.tag }

Idempotency: the same clientOrderId must never create a second order. Tags must be echoed: a paused strategy reconciles its resting orders by tag when it resumes. meta is yours; the chart never reads it.

Reaching the controller from outside: build it yourself and pass it as trading (the shell will not wrap it twice), or read useShell().tradingController inside. A controller you build needs the instrument specs:

import { createTradingController, instrumentsFor } from "@option-x-tech/ox-charts";

const controller = createTradingController(adapter, {
  instrumentFor: instrumentsFor(datafeed).spec,   // the same registry the shell uses
  orderConfirmTimeoutMs: 15_000,
});

Then controller.setKillSwitch(true), controller.pauseAll("feed down"), controller.getSnapshot().audit, controller.subscribeMarks() for the last price per symbol.

Start with createPaperBroker(): it implements the whole contract plus the ledger, one account over every symbol, and the shell feeds it every cell's bars (onBar(symbol, bar)). The demo's ?broker=ack runs it behind a place endpoint that returns no id (and ?broker=ack-lost one that never publishes); ?caps=noMarket,requireSl,fixedBrackets,noStop makes it declare less.

3. ChartShell props

interface ChartShellProps {
  datafeed: IDatafeed;                  // §1: resolveSymbol supplies names and specs
  defaultSymbol: string;
  defaultResolution: Resolution;
  theme?: OxTheme | string;             // a name seeds the picker; an OBJECT is followed live
  themes?: readonly OxTheme[];
  colorThemes?: readonly ColorTheme[];
  colorTheme?: ColorTheme | string;
  features?: ShellFeatures;             // §4
  onActiveCellChange?: (cell: { cellId: string; symbol: string; resolution: Resolution }) => void;
  onCellSymbolChange?: (cellId: string, symbol: string) => void;
  storageKey?: string;                  // prefix for every persisted key; default "ox"
  trading?: ITradingAdapter;
  tradingOptions?: TradingOptions;      // currencySymbol, locale, qtyPresets (lots), commissionPercent,
                                        // orderConfirmTimeoutMs,
                                        // automation: { allowAutoExecution, scriptLimits, manualLimits,
                                        //               confirmTimeoutMs, resolveOptionContract }
  defaultIndicators?: readonly DefaultIndicatorSpec[];
  defaultLayout?: GridLayoutId;
  storage?: { get(key: string): string | null; set(key: string, value: string): void };
  scriptStore?: ScriptStore;
  docsBaseUrl?: string;
}

tradingOptions carries no tickSize, decimals or lotSize: those come per symbol from SymbolInfo (§1).

docsBaseUrl is the docs root that the toolbar's Docs button and the "?" help buttons open pages under, in a new tab. It defaults to the published docs, https://optionx.trade/charts/docs (DEFAULT_DOCS_BASE_URL). To use a self-hosted copy, pass its root with the /docs path included, such as http://localhost:3000/docs. features.learn: false hides the links.

onActiveCellChange fires once when the shell mounts, with the focused cell it restored from storage (so you can mirror it without reaching into useShell), and then whenever the focused cell changes or its symbol / interval does: a focus click, symbol search, the interval menu, a symbol-sync fan-out, or a layout change that moves the focus. Change notifications fire from the event handler that made the change, never from an effect. onCellSymbolChange fires on changes only. Use them to drive your own watchlist, option chain or news pane.

4. Feature switches

Everything the shell is capable of is on by default (trading needs an adapter, the footprint a footprint feed, the heatmap a depth feed). Switch families off with false, or pieces with the object form:

<ChartShell
  features={{
    trading: { ticket: true, axisPlus: true, dragLines: false, panel: true, panelPlacement: "bottom" },
    automation: { modes: ["test", "paper", "confirm"] },   // "auto" also needs allowAutoExecution
    scripts: false,
    indicators: true,
    drawings: true,
    orderflow: { footprint: true, depthHeatmap: false, profile: true },
    replay: false,
    alerts: true,
    learn: false,
    layouts: ["1", "2h", "2x2"],   // or false to hide the picker
    gridSync: true,
    themePicker: false,
  }}
/>

Inside the shell, chrome reads useShell().features (a ResolvedFeatures, every flag resolved) and useShell().tradingCapabilities (the adapter's capabilities with defaults filled in): no ticket means no quote row and only "Draw horizontal line" on the "+" menu; no stop support drops the Stop entries; no brackets hides the TP/SL rows and handles; no reverse hides ↕.

5. The trading panel (Orders, Positions, Closed, Stats, Automation, Tester)

With a trading adapter, the status bar at the bottom of the shell carries a Trading toggle (with the account's working orders, positions and any unknown orders beside it). It opens a resizable dock under the charts: drag its top edge to resize, press ⌄ in its strip (or the toggle) to hide it. Its open state and height persist with the rest of the shell state under storageKey. Opening, closing or resizing the dock never remounts a chart.

  • features.trading.panel: false removes the panel and its toggle.
  • features.trading.panelPlacement: "none" keeps the panel feature on but the shell renders no dock and no toggle, so you can place <TradingPanel> yourself (a tab in your own layout, a drawer). It fills the box you give it:
import { TradingPanel, useShell } from "@option-x-tech/ox-charts/ui";
import { instrumentsFor } from "@option-x-tech/ox-charts";

// Inside <ChartShell> (as a child), with the shell's own controller:
function MyDock() {
  const { tradingController, instruments } = useShell();
  return tradingController ? <TradingPanel controller={tradingController} instruments={instruments} currencySymbol="₹" /> : null;
}

// Or anywhere, with a controller you built and passed as `trading` (§2):
<TradingPanel controller={controller} instruments={instrumentsFor(datafeed)} currencySymbol="₹" />

6. Host-controlled theme

Pass a theme object and the shell follows it live on every prop change: --ox-* variables on the shell root, the shadcn bridge, every chart's canvas colours, the trading lines and order-flow layers. Its dark flag sets the mode, and the base picker / mode toggle are not offered (hide the colour picker too with features.themePicker: false).

Build the object from your tokens:

import { themeFromTokens, tokensFromCssProperties } from "@option-x-tech/ox-charts/ui";

// From a plain token object (Phoenix's tokens on every theme change):
const theme = themeFromTokens(
  { background, foreground, surface, border, mutedForeground, primary, up, down, radius, font },
  isDark ? "dark" : "light",
  { name: "phoenix" },
);

// Or read them off your CSS custom properties:
const tokens = tokensFromCssProperties(document.documentElement, {
  background: "--background", foreground: "--foreground", border: "--border",
  primary: "--primary", up: "--chart-up", down: "--chart-down", surface: "--card",
});
<ChartShell theme={themeFromTokens(tokens, mode)} features={{ themePicker: false }} />

Keep the object's identity stable between renders (memoise on your theme state) so the shell re-themes only when the tokens change. createTheme(base, overrides) remains for hosts that want to start from a built-in.

7. Storage

Everything the shell persists (grid state, the trading dock, per-symbol drawings, the replay session, script drafts and the default local script store) goes through the storage prop under one namespace. storageKey="acme" rewrites the default ox prefix (ox-shell:v1 → acme-shell:v1, ox:scripts:v1:items → acme:scripts:v1:items), so two shells or two hosts on one origin never share state. With a storageKey and no storage, localStorage is used.

8. Installing the package

@option-x-tech/ox-charts is published to GitHub Packages, private to the option-x-tech organisation. The published package is compiled ES modules with TypeScript declarations (dist/), so your bundler treats it like any other dependency. It is built from packages/ox-charts in the ox-charts monorepo (pnpm + Turborepo). Entry points:

ImportWhat
@option-x-tech/ox-chartsengine, datafeeds, trading core (createTradingController, instrumentsFor, brokers, types), and everything in ./ui
@option-x-tech/ox-charts/uiChartShell, TradingPanel, themes, themeFromTokens, shell context
@option-x-tech/ox-charts/reactOxChart and useOxChart, the bare canvas

From GitHub Packages

  1. Registry. Send the @option-x-tech scope to GitHub Packages in your app's .npmrc. The line holds no secret, so commit it:

    @option-x-tech:registry=https://npm.pkg.github.com
  2. Token. Reading the package takes a GitHub token with read:packages that can see the option-x-tech organisation. Keep it out of the repo.

    • Locally, create a classic personal access token with read:packages and store it in your user config (~/.npmrc): pnpm config set "//npm.pkg.github.com/:_authToken" <token>.

    • In CI, write it to the user .npmrc before the install. pnpm does not expand variables in a project .npmrc's credentials, so the token can't live there:

      - run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      GITHUB_TOKEN can read the package once the package's settings (Manage Actions access) grant your repository access. Otherwise store a classic token with read:packages as a secret and use that.

  3. Install. react and react-dom 18 or later are peer dependencies; your app's own copies are used.

    pnpm add @option-x-tech/ox-charts

A Vite 8 app needs nothing else: no alias, no tsconfig paths, no React dedupe, no optimizeDeps entry and no React Compiler exclusion. The package imports your app's react, its declarations type-check against your own @types/react, and the React Compiler leaves node_modules alone. TypeScript needs moduleResolution: "bundler" (Vite's default) to follow the exports map and the extensionless imports inside the declarations.

Vite 7 and earlier. In dev, these versions pre-bundle dependencies into node_modules/.vite/deps, which breaks the script worker's relative URL: the browser gets a 404 for oxscript-worker.js. Production builds are fine. Keep the package out of pre-bundling, and pre-bundle the CommonJS shim it depends on by name instead:

// vite.config.ts (Vite 7 and earlier only)
optimizeDeps: {
  exclude: ["@option-x-tech/ox-charts"],
  include: [
    "@option-x-tech/ox-charts > @base-ui/react > use-sync-external-store/shim",
    "@option-x-tech/ox-charts > @base-ui/react > use-sync-external-store/shim/with-selector",
  ],
},

Versions. Releases follow semver, and while the version is 0.x a minor release (0.1 → 0.2) may change interfaces outright; there is no compatibility layer. A patch release only fixes. Depend on a caret range such as ^0.1.0, which on 0.x takes patches only, and read the release notes before moving to the next minor.

Developing against local source (React 19 hosts)

To change the library and a host together, link the package from a checkout of the monorepo instead of installing it. In the repo, the package's main/exports point at src/*.ts, so the host's bundler compiles the TypeScript source; only the packed package points at dist/.

Linking

# link the package, not the monorepo root
cd ox-charts && pnpm install --frozen-lockfile
cd packages/ox-charts && pnpm link --global          # or: npm link
cd ../../../phoenix && pnpm link --global @option-x-tech/ox-charts   # or: npm link @option-x-tech/ox-charts

A path dependency works too: "@option-x-tech/ox-charts": "link:../ox-charts/packages/ox-charts". The library's own dependencies (@base-ui/react, CodeMirror, @phosphor-icons/react, react-resizable-panels, tailwind-merge, clsx) resolve from packages/ox-charts/node_modules, so install the monorepo first.

React: one runtime, your types

The library declares react / react-dom >=18 as peers and develops against React 18 types. A React 19 host must make the linked source use its own React, at runtime and in the type checker.

  1. Types. Point react and react-dom at your own @types in tsconfig.json, so the linked source type-checks against your React 19 types instead of the React 18 ones in the library's node_modules (the source imports JSX from react, never the removed global):

    {
      "compilerOptions": {
        "moduleResolution": "bundler",
        "jsx": "react-jsx",
        "target": "ES2022",
        "paths": {
          "react": ["./node_modules/@types/react"],
          "react/*": ["./node_modules/@types/react/*"],
          "react-dom": ["./node_modules/@types/react-dom"],
          "react-dom/*": ["./node_modules/@types/react-dom/*"]
        }
      }
    }

    moduleResolution: "bundler" (or node16) resolves the exports map. The source is noUncheckedIndexedAccess-clean, so any strictness works. apps/web in the monorepo is a React 19 consumer set up exactly this way.

  2. Runtime. Dedupe React in Vite so the linked source never loads a second copy (two Reacts break every hook), and keep the linked package out of dependency pre-bundling so edits in the library are picked up without a re-pre-bundle:

    // vite.config.ts
    export default defineConfig({
      resolve: { dedupe: ["react", "react-dom"] },   // preserveSymlinks stays false (the default)
      optimizeDeps: { exclude: ["@option-x-tech/ox-charts"] },
    });
  3. React Compiler. The library is written without the React Compiler in mind (it reads refs in render and drives imperative engine handles), so compile your app, not the linked source. Exclude it in babel-plugin-react-compiler's sources:

    // vite.config.ts
    react({
      babel: {
        plugins: [["babel-plugin-react-compiler", {
          sources: (filename: string) =>
            !filename.includes("/packages/ox-charts/") && !filename.includes("/@option-x-tech/ox-charts/"),
        }]],
      },
    }),

CSS

Import nothing for the shell: it injects its own compiled stylesheet at runtime (ensureUiStyles), its Tailwind classes are prefixed ox: and preflight is off, so your global styles are untouched and your own Tailwind never needs to scan the library. What you must provide is a sized container: the shell fills 100% of its parent.

Workers

The OxScript sandbox runs in a module worker. The source creates it with new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }), and the published package with the same call on ./oxscript-worker.js, a self-contained module in dist/ beside the code that spawns it. Vite emits the worker as its own file in a production build and serves it in dev; Vite 8 also follows the URL when it pre-bundles the package, and earlier versions need the optimizeDeps entry under From GitHub Packages. Any bundler must support new Worker(new URL(…, import.meta.url)) with type: "module". The worker is a same-origin script file, so a Content Security Policy must allow it (worker-src 'self'). An environment with no Worker at all (SSR, tests) gets the in-process runner.

Minimal host

import { ChartShell, themeFromTokens } from "@option-x-tech/ox-charts/ui";
import type { IDatafeed, ITradingAdapter } from "@option-x-tech/ox-charts";

export function Terminal({ feed, adapter, tokens, mode }: Props) {
  const theme = useMemo(() => themeFromTokens(tokens, mode, { name: "phoenix" }), [tokens, mode]);
  return (
    <div style={{ height: "100%" }}>
      <ChartShell
        datafeed={feed}                  // searchSymbols(q, filter) + resolveSymbol with name/tickSize/lotSize
        defaultSymbol="NSE:NIFTY"
        defaultResolution="5"
        theme={theme}
        features={{ themePicker: false, learn: false }}
        trading={adapter}                // placeOrder may answer { clientOrderId, status: "pending-new" }
        tradingOptions={{ currencySymbol: "₹" }}
        onActiveCellChange={(c) => setSelectedSymbol(c.symbol)}   // also fires once on mount
        storageKey="phoenix"
        storage={{ get: (k) => localStorage.getItem(k), set: (k, v) => localStorage.setItem(k, v) }}
      />
    </div>
  );
}

On this page