ox-charts

The trading adapter

What your broker adapter implements, and how every order reaches it through one controller.

The library never talks to a broker. You pass one ITradingAdapter for the whole account, and every order the chart raises ends there: a click on Buy, a dragged order line, a Close in the panel, or a strategy.entry() in a script. Every call names its symbol; the subscriptions publish every order and position and each grid cell filters to its own symbol.

const adapter: ITradingAdapter = {
  capabilities: { supportsReverse: false },       // hides what the broker cannot do
  placeOrder(request) { /* REST/WS call: resolve to the Order (echo clientOrderId, tag, meta),
                           or { clientOrderId, status: "pending-new" } when your broker has no id yet */ },
  modifyOrder(id, changes) { /* ... */ },
  cancelOrder(id) { /* ... */ },
  closePosition(symbol) { /* ... */ },
  modifyPositionBrackets(symbol, tp, sl) { /* ... */ },
  // Emit the current list synchronously, then on every change. All symbols.
  subscribeOrders(cb) { /* return unsubscribe */ },
  // Every open position; [] when flat.
  subscribePositions(cb) { /* return unsubscribe */ },
  getOrders() { return current.orders; },
  getPositions() { return current.positions; },
};
Buy / SellDragged linestrategy.entryscript intentControllerrisk gate · auditYour adapterplaceOrderDenied orders stop at the gate, with the reason in the audit log
Every source goes through the same controller: risk gate, audit row, then your adapter.
FieldWhat to do with it
symbolAlways set: the cell's symbol, or a resolved option contract. See resolveOptionContract.
clientOrderIdAlways set: the controller's idempotency key. Reject a second order with the same key and echo it on the Order.
sourceWhich chart surface raised the order: ticket, quote, axis-plus, line-drag, panel, script.
tagox:<instance>:<pineId>:<role> on script orders. Echo it: resuming a strategy matches resting orders by tag.
metaYour own passthrough fields (product type, validity…), echoed on the Order. The chart never reads them.
qtyUnits, already multiplied by the symbol's lot size (SymbolInfo.lotSize). The UI counts lots; the adapter never sees lots.

A place endpoint that returns no order id

Resolve placeOrder with { clientOrderId, status: "pending-new" }. The chart shows a pending placeholder, and replaces it with the Order your subscribeOrders publishes carrying the same clientOrderId: one line, one row, the audit row linked to your id. An order never published within tradingOptions.orderConfirmTimeoutMs (15 s) becomes an "order status unknown — check the order book" row. See Host integration.

capabilities hides what your broker cannot do before anyone tries: supportsMarketOrders, supportsStop, supportsBrackets, bracketsRequireStopLoss (a TP needs an SL), canModifyRestingBrackets, supportsReverse, supportsModifyQty, supportsModifyPrice.

ChartShell wraps your adapter in a trading controller (createTradingController). The controller implements ITradingAdapter too, so the chart surfaces call it without knowing. To reach it from outside, build it yourself and pass it as trading; the shell recognises it and does not wrap it twice.

const controller = createTradingController(adapter, {
  instrumentFor: instrumentsFor(datafeed).spec,   // per-symbol lot and tick sizes, shared with the shell
  allowAutoExecution: true,
});
controller.setKillSwitch(true);   // your own panic button
controller.pauseAll("feed down"); // call on a datafeed disconnect

Start with the paper broker

createPaperBroker() implements the whole contract plus a ledger (account, closed trades): one account over every symbol, fed by each cell's bars. The demo trades against it. See Paper trading.

Script orders, modes and limits

ox-charts is a library. It never talks to a broker itself: the host (Phoenix, the demo) passes ONE trading adapter for the whole account, just as it passes an IDatafeed, and every order the chart raises — a click on Buy, a dragged order line, a Close in the panel, or a strategy.entry() in an OxScript, on any grid cell — ends in that adapter. This document covers how script-driven orders reach you and how to set the limits and modes. The adapter contract itself, the shell props, feature switches, theming and npm link are in host-integration.md.

1. The adapter

