JavaScript Bindings (QuickJS)¶
lrvx embeds QuickJS to run trading strategies written in JavaScript. Strategies use string symbol names, options objects for orders, and get TypeScript declarations for IDE autocompletion.
Building¶
cmake -B build \
-DLRVX_BUILD_CAPI=ON \
-DLRVX_BUILD_QUICKJS=ON \
-DCMAKE_BUILD_TYPE=Release
cmake --build build
This produces:
build/src/quickjs/lrvx_js_runner-- CLI to load and run JS strategiesbuild/src/quickjs/liblrvx_quickjs.a-- static library for embedding
Writing a Strategy¶
Create a .js file. Extend the Strategy base class, override onTrade, and call lrvx.register:
class MyStrategy extends Strategy {
constructor() {
super({ exchange: "Binance", symbols: ["BTCUSDT"] });
}
onTrade(ctx, trade) {
// ctx.symbol === "BTCUSDT"
// trade.price, trade.qty, trade.side ("buy"/"sell")
if (trade.price > 50000) {
this.marketBuy({ qty: 0.1 });
}
}
onStart() { console.log("Started on", this.primarySymbol); }
onStop() { console.log("Stopped"); }
}
lrvx.register(new MyStrategy());
Run it:
Symbol Configuration¶
Specify exchange and symbols in the constructor:
// Single exchange (most common):
super({ exchange: "Binance", symbols: ["BTCUSDT", "ETHUSDT"] })
// Multi-exchange (qualified format):
super({ symbols: ["Binance:BTCUSDT", "Bybit:ETHUSDT"] })
Methods default to the primary symbol (first in the list) when symbol is omitted.
Order Methods¶
Order methods accept an options object. Symbol defaults to primary when omitted.
// Market
this.marketBuy({ qty: 1.0 })
this.marketSell({ symbol: "ETHUSDT", qty: 2.0 })
// Limit (tif defaults to "GTC")
this.limitBuy({ price: 50000, qty: 0.1 })
this.limitSell({ price: 51000, qty: 0.1, tif: "IOC" })
// Stop
this.stopMarket({ side: "sell", trigger: 48000, qty: 0.1 })
this.stopLimit({ side: "buy", trigger: 52000, price: 52100, qty: 0.1 })
// Take profit
this.takeProfitMarket({ side: "sell", trigger: 55000, qty: 0.1 })
this.takeProfitLimit({ side: "sell", trigger: 55000, price: 54900, qty: 0.1 })
// Trailing stop
this.trailingStop({ side: "sell", offset: 100, qty: 0.1 })
this.trailingStopPercent({ side: "sell", callbackBps: 50, qty: 0.1 })
// Order management
this.cancel(orderId)
this.cancelAll()
this.modify(orderId, { price: 50100, qty: 0.2 })
this.closePosition()
Context Queries¶
this.position() // current position (primary symbol)
this.position("ETHUSDT") // specific symbol
this.bestBid() // number, or null when there is no bid
this.bestAsk() // number, or null when there is no ask
this.midPrice() // number, or null when either side is empty
this.lastPrice()
this.orderStatus(orderId)
this.hasPosition // boolean
this.primarySymbol // "BTCUSDT"
this.symbols // ["BTCUSDT", "ETHUSDT"]
Callback Data¶
onTrade(ctx, trade) receives:
ctx.symbol // "BTCUSDT" (string)
ctx.symbolId // 1 (numeric, for advanced use)
ctx.position // current position size
ctx.avgEntryPrice // average entry price, NaN when the position manager reports none
ctx.book.bidPrice // best bid
ctx.book.askPrice // best ask
trade.symbol // "BTCUSDT"
trade.price // 50123.45
trade.qty // 1.5
trade.side // "buy" or "sell"
trade.isBuy // true/false
trade.timestampNs // nanosecond timestamp, a BigInt
Nanosecond timestamps are BigInt, not Number. A clock reading in
nanoseconds sits far past the 2^53 where a double still holds every
integer, and rounding it there gave two events 100 ns apart the same
value. Durations and order ids are still numbers. See
the JavaScript value boundary
for the full list and what a strategy written against the old types has to
change.
Indicators¶
Each indicator supports .update() for per-tick and static compute() for batch:
| Single-value input | OHLC input | Volume input |
|---|---|---|
| SMA, EMA, RMA, DEMA, TEMA, KAMA, RSI, Slope | ATR, ADX, Stochastic, CCI, CHOP | OBV, VWAP, CVD |
| MACD, Bollinger |
// Per-tick
var atr = new ATR(14);
atr.update(high, low, close);
var macd = new MACD(12, 26, 9);
macd.update(price);
console.log(macd.line, macd.signal, macd.histogram);
// Batch
var adxResult = ADX.compute(highArr, lowArr, closeArr, 14);
// adxResult.adx, adxResult.plusDi, adxResult.minusDi
Order Book¶
var book = new OrderBook(0.01); // tick size
book.applySnapshot([50000, 49999], [1.5, 2.0], [50001, 50002], [0.5, 1.0]);
book.bestBid(); // 50000
book.bestAsk(); // 50001
book.mid(); // 50000.5
book.spread(); // 1.0
book.getBids(5); // [[50000, 1.5], [49999, 2.0]]
book.getAsks(5); // [[50001, 0.5], [50002, 1.0]]
// L3 (order-level)
var l3 = new L3Book();
l3.addOrder(1, 50000, 1.5, "buy");
l3.addOrder(2, 50001, 0.5, "sell");
l3.bestBid(); // 50000
l3.removeOrder(1);
Backtesting¶
The QuickJS binding exposes the bare SimulatedExecutor — flat fee, no funding, no liquidation, no rate limits. Good for prototyping and indicator checks.
var executor = new SimulatedExecutor();
executor.submitOrder(1, "buy", 50000, 1.0, 0, 1); // id, side, price, qty, type, symbol
executor.onBar(1, 50100); // symbol, close price
executor.advanceClock(ts);
executor.fillCount; // number of fills
For the realistic venue stack (cross-margin Account, MM tier ladder + ADL, VIP fee schedule, funding, rate limits) drive the same strategy file from Node.js via lrvx.VenueStack.binanceUmFutures(...) — the QuickJS strategy class is unchanged. Paper trading and live (ccxt.pro) wrap the same executor; see Paper trading and Connect lrvx to a CCXT exchange.
One strategy file runs backtest, paper, and live.
Position Tracking¶
var tracker = new PositionTracker();
tracker.onFill(1, "buy", 50000, 1.0);
tracker.onFill(1, "sell", 50100, 1.0);
tracker.position(1); // 0
tracker.realizedPnl(1); // 100
tracker.totalRealizedPnl(); // 100
// Group tracking
var groups = new PositionGroupTracker();
var pid = groups.openPosition(1, 1, "buy", 50000, 1.0);
groups.closePosition(pid, 50500);
groups.totalRealizedPnl(); // 500
Profiling¶
// Volume Profile
var vp = new VolumeProfile(0.01);
vp.addTrade(50000, 1.0, true);
vp.poc(); // point of control
vp.valueAreaHigh();
vp.valueAreaLow();
// Market Profile
var mp = new MarketProfile(0.01, 30, 0);
mp.addTrade(Date.now() * 1e6, 50000, 1.0, true);
mp.poc();
mp.initialBalanceHigh();
mp.isPoorHigh();
// Footprint
var fp = new FootprintBar(0.01);
fp.addTrade(50000, 1.0, true);
fp.totalDelta();
fp.totalVolume();
Statistics¶
lrvx.correlation([1,2,3], [1,2,3]); // 1.0
lrvx.profitFactor([100, -50, 200, -30]); // gross_profit / gross_loss
lrvx.winRate([100, -50, 200, -30]); // 0.5
lrvx.bootstrapCI([1,2,3,4,5], 0.95, 10000);
// { lower: ..., median: ..., upper: ... }
lrvx.permutationTest([1,2,3], [4,5,6], 10000); // p-value
Segment Operations¶
lrvx.validateSegment("/path/to/segment.flx");
lrvx.mergeSegments("/path/to/input_dir", "/path/to/output_dir");
IDE Support¶
Copy quickjs/types/lrvx.d.ts and quickjs/jsconfig.json into your project directory for VS Code autocompletion.
Error Handling¶
JS exceptions in callbacks are caught, logged to stderr, and the strategy continues. Invalid symbol names or side values throw immediately with descriptive errors.
Memory Limit¶
The JS runtime defaults to 32MB per strategy. When embedding via C++, pass a custom limit to the LrvxJsEngine constructor:
Threads¶
A LrvxJsStrategy is single-threaded by construction. It owns one QuickJS
runtime, and that runtime may only be entered from the thread that created
it. lrvx_js_runner and every test in this repo already work that way (one
thread, one call at a time), so most embeddings never have to think about
this at all.
A live engine is different. lrvx_live_engine_add_strategy subscribes a
strategy to three independently-threaded event buses (trades, book
updates, bars), each with its own consumer thread, and calls straight into
the strategy's callbacks from whichever thread that is. Hand a bare
LrvxJsStrategy's callbacks to a live engine and up to three threads can
enter the same runtime at once. QuickJS was not built for that, and it
does not fail safely when it happens.
For any embedding that dispatches events from more than one thread, build
a LrvxJsExecutor instead of a LrvxJsStrategy:
#include "js_executor.h"
lrvx::SymbolRegistry registry;
lrvx::LrvxJsExecutor executor("strategy.js", registry);
auto strategy = lrvx_strategy_create(
1, executor.symbolIds().data(), executor.symbolIds().size(),
registryHandle, executor.getCallbacks());
executor.injectHandle(strategy);
lrvx_live_engine_add_strategy(engine, strategy, onSignal, userData);
LrvxJsExecutor builds the runtime on one dedicated thread and keeps it
there for the object's whole lifetime. The callbacks it hands back only
ever enqueue an event onto a bounded queue: the JS side of every callback
(market data, fills, order updates, start, stop, a hot-reload's callback
swap) always runs on that one thread. A full queue blocks the calling
thread rather than dropping the event. Default capacity is 65,536,
configurable through the constructor.
If an embedder reaches into the runtime from a second thread anyway (say,
by calling into a LrvxJsStrategy it obtained some other way), it gets a
loud failure in a debug build, or a refused and logged call in a release
build. Not memory corruption.
Limitations¶
- QuickJS is an interpreter — suitable for prototyping and backtesting, latency-sensitive production should use Codon or C++
- Scripts are evaluated with
JS_EVAL_TYPE_GLOBAL; ES module syntax (import/export) is not supported - A strategy subscribed to more than one live event bus for the same symbol (the normal live-engine setup) can still race inside the core strategy/book-keeping state that feeds the callback, separately from the JS threading model above. That is being tracked on its own.
- A strategy subscribed to more than one live event bus for the same symbol (the normal live-engine configuration) can race inside the core strategy/book-keeping state that feeds the callback, independent of the JS layer described above. This is being tracked separately from the JS threading model.