Write your own indicators.
The built-in indicators cover the basics. CX Script is for everything past that: your own signals, in plain JavaScript, running live on the footprint chart — historical chart support is coming. No build step, no account, no cost.
The language is ordinary JavaScript on purpose. It is what code assistants write best, so a working indicator is often one prompt away. Paste the API into any assistant, describe the idea, drop the result into the editor.
ES2020, strict mode, a tight API. No DSL to learn, no compile.
Each script runs in its own Web Worker. A runaway loop hits a budget and gets killed, never the chart.
Overlay the price chart or claim a dedicated sub pane on the footprint chart. Historical chart support is coming.
No premium gate. Scripting is part of the terminal for everyone.
Quickstart
Open the indicators panel on any footprint chart, add a script, and paste this in. It reads per-bar delta as a share of volume, colours it by sign, and draws a smoothed line underneath. Save (Ctrl+S) and it warms up over history, then updates live.
//@cx v1The first line. A version pragma so future changes never silently break an old script.study(name, opts)Names the indicator and picks its default pane: "overlay" (price chart) or "sub".input.*, candles(), feedsTop-level declarations. They run once, then freeze.onCandle(fn)Your per-bar logic. Read series, compute, draw.
//@cx v1
study("Delta pressure", { pane: "sub" })
const len = input.int("Smoothing", 14, { min: 2, max: 200 })
const bars = candles()
const vd = volumeDelta()
onCandle((bar) => {
const norm = vd(0) / bars.volume(0) // per-bar delta as a share of volume
plot("delta", norm, { color: norm >= 0 ? "#22c55e" : "#ef4444" })
plot("delta_ma", ta.sma(vd.map(v => v / bars.volume(0)), len), { color: "#8ecae6", width: 2 })
})How it works
Top level runs once to collect declarations. The chart then serializes the feeds you asked
for and hands them to the worker, which runs onCandle once per historical bar (warmup), then again for the live bar on every update. Drawing output
streams back and the chart renders it from a cache, so rendering costs nothing per frame.
Scripts work at bar granularity. Updates batch
at up to 10 Hz, so onCandle fires on the live bar many times, not once per trade. Gate once-per-bar logic on bar.isNew.
Script structure
A script runs in two passes. The top level is where declarations live, and it executes exactly
once. Once it returns, the inputs, feeds and study call are sealed — reach for one from inside onCandle and the run stops with an error.
//@cx v1
study("My indicator", { pane: "overlay" }) // or pane: "sub"
// top level runs ONCE — declarations only
const len = input.int("Length", 14, { min: 2, max: 200 })
const bars = candles()
// per bar — runs during warmup then on every live flush
onCandle((bar) => {
plot("sma", ta.sma(bars.close, len), { color: "#8ecae6" })
})The bar object
onCandle receives the bar being processed. Plots and entities written for a bar are last-write-wins within that bar.
onCandle((bar) => { /* ... */ })
// bar.index absolute bar index, 0 = oldest loaded bar
// bar.unix bar open time, unix SECONDS (matches the chart X axis)
// bar.isNew true the first time this bar is processed
// bar.isLast true if this is the newest bar
// bar.isWarmup true during warmup, false for live updatesA read-only chart object
is always in scope: chart.symbol, chart.venue, chart.intervalSec, chart.aggMode, chart.tickSize, chart.barCount, and the theme
candle colours chart.upColor / chart.downColor.
Inputs
Declared at top level, each returns its current value. The terminal renders the settings panel from them automatically. Changing a value re-runs the script over the data it already has.
input.bool(label, def, opts?) // -> boolean
input.int(label, def, opts?) // -> number opts: min, max, step
input.float(label, def, opts?) // -> number opts: min, max, step
input.select(label, def, { options: string[] }) // -> string def is the value, not an index
input.color(label, def, opts?) // -> "#RRGGBB" or "#RRGGBBAA"
input.string(label, def, opts?) // -> string opts: secret
input.group(label) // section header, returns undefinedShared options: key (persistence key,
defaults to a slug of the label), inline: true (render on the same row as the previous input), and showIf: { key, eq | neq | in } for conditional visibility. Mark a string input secret: true and it is stripped from shared layouts.
Series
A Series is a callable: s(offset) returns its value that many bars back, where 0 is the current bar. Out of range is NaN. There is one index
convention everywhere: offset from the current bar. Series are what the TA functions consume.
const bars = candles()
bars.close(0) // current bar's close; bars.close(1) = one bar back
bars.high.shift(1) // a Series of prior-bar highs
vd.map(Math.abs) // derived Series, lazy + memoized
series(i => bars.high(i) - bars.low(i)) // custom Series from an offset fn
bars.close.length // bars availableFeeds
Feeds are the market data a script can subscribe to, declared at top level (max 12). Each takes
an optional { venue } that defaults to the chart's primary exchange.
| Feed | Returns | Data | Footprint | Historical |
|---|---|---|---|---|
candles() | open, high, low, close, volume, buyVolume, sellVolume, tradeCount, unix | live | ||
volumeDelta(opts?) | per-bar delta Series (size tiers on the footprint chart) | live | ||
cumulativeDelta(opts?) | running delta sum Series | live | ||
footprintLevels() | per-price bid / ask / trades, plus poc(offset) | live | no | |
openInterest() | open, high, low, close | live | ||
liquidations() | longVol, shortVol, longQty, shortQty | live | ||
orderbook() | bestBid, bestAsk, mid, spread + bidDepth / askDepth / imbalance by band | NaN | ||
funding() | rate, nextFundingTime (step series) | NaN | ||
bigTrades(opts?) | list(offset) of prints: unix, price, qty, notional, side | NaN | ||
remote(url, opts) | a bar-aligned Series from an external API | live |
Data column: live means the feed carries
real data today. NaN means the API is wired but its sourcing lands in a future update — the script still loads and
runs, it just reads empty. Footprint / Historical is about where a script renders:
today that is the footprint chart, historical-chart rendering is a future update.
openInterestis in coin units (BTC, not USD — USD would move with price alone). It defaults tobinancefand also acceptsbybitf/hyperliquidf; any other venue returns NaN rather than an error. The OI venue is independent of the chart's venue, so a spot chart still reads perp OI — same rule as the OI subplot.liquidationsis one primary-symbol stream, so{ venue }is accepted and ignored.footprintLevelscarries per-price rows for the most recent ~3,000 bars; older bars return an empty list andpoc()= NaN.
Delta size tiers
On the footprint chart, volumeDelta and cumulativeDelta can filter by trade size.
volumeDelta() // all sizes
volumeDelta({ tiers: [6, 7] }) // bucket index range, inclusive
volumeDelta({ tiers: "whale" }) // named groups:
// "small" = < $100K "mid" = $100K-$1M "whale" = >= $1M- Data availability today: candles,
volumeDelta,cumulativeDelta,footprintLevels,openInterestandliquidationsare live. Orderbook depth, funding and big trades returnNaN/ empty until their sourcing lands. - Orderbook depth returns
NaNuntil sourcing lands. - Every feed reads the chart's own stores, so it behaves identically under either history source — only the depth and the detail differ. Under Full history the archive covers the last 48 hours and its bars carry no per-trade detail:
tradeCountand every tiered read (volumeDelta({ tiers })/cumulativeDelta({ tiers })) are0there and only move once your session's live bars start. Plain delta and CVD work across the whole window. Under Live exchange tiers work over the BTC history prefix, and non-BTC symbols have no footprint history at all — the script warms up over whatever your session has built. - Liquidation retention is 8 days, open interest around 130k snapshots. Beyond that,
NaN. A bar inside the retained window with no liquidations is a real0; a bar older than the oldest retained event isNaN. - Accumulators are primary-symbol-centric. A symbol without data yields a
NaNseries. Nothing is ever fabricated. - TA seeding:
ta.ema/ta.rma(and rsi/atr on top of them) seed with an SMA over the loaded window, while TradingView recurses over its full history — early bars can differ slightly and converge. ANaNentering an ema/rma window persists in the recursive state — clean your inputs before smoothing.
TA library
Around 36 functions on the ta namespace.
Each takes a Series and returns a number at the current bar (a few return objects, noted with ->). They are pure functions of
their inputs, safe inside conditionals; a period longer than the available history returns NaN rather than a partial window.
The ta.* names follow Pine Script conventions. If you have written a TradingView indicator — or you hand this
page to an assistant that has seen thousands of them — the vocabulary already fits.
Averages ta.sma(src,p) ta.ema(src,p) ta.rma(src,p) ta.wma(src,p) ta.hma(src,p)
ta.vwma(src,vol,p) ta.alma(src,p,offset=0.85,sigma=6) ta.swma(src)
ta.linreg(src,p,offset=0)
Oscillators ta.rsi(src,p) ta.stoch(high,low,close,p) ta.mfi(high,low,close,vol,p)
ta.roc(src,p) ta.macd(src,fast,slow,sig) -> {macd,signal,hist}
ta.dmi(high,low,close,p) -> {plus,minus,adx}
Volatility ta.atr(high,low,close,p) ta.tr(high,low,close) ta.stdev(src,p)
ta.variance(src,p) ta.dev(src,p) ta.bb(src,p,mult) -> {basis,upper,lower}
Aggregation ta.highest(src,p) ta.lowest(src,p) ta.median(src,p) ta.mode(src,p)
ta.sum(src,p) ta.pivothigh(src,left,right) ta.pivotlow(src,left,right)
Change ta.change(src,n=1) ta.cum(src) ta.barssince(condSeries)
ta.rising(src,p) ta.falling(src,p)
Crosses ta.cross(a,b) ta.crossover(a,b) ta.crossunder(a,b) // a,b: Series or number
VWAP ta.vwap(high,low,close,vol,{ anchor: "day"|"week"|"session" }) -> number
fmt.price(v) // tick-size-aware decimals
fmt.compact(v) // 1.24M
fmt.pct(v, dp) // percentageColors
Colours are hex strings everywhere ("#RRGGBB" or "#RRGGBBAA"). The color helpers build and blend them.
color.rgb(r, g, b, a=1) color.alpha(c, a) // a in [0,1], NOT a transparency %
color.lighten(c, pct) color.darken(c, pct) color.mix(a, b, t)
color.gradient(value, min, max, colorLo, colorHi)Plotting
Drawing functions run inside onCandle.
Each series has a stable id; the first call
fixes its pane. A value of NaN leaves a gap, so
conditional markers are just a value-or-NaN ternary.
plot(id, value, { color, width=1.5, style: "solid"|"dotted"|"dashed", pane })
histogram(id, value, { color, pane })
marker(id, yOrNaN, { shape, color, size=10, border, text, pane })
plotCandle(id, o, h, l, c, { up, down, pane })
bg(colorOrNaN) // bar background tint
fill(idA, idB, { color }) // both series must share a pane
// marker shapes:
// circle square diamond triangle-up triangle-down
// cross plus asterisk arrow-up arrow-downEntities
Persistent, keyed drawing objects: lines, boxes, labels and markers positioned in chart coordinates. They upsert by key, so re-calling with the same key updates in place and hands back the same handle. Useful for levels, ranges and annotations that live across bars.
const h = Line(key, { x1, y1, x2, y2, color, width=1, style="solid", pane })
Box(key, { x1, y1, x2, y2, fill, border, borderWidth=1, text, textColor, pane })
Label(key, { x, y, text, color, size=12, align:"left"|"center"|"right",
anchor:"above"|"below"|"center", pane })
Marker(key,{ x, y, shape, color, size, pane })
h.update({ ...partial }) // merge props in place
h.remove() // delete the entity
// x is unix seconds, y is price (or sub-pane units when pane:"sub").
// Same key = upsert: you get the same handle back.Alerts
Fire on the moment a condition becomes true. Alerts run during realtime only; warmup just arms the edge detector, so reloading history never spams old signals.
alert(id, condition, { message, cooldownSec=0, oncePerBar=true, once=false })
// Fires on a false -> true transition of condition, during realtime only.
// oncePerBar re-arms each new bar; once fires a single time until inputs change.
// Delivery: in-app toast + alert bell history + a background-tab notification.Remote data
Pull an external time series and align it to the chart's bars. remote() fetches inside the worker,
so scripts never touch fetch directly. It reads
JSON or CSV and step-fills forward.
const fng = remote("https://api.alternative.me/fng/?limit=0", {
path: "data", // dot-path to the array in the JSON response
time: "timestamp", // field with unix time (sec or ms, auto-detected) or ISO string
value: "value", // field with the numeric value
refreshSec: 3600, // accepted, but polling is not active yet — fetches once per load
format: "json" // "json" | "csv" (csv: time/value are column names)
})
// -> a Series aligned to chart bars, step-forward filled. NaN before the first datapoint.Works for CORS-enabled APIs. For blocked ones, relative URLs resolve against the site and hit
the existing proxies (/api/okx-funding, /api/mexc, and more). Limits: 4 calls per
script, 5 MB and 10 s each. Every remote URL is shown in the trust prompt before a shared script runs.
Limits
Guardrails that keep a bad script from taking the terminal down with it. A missing value is
always NaN, never null. Most breaches are hard
errors surfaced as a red badge on the legend with the line and column.
| Limit | Value | On breach |
|---|---|---|
| Inputs | 64 | hard error |
| Feed subscriptions | 12 | hard error |
| Plot series ids | 64 | hard error |
| Entities per type | 2,000 | evict oldest + one-time warning |
| Warmup budget | 10 s | terminate, error state |
| Realtime batch budget | 2 s | terminate, error state |
| Unresponsive worker | 15 s | terminate, error state |
| Output payload | 16 MB | error state |
| remote() calls | 4 (5 MB, 10 s each) | hard error |
| Script source size | 256 KB | reject at load |
Examples
Seven complete scripts, each copy-paste ready. Orderflow Signals, Tiered CVD, Prior Day Levels and Trading Sessions also ship as templates in the add-script menu; the ones marked preview use feeds whose data sourcing lands in a future update.
//@cx v1
study("Orderflow Signals", { pane: "overlay" })
input.group("Signals")
const showAbsorption = input.bool("Absorption", true)
const showExhaustion = input.bool("Exhaustion", true, { inline: true })
const showAggression = input.bool("Aggression", true)
const showDivergence = input.bool("Delta divergence", true, { inline: true })
const showConfluence = input.bool("Confluence", true)
const showMarkers = input.bool("Signal markers", true, { inline: true })
const textMode = input.select("Signal text", "Short", { options: ["Off", "Short", "Full"] })
input.group("Thresholds")
const normLength = input.int("Normalization length", 75, { min: 25, max: 1000 })
const swingLookback = input.int("Swing lookback", 20, { min: 5, max: 200, inline: true })
const deltaStrengthMin = input.float("Delta strength", 3.0, { min: 0.5, max: 12 })
const volStrengthMin = input.float("Volume strength", 2.2, { min: 0.5, max: 12, inline: true })
const absorptionEffMax = input.float("Absorption efficiency", 0.38, { min: 0.05, max: 0.9 })
const aggrClosePct = input.float("Aggression close %", 0.72, { min: 0.5, max: 0.98, inline: true })
const exhaustionMax = input.float("Exhaustion max strength", 1.25, { min: 0.1, max: 5 })
const confluenceMin = input.int("Confluence count", 2, { min: 2, max: 4, inline: true })
input.group("Style")
const markerSize = input.int("Marker size", 10, { min: 4, max: 30 })
const labelSize = input.int("Text size", 13, { min: 8, max: 36, inline: true })
const markerOffset = input.float("Marker offset", 0.16, { min: 0.02, max: 1 })
const textOffset = input.float("Text offset", 1.15, { min: 0.2, max: 4, inline: true })
const buyColor = input.color("Buy", "#00c2ff")
const sellColor = input.color("Sell", "#ff4d6d", { inline: true })
const absColor = input.color("Absorption", "#d7f2ff")
const exhColor = input.color("Exhaustion", "#ffd166", { inline: true })
const divColor = input.color("Divergence", "#ffffff")
const confColor = input.color("Confluence", "#ffe45e", { inline: true })
const bars = candles()
const vd = volumeDelta()
const cvd = cumulativeDelta()
const absDelta = vd.map(Math.abs)
const prevHigh = bars.high.shift(1)
const prevLow = bars.low.shift(1)
const prevCvd = cvd.shift(1)
onCandle((bar) => {
const range = bars.high(0) - bars.low(0)
const body = Math.abs(bars.close(0) - bars.open(0))
const atr = ta.atr(bars.high, bars.low, bars.close, 14)
const pad = atr > 0 ? atr * markerOffset : chart.tickSize * 12
const closePos = range > 0 ? (bars.close(0) - bars.low(0)) / range : 0.5
const efficiency = range > 0 ? body / range : 0
const expansion = atr > 0 ? range / atr : 0
const volStdev = ta.stdev(bars.volume, normLength)
const deltaStdev = ta.stdev(absDelta, normLength)
const volStrength = volStdev > 0 ? bars.volume(0) / volStdev : NaN
const deltaStrength = deltaStdev > 0 ? Math.abs(vd(0)) / deltaStdev : NaN
const priorHigh = ta.highest(prevHigh, swingLookback)
const priorLow = ta.lowest(prevLow, swingLookback)
const priorCvdHigh = ta.highest(prevCvd, swingLookback)
const priorCvdLow = ta.lowest(prevCvd, swingLookback)
const newHigh = bars.high(0) > priorHigh
const newLow = bars.low(0) < priorLow
const strongDelta = deltaStrength >= deltaStrengthMin
const strongVol = volStrength >= volStrengthMin
const bullAbsorption = showAbsorption && newLow && vd(0) < 0 && strongDelta && strongVol
&& closePos >= 0.45 && efficiency <= absorptionEffMax
const bearAbsorption = showAbsorption && newHigh && vd(0) > 0 && strongDelta && strongVol
&& closePos <= 0.55 && efficiency <= absorptionEffMax
const bullExhaustion = showExhaustion && newLow && deltaStrength <= exhaustionMax
&& volStrength <= exhaustionMax && closePos >= 0.35
const bearExhaustion = showExhaustion && newHigh && deltaStrength <= exhaustionMax
&& volStrength <= exhaustionMax && closePos <= 0.65
const buyAggression = showAggression && vd(0) > 0 && strongDelta && strongVol
&& closePos >= aggrClosePct && efficiency >= 0.45 && expansion >= 0.75
const sellAggression = showAggression && vd(0) < 0 && strongDelta && strongVol
&& closePos <= 1 - aggrClosePct && efficiency >= 0.45 && expansion >= 0.75
const bullDivergence = showDivergence && bars.low(0) < priorLow && cvd(0) > priorCvdLow && deltaStrength >= 1.0
const bearDivergence = showDivergence && bars.high(0) > priorHigh && cvd(0) < priorCvdHigh && deltaStrength >= 1.0
const bullScore = (bullAbsorption?1:0) + (bullExhaustion?1:0) + (buyAggression?1:0) + (bullDivergence?1:0)
const bearScore = (bearAbsorption?1:0) + (bearExhaustion?1:0) + (sellAggression?1:0) + (bearDivergence?1:0)
const bullConfluence = showConfluence && bullScore >= confluenceMin
const bearConfluence = showConfluence && bearScore >= confluenceMin
const yB1 = bars.low(0) - pad, yA1 = bars.high(0) + pad
const yB2 = bars.low(0) - pad * 1.65, yA2 = bars.high(0) + pad * 1.65
const yB3 = bars.low(0) - pad * 2.3, yA3 = bars.high(0) + pad * 2.3
marker("bull_abs", showMarkers && bullAbsorption ? yB1 : NaN,
{ shape: "square", color: color.alpha(absColor, 0.9), size: markerSize, border: buyColor })
marker("bear_abs", showMarkers && bearAbsorption ? yA1 : NaN,
{ shape: "square", color: color.alpha(absColor, 0.9), size: markerSize, border: sellColor })
marker("bull_exh", showMarkers && bullExhaustion ? yB2 : NaN,
{ shape: "diamond", color: color.alpha(exhColor, 0.95), size: markerSize, border: buyColor })
marker("bear_exh", showMarkers && bearExhaustion ? yA2 : NaN,
{ shape: "diamond", color: color.alpha(exhColor, 0.95), size: markerSize, border: sellColor })
marker("buy_aggr", showMarkers && buyAggression ? yB3 : NaN,
{ shape: "triangle-up", color: buyColor, size: markerSize + 3 })
marker("sell_aggr", showMarkers && sellAggression ? yA3 : NaN,
{ shape: "triangle-down", color: sellColor, size: markerSize + 3 })
marker("bull_div", showMarkers && bullDivergence ? yB1 : NaN,
{ shape: "triangle-up", color: divColor, size: markerSize + 1, border: buyColor })
marker("bear_div", showMarkers && bearDivergence ? yA1 : NaN,
{ shape: "triangle-down", color: divColor, size: markerSize + 1, border: sellColor })
marker("bull_conf", showMarkers && bullConfluence ? yB3 : NaN,
{ shape: "asterisk", color: confColor, size: markerSize + 7 })
marker("bear_conf", showMarkers && bearConfluence ? yA3 : NaN,
{ shape: "asterisk", color: confColor, size: markerSize + 7 })
if (textMode !== "Off") {
const t = (full, short) => textMode === "Full" ? full : short
const put = (key, y, text, c, anchor) =>
Label(`${key}_${bar.unix}`, { x: bar.unix, y, text, color: c, size: labelSize,
align: "center", anchor })
if (bullAbsorption) put("abs_b", yB1 - pad * textOffset, t("Absorption", "ABS"), absColor, "below")
if (bearAbsorption) put("abs_s", yA1 + pad * textOffset, t("Absorption", "ABS"), absColor, "above")
if (bullExhaustion) put("exh_b", yB2 - pad * textOffset, t("Exhaustion", "EXH"), exhColor, "below")
if (bearExhaustion) put("exh_s", yA2 + pad * textOffset, t("Exhaustion", "EXH"), exhColor, "above")
if (buyAggression) put("agr_b", yB3 - pad * textOffset, t("Aggression", "AGR"), buyColor, "below")
if (sellAggression) put("agr_s", yA3 + pad * textOffset, t("Aggression", "AGR"), sellColor, "above")
if (bullDivergence) put("div_b", yB1 - pad * textOffset, t("Divergence", "DIV"), divColor, "below")
if (bearDivergence) put("div_s", yA1 + pad * textOffset, t("Divergence", "DIV"), divColor, "above")
}
alert("bull_confluence", bullConfluence, {
message: `Bull confluence (${bullScore}/4) @ ${fmt.price(bars.close(0))}`, cooldownSec: 60 })
alert("bear_confluence", bearConfluence, {
message: `Bear confluence (${bearScore}/4) @ ${fmt.price(bars.close(0))}`, cooldownSec: 60 })
})Author with an LLM
Because CX Script is plain JavaScript with a small, documented surface, code assistants write it well. There is a machine-readable version of this entire API, condensed for pasting into a chat. Give it the spec, describe the indicator in a sentence or two, and paste what comes back into the editor. If it throws, the error carries the line and column.
The full API and both flagship examples, as a single text file an assistant can read in one shot.