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 construct | Indicator form | Notes |
|---|---|---|
//@version=3 on line 1 | //@lang=wrun-ts on line 1 | The 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 output | Placement 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 gate | The cookbook's key levels do this with show_open. |
input(type="color") | none in the file: color is declared per output, box, or segment | A runtime color picker exists only as a style knob in a hand-written sheet, Styling. |
input(type="string") | none | Params 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 buffer | There is no history array, Execution model. |
sma(), ema(), stddev(), rsi(), roc(), crossover() / crossunder(), every other TA builtin | Sma, Ema, Stdev, Rsi, Roc, Cross, and one class per builtin from ./sdk/ta, constructed in init(), .update() once per bar | NaN until warm; Cross.update(a, b) returns +1, -1, or 0. |
highest(), lowest(), vwap(anchor=), atr(), other builtins | write them: a ring-buffer scan, a sum that resets on a session boundary | The 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 / .sell | input("buy", trades.volume, { side: "BUY" }) and { side: "SELL" } | missing: "zero" for a venue that skips bars. |
source(..., symbol=, exchange=) on another venue | input(..., { symbol, exchange }), both together | Honored on your machine; the chart reads its own market. |
orderbook(), volume profile functions | input("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 times | Epoch 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 = 0 | a module-level let x: f64 = 0.0 | reset() must restore it. |
type Zone { ... }, func | AssemblyScript class and functions | Typed 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 output | The 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 output | The 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 chart | A 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 line | Candle bodies themselves are not tinted. |
plotTable(data=[[...]]) under isLastBar | render.table("stats", { rows, cols, cells }) over string slots written every bar | The newest complete row wins; no isLastBar needed. |
plotPriceLabel(text, price) on every signal bar | a text renderer over a string slot with "style": "price_label" in the sheet | A 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 sheet | A 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), Alerts | An alert armed from the chart on a published Indicator runs hosted where the site has it switched on, and reads candles only today. |
isLastBar | bar.isLast() from ./gen/draw, true on the newest bar the host holds | Run-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 alone | box("zone", { top, bottom, from, to, when }), evaluated per bar | Disappearance 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 boxes | handles.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 bar | from: 0, to: 1 on one output is a horizontal level (key levels). |
line.new(...) then .set_xy2() / .set_color() / .set_extend() on later bars | handles.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 tag | render.label over a string slot: one label, the newest bar wins | Plotting. |
label.new(...) moved, re-worded, or deleted later | handles.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 bars | handles.polyline({ ... }) once, then draw.polyline(id).setPoints(points, count) over a StaticArray<f64> of x, y pairs | Re-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 outputs | The 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 side | One 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 getters | the same names on strategy | Getters answer in state() and finalize() and read the position after the bar's fills, the engine's rule. |
| the Backtest action and the Strategy Tester | om backtest @scope/name --asset EXCHANGE:SYMBOL --window 90d, or the package on a chart | The 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 console | none | Emit a debug output and read it in the legend or with om metric series. |
maxBarsBack | nothing to declare | State 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:
| Class | First value at bar | Why |
|---|---|---|
Sma(5) | 4 | five values in the window |
Ema(5) | 4 | seeded with the average of the first five values, then smoothed with 2 / (period + 1) |
Stdev(5), Zscore(5) | 4 | population standard deviation over the window; Zscore returns 0 when the deviation is 0 |
Rsi(5) | 5 | one bar to take a first difference, five differences to seed the Wilder averages |
Roc(5) | 5 | needs the value five bars back |
Cross | the second ready bar | 0 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
- Open the kScript in the editor (kScript (legacy), then My Scripts or Community), or fetch a published one:
om install @scope/their-indicatoris the plumbing (packages are inert text; nothing executes at install), then read the installedkscript/script.ks. - Start the target: New indicator in the editor, or on your machine
om wrun create @you/their-indicator-port --template sma-codefirst. - Translate with the table: settings to params, series reads to inputs, builtins to
./sdk/taclasses, plots to outputs, per-bar drawings to boxes and segments, owned drawings to handles. Keep the names: the accessors keep the code readable. - Run both on the same market and interval and eyeball the two curves before trusting the port. On your machine,
om wrun install . --replacethenom metric serieson the same market the chart shows. - 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-indicatorA 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 60Warm-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
- Port the file with the table; keep every name so the accessors read like the original.
- Run both on one market and interval and compare from the first ready bar; the table above says where that bar is.
- Replace
alert()with a condition watch on the metric id after install; portstrategy()and thestrategy.*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. - 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
tapebuffer. - 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).