WASM Plugins

Scripts are fine until they aren't. If your indicator walks every trade in a million-row session, JavaScript is going to be the reason the chart stutters. So you can hand the maths to a compiled module instead:

const p = plugin({
    name: "SMA (wasm)",
    type: PluginType.indicator,
    layout: Layout.overlay,
    params: {
        period: { label: 'Period', type: 'number', default: 20, min: 1, max: 500 },
    },
})

p.wasm = "/plugins/sma.wasm"

p.draw = (values) => [drawLine(values, "#3b82f6", 2)]
js

That's the whole integration. p.wasm replaces init and update and nothing else - draw still runs in JS on the plugin's worker, drawDirect and drawUI still run on the main thread. The module never has to know a chart exists.

Note

The module is fetched and compiled once per URL. Two plugins pointing at the same file share the compile but each gets its own instance, with its own linear memory - so your module's globals are its state, and you never have to thread a state pointer back through the ABI. Saving the script refetches it, so rebuild the .wasm, hit save, and you're looking at the new one.

The ABI

Four exports are required, three more are optional, and the host provides two imports you can ignore.

PropTypeDefaultDescription
memoryrequiredmemory-Your linear memory. The host writes input into it and reads results back out of it.
allocrequired(bytes: i32) => i32-Give back an 8-aligned pointer, or 0 if you can't. The host writes doubles there, so the alignment matters.
deallocrequired(ptr: i32, bytes: i32) => void-Called in the reverse order the host allocated, so a bump allocator gets its space back.
initrequired(bars, barRows, trades, tradeRows, barMs: f64) => i32-Full recompute. Runs on load, on a backward seek, on a timeframe change, and whenever a setting changes. Return the whole series.
update(bars, barRows, trades, tradeRows, barMs: f64) => i32-One playback tick, with only the rows that just arrived. Return only the values you just produced - the host appends them. Leave it out and init runs over the full window every tick instead: correct, just slower.
set_params(ptr: i32, bytes: i32) => void-Your declared params, as UTF-8 JSON, right before init.

The two imports, both under env, are log(ptr, len) which prints UTF-8 to the browser console, and abort() which throws on the host side.

Input

bars and trades are pointers to rows of f64. Whichever one your plugin's require means there's none of arrives as a null pointer with a row count of 0.

| Buffer | Stride | Layout | | --- | --- | --- | | bars | 6 doubles | ts(ms), open, high, low, close, volume | | trades | 4 doubles | ts(ms), price, size, side — side is 0 for a buy, 1 for a sell |

Timestamps cross as milliseconds, not the nanoseconds you see everywhere else in the chart. A double holds ms exactly for the next 285,000 years, where nanoseconds start rounding the moment they pass 2^53.

barMs is the timeframe's bar duration in milliseconds, and 0 in tick mode.

Output

Return a pointer to a block laid out like this:

i32   count
i32   padding, so the doubles land 8-aligned
f64 * count
text

Return 0 for "nothing". The host copies the doubles out immediately, so the buffer is yours to reuse on the very next call.

Tip

A NaN means "no value here" and the chart leaves a gap for it. That's how you draw a warmup period.

Building one

Anything that compiles to plain wasm32 works, as long as it doesn't need WASI or a JavaScript glue file. With clang:

clang --target=wasm32 -nostdlib -O2 -Wl,--no-entry -o sma.wasm sma.c
bash

-nostdlib means no malloc, so the module brings its own allocator. A bump allocator that resets on init is a good fit here - a chart recomputes from scratch often enough that freeing individual blocks isn't worth the code.

Here's the whole thing, minus the rolling-average bookkeeping:

#define EXPORT(name) __attribute__((export_name(name)))
#define BAR_STRIDE 6
#define BAR_CLOSE 4

static __attribute__((aligned(8))) char heap[8 << 20];
static int heap_used = 0;

EXPORT("alloc") int wasm_alloc(int bytes) {
    int offset = (heap_used + 7) & ~7;
    if (offset + bytes > (int)sizeof(heap)) return 0;
    heap_used = offset + bytes;
    return (int)(long)(heap + offset);
}

EXPORT("dealloc") void wasm_dealloc(int ptr, int bytes) { (void)ptr; (void)bytes; }

static __attribute__((aligned(8))) char result[8 + MAX_BARS * 8];

static int emit(const double *values, int count) {
    *(int *)result = count;
    double *out = (double *)(result + 8);
    for (int i = 0; i < count; i++) out[i] = values[i];
    return (int)(long)result;
}

EXPORT("init")
int wasm_init(int bars_ptr, int bar_rows, int trades_ptr, int trade_rows, double bar_ms) {
    heap_used = 0;
    const double *bars = (const double *)(long)bars_ptr;
    for (int i = 0; i < bar_rows; i++) push(bars[i * BAR_STRIDE + BAR_CLOSE]);
    return emit(sma, len);
}
c

The complete file - allocator, param parsing, rolling state, incremental update - is examples/wasm/sma.c in the package, at about 120 lines.

Params

If your plugin declares params and the module exports set_params, the current values arrive as UTF-8 JSON just before init. Compound param types use the same suffixed keys they do everywhere else, so a colorWithOpacity named fill shows up as fill_color and fill_opacity.

Parsing real JSON in C isn't worth it for one number. Scan for the key:

EXPORT("set_params") void wasm_set_params(int ptr, int len) {
    // find "period": and read the digits after it
}
c

Shaping the output

draw is handed the block of doubles and nothing else - the bars aren't in scope - so a module emitting one value per bar leaves nothing to line those values up against. Either emit the timestamp alongside every value, which is what examples/wasm/sma.c does, or define init as well and the module's output arrives there as wasm, next to the bars:

p.wasm = "/plugins/bands.wasm"

// three values per bar: upper, mid, lower
p.init = ({ data, wasm }) => {
    const upper = [], mid = [], lower = []
    for (let i = 0; i < data.ohlcv.length; i++) {
        upper.push({ t: data.ohlcv[i].ts, price: wasm[i * 3] })
        mid.push({ t: data.ohlcv[i].ts, price: wasm[i * 3 + 1] })
        lower.push({ t: data.ohlcv[i].ts, price: wasm[i * 3 + 2] })
    }
    return { upper, mid, lower }
}

p.draw = (s) => [
    drawLine(s.upper, "#ef4444", 1),
    drawLine(s.mid, "#e0e0e0", 1),
    drawLine(s.lower, "#22c55e", 1),
]
js

init gets the full output, update gets only the slice the module just produced. Whatever they return becomes the state draw sees, exactly as it would without wasm in the picture.

Note

Several values per bar have to be interleaved, as above - never grouped into one block per series. update hands back only the rows it just produced and the host concatenates them onto the ones it already has, so grouped blocks stop lining up on the first tick.

Chart types too

A chart type takes p.wasm on the same terms - same ABI, same input, and the block of doubles it returns is the state its draw gets.

Things worth knowing

  • The module runs inside the plugin's worker. No DOM, no network, no way out except its return value and env.log.
  • The URL is fetched by the browser with the page's own credentials, so it has to be same-origin or served with CORS headers.
  • A trap on the wasm side, or calling env.abort(), surfaces as a plugin error in the editor - same as a thrown exception in a script.
  • Growing memory detaches every view the host holds, which it already accounts for. You don't have to avoid growing.

Was this page helpful?