CX Script v1

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.

Plain JavaScript

ES2020, strict mode, a tight API. No DSL to learn, no compile.

Sandboxed

Each script runs in its own Web Worker. A runaway loop hits a budget and gets killed, never the chart.

Overlay or sub pane

Overlay the price chart or claim a dedicated sub pane on the footprint chart. Historical chart support is coming.

Free

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.

  1. //@cx v1 The first line. A version pragma so future changes never silently break an old script.
  2. study(name, opts) Names the indicator and picks its default pane: "overlay" (price chart) or "sub".
  3. input.*, candles(), feeds Top-level declarations. They run once, then freeze.
  4. onCandle(fn) Your per-bar logic. Read series, compute, draw.
delta-pressure.js
//@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.

structure.js
//@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 updates
The global chart context

A 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 undefined

Shared 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 available

Feeds

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.

FeedReturnsDataFootprintHistorical
candles()open, high, low, close, volume, buyVolume, sellVolume, tradeCount, unixlive
volumeDelta(opts?)per-bar delta Series (size tiers on the footprint chart)live
cumulativeDelta(opts?)running delta sum Serieslive
footprintLevels()per-price bid / ask / trades, plus poc(offset)liveno
openInterest()open, high, low, closelive
liquidations()longVol, shortVol, longQty, shortQtylive
orderbook()bestBid, bestAsk, mid, spread + bidDepth / askDepth / imbalance by bandNaN
funding()rate, nextFundingTime (step series)NaN
bigTrades(opts?)list(offset) of prints: unix, price, qty, notional, sideNaN
remote(url, opts)a bar-aligned Series from an external APIlive

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.

  • openInterest is in coin units (BTC, not USD — USD would move with price alone). It defaults to binancef and also accepts bybitf / 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.
  • liquidations is one primary-symbol stream, so { venue } is accepted and ignored.
  • footprintLevels carries per-price rows for the most recent ~3,000 bars; older bars return an empty list and poc() = 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
Honesty rules
  • Data availability today: candles, volumeDelta, cumulativeDelta, footprintLevels, openInterest and liquidations are live. Orderbook depth, funding and big trades return NaN / empty until their sourcing lands.
  • Orderbook depth returns NaN until 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: tradeCount and every tiered read (volumeDelta({ tiers }) / cumulativeDelta({ tiers })) are 0 there 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 real 0; a bar older than the oldest retained event is NaN.
  • Accumulators are primary-symbol-centric. A symbol without data yields a NaN series. 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. A NaN entering 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)  // percentage

Colors

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-down

Entities

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.

LimitValueOn breach
Inputs64hard error
Feed subscriptions12hard error
Plot series ids64hard error
Entities per type2,000evict oldest + one-time warning
Warmup budget10 sterminate, error state
Realtime batch budget2 sterminate, error state
Unresponsive worker15 sterminate, error state
Output payload16 MBerror state
remote() calls4 (5 MB, 10 s each)hard error
Script source size256 KBreject 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.

orderflow-signals.js
//@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.

Machine-readable spec

The full API and both flagship examples, as a single text file an assistant can read in one shot.