The JavaScript value boundary¶
Values move between C++ and the embedded QuickJS runtime on every callback. Two of those crossings used to lose information without saying so. This page covers what each does now, and what a strategy written against the old behaviour has to change.
Nanosecond timestamps cross as BigInt¶
This is a breaking change. Fields carrying an absolute nanosecond
clock reading used to arrive in JavaScript as a Number. They arrive as a
BigInt.
A JavaScript Number is a double. It holds every integer exactly up to
2^53, roughly 9.0e15. A nanosecond timestamp today is around 1.76e18,
about 200 times past that point, and at that magnitude the gap between one
representable double and the next is 256 ns. Two events 100 ns apart were
handed to the strategy carrying the same number.
A script that only reads a timestamp never notices. A script that orders by one does. Merging two feeds, keying a map by timestamp, deduplicating a replayed tape all compare timestamps, and two events the engine dispatched in a definite order arrive indistinguishable. A merge that resolves ties toward one feed then emits the pair backwards.
Measured on a real dispatch, before and after:
| before | after | |
|---|---|---|
String(trade.timestampNs), input 1757000000123456789 |
1757000000123456800 |
1757000000123456789 |
String(trade.timestampNs), input 1757000000123456889 |
1757000000123456800 |
1757000000123456889 |
| merge order of the two | second, first | first, second |
What changed¶
Read side, now BigInt:
ctx.lastUpdateNstrade.timestampNs,book.timestampNsbar.startTimeNs,bar.endTimeNs- on an order event:
exchangeTsNs,submittedAtNs,acceptedAtNs,firstFillAtNs,lastFillAtNs,canceledAtNs,rejectedAtNs,triggeredAtNs,expiredAtNs - on a tape record:
exchangeTsNs,recvTsNs,tsNs - backtest stats
startTimeNs/endTimeNs, equity-pointtimestampNs - trace reader run bounds, partition
fromNs/toNs/warmupFromNs, merged-tapefirstEventNs/lastEventNs bar.tson every bar object: the aggregators (lrvx.timeBarsand the rest) andlrvx.loadCsv/Engine.loadCsv
bar.ts from loadCsv changed unit as well as type. It used to be a
millisecond Number, while every other bar source reported nanoseconds,
so a script that read one bar from a CSV and one from an aggregator was
out by 1e6 and threw a TypeError the moment it subtracted one from the
other. A CSV column in seconds, milliseconds or microseconds is still
detected and scaled, but what reaches the script is always nanoseconds.
SignalBuilder and Engine.run follow: they timestamp, order and match
signals against bars in nanoseconds, and take a BigInt or a Number.
Write side: every binding that takes a nanosecond argument accepts a
BigInt or a Number, so advanceClock, writeTrade, writeBook,
addTrade and the rest keep working with the literals already in your
scripts.
What did not change¶
Durations stay Number. A latency, a bar interval or a trade duration is
a difference, not a clock reading, and a duration under 104 days is under
2^53 nanoseconds and so exact in a double. medianAckLatencyNs(),
avgTradeDurationNs, feedNs / orderNs / fillNs and
bar.barTypeParam are all still numbers.
Order ids stay Number too. The id comes from a counter that starts at 1
and steps once per order, so reaching 2^53 takes more orders than a
session places. A nanosecond clock reading is past that range on the first
event.
Migrating a strategy¶
BigInt and Number do not mix in arithmetic, though they compare fine.
// Comparisons need no change: <, >, <=, >=, == all work across the two.
if (trade.timestampNs > lastSeenNs) { /* ... */ }
// Arithmetic does. Mixing the two throws a TypeError.
var ageNs = trade.timestampNs - bar.startTimeNs; // BigInt - BigInt, fine
var ageMs = Number(ageNs) / 1e6; // convert once, at the edge
// Anything that wants a Number wants an explicit conversion.
var when = new Date(Number(trade.timestampNs / 1000000n));
// Strict equality against a literal needs the n suffix.
if (bar.endTimeNs === 1757000000000000000n) { /* ... */ }
// JSON.stringify refuses a BigInt outright. Convert, or use String().
console.log('ts=' + trade.timestampNs);
console.log on a whole event object still works: the console shim falls
back to a plain string conversion when JSON.stringify refuses the
object.
A pre-trade gate that fails denies the order¶
The three pre-trade gates — RiskManager.allow, KillSwitch.check,
OrderValidator.validate — are C function pointers the engine calls
inline while a signal is in flight. Two failure modes used to be
unhandled: the gate threw, and the thrown value unwound out of a bridge
declared to C and surfaced at whatever JavaScript frame was on the stack;
or the gate returned something that was not a boolean, which one binding
folded into a deny and another into an allow.
The policy, one for every binding: a host-language throw and a non-boolean return both DENY the order, and both are reported. Neither ever lets the order through, and neither escapes through the C boundary. A gate that plainly returns false is a decision, not a failure, and is not reported.
In Node, reported means runner.hookErrors(): an array of
{ hook, method, message } records accumulated on the runner, oldest
first, readable synchronously after the call that failed. The
process-wide log callback is asynchronous and shared, so it cannot say
which runner denied what.
runner.setKillSwitch({ check() { throw new Error('feed is down'); } });
runner.onTrade(sym, 100, 1, true, 1000n); // the order is denied
runner.hookErrors();
// [ { hook: 'killSwitch', method: 'check', message: 'feed is down' } ]
A throw out of Executor.capabilities() crosses the same boundary and is
handled the same way: the executor is read as supporting nothing, and the
throw is recorded.
String arguments reject values they cannot convert¶
A binding taking a string used to hand whatever QuickJS returned straight
to the C API. QuickJS returns a null pointer for any value it cannot turn
into a string, and several qualify: a Symbol, an object whose toString
throws, a Proxy with a throwing trap, a runtime that hit its memory
limit mid-conversion. The C API then called strlen on that null pointer
and the process died.
// Each of these used to be a segmentation fault. Each now throws.
new lrvx.DataWriter(Symbol('out'));
new lrvx.DataReader({ toString: function () { throw new Error('no'); } });
graph.addNode(Symbol('ema'), [], function () { return []; });
The exception you get is the one QuickJS raised while trying to convert,
so a throwing toString surfaces its own error rather than a generic
message.
Optional string arguments keep their defaults when absent, and are checked
the same way when present: passing a Symbol where a string is optional
is an error, not a silent fall back to the default.
Where an empty string is a sensible reading of "no value", the binding
still substitutes one rather than throwing. Logging is the clearest case:
a value console.log cannot render prints as [unprintable] instead of
taking the process down or vanishing from the line.
There is one entry point for all of this, JsCString in
src/quickjs/js_cstring.h, which owns the converted string and frees it on
every exit path. scripts/check_quickjs_tocstring.py fails the build if a
binding calls the raw QuickJS conversion directly, or declares a holder
whose pointer reaches a consumer untested.