import { ChartShell } from "@option-x-tech/ox-charts/ui";
import type { ITradingAdapter, PlaceOrderRequest, PlaceOrderResult, Order, Position } from "@option-x-tech/ox-charts";

const adapter: ITradingAdapter = {
  capabilities: { supportsReverse: false },
  // Your REST/WS call: resolve with the Order, or { clientOrderId, status: "pending-new" }
  // when the broker assigns the id later (echo clientOrderId on the Order you publish then).
  placeOrder(request: PlaceOrderRequest): Promise<PlaceOrderResult> { … },
  modifyOrder(id, changes): Promise<Order> { … },
  cancelOrder(id): Promise<void> { … },
  closePosition(symbol): Promise<void> { … },
  modifyPositionBrackets(symbol, tp?, sl?): Promise<void> { … },
  subscribeOrders(cb): () => void { /* every symbol; emit the current list synchronously, then on every change */ },
  subscribePositions(cb): () => void { /* every open position; [] when flat */ },
  getOrders(): Order[] { … },
  getPositions(): Position[] { … },
};

<ChartShell datafeed={feed} trading={adapter} tradingOptions={…} />

Every request names its symbol; every Order and Position carries one. Each grid cell draws only its own symbol's lines; the panel lists them all.

The fields script orders rely on

FieldWhereMeaning
clientOrderId: stringPlaceOrderRequest, echoed on OrderThe idempotency key, always set by the controller. A script intent's content key goes here, so the same signal is never sent twice and a reconnect can match what was sent. Reject a second order with the same key; echo it back.
tag?: stringPlaceOrderRequest, echoed on Orderox:<instance>:<pineId>:<role> for script orders. Echo it: it is how open orders are reconciled after a pause. Put your algo id in front if your broker wants one.
symbol: stringPlaceOrderRequestThe strategy's cell symbol, or a resolved option contract (§3).
sourcePlaceOrderRequest"script" for every strategy order.
getOrders(), getPositions()ITradingAdapterSynchronous reads used when a strategy resumes.

The paper broker (createPaperBroker) implements all of it — one account over every symbol — and is what the demo trades against.

2. The order pipeline

Every order goes through one object, the trading controller (createTradingController, built by ChartShell around your adapter). It implements ITradingAdapter itself, so the quote buttons, the ticket, the "+" menu, the panel and the draggable order lines on every cell all call it without knowing; scripts reach it through their worker replies. The controller is account-wide: one kill switch, one cool-down, one set of rate counters and one daily P&L for every symbol; position limits are checked per symbol, and a strategy reads back only its own symbol's position, orders and trades.

script (worker)      strategy.entry/order/exit/close/cancel  →  OrderIntent[] on the reply
                     ─ history never emits in live modes
                     ─ a forming bar's intents carry onClose=false; the closed bar's arrive on the next push with onClose=true
host (controller)    drop forming-bar intents (unless tick execution is allowed)
                     dedupe by content key (bounded LRU)
                     plan broker actions (reverse, pyramiding, exit legs → brackets, close, cancel)
                     risk gate → audit row
                     Paper: paper broker · Confirm: card, then adapter · Auto: adapter
adapter              placeOrder / modifyOrder / cancelOrder / closePosition(symbol) / modifyPositionBrackets(symbol, …)
                     subscribeOrders / subscribePositions  →  the strategy's symbol slice, sent to the worker before its next bar

Manual orders take the shorter path: submit() → gate (manual limits) → audit → adapter.

3. Options: option.leg(...)

A script can write

strategy.entry("straddle", strategy.short,
  legs=[option.leg("CE", option.atm, option.current), option.leg("PE", option.atm, option.current, side="sell")])

The worker only carries the legs as data. The host resolves them:

tradingOptions={{
  automation: {
    resolveOptionContract: async ({ underlying, right, strike, expiry, lastPrice }) => {
      // strike: "atm" | "atm+1" | "atm-2" | "24500"; expiry: "current" | "next" | "monthly" | ISO date
      const contract = await chain.resolve(underlying, right, strike, expiry, lastPrice);
      return contract ? { symbol: contract.tradingsymbol, lotSize: contract.lotSize } : null;
    },
  },
}}

