Code API
A code indicator runs your TypeScript against replay market data on every book update, with globals for the order book, tape, footprint, and chart drawing. Copy or download the reference below into an LLM to write one, then paste it into an indicator's code window and enable it on the chart.
# OrderFlow Replay - Code Indicators and Algos API
Reference for writing chart indicators in code. Paste this whole file into an LLM,
ask it to write or edit an indicator, then paste the result into an indicator's code
window (Chart Settings -> Indicators) and enable it on the chart.
## Execution model
- An indicator is plain TypeScript. It runs once when you enable it on a chart, then re-runs on every book update for the active symbol.
- Only `state` persists across runs. Every other value is a fresh snapshot each run.
- All chart output is cleared when the indicator stops (disabled / edited / symbol change).
- There is no in-app console; the only visible output is what you draw via `chart.*`.
- Every price is a real price (e.g. 5000.25). Never scale prices.
## Globals (TypeScript declarations)
```ts
// Every price in this API is a real price (e.g. 5000.25). Never scale prices.
interface BookLevel { price: number; size: number }
// bid/ask are the CURRENT book, best price first. book.at(timestampMs) is the
// book as of a past moment: the newest stored snapshot at or before that time
// (sampled every ~200ms, so up to that much older - its .time says exactly
// when), or null outside book.coverage(). Book history is the pre-roll before
// the replay start plus what has played, so it spans far less than the tape;
// treat uncovered time as unknown, not as an empty book.
interface BookSnapshot { time: number; bid: BookLevel[]; ask: BookLevel[] }
interface TimeRange { start: number; end: number }
declare const book: {
bid: BookLevel[]
ask: BookLevel[]
at: (timestampMs: number) => BookSnapshot | null
coverage: () => TimeRange[]
}
declare const symbol: string
declare const time: Date
declare const state: Record<string, unknown>
// candles is the chart's candles, oldest first, at the chart's display interval -
// time intervals and tick intervals ("133t") alike, so it always lines up with the
// rendered columns. candles.at(-1) is the newest. Each level is one price row,
// sorted ascending. Volume, delta and POC are one-line folds over levels:
// const volume = c.levels.reduce((s, l) => s + l.buys + l.sells, 0)
// const delta = c.levels.reduce((s, l) => s + l.buys - l.sells, 0)
// const poc = c.levels.reduce((a, b) => (b.buys + b.sells > a.buys + a.sells ? b : a))
// ohlc is null when the dataset carries no OHLC for that bucket.
// increment: instrument tick size as a REAL price step (one price row).
// startMs / endMs are the candle's span in MARKET time - the same clock the
// replay runs on and the tape is stamped with. On a time interval startMs is
// the same number as time; on a tick interval ("133t") time counts trades
// instead of ms, so startMs / endMs are the candle's only real timestamps.
// Match a trade to its candle with them:
// c.startMs <= trade.timestamp && trade.timestamp < c.endMs
// For individual trades (any size), walk the tape: for (const trade of tape) ...
interface CandlestickLevel { price: number; buys: number; sells: number }
interface CandlestickOhlc { open: number; high: number; low: number; close: number }
interface Candlestick { time: number; interval: number; increment: number; startMs: number; endMs: number; ohlc: CandlestickOhlc | null; levels: CandlestickLevel[] }
declare const candles: Candlestick[]
// daily holds the days BEFORE this session, newest first, so daily[0] is the
// previous day and daily[0].high / daily[0].low are the levels to mark. It is
// separate from candles because the chart never loads a previous day: panning
// back does not fill it, and no fold over candles can produce it.
// A public replay session loads these, about ten days of them. A historical
// replay does not yet, and daily is EMPTY there.
// Check daily.length before a fold that needs a fixed lookback.
// A bar is one UTC day from startMs. The day rolls at 00:00 UTC, not at CME's
// 17:00 CT open, so a futures previous day will not match a platform that bars
// by exchange trading date. Only days that traded get a bar, so pick a window
// by startMs rather than by counting bars.
interface DailyBar { startMs: number; open: number; high: number; low: number; close: number; volume: number }
declare const daily: DailyBar[]
// volumeByPrice(items): the volume-by-price histogram, sorted by price ascending.
// Takes anything carrying levels - candlesticks or tape trades:
// volumeByPrice(candles) // whole loaded session
// volumeByPrice(candles.slice(-40)) // last 40 candles
// volumeByPrice(candles.filter(c => myCondition(c)))
// volumeByPrice(tape.range(fromMs, toMs)) // from individual trades
// Tape trades attribute their fills by the trade's side; side "none" trades are
// skipped, matching candle levels (unclassified volume is never a buy or a sell).
declare function volumeByPrice(items: Iterable<Pick<Candlestick, "levels"> | Pick<Trade, "levels" | "side">>): { price: number; buys: number; sells: number }[]
// onPress makes a drawing clickable: pressing it shows a tooltip with your text (\n for multiple lines). Use it to reveal the numbers behind a drawing, e.g. the buys/sells that sized a bar.
interface ChartOnPress { text: string }
// A border draws only if borderColor is set; borderWidth is px and defaults to 1.
// shade lights a dot as a sphere (true, or 0..1 to soften). slices splits it into
// pie wedges from 12 o'clock clockwise; `value` is a relative weight normalized
// against the other slices, so raw buys/sells can go straight in. Max 8 slices,
// and both effects are skipped on dots a few px wide where they cannot be seen.
// chart.scroll: "bars" (default) steps the live edge one candle at a time;
// "continuous" advances it with the replay clock, as the heatmap does. Pair it
// with chart.depth so the heatmap does not step.
interface ChartCircleSlice { value: number; color: string }
interface ChartMarker { price: number; color?: string; radius?: number; time?: Date | number; borderColor?: string; borderWidth?: number; shade?: boolean | number; slices?: ChartCircleSlice[]; onPress?: ChartOnPress }
// width is in px; widthTime (ms of chart time) is an alternative that scales with zoom (e.g. a % of a region: fraction * regionDurationMs). Provide one.
interface ChartHorizontalBar { price: number; width?: number; widthTime?: number; time?: Date | number; color?: string; align?: "left" | "right"; anchor?: "time" | "rightAxis" | "leftAxis"; offsetTime?: number; height?: number; borderColor?: string; borderWidth?: number; onPress?: ChartOnPress }
interface PanelPoint { time?: Date | number; value: number }
// A cell in a per-candle stat row: one candle-column-wide box under the chart. time defaults to
// current time and is floored to the chart interval. text overrides the value display (e.g. "7.4%");
// color overrides the sign-based fill (positive blue, negative red, zero gray).
interface PanelCell { time?: Date | number; value: number; text?: string; color?: string }
// cells() draws per-candle stat rows (like delta / delta % / volume diff tables). Every cell draws
// its colored box; the number inside hides when the candle column is under 18px wide.
// REPLACE semantics per call (overwrite the whole row each tick). A panel should use plot OR cells, not both.
interface PanelHandle { plot: (seriesId: string, points: PanelPoint[], options?: { color?: string; type?: "line" | "histogram" }) => void; cells: (rowId: string, cells: PanelCell[], options?: { label?: string }) => void }
interface ChartLinePoint { time?: Date | number; price: number }
interface ChartHorizontalLine { price: number; color?: string; width?: number; style?: "solid" | "dashed"; onPress?: ChartOnPress }
interface ChartBox { time1: Date | number; price1: number; time2: Date | number; price2: number; color?: string; borderColor?: string; borderWidth?: number; onPress?: ChartOnPress }
// rotate turns the text clockwise about its anchor, in degrees (180 flips it).
interface ChartText { price: number; text: string; time?: Date | number; color?: string; align?: "left" | "center" | "right"; offsetX?: number; size?: number; rotate?: number; onPress?: ChartOnPress }
// chart.candle(render): register a per-candle renderer. The chart invokes it for
// each VISIBLE candle on each frame with the aggregated candle view - no history
// loops needed, no per-tick cost. draw.rectangle places rows by price and fractions of
// the candle column ([from, from+width], defaults full width); draw.text anchors
// at 'at' (the same column fraction, default 0.5 = center) + offsetX px, so text
// can be kept inside a sub-region when a renderer splits the column into a
// profile and a delta histogram. This is the ONLY footprint renderer - whatever
// your script draws here is what the chart shows, candle included.
// A chart whose script registers NO chart.candle renderer draws plain
// candlesticks. Registering one takes the candles over: draw whatever you like,
// and call draw.candlestick() for the default candle inside your own layout.
// chart.candle(() => {}) is therefore how a script says "no candles at all".
// draw.candlestick() with no arguments is that same plain candle - a filled body
// over the middle 70% of the column with a wick that thickens from 1px to 3px
// as the column widens - and it is a no-op on a
// candle with no OHLC, so it needs no showCandle guard. Pass `from`/`width` to
// confine it to part of the column, `style: 'hollow'` to outline the body so
// rows show through it, `up`/`down`/`flat` for colours, `wickPx` for wick
// thickness. Call it AFTER your rows so the candle sits on top of them.
// showRowText / showSummary are the chart's readability gates: honour them or
// the numbers turn to mush when zoomed out.
// TEXT SIZE: draw.text's `size` is a BASE size at reference zoom, not fixed px.
// `scale` picks the axis that stretches it: 'y' (default) grows the text as you
// stretch the price axis / rows get taller, 'x' as candle columns get wider,
// 'both' takes the smaller of the two, 'fixed' pins it to exactly `size` px.
// Omitting `size` takes the chart's automatic sizing, which is CAPPED at ~0.7 *
// row height - on short rows the numbers shrink and leave the column empty,
// which looks like a huge gap between candles. Pass a size with scale 'x' to
// tie text to the column width instead, so it fills the column at any zoom.
interface CandleViewLevel { buys: number; sells: number }
interface CandleView { time: number; interval: number; increment: number; widthPx: number; rows: Record<string, CandleViewLevel>; delta: number; volume: number; poc: number; maxLevelVolume: number; maxLevelDelta: number; open: number; high: number; low: number; close: number; showCandle: boolean; showRowText: boolean; showSummary: boolean; numberDisplay: "sellBuy" | "deltaVol" }
interface CandleDrawRectangle { price: number; price2?: number; from?: number; width?: number; color: string; borderColor?: string; borderWidth?: number }
type TextScale = "fixed" | "x" | "y" | "both"
interface CandleDrawText { price: number; text: string; color?: string; size?: number; scale?: TextScale; at?: number; align?: "left" | "center" | "right"; offsetX?: number }
interface CandleDrawCandlestick { from?: number; width?: number; style?: "filled" | "hollow"; up?: string; down?: string; flat?: string; wickPx?: number }
interface CandleDraw { rectangle: (rectangle: CandleDrawRectangle) => void; text: (text: CandleDrawText) => void; candlestick: (options?: CandleDrawCandlestick) => void }
// chart.frame(render) runs once per frame, in absolute coordinates (real prices,
// ms timestamps - no bucket flooring), clipped to the plot.
// view.candles holds the visible candles and view.trades every trade in the visible
// range, UNFILTERED, so filtering it is the trade-size control - view.minTradeSize is
// the instrument's own floor (ES 20, most instruments 1) if you want its default.
// Scale radii by view.msPerPx (min(1, baseline / view.msPerPx)) to keep dots readable zoomed out.
// view.widthPx is the plot's width in px - use it for a screen-area budget rather than dividing the
// market-time span by msPerPx, which is the chart's own axis unit and differs on tick/range charts.
// draw.circle's onPress makes a dot clickable and reveals your text.
// view.scroll is the chart's scroll mode: on "bars" the x axis is candle-indexed, so anchor a per-trade
// drawing with anchor: 'candle'; on "continuous" it follows the clock, so use anchor: 'time'.
// anchor: 'candle' on a rectangle, text or line maps its times into the drawable span of the column
// holding them (the gap between candles is skipped), so a box around one footprint row is
// draw.rectangle({ price1: p - c.increment / 2, price2: p + c.increment / 2, time1: c.time,
// time2: c.time + c.interval, anchor: 'candle' }) and it hugs the column exactly like a chart.candle row.
type FrameAnchor = "time" | "candle"
interface FrameTrade { time: number; price: number; size: number; side: "buy" | "sell" | "none" }
interface FrameView { time: number; candles: CandleView[]; minPrice: number; maxPrice: number; startTime: number; endTime: number; increment: number; rowHeight: number; widthPx: number; msPerPx: number; scroll: "bars" | "continuous"; trades: FrameTrade[]; minTradeSize: number }
interface FrameDrawLine { points: { time: number; price: number }[]; anchor?: FrameAnchor; color?: string; width?: number; style?: "solid" | "dashed" }
interface FrameDrawHLine { price: number; color?: string; width?: number; style?: "solid" | "dashed" }
// rectangle's below: true paints BEHIND everything already drawn (candles,
// numbers, other drawings) - a full-height band with view.minPrice/maxPrice and
// below: true is background shading (e.g. session hours) rather than an overlay.
interface FrameDrawRectangle { price1: number; price2: number; time1?: number; time2?: number; anchor?: FrameAnchor; color?: string; borderColor?: string; borderWidth?: number; below?: boolean }
interface FrameDrawText { price: number; text: string; time?: number; anchor?: FrameAnchor; color?: string; size?: number; scale?: TextScale; align?: "left" | "center" | "right"; offsetX?: number }
// shade / slices behave exactly as on chart.mark above.
// anchor: 'time' (default) draws at the moment itself; 'candle' draws at the
// center of the column holding it. On a tick interval both are the column,
// since the axis has no finer position than a bar.
interface FrameDrawCircle { time: number; price: number; radius: number; anchor?: FrameAnchor; color?: string; borderColor?: string; borderWidth?: number; shade?: boolean | number; slices?: ChartCircleSlice[]; onPress?: ChartOnPress }
interface FrameDraw { line: (line: FrameDrawLine) => void; hline: (line: FrameDrawHLine) => void; rectangle: (rectangle: FrameDrawRectangle) => void; text: (text: FrameDrawText) => void; circle: (circle: FrameDrawCircle) => void }
// chart.condenseLevel / numberDisplay: chart display config, honored only in the
// chart SETTINGS script (gear panel). They shape the DATA a renderer receives
// (row merging, which pair of numbers is meaningful); the LOOK is entirely your
// chart.candle renderer.
// Every config value below is read back after every run of the settings script,
// and a line you leave out resolves to its default - so deleting one reverts
// that setting rather than keeping whatever was last set.
// chart.columnWidth: px width of one candle column - the space draw.rectangle's
// `from`/`width` and draw.text's `at` fractions divide up. Set it when a layout
// needs room (e.g. a profile beside a delta histogram); 0 follows the zoom.
// Applied when the settings script runs (save, preset pick, symbol or interval
// change); zooming afterwards works normally, up to this width.
// chart.columnGap: px of empty space between neighbouring columns. It comes out
// of the drawable span, so a smaller gap means wider rows; 0 keeps the automatic
// gap, which scales with the chart's size as well as the column.
// chart.initialWindow: how much time the chart opens showing - ms, or a duration string
// like '5m'. The zoom follows from it and the chart's width. Time intervals only.
// A starting point, not a lock - zooming afterwards is untouched.
// chart.yZoom: vertical stretch of the auto-fitted price scale - 2 shows half
// the range, so rows are twice as tall. Dragging the price axis overrides it.
// chart.depth as a gradient: sizes ramp from nothing to the colour's own alpha at
// `max` (0 keeps the instrument's cap), bent by `curve` (1 linear, <1 lifts
// ordinary levels, >1 leaves only the biggest books lit).
// chart.bbo: the bid/offer step lines the depth layer strokes, and the boundary
// its gradient is clipped to. Off by default; true, or 0..1. Needs depth on.
// It is the CHART's setting, not the depth declaration's - the Depth indicator is
// shared by every chart importing it, and stroking the edge is a per-chart look.
// chart.depth is the one config an INDICATOR may set too - the built-in Depth
// indicator is just this assignment. An indicator that declares it wins over the
// settings script, and the last one enabled wins over the rest.
interface ChartDepthStyle { opacity?: number; bid?: string; ask?: string; max?: number; curve?: number }
declare const chart: { mark: (marker: ChartMarker) => void; panel: (options: { id: string; height?: number }) => PanelHandle; horizontalBar: (bar: ChartHorizontalBar) => void; line: (points: ChartLinePoint[], options?: { color?: string; width?: number; style?: "solid" | "dashed"; onPress?: ChartOnPress }) => void; horizontalLine: (line: ChartHorizontalLine) => void; box: (box: ChartBox) => void; text: (text: ChartText) => void; candle: (render: (candle: CandleView, draw: CandleDraw) => void) => void; frame: (render: (view: FrameView, draw: FrameDraw) => void) => void; condenseLevel?: number; numberDisplay?: "sellBuy" | "deltaVol"; columnWidth?: number; columnGap?: number; depth?: boolean | number | ChartDepthStyle; bbo?: boolean | number; initialWindow?: number | string; yZoom?: number; scroll?: "continuous" | "bars" }
// use('VWAP'): import a library indicator so it runs on this chart. Honored only in
// the chart SETTINGS script (gear panel); ignored in indicators. Resolves by
// indicator id first, then exact name. Can be conditional (the import set is
// re-evaluated live), e.g. only enable an indicator above a volume threshold.
declare function use(name: string): void
// dom.columns is the ladder, left to right. Honored only in the DOM SETTINGS
// script (the ladder's gear panel); ignored on charts. It already holds the
// built-in columns, so the default ladder is:
// dom.columns = [dom.price, dom.bid, dom.sells, dom.buys, dom.ask, dom.volume]
// Leave a name out to drop that column, move one to move it, assign your own list
// to replace the ladder. Each column is an object you can edit in place:
// dom.bid.width = 80, or dom.sells.draw = (row, draw) => { ... }. The ladder
// invokes draw for each VISIBLE row each frame - no per-tick cost, no
// subscriptions.
// dom.column(options, render) builds a column of your own; place it in
// dom.columns for it to appear.
// header labels a column in a strip above the ladder (dom.bid.header = 'Bid').
// The strip appears only once a column asks for one, and the app draws it, so a
// header cannot be repainted or moved the way a cell can.
// draw.rectangle places a fill by fractions of the column ([from, from+width],
// defaults to the full column); draw.text anchors at 'at' (the same fraction,
// default 0.5 = center) + offsetX px.
// entry binds the app's order entry to a column, and dom.bid / dom.ask already
// carry it: 'bid' places a bid limit below the spread and a buy stop above it,
// 'ask' the mirror, and 'both' resolves per row against the spread (bid below,
// ask above) for one merged book column. Set it on a column of your own to make
// it tradeable too. You
// choose where the column is and how it looks; what pressing it DOES is the
// app's, and resting orders / stops / your open position are drawn on top of
// every column, so no script can hide them.
// dom.rowHeight / condenseLevel / eraseTrades shape the DATA and geometry a
// column receives. They resolve authoritatively after each run: a line you delete
// reverts to its default rather than keeping the last value set.
// volumeShare / bidDepthShare / askDepthShare are 0-1 fractions of the ladder's
// scale maxima, so a bar is draw.rectangle({ width: row.bidDepthShare }).
// bidStackShare / offerStackShare are the stack/pull SIZE against the same depth
// scale, unsigned - the sign is bidStackPull's, so a bar sized to a pull is
// draw.rectangle({ width: row.bidStackShare }).
interface DomRow { price: number; priceText: string; bid: number; ask: number; volume: number; recentBuy: number; recentSell: number; bidStackPull: number; offerStackPull: number; buyFade: number; sellFade: number; bidStackFade: number; offerStackFade: number; aboveSpread: boolean; belowSpread: boolean; buyHighlight: boolean; sellHighlight: boolean; priceHighlight: boolean; isUserTrade: boolean; volumeShare: number; bidDepthShare: number; askDepthShare: number; bidStackShare: number; offerStackShare: number }
// Every field below with a ? has a default that matches what the ladder already
// does, so a column only states what it wants to differ: a full-width fill is
// draw.rectangle({ color }), a centered label at the row's size is
// draw.text({ text }), and the ladder's row separator is draw.line().
interface DomCellRectangle { from?: number; width?: number; color: string; inset?: number }
interface DomCellText { text: string; color?: string; size?: number; at?: number; align?: "left" | "center" | "right"; offsetX?: number }
// draw.line draws a rule across the full column on one edge of the row - the
// ladder's row separators. Draw it AFTER any fill, or the fill covers it.
interface DomCellLine { edge?: "top" | "bottom"; color?: string; width?: number }
interface DomCellDraw { rectangle: (rectangle: DomCellRectangle) => void; text: (text: DomCellText) => void; line: (line?: DomCellLine) => void }
interface DomColumnContext { rowHeight: number; textSize: number; smallTextSize: number; columnWidth: number }
interface DomColumn { width: number; entry?: "bid" | "ask" | "both"; header?: string; draw: (row: DomRow, draw: DomCellDraw, ctx: DomColumnContext) => void }
declare const dom: { columns: DomColumn[]; price: DomColumn; bid: DomColumn; sells: DomColumn; buys: DomColumn; ask: DomColumn; volume: DomColumn; column: (options: { width?: number; entry?: "bid" | "ask" | "both"; header?: string }, render: (row: DomRow, draw: DomCellDraw, ctx: DomColumnContext) => void) => DomColumn; rowHeight?: number; condenseLevel?: number; eraseTrades?: number }
// The tape is every trade, oldest first - one entry per aggressive
// order (consecutive fills sharing a timestamp and side), merged from loaded
// history and the live session. Iterate it directly:
// for (const trade of tape) { if (trade.size >= 100) ... }
// levels is the per-price fills in fill order (a trade moves monotonically, so
// levels[0].price to levels.at(-1).price is the exact price path); size is the
// trade total. Unlike candle levels there is no minimum size and no bucketing -
// this is the sequence source for run/retrace/absorption logic.
// tape.range(fromMs, toMs) is the windowed form. Only ranges inside
// tape.coverage() are complete; history loads with the chart's viewport, so
// treat uncovered ranges as unknown rather than empty.
// tape.tick() is what JUST traded: the trades that arrived with this book
// update, empty on updates that carried no trade. That is the window a live
// indicator reacts to, and range() cannot express it - a trade is stamped with
// its own trade time, not with the update's.
interface TapeLevel { price: number; size: number }
interface Trade { timestamp: number; side: "buy" | "sell" | "none"; size: number; levels: TapeLevel[] }
interface TapeRange { start: number; end: number }
declare const tape: Iterable<Trade> & { range: (fromMs: number, toMs: number) => Trade[]; tick: () => Trade[]; coverage: () => TapeRange[] }
```
## Bindings
### book
Resting limit orders, best-first. `book.bid` / `book.ask` are `BookLevel[]`. `book.bid[0]` / `book.ask[0]` are best bid / ask. `price` is a real price, `size` is resting quantity. Zero-size levels are removed.
### tape
Every trade in order - one entry per aggressive order (consecutive fills sharing a timestamp and side), merged from loaded history and the live session. Iterate it (`for (const trade of tape)`) for everything loaded, oldest first. `trade.levels` is `{ price, size }[]` in fill order (a trade moves in one direction, so first to last level is its price path), `trade.size` is the trade total, `trade.side` is `"buy" | "sell" | "none"`.
- `tape.range(fromMs, toMs)`: the windowed form, inclusive of both bounds.
- `tape.tick()`: the trades that arrived on THIS book update - what just traded, and the per-update delta a live indicator reacts to. Empty on updates with no trade. Not expressible as a `range()`: a trade carries its own trade time, not the update's.
- `tape.coverage()`: the `{ start, end }` ranges the tape is complete for. History loads with the chart's viewport, so treat uncovered ranges as unknown rather than empty.
### daily
The days BEFORE this session as daily bars, newest first, so `daily[0]` is the previous day and `daily[0].high` / `daily[0].low` are the previous day's high and low. Fields: `startMs, open, high, low, close, volume`.
- This is the ONLY source of a previous day. `candles` holds the replayed session alone, panning back does not load an earlier day, and no fold over `candles` can produce one.
- A public replay session loads these. A historical replay does not yet, and `daily` is empty there, so guard before indexing: `const previous = daily[0]; if (!previous) return`.
- A bar is one UTC day from `startMs`. The day rolls at 00:00 UTC, not at CME's 17:00 CT session open, so a futures previous day will not match a platform that bars by exchange trading date. For a stock the UTC day is the session.
- A multi-day high/low is a fold over a window picked by `startMs`, not by counting bars (only days that traded get one): `const week = daily.filter(d => d.startMs > daily[0].startMs - 7 * 86400000)`, then `Math.max(...week.map(d => d.high))`.
### candles
The chart's candles as data (running history, unlike `tape.tick()`): `Candlestick[]`, oldest first, at the chart's display interval - aligned with the rendered columns on time intervals AND tick intervals (`"133t"`), so `candle.time` feeds straight into drawing times. `candles.at(-1)` is the newest.
- `candle.levels`: `{ price, buys, sells }[]`, sorted by price ascending - the candle's per-price traded volume. Derived stats are folds: `volume = levels.reduce((s, l) => s + l.buys + l.sells, 0)`, `delta = levels.reduce((s, l) => s + l.buys - l.sells, 0)`, POC = the row with the largest `buys + sells`.
- `candle.ohlc`: `{ open, high, low, close } | null` (real prices). Null when the dataset has no OHLC for that bucket - guard before reading.
- `candle.time` / `candle.interval`: candle start and span in ms (synthetic on tick intervals, still column-aligned).
- `candle.startMs` / `candle.endMs`: the candle's span in MARKET time - the clock the replay runs on and the tape is stamped with. On a time interval `startMs` equals `time`; on a tick interval `time` counts trades, so these are the candle's only real timestamps. Matching a trade to a candle is `c.startMs <= t.timestamp && t.timestamp < c.endMs`.
- Every `time` a drawing takes is market time as well, and the chart resolves it: the exact position where the x axis is time, the containing candle where it is not. A trade's own timestamp therefore draws correctly on time, tick and range charts without conversion.
- `candle.increment`: instrument tick size as a real price step (one level per increment).
- Code-panel runs have no chart binding and fall back to the 30s base interval.
### volumeByPrice
`volumeByPrice(items)`: the volume-by-price histogram. Takes an iterable of anything carrying `levels` - candlesticks or tape trades - and returns `{ price, buys, sells }[]` sorted by price ascending. A volume profile is `volumeByPrice` of whatever slice you care about: `volumeByPrice(candles)` (session), `volumeByPrice(candles.slice(-40))`, `volumeByPrice([...tape].filter(t => t.size >= 50))` (per-trade filters, which summed candle levels cannot express). Tape trades attribute fills by the trade's side; side-`"none"` trades are skipped, matching candle levels.
### time / symbol / state
`time` is the replay clock `Date`. `symbol` is the active symbol string. `state` is a `Record<string, unknown>` that persists across runs within a single Run; use it to remember data between ticks.
### chart
- `chart.mark({ price, color?, radius?, time?, borderColor?, borderWidth?, shade?, slices?, onPress? })`: circle at a price/time. A border draws only if `borderColor` is set (`borderWidth` px defaults 1) - give a translucent fill an edge so overlapping circles stay countable. `shade` lights the dot as a sphere (`true`, or `0`..`1` to soften). `slices` is `{ value, color }[]` and splits it into pie wedges from 12 o'clock clockwise - `value` is a relative weight normalized against the other slices, so raw quantities go straight in (`[{ value: buys, color: blue }, { value: sells, color: red }]`), max 8 slices, and `color` is the fallback fill. Both are skipped on dots a few px wide where they cannot be seen. Deduped by `price|timestamp`, capped at 5000 per symbol.
- `chart.horizontalBar({ price, width?, widthTime?, time?, color?, align?, anchor?, height?, borderColor?, borderWidth?, onPress? })`: one horizontal bar; a border draws only if `borderColor` is set (`borderWidth` px defaults 1) - e.g. outline the POC. the building block for a volume profile. Provide `width` (px, fixed) or `widthTime` (ms of chart time, scales with zoom - use `fraction * regionDurationMs` to size a bar as a % of a box/session/N-candle region); `widthTime` wins if both set. `anchor` is `"time"` (default, candle column) | `"rightAxis"` | `"leftAxis"`. Replace-per-tick: redraw the whole set each run, do NOT accumulate bars in `state`. Capped at 5000.
- `chart.line(points, { color?, width?, style?, onPress? })`: polyline on the price chart (VWAP, moving averages). `points` is `{ time?, price }[]`, needs >= 2 points. `style` is `"solid"` (default) | `"dashed"`. Replace-per-tick: redraw the whole line each run, do NOT accumulate points in `state`.
- `chart.horizontalLine({ price, color?, width?, style?, onPress? })`: full-width horizontal line at a price (support/resistance, price levels). `style` is `"solid"` (default) | `"dashed"`. Shares the `chart.line` frame, so replace-per-tick applies: redraw each run, do NOT accumulate in `state`.
- `chart.box({ time1, price1, time2, price2, color?, borderColor?, borderWidth?, onPress? })`: filled rectangle / selection highlight spanning a time range and price range (a zone reaching a high and low across a time window). Times are `Date | number` (default current `time`), prices are real; corners are order-independent. `color` (fill) defaults translucent blue; a border draws only if `borderColor` is set. Replace-per-tick: redraw the whole set each run, do NOT accumulate in `state`.
- `chart.panel({ id, height? })` returns `{ plot(seriesId, points, { color?, type? }), cells(rowId, cells, { label? }) }`: a sub-panel below the price chart. `points` is `{ time?, value }[]`; `type` is `"line"` (default) | `"histogram"`. `plot` replaces the whole series each call, so DO accumulate points in `state`. Capped at 5000 points per series.
- `panel.cells(rowId, cells, { label? })`: per-candle stat rows (a table under the chart: one colored box per candle column, e.g. delta / delta % / volume diff). `cells` is `{ time?, value, text?, color? }[]`; `time` is floored to the chart interval so each cell aligns with a candle column. Fill is sign-based (positive blue, negative red, zero gray) unless `color` is set; `text` overrides the displayed value (e.g. `"7.4%"`); `label` draws in the right axis margin. Every cell draws its colored box; the number inside hides when the candle column is under 18px wide. Replace-per-call: rebuild the whole row each run. A panel should use `plot` OR `cells`, not both; a cells panel sizes from its row count (20px per row) and ignores `height`. Capped at 5000 cells per row.
- `onPress`: any drawing (`mark`, `horizontalBar`, `line`, `horizontalLine`, `box`) accepts `onPress: { text }`. It makes the drawing clickable; pressing it shows a tooltip with your `text` (`\n` for multiple lines). Use it to reveal the numbers behind a drawing, e.g. the buys/sells that sized a bar.
## Not covered here: research studies
## Example: volume profile
```ts
// Traded volume per price as buy/sell split bars pinned to the right axis.
const MAX_WIDTH = 80
const profile = volumeByPrice(candles)
let maxTotal = 0
for (const row of profile) {
if (row.buys + row.sells > maxTotal) maxTotal = row.buys + row.sells
}
if (maxTotal > 0) {
for (const row of profile) {
const total = row.buys + row.sells
const onPress = { text: `Buys: ${row.buys}\nSells: ${row.sells}` }
chart.horizontalBar({ price: row.price, width: (total / maxTotal) * MAX_WIDTH, color: "rgba(239, 68, 68, 0.7)", anchor: "rightAxis", onPress })
chart.horizontalBar({ price: row.price, width: (row.buys / maxTotal) * MAX_WIDTH, color: "rgba(59, 130, 246, 0.7)", anchor: "rightAxis", onPress })
}
}
```
## Replay algos
Open Algos in the order panel. These scripts use their own globals below;
chart, book, candles, tape, state, and report globals are not available.
Initialize once at Start and register one synchronous onUpdate callback. Its
trades array is the new aggressive orders in a replay batch, oldest first. A
batch may have only book updates. Each trade has side buy/sell/none, size, levels
of price/size, and a millisecond timestamp. The update time is Unix milliseconds;
bid/ask are the batch's final best prices. book is the resting book at the end
of the batch: book.bid and book.ask are arrays of price/size, best price first,
every loaded level. Resting size pulled or stacked at a price is the difference
between one update's book and the previous one's; keep the last book in a local
variable to see it. Existing fills process before the
callback. Batch size can depend on playback speed. Do not assume a historical
bid/ask for each individual trade in the array. Local variables persist between
callbacks. print writes to the Activity log.
orders.buy/sell fill at current ask/bid. buyLimit/sellLimit return a working
order, or an immediate market fill when crossing the spread. Quantities are
positive integers and all prices must align to tickSize. tp/sl are prices,
not tick offsets. Take profit is beyond entry in the trade direction; stop loss
is behind it. A reducing order cannot attach an entry bracket. Market brackets
apply to the resulting net position. Repeating a limit at the same side/price
throws; explicitly cancel it before replacing it.
orders.position and orders.working read the shared Sim instrument state,
including manual trades and orders. cancel(id) cancels that working order and
its linked orders according to replay rules. cancelAll cancels all working
orders for the bound instrument. flatten exits its net position and cancels
working orders. orders.fills lists every fill so far with time, side, price,
qty, and whether it opened position.
Backtest (button beside Start) runs the same script over the trades already
loaded for the instrument, one callback per trade, against a scratch account
with the same fill rules. bid/ask and book come from a book snapshot within two
seconds when one exists; otherwise bid/ask are inferred from the trade with a one
tick spread and book is null, so check for null before reading levels. Fills
draw on the chart in a separate colour; the finishing line reports round trips,
wins, losses and net PnL. Loaded history is what the chart has fetched.
Start takes a source snapshot. Autosaving does not change execution. Stop,
then Start to apply edits with fresh state. Closing the editor does not stop
execution. Pausing replay pauses callbacks; resetting/seeking, changing sessions,
removing the instrument or leaving replay stops the algo. Stop leaves orders
and position intact; Stop & Flatten also closes/cancels them, including manual
orders. Errors stop execution without flattening. Only one run per Sim instrument.
Runs never restart on page load; saved trade playback is read-only. Orders may
only be submitted inside onUpdate, with a maximum of 20 operations per update.
No imports, async callbacks, or chart renderer callbacks in algo scripts.
Full docs: https://marketbyorder.com/docs/api/algos
```ts
interface AlgoTrade { timestamp: number; side: "buy" | "sell" | "none"; size: number; levels: { price: number; size: number }[] }
interface AlgoBookLevel { price: number; size: number }
interface AlgoBook { bid: AlgoBookLevel[]; ask: AlgoBookLevel[] }
interface AlgoUpdate { time: number; bid: number; ask: number; book: AlgoBook | null; trades: AlgoTrade[] }
interface AlgoBracket { tp?: number; sl?: number }
interface AlgoOrder { id: string; kind: "limit" | "stop"; side: "buy" | "sell"; price: number; qty: number }
interface AlgoFill { kind: "market"; time: number; side: "buy" | "sell"; price: number; qty: number }
interface AlgoFillRecord { time: number; side: "buy" | "sell"; price: number; qty: number; opened: boolean }
interface AlgoPosition { side: "long" | "short" | "flat"; qty: number; avgPrice: number }
declare const symbol: string
declare const tickSize: number
declare function onUpdate(callback: (update: AlgoUpdate) => void): void
declare function print(...values: unknown[]): void
declare const orders: {
buy(qty: number, bracket?: AlgoBracket): AlgoFill
sell(qty: number, bracket?: AlgoBracket): AlgoFill
buyLimit(price: number, qty: number, bracket?: AlgoBracket): AlgoOrder | AlgoFill
sellLimit(price: number, qty: number, bracket?: AlgoBracket): AlgoOrder | AlgoFill
cancel(id: string): void
cancelAll(): void
flatten(): AlgoFill | null
working(): AlgoOrder[]
fills(): AlgoFillRecord[]
position(): AlgoPosition
}
```
### Example algo
```ts
// Follow a large trade with a bracketed entry.
// https://marketbyorder.com/docs/api/algos
const MIN_SIZE = 100
const TAKE_PROFIT_TICKS = 8
const STOP_LOSS_TICKS = 4
const COOLDOWN_MS = 30_000
let nextEntryTime = 0
onUpdate(({ time, bid, ask, trades }) => {
if (time < nextEntryTime) return
if (orders.position().side !== "flat" || orders.working().length > 0) return
const signal = trades.findLast(trade => trade.size >= MIN_SIZE && trade.side !== "none")
if (!signal) return
if (signal.side === "buy") {
orders.buy(1, {
tp: ask + TAKE_PROFIT_TICKS * tickSize,
sl: ask - STOP_LOSS_TICKS * tickSize,
})
} else {
orders.sell(1, {
tp: bid - TAKE_PROFIT_TICKS * tickSize,
sl: bid + STOP_LOSS_TICKS * tickSize,
})
}
nextEntryTime = time + COOLDOWN_MS
})
```
Reference
Jump into each part of the Code API.
Book
Resting limit orders in the order book, best-first.
const bestBid = book.bid[0]
// { price, size }Candles
The chart's candles as data: OHLC plus per-price traded volume.
const profile = volumeByPrice(candles)Tape
Every trade in order, live or historical - including what just traded.
for (const trade of tape.tick()) {
if (trade.size >= 100) ...
}Chart
Draw marks, bars, lines, boxes, and sub-panels onto the chart.
chart.horizontalLine({ price: 5000.25 })DOM
Build the price ladder out of columns: depth, trades, volume, anything you can draw.
dom.column({ id: 'bid', width: 64, role: 'bid' }, render)Settings Script
Import indicators with use() and render the chart itself: condense, number display, candle/frame renderers.
use("VWAP")
chart.condenseLevel = 2Algos
Automate simulated orders as replay advances, from the order panel's algo editor.
orders.buy(1, { tp: entry + 8 * tickSize, sl: entry - 4 * tickSize })Iceberg Detection
Worked example: flag refreshing hidden size resting at a level.
// full indicator walkthroughPeriodic Volume Profile
Worked example: per-block volume profiles with range boxes.
const profile = volumeByPrice(candles.slice(-40))