Chart trading design
Design notes for chart trading and the paper broker: broker contract, bracket drag model, position chip, order ticket and replay behaviour.
Two user asks, one design:
- Order placement like TradingView, with take-profit and stop-loss set by dragging on the chart, side rules enforced (long: TP above entry, SL below; short: the inverse).
- Paper trading inside replay: place orders, step bars, watch the position open, P&L move, brackets fill, and the trade close, with a ledger.
Research inputs: TradingView's Trading Platform docs (bracket model, order projection, line adapters, edit protocol), Chartwo's widget demo (chip with add-TP/SL segments, half-plane tint while dragging, drag-then-confirm ticket), and the existing ox-charts trading layer.
What exists (reuse, do not replace)
| Piece | File | Status |
|---|---|---|
| Adapter contract: limit/stop orders, brackets, close/reverse, snapshot subscriptions | src/core/trading/adapter.ts | keep; extend |
| Order line: drag price, drag TP/SL (Shift moves both), cancel × | src/core/trading/order-line-primitive.ts | keep; add constraints + labels |
| Position line: label, close ×, reverse ⇄, draggable TP/SL children | src/core/trading/position-line-primitive.ts | keep; add chip segments, add-by-drag, constraints, labels |
attachTrading(chart, adapter) controller | src/core/trading/trading-controller.ts | keep |
| Mock broker (last-price fills) | src/core/trading/mock-broker.ts | keep for tests/demos; the paper broker supersedes it in the shell demo |
| Price-axis "+" → limit-only ticket | src/ui/trading-ui/PriceAxisPlusButton.tsx, OrderTicket.tsx | keep; ticket grows |
ShellTrading connector (mounts lines, feeds last price, ticket) | src/ui/trading-ui/ShellTrading.tsx | keep; grows |
1. Broker contract changes (src/core/trading/adapter.ts)
OrderTypegains"market". A market order is transient: the adapter fills it in the same call (or on the next bar per config) and it never renders as a line.OrdergainscreatedAt: UTCTimestampandfilledPrice?: number;PositiongainsopenedAt?: UTCTimestamp(optional: a broker that does not report it shows "—" as the hold time).- New types:
export type ExitReason = "signal" | "tp" | "sl" | "flatten" | "reverse";
export interface ClosedTrade {
id: string; side: "long" | "short"; qty: number;
entryTime: UTCTimestamp; entryPrice: number;
exitTime: UTCTimestamp; exitPrice: number;
pnl: number; pnlPct: number; rMultiple: number | null; exitReason: ExitReason;
barsHeld: number; commission: number;
}
export interface AccountState {
initialCapital: number; balance: number; // realised
equity: number; // balance + open P&L
openPnl: number; realizedPnl: number; commissionPaid: number;
}
/** Optional capability: brokers that keep an account and a ledger. */
export interface ITradingLedger {
subscribeAccount(cb: (a: AccountState) => void): () => void;
subscribeClosedTrades(cb: (t: readonly ClosedTrade[]) => void): () => void;
}
export function hasLedger(a: ITradingAdapter): a is ITradingAdapter & ITradingLedger;2. Paper broker (src/core/trading/paper-broker.ts)
createPaperBroker(config: Partial<BrokerConfig>): PaperBroker where
PaperBroker extends ITradingAdapter, ITradingLedger and adds onBar(bar: OhlcBar): void,
reset(): void, getConfig(), setConfig(patch).
BrokerConfig is the one from docs/scripting/replay-and-strategies.md §2, plus
marketFill: "close" | "nextOpen" (default "close": a market order fills at the current bar's
close plus slippage, which is the price the user is looking at; "nextOpen" is the backtest
convention and what the strategy runner will use).
Fill rules per bar (onBar with a bar that is new or an in-place update of the last bar):
- Protective exits first (position TP/SL), then pending signal exits, then entries; insertion order within each group.
- Limit fills when the bar range reaches the level, at the better of open and level, no slippage. Stop fills when the range reaches the level, at the worse of open and level, plus slippage. A gap through a level fills at the open.
- TP and SL both reachable in one bar:
pessimistic(default) fills the SL and incrementsambiguousFills;pathHeuristicwalks O→H→L→C when the open is nearer the high, else O→L→H→C. - Netting position; reversal closes fully then opens;
pyramidingcaps same-direction adds. - Brackets are OCO: when one fills the sibling is cancelled with status
"cancelled"andcancelReason: "oco"so the ledger shows it. - Commission on every fill notional; slippage in ticks on market and stop fills.
- The same bar may be delivered several times while forming (live ticks, sub-bar replay). The
broker keys evaluation on
bar.timeand only re-evaluates orders that were still open at the previous evaluation of that bar, so a tick cannot fill an order twice.
Ledger: every fill appends an execution; a position closing (fully) appends a ClosedTrade with
rMultiple = pnl / (|entry − initialStop| × qty) when an SL existed at entry, else null.
Account: balance moves on realised P&L and commission; equity and openPnl recompute on every
bar and on every position change.
3. Bracket rules and the drag model (core, shared by order and position lines)
src/core/trading/brackets.ts (pure):
export function clampBracket(kind: "tp" | "sl", side: "long" | "short", anchor: number, price: number, tick: number): { price: number; clamped: boolean };
export function bracketStats(kind, side, anchor, price, qty, tick, initialStop?: number): { distance: number; ticks: number; pct: number; money: number; r: number | null };- Long: TP must be
≥ anchor + tick, SL≤ anchor − tick. Short: TP≤ anchor − tick, SL≥ anchor + tick.anchoris the position average price or the pending order's price. - The drag clamps at the boundary and keeps the drag alive (Chartwo allows wrong-side; TradingView
warns; we clamp with visible resistance). A clamped drag dims the whole ghost (
globalAlpha0.55, dash[2,2]) rather than hatching it, and the reason sits on its own 16px strip beside the label: "Limit: TP for a long sits above 173.68". A pinned leg is one tick from its anchor, so the strip goes on the side of the leg's label away from the chip / order label (above a TP of a long, below its SL), never over the chip's text. - Prices snap to tick during the drag, so the label and the resulting order agree.
- Labels are compact outlined boxes (surface at 92%, 1px border and text in the leg's colour), not
filled ones. A committed leg reads as a price —
TP 44,104.20— and only the drag carries the arithmetic:TP 44,104.20 · +$178 (+0.41%), gaining· 2.4Rwhen a risk leg exists. Without one, R is omitted rather than shown as–R. Money uses the host'scurrencySymbol, and every price and money figure (canvas, ticket, panel, quote row, axis tags, the "+" label) groups digits inTradingOptions.locale, which defaults from the currency:₹→en-IN(₹1,50,000), anything else →en-US($150,000). The engine's own price text (axis ticks, the last-price plate, the crosshair label) groups the same way: the shell passesTradingOptions.locale(or the currency's default) tocreateLocalization(offset, locale), so an order tag never reads43,739.38beside an axis reading43739.38. A grouped label wider than the 64px axis (a 6-digit en-IN price) drops its separators rather than clipping digits. - While dragging, tint the half-plane on the profit side of the anchor green and the risk side red at low alpha (Chartwo), and draw a thin rail between anchor and the dragged level with a dot and an end tick at each end. Only while dragging. The wash covers the full width with a label-shaped hole cut out of it, so the label stays readable and the rail stays tinted.
- Shift-drag moves both brackets preserving the offset (already implemented on order lines; add to the position line).
- A pending order's legs are plans, not live exits: they draw at 60% alpha with a finer 2/3 dash, and a dotted connector runs from the order's own label to each leg's label, so brackets of two nearby orders cannot be read as each other's. Dragging a leg lifts the dimming so its live label reads. A position's legs keep the full 4/4 look.
4. Position line chip (position-line-primitive.ts)
One bordered object, right-aligned next to the price axis, 18px tall, with hover states and tooltips on every cell (Chartwo has neither): a rounded container in the position colour, 1px dividers between cells, and exactly one filled cell.
[ ↕ ] [ TP ] [ SL ] [ +1 ] [ +$273.71 (+0.62%) ] [ ✕ ]
- qty is the one filled cell, signed (
+1long,−1short); the line colour is neutral (accent) for both sides, direction lives in the sign (Chartwo, TradingView). - The average price is not a chip cell. It sits on its own price-axis tag, beside TP and SL tags — the axis is where a trader already reads a level, and it is the one place a price can sit without covering candles.
- P&L cell coloured by sign (a flat position is unsigned and muted); P&L mode follows a shell
setting: money (default), points (
+45.50 pts), ticks, percent. Money uses the host'scurrencySymbol. TP/SLare press-and-drag to add (tooltip "Take profit · drag to set"). The cell disappears once that leg exists; the leg's own line then carries the drag handle, a live label and its own ✕.↕reverses (tooltip "Reverse position"),✕closes, both through the adapter; with "confirm actions" on (shell setting, default on outside replay, off inside replay) a small inline confirm pill appears on that line's own row, not a modal.- Existing TP/SL children keep their drag; they gain the clamp, the live label and the ✕.
- The chip, the order labels and the bracket labels all stop short of the price-axis "+" pill's column, so a label can never sit under it. Because that column is the pill's alone, a click anywhere in it (within 4px of travel) opens the "+" menu at the clicked row, even when the pointer arrived faster than the pill could follow it (it tracks the pointer on the next frame, and a flick-and-click used to land on the chart instead).
5. Order ticket (OrderTicket.tsx)
Anchored popover (existing), grown to:
- Side segmented control; type
Market | Limit | Stop; quantity stepper with presets (1, 2, 5, 10 lots, configurable viaShellTradingProps.qtyPresets). - Quantities count lots. The lot size is the symbol's own
SymbolInfo.lotSize, from the datafeed'sresolveSymbol(as are its tick size and decimals); the stepper, the presets, the quote row's dropdown, the replay bar, the "+" menu and the toasts all read1 lot (30)/2 lots (60), and the broker receives units (lots × lot size), which is what the canvas labels and the panel show. A lot size of 1 reads as a plain quantity. Until the spec is resolved the symbol is not tradable. - Price row (disabled for market, prefilled with the hovered price for limit/stop).
- Brackets:
TPandSLrows, each with a value and a unit selectorprice | pts | ticks | % | R(R only when an SL is set; entering R for the TP derives the price from the SL distance). Every row shows the derived price, money and distance — in points (90 pts, the price distance index traders count) unless the row is entered in ticks, when it reads ticks. - Footer: estimated notional, commission, and
R:Rwhen both legs are set. - Focus opens where the next keystroke belongs: the price (selected) for limit/stop, the commit
button for market. Enter commits from the inputs, the ticket and the commit button; on any
other button (side, type, preset, unit) Enter presses that button. Escape closes. The confirm
button reads
Buy 2 @ market/Sell 1 @ 43,810. - The price-axis "+" menu follows the WAI-ARIA menu model: the first entry takes focus when it opens, ↑/↓ move (wrapping), Home/End jump, Enter chooses, Escape closes and returns focus to the "+" (which stays shown while focused, until the pointer moves). These keys are listed in the shortcuts sheet's "Order menu & ticket" group.
- While a limit/stop ticket is open, a ghost of the order sits on the chart at its price: a
70%-alpha 6/4-dashed line in the side's colour with an outlined
Buy limit · 43,500.00 · ticketlabel (no qty, no ✕, not draggable). It follows the ticket's side, type and price edits and leaves when the ticket closes; a market ticket draws none. - A rejection never closes the ticket: it shakes once and shows
Rejected · <broker's reason>with a Retry button; the trader fixes a field, retries or closes it. - The "+" menu's order rows say where the level sits against the last price, in points:
Buy 1 lot (30) stop · 175.00 pts above. The side word carries the colour (no side stripes) and every row, including "Draw horizontal line at …", starts at the same inset; the price is in each row's accessible name. - Opens from: the price-axis "+" (limit/stop prefilled at that price), the Buy/Sell quote buttons (market) and the replay bar's Buy/Sell (market) — unless one-click trading is on, in which case all three place immediately and no ticket opens.
- A drag never opens the ticket. Price drags and bracket drags apply on release, in every mode:
the gesture is the confirmation, and a dialog mid-drag loses the intent (TradingView, Chartwo).
"Confirm actions" gates only
closeandreverse— and turning one-click on.
6. Quote buttons and shell wiring
BuySellButtonsin the pane's top-left, in the overlay column's flow: the OHLC line, then[ Sell 43,809 ] [ qty ] [ Buy 43,810 ], then the indicator legend rows, which move down as indicators are added instead of running under the buttons (ShellContextValue.quoteSlot; a host-supplied container without the column keeps the fixed spot under the OHLC line). Without bid/ask in the datafeed both show the last price. Click opens the ticket for that side at market; with "one-click trading" on (shell setting, off by default) it places immediately.ShellTradinggains:isPaperBroker(adapter)→ subscribescellHandle.subscribeBarsand callsbroker.onBar(lastBar)on every change, so live ticks and replay steps both drive fills; feeds the account and ledger to the panel.- Side colour comes in two tokens.
buy/sell(#2962ff / #d9283a) FILL things: buttons, the qty cell, order-line frames.buyInk/sellInkare the side as TEXT (menu side words, the panel's Side column, toast verbs, order-label bodies, the ticket ghost label, "Rejected"): #6e9eff / #ff6b76 in dark mode, #1e53e5 / #c41e30 in light, each ≥4.5:1 on every surface of its mode. - One-click armed is a state, not a hover: the toggle fills amber with dark ink, swaps its cursor
icon for a bolt and reads
1-click ON(11px); the replay bar's compact toggle fills the same way. While armed, the price-axis menu's order rows carry a ⚡. - Placement feedback: every order the shell sends reports back in a small toast beside what it is
about — to the right of the quote row for market orders (
Bought 1 @ 43,739.40), just above the level for a resting order (Buy limit placed 1 at 43,500.00) and when a resting order or a TP/SL leg fills later (Take profit filled 1 @ 44,104.20). A rejection toasts the broker's reason (Rejected insufficient margin,role="alert", 5s); nothing is swallowed. - Context additions (registration pattern):
placeMarket(side, qty),openOrderTicket(opts). The replay bar's Buy/Sell/qty (spec §3) callplaceMarket.
7. Trading panel (src/ui/trading-ui/TradingPanel.tsx)
Resizable bottom dock under the chart area, opened from the Trading toggle in the status bar
(open state and height persisted); a host can render it itself (features.trading.panelPlacement: "none"). Its strip reads Position +1 @ 43,812 · Open +$273 · Realized +$670 · Equity $10,943. Tabs:
- Orders: time, side, type, qty, price, status (
Filled,Working,Cancelled (OCO)), fill price. Newest first. A resting order's TP/SL legs nest under it as↳ TP/↳ SLrows (exit side, statusOn fill); once it fills, the position's leg rows readTP · position/SL · position. - Positions: side, qty, avg, mark, open P&L, TP, SL.
- Closed: time, side, qty, entry, exit, P&L, %, R, exit reason, bars held.
- Stats: net profit, win rate, profit factor, avg win/loss, expectancy, max drawdown, ambiguous
fills. Definitions from
docs/scripting/replay-and-strategies.md§5.
Orders and Positions work with any adapter; Closed and Stats (and the account figures) need a
ledger (hasLedger). Rows repaint from the subscriptions;
hovering a closed trade highlights its entry and exit markers on the chart.
8. Replay behaviour
- The replay bar keeps
[Buy] [qty] [Sell]; both place market orders through the shell. - Stepping forward feeds the new bar to the broker: pending orders evaluate, brackets trigger, the chip's P&L and the panel update.
- Stepping back does not rewind the broker (the ledger is history, like a journal). The status strip shows "orders evaluate on the next step" while paused.
- Exiting replay keeps the paper account;
reset()is a button in the panel header. - A host that swaps in a new adapter instance starts a new account; the panel's strip then says
Paper account restarted — orders and history from before are no longer shown(dismissable) instead of silently showing an empty account. The demo holds its broker and feed in lazy state, not memos, because React Fast Refresh re-runs memos on every hot update — that was the demo's "reset on CONNECTING…": each save rebuilt the broker (dropping working orders) and reconnected the feed. - Entry and exit markers are drawn on candles from the execution list (arrow up below the bar for buys, arrow down above for sells).
Ending a session does not evaluate the bars between the cursor and the live edge: the feed is a journal that only moves forward, so those bars are never handed to the broker. A bracket the market crossed inside that gap does not fill — the position resumes marking from the live edge instead.
9. Out of scope for this iteration
Multiple exit levels, trailing stops, partial closes, margin and leverage, DOM ladder trading, strategy scripts (they will drive the same broker later), persistence of the paper account.