Each leg becomes its own placeOrder with the contract's symbol and the leg's side. Without a resolver every option intent is denied with the reason no option contract resolver is configured; a null result denies that intent. Nothing falls back to a nearest match.

4. Modes

Per script instance, from the legend row's mode control:

ModeIntents go toHuman in the loopNotes
Testthe in-worker paper brokernoneA backtest over the loaded history; the Strategy tester tab shows it. Default.
Paperthe paper broker (your adapter if it is one, else a private one fed by the chart's bars)noneThe full pipeline with nothing at stake.
Confirmyour adapterone click per orderA card with the order, the gate's notes and a countdown; Send or Skip; untouched cards expire (confirmTimeoutMs, default 30 s).
Autoyour adapternoneHidden unless automation.allowAutoExecution: true.

Arming a live mode asks once, in a popover naming the symbol, the lot cap and the cutoff. Disarming never asks. Mode is not persisted: a reload starts every strategy in Test.

Pausing and resuming

A strategy pauses on: the kill switch, pauseAfterRejects consecutive broker rejects or errors, a gap in bars larger than two resolutions, a script error or a killed worker, the tab sleeping for more than 30 s, or controller.pauseAll(reason) from the host (call it on your own disconnect). While paused, intents are dropped and recorded; missed bars are never executed later. Resume re-runs the script silently, then compares the instance's resting orders at the broker (by tag) with what it sent. A clean match re-arms; a difference is shown and re-arms only when the user accepts it.

5. Limits

import type { RiskLimits } from "@option-x-tech/ox-charts";

tradingOptions={{
  automation: {
    allowAutoExecution: false,
    confirmTimeoutMs: 30_000,
    scriptLimits: {            // Partial<RiskLimits>; unset keys keep the defaults below
      maxLotsPerOrder: 2,
      maxPositionLots: 4,
      dailyLossLimit: 25_000,
      squareOffOnDailyLoss: true,
      allowSymbols: ["NSE:NIFTY", "NSE:BANKNIFTY"],
    },
    manualLimits: { maxLotsPerOrder: 20 },
  },
}}
LimitScript defaultManual defaultCheck
allowSymbols / denySymbolsany / noneany / nonesymbol on the lists
maxLotsPerOrder110order size
maxPositionLots220the symbol's position after the fill, resting entries included; reducing orders pass
maxNotionalPerOrderoffoffqty × limit price (last price for market)
maxOrdersPerMinute / maxOrdersPerDay6 / 6030 / 500sliding minute, calendar day; one counter for the whole account, every symbol and source; cancels and closes never count
dailyLossLimit (+ squareOffOnDailyLoss)offoffno new risk once the day's P&L falls this far; optionally flatten, cancel and pause everything
tradingHours (start, end, entryCutoff) + timezone09:15–15:30, cutoff 15:20, Asia/Kolkatanoneoutside the window nothing passes; after the cutoff only reducing actions pass
priceBandPercent±5 %offlimit and stop prices against the last price
pauseAfterRejects33consecutive broker rejects pause the strategy
cooldownMs5 min5 minafter a kill or a pause, re-arming waits
allowTickExecutionfalsetrueforming-bar intents (calc_on_every_tick)

Effective limits are defaults ← host config ← the user's Settings → Automation, per limit. The kill switch is global: it cancels every script order, pauses every strategy, denies new manual risk and starts the cool-down. Every decision — allow, deny with its reason, sent, rejected, confirmed, skipped, expired, dropped, paused, resumed — is a row in the Automation tab and in controller.getSnapshot().audit.

6. Reaching the controller from the host

useShell().tradingController exposes it inside the shell. Outside, build one yourself around your adapter and pass it as trading: ChartShell recognises it (isTradingController) and does not wrap it twice.

const controller = createTradingController(adapter, { instrumentFor: instrumentsFor(datafeed).spec, allowAutoExecution: true, scriptLimits: {…} });
controller.subscribe(() => log(controller.getSnapshot().audit[0]));
controller.setKillSwitch(true);       // your own panic button
controller.pauseAll("feed down");     // on a datafeed disconnect

On this page