From kScript (legacy)

kScript (legacy) is frozen at v3: published kScript indicators keep charting and hosted-alerting forever on the pinned engine, and nothing existing breaks.…

kScript (legacy) is frozen at v3: published kScript indicators keep charting and hosted-alerting forever on the pinned engine, and nothing existing breaks. Porting one to an Indicator is how it gains what the kScript lane cannot give it: a file that compiles in the browser and runs on your machine, first-class metric ids for alerts, screens, series, and backtests, and a sandboxed package people install instead of reference. This page is the construct table: for each thing a kScript does, the Indicator form, and the cases that stay on kScript for now.

Which kScript version you start from does not matter

The kScript tree's own migration guide is v2 to v3, and that split has no counterpart here. A //@version=2 script and a //@version=3 script port through the same table below, because the port replaces every construct rather than translating a marker: define() becomes declarations, timeseries becomes an input, builtins become classes, plots become outputs. An Indicator has no language versions to migrate between; its sheet names one of four ABI contracts (abi_version: "wrun-1", frozen; "wrun-2", additive, for text and declared drawings; "wrun-3", additive again, for drawing handles and the last-bar signal; "wrun-4", additive, for frames: levels, panels and the ladder, feed and meter widgets), and a module written under any of them keeps computing bit-identically, Script definition.

The construct table

The table below covers the numeric core of a port. The full construct map, including frames, panels, levels, handles and anchors, series inputs and the constructs with no home yet, lives on the construct map.

