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; },
};| Field | What to do with it |
|---|---|
symbol | Always set: the cell's symbol, or a resolved option contract. See resolveOptionContract. |
clientOrderId | Always set: the controller's idempotency key. Reject a second order with the same key and echo it on the Order. |
source | Which chart surface raised the order: ticket, quote, axis-plus, line-drag, panel, script. |
tag | ox:<instance>:<pineId>:<role> on script orders. Echo it: resuming a strategy matches resting orders by tag. |
meta | Your own passthrough fields (product type, validity…), echoed on the Order. The chart never reads them. |
qty | Units, 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 disconnectStart 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
| Field | Where | Meaning |
|---|---|---|
clientOrderId: string | PlaceOrderRequest, echoed on Order | The 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?: string | PlaceOrderRequest, echoed on Order | ox:<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: string | PlaceOrderRequest | The strategy's cell symbol, or a resolved option contract (§3). |
source | PlaceOrderRequest | "script" for every strategy order. |
getOrders(), getPositions() | ITradingAdapter | Synchronous 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 barManual 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:
| Mode | Intents go to | Human in the loop | Notes |
|---|---|---|---|
| Test | the in-worker paper broker | none | A backtest over the loaded history; the Strategy tester tab shows it. Default. |
| Paper | the paper broker (your adapter if it is one, else a private one fed by the chart's bars) | none | The full pipeline with nothing at stake. |
| Confirm | your adapter | one click per order | A card with the order, the gate's notes and a countdown; Send or Skip; untouched cards expire (confirmTimeoutMs, default 30 s). |
| Auto | your adapter | none | Hidden 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 },
},
}}| Limit | Script default | Manual default | Check |
|---|---|---|---|
allowSymbols / denySymbols | any / none | any / none | symbol on the lists |
maxLotsPerOrder | 1 | 10 | order size |
maxPositionLots | 2 | 20 | the symbol's position after the fill, resting entries included; reducing orders pass |
maxNotionalPerOrder | off | off | qty × limit price (last price for market) |
maxOrdersPerMinute / maxOrdersPerDay | 6 / 60 | 30 / 500 | sliding minute, calendar day; one counter for the whole account, every symbol and source; cancels and closes never count |
dailyLossLimit (+ squareOffOnDailyLoss) | off | off | no new risk once the day's P&L falls this far; optionally flatten, cancel and pause everything |
tradingHours (start, end, entryCutoff) + timezone | 09:15–15:30, cutoff 15:20, Asia/Kolkata | none | outside the window nothing passes; after the cutoff only reducing actions pass |
priceBandPercent | ±5 % | off | limit and stop prices against the last price |
pauseAfterRejects | 3 | 3 | consecutive broker rejects pause the strategy |
cooldownMs | 5 min | 5 min | after a kill or a pause, re-arming waits |
allowTickExecution | false | true | forming-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