kScript constructIndicator formNotes
//@version=3 on line 1//@lang=wrun-ts on line 1The editor's language marker; the CLI does not need it.
define(title=, position="onchart"/"offchart", axis=)output(name, plot, overlay) or output(name, plot, lower) per outputPlacement is per output; the tab title names the package.
input(type="number"/"slider", defaultValue, constraints={min, max, step})param("name", default, { min, max, description }), read in init() via p_name()No step; label is description. Every param is a field in the overlay's settings.
input(type="boolean")param("flag", 1, { min: 0, max: 1 }) turned into a none output and used as a when or shape_where gateThe cookbook's key levels do this with show_open.
input(type="color")none in the file: color is declared per output, box, or segmentA runtime color picker exists only as a style knob in a hand-written sheet, Styling.
input(type="string")noneParams are numbers. A venue symbol is a pinned declaration instead (input(..., { symbol, exchange })).
timeseries d = ohlcv(symbol=currentSymbol, exchange=currentExchange), d.close[0]input("close", ohlcv.close), read in state() via in_close()One input per field you read; an unpinned input follows the chart.
d.close[1], series[n]keep the previous value in a module-level variable; a window is a StaticArray ring bufferThere is no history array, Execution model.
sma(), ema(), stddev(), rsi(), roc(), crossover() / crossunder(), every other TA builtinSma, Ema, Stdev, Rsi, Roc, Cross, and one class per builtin from ./sdk/ta, constructed in init(), .update() once per barNaN until warm; Cross.update(a, b) returns +1, -1, or 0.
highest(), lowest(), vwap(anchor=), atr(), other builtinswrite them: a ring-buffer scan, a sum that resets on a session boundaryThe cookbook has highest / lowest (zone tracker) and an anchored VWAP.
htf(d, "4h")bucket bars by time.bar_open_sec and fold a bucket in on the next bucket's first bar; on your machine, a second input with { interval: "4h" }The chart reads its own interval for every input.
source(type="buy_sell_volume", ...), .buy / .sellinput("buy", trades.volume, { side: "BUY" }) and { side: "SELL" }missing: "zero" for a venue that skips bars.
source(..., symbol=, exchange=) on another venueinput(..., { symbol, exchange }), both togetherHonored on your machine; the chart reads its own market.
orderbook(), volume profile functionsinput("book", book.cells, { max_cells, block_size }), input("profile", volume_profile.cells, { max_cells }), read via in_book_cells() / in_book_read(ptr)Celled inputs, Data sources.
currentInterval, barIndex, d.time[0]input("bar_t", time.bar_open_sec); interval math from consecutive bar timesEpoch seconds, UTC.
timestamp(), year(), dayOfWeek()integer math on bar_open_sec (Math.floor(t / 86400) is the UTC day)The cookbook's anchored VWAP and key levels.
persist x = 0a module-level let x: f64 = 0.0reset() must restore it.
type Zone { ... }, funcAssemblyScript class and functionsTyped fields; no any.
na, isnum(x), isna(x)NaN, !isNaN(x), isNaN(x)
plotLine(value, colors=[c], width=, label=, desc=)output("name", line, panel, { color, width, description })
plotLine(..., colors=[a, b], colorIndex=expr)a data-only output holding the index plus color_by and colors on the drawn outputThe regime filter and the CVD recipes.
if (cond) plotShape(value, shape=, colors=)output("mark", shape, panel, { shape_where: "gate", color }) plus a 0/1 gate outputThe shape kind is the host's default mark; render.shape picks one.
hline(value)an output written to the same value every bar
fillBetween(a, b, color, opacity)range("upper", "lower", { colors, opacity }) declares a filled band between two outputs, drawn by the chartA band that should appear on some bars only is a per-bar box with a when gate instead; fills exists in hand-written sheets only.
barcolor(color)render.bgcolor("tint", { where: "gate", color }) tints the background per bar; or color_by on a lineCandle bodies themselves are not tinted.
plotTable(data=[[...]]) under isLastBarrender.table("stats", { rows, cols, cells }) over string slots written every barThe newest complete row wins; no isLastBar needed.
plotPriceLabel(text, price) on every signal bara text renderer over a string slot with "style": "price_label" in the sheetA tag per bar whose slot was written; leave the slot unwritten on quiet bars, Plotting.
plotLabel(text, position="top_right")a label renderer over a string slot with "position" in the sheetA corner readout; the newest bar that wrote the slot wins.
opacity(color, 30)the opacity option, or an 8-digit hex color
alert(message, condition), alertcondition()nothing in the file; after install or publish, a condition watch on the metric (om watch create --condition) with a level (gt, lt) or an edge (crosses_above, crosses_below), AlertsAn alert armed from the chart on a published Indicator runs hosted where the site has it switched on, and reads candles only today.
isLastBarbar.isLast() from ./gen/draw, true on the newest bar the host holdsRun-level renderers and declared drawings evaluate the newest ready bar without it; use it for a handle that should exist on the newest bar only, Execution model.
box.new(...) drawn once and left alonebox("zone", { top, bottom, from, to, when }), evaluated per barDisappearance is the when gate turning 0; the bars it was alive on stay drawn.
box.new(...) then .set_lefttop() / .set_rightbottom() / .set_bgcolor() / .delete(), a growing list of boxeshandles.box({ ... }) once, then draw.box(id).set(left, top, right, bottom), .setLeftTop(), .setRightBottom(), .fill(rgba(...)), .delete()The same object from creation to deletion, under an integer id you choose; 500 live per kind, Drawing objects.
line.new(x1, y1, x2, y2)segment(...) per bar; draw.line for one object placed from the newest barfrom: 0, to: 1 on one output is a horizontal level (key levels).
line.new(...) then .set_xy2() / .set_color() / .set_extend() on later barshandles.line({ ... }) once, then draw.line(id).set(...), .setXy2(), .color(rgba(...)), .extend(Extend.Right)Every setter stamps the bar it ran on, as the engine does.
label.new(x, y, text) once, a price tagrender.label over a string slot: one label, the newest bar winsPlotting.
label.new(...) moved, re-worded, or deleted laterhandles.label({ text: "slot" }) once, then draw.label(id).set(x, y).text(str_<slot>_sb), .size(), .delete()The text is the slot's bytes on the bar of the call; stickyRight has no form.
polylines built point by point across barshandles.polyline({ ... }) once, then draw.polyline(id).setPoints(points, count) over a StaticArray<f64> of x, y pairsRe-send as the path grows; at most 256 points per handle.
strategy(title, initialCapital, ...)strategy({ initialCapital, qtyType, qtyValue, commissionPercent, slippageBps, ... }) from ./gen/strategy, one top-level statement beside the outputsThe engine's own setting names, every field optional; a numeric setting may take the handle param(...) returns. The file stays an Indicator: its outputs are metrics, and the broker adds strategy.position and strategy.equity, Strategies overview.
strategy.entry(id, "long", qty, limit, stop, ocaName)strategy.long(id).qty(n).limit(px).stop(px).oca(name).send() in finalize(); strategy.short(id) for the other sideOne builder per call, send() places it; the same broker fills it at the next open, Writing strategies.
strategy.exit(id, fromEntry, stop, limit, trailPoints, trailOffset, ...)strategy.exit(id).from(entry).stop(px).limit(px).trail(points, offset).send()The legs are one-cancels-all under the exit id, as before; comment has no form.
strategy.close(id), strategy.closeAll(), strategy.cancel(id), strategy.cancelAll(), the gettersthe same names on strategyGetters answer in state() and finalize() and read the position after the bar's fills, the engine's rule.
the Backtest action and the Strategy Testerom backtest @scope/name --asset EXCHANGE:SYMBOL --window 90d, or the package on a chartThe same panel from the same run key; --fee-bps and --slippage-bps are refused because costs live in the file, Reading the Strategy Tester. Finer-bar fills, the book-estimate slippage model and recorded funding are named next steps, each refused or counted by name.
print() / the consolenoneEmit a debug output and read it in the legend or with om metric series.
maxBarsBacknothing to declareState lives in your variables; size buffers from a param's max.

Historical raw tape remains a porting gap. The daemon's tape buffer serves live prints above a required size threshold, with no historical backfill (per-price footprint and book data port fine, Data sources). Drawings port whole: a shape that is set once per bar is a declaration, and a shape the script keeps, moves, or deletes is a handle with the same caps kScript had. Everything scalar ports cleanly.

Warm-up moves by a bar or two

The kScript v3 engine corrected eight builtins to their textbook warm-up, and the same question comes up in a port: on which bar does the Indicator's class first return a number? Every class in ./sdk/ta returns NaN until its window is full and never averages a partial window, so with a period of 5 the first value lands on these 0-based bars:

ClassFirst value at barWhy
Sma(5)4five values in the window
Ema(5)4seeded with the average of the first five values, then smoothed with 2 / (period + 1)
Stdev(5), Zscore(5)4population standard deviation over the window; Zscore returns 0 when the deviation is 0
Rsi(5)5one bar to take a first difference, five differences to seed the Wilder averages
Roc(5)5needs the value five bars back
Crossthe second ready bar0 until both inputs have a previous value

Those match the kScript v3 table for sma, ema and rsi, so a port that waits for its indicator to be ready (the usual case) sees the same first signal. A kScript script that keyed off an indicator's very first bars, or one still running on the v2 engine's earlier partial-window values, shifts by a bar or two at the left edge of history; nothing changes past the warm-up window.

The port loop

  1. Open the kScript in the editor (kScript (legacy), then My Scripts or Community), or fetch a published one: om install @scope/their-indicator is the plumbing (packages are inert text; nothing executes at install), then read the installed kscript/script.ks.
  2. Start the target: New indicator in the editor, or on your machine om wrun create @you/their-indicator-port --template sma-codefirst.
  3. Translate with the table: settings to params, series reads to inputs, builtins to ./sdk/ta classes, plots to outputs, per-bar drawings to boxes and segments, owned drawings to handles. Keep the names: the accessors keep the code readable.
  4. Run both on the same market and interval and eyeball the two curves before trusting the port. On your machine, om wrun install . --replace then om metric series on the same market the chart shows.
  5. Publish under your scope (Publishing) once it matches.

The loop's machine commands, end to end:

om install @scope/their-indicator
om wrun create @you/their-indicator-port ./their-indicator-port --template sma-codefirst
om wrun install ./their-indicator-port --replace
om open @scope/their-indicator

A worked port: RSI

The kScript shape being ported (a settings input, one stateful builtin, one lower-pane plot):

study("RSI", overlay=false)
len = input(14, "Length")
plot(rsi(close, len))

The Indicator, shown here in its hand-written-sheet form (the shape a machine workspace scaffolded from the sma template uses; the declaration form is the same file with param, input, and output lines at the top and no sheet):

{
  "id": "ported-rsi",
  "name": "Ported RSI",
  "abi_version": "wrun-1",
  "warmup_bars": 14,
  "params": [{ "name": "len", "default": 14, "min": 2, "max": 200, "description": "Length" }],
  "inputSources": { "close": { "source": "ohlcv", "field": "close" } },
  "inputs": [{ "index": 0, "name": "close" }],
  "outputs": [{ "index": 0, "name": "rsi", "plot": "line", "panel": "lower" }]
}
import { in_close } from "./gen/inputs";
import { emitRow, out_rsi } from "./gen/outputs";
import { p_len } from "./gen/params";
import { Rsi } from "./sdk/ta";

let rsi = new Rsi(14);
let value: f64 = NaN;

export function init(): void {
  rsi = new Rsi(i32(p_len()));
}

export function state(): i32 {
  value = rsi.update(in_close());
  return isNaN(value) ? 0 : 1;
}

export function finalize(): void {
  out_rsi(value);
  emitRow();
}

export function reset(): void {
  rsi.reset();
  value = NaN;
}

Then prove it moves like the original:

om metric series --metric wrun/@you/ported-rsi/rsi --params len=14 --symbol BTCUSDT --exchange BINANCE_FUTURES --interval 1h --bars 60

Warm-up semantics differ between engines (Rsi is Wilder-smoothed and returns NaN until its window fills), so compare from the first ready bar onward, not from bar zero.

Migration checklist

  1. Port the file with the table; keep every name so the accessors read like the original.
  2. Run both on one market and interval and compare from the first ready bar; the table above says where that bar is.
  3. Replace alert() with a condition watch on the metric id after install; port strategy() and the strategy.* calls to the declaration and the builders (Strategies overview), and compare the trade lists on one market and interval: the broker is the same, so they pair.
  4. Port owned drawings as handles (create, move, restyle, delete under an id) and keep the pool bounded; historical tape reads remain on kScript, while live prints have the daemon's tape buffer.
  5. Publish once the curves match, then add the Indicator from the Indicators tab and let the kScript row keep its Use kScript engine fallback.

What stays on the kScript engine

Launching the ORIGINAL kScript on a chart stays a reference flow (om open @scope/their-indicator), and alerts on it run on the platform's hosted engine, not on your machine. Port when the value itself must be local or alertable from your daemon; keep pointing at the original when it only needs to be drawn, and keep it on kScript when it lives on the constructs the table marks as staying there (the construct map lists them under no home yet).