Record a strategy run as .lrvxrun¶
A .lrvx tape holds what the exchange sent. A .lrvxrun directory holds what the strategy did with it: the signals, order events, and fills it produced. The two formats live next to each other so a viewer or a diff tool can line them up on one timeline.
This page covers the write-then-read loop. Format details live in the lrvxrun spec.
When to write a .lrvxrun¶
- During a backtest, so the viewer can scrub through signals and orders alongside the input tape.
- During paper trading, so a post-run diff confirms the strategy did what its expected output says it should.
- During live trading, when you want a structured trace separate from the engine log.
- Inside a
lrvx bundle packartifact as the canonical "expected output". The bundle CLI integration is a follow-up task; today the recorder takes any path.
Quick start¶
from lrvx._lrvx import (
FillLiquidity, OrderEventKind, TapeRef, TraceReader, TraceRecorder,
)
rec = TraceRecorder(
path="run.lrvxrun",
strategy_id="ratio-cross",
strategy_hash="sha256:abc",
run_started_ns=1_700_000_000_000_000_000,
tape_refs=[TapeRef("BTCUSDT.lrvx", "sha256:def", 0, 0)],
)
rec.write_signal(
run_ts_ns=1_700_000_000_100_000_000,
signal_id=42,
name="ratio-cross",
symbol_ids=[1, 2],
payload=b'{"src":"ETH","dst":"BTC"}',
)
rec.write_order_event(
run_ts_ns=1_700_000_000_200_000_000,
order_id=7,
parent_signal_id=42,
symbol_id=1,
event_kind=OrderEventKind.SUBMIT,
side=0,
order_type=1,
price_raw=5_000_000_000_000,
qty_raw=100_000_000,
)
rec.write_fill(
run_ts_ns=1_700_000_000_300_000_000,
order_id=7,
fill_id=12345,
price_raw=5_000_000_000_000,
qty_raw=100_000_000,
liquidity=FillLiquidity.MAKER,
)
rec.close()
reader = TraceReader("run.lrvxrun")
print(reader.strategy_id, len(reader.read_all_signals()))
const { TraceRecorder, TraceReader } = require('@lrvx/lrvx');
const rec = new TraceRecorder({
path: 'run.lrvxrun',
strategyId: 'ratio-cross',
runStartedNs: 1_700_000_000_000_000_000,
});
rec.addTapeRef({ path: 'BTCUSDT.lrvx', contentHash: 'sha256:def' });
rec.writeSignal({
runTsNs: 1_700_000_000_100_000_000,
signalId: 42,
name: 'ratio-cross',
symbolIds: [1, 2],
payload: Buffer.from('{"src":"ETH","dst":"BTC"}'),
});
rec.writeOrderEvent({
runTsNs: 1_700_000_000_200_000_000,
orderId: 7,
parentSignalId: 42,
symbolId: 1,
eventKind: 1,
side: 0,
orderType: 1,
priceRaw: 5_000_000_000_000,
qtyRaw: 100_000_000,
});
rec.writeFill({
runTsNs: 1_700_000_000_300_000_000,
orderId: 7,
fillId: 12345,
priceRaw: 5_000_000_000_000,
qtyRaw: 100_000_000,
liquidity: 1,
});
rec.close();
const reader = new TraceReader('run.lrvxrun');
console.log(reader.strategyId(), reader.readAllSignals().length);
from lrvx.run_trace import TraceRecorder, TraceReader, ORDER_KIND_SUBMIT, LIQUIDITY_MAKER
rec = TraceRecorder("run.lrvxrun", strategy_id="ratio-cross",
run_started_ns=1700000000000000000)
rec.add_tape_ref("BTCUSDT.lrvx", "sha256:def")
rec.write_signal(1700000000100000000, name="ratio-cross",
symbol_ids=[1, 2], signal_id=42,
payload='{"src":"ETH","dst":"BTC"}')
rec.write_order_event(1700000000200000000, order_id=7,
parent_signal_id=42, symbol_id=1,
event_kind=ORDER_KIND_SUBMIT,
price_raw=5000000000000, qty_raw=100000000)
rec.write_fill(1700000000300000000, order_id=7, fill_id=12345,
price_raw=5000000000000, qty_raw=100000000,
liquidity=LIQUIDITY_MAKER)
rec.close()
reader = TraceReader("run.lrvxrun")
print(reader.counts().signals)
var rec = new lrvx.TraceRecorder({
path: 'run.lrvxrun',
strategyId: 'ratio-cross',
runStartedNs: 1700000000000000000,
});
rec.addTapeRef({ path: 'BTCUSDT.lrvx' });
rec.writeSignal({
runTsNs: 1700000000100000000,
signalId: 42,
name: 'ratio-cross',
symbolIds: [1, 2],
payload: '{"src":"ETH","dst":"BTC"}',
});
rec.writeOrderEvent({
runTsNs: 1700000000200000000,
orderId: 7,
parentSignalId: 42,
symbolId: 1,
eventKind: 1,
priceRaw: 5000000000000,
qtyRaw: 100000000,
});
rec.writeFill({
runTsNs: 1700000000300000000,
orderId: 7,
fillId: 12345,
priceRaw: 5000000000000,
qtyRaw: 100000000,
liquidity: 1,
});
rec.close();
var reader = new lrvx.TraceReader('run.lrvxrun');
console.log(reader.readAllSignals().length);
Multi-symbol decisions¶
A signal can carry several symbol ids. A pair-trade signal that goes long ETH and short BTC lists both:
rec.write_signal(
run_ts_ns=now_ns,
name="pair-rotate",
symbol_ids=[btc_id, eth_id],
flags=lrvx._lrvx.SIGNAL_FLAG_REBALANCE,
payload=b'{"target_ratio": 0.5}',
)
This is why .lrvxrun does not live inside .lrvx. A tape captures one feed for one symbol; writing a cross-symbol decision into one of those tapes would lie about its scope. The trace lists the symbols a signal touches; each tape stays scoped to its own feed.
Aligning trace events with tape events¶
Every record carries two timestamps:
run_ts_nsis wall-clock when the strategy emitted the event.feed_ts_nsis the tape event timestamp that triggered the decision.
To line up a trace against the tape that produced it, merge on feed_ts_ns. To inspect strategy behavior on its own wall clock (latency between feed event and decision, for instance), order by run_ts_ns. A diff tool comparing two runs against the same tape uses feed_ts_ns as the alignment key.
If a record was not triggered by a specific tape event (a periodic rebalance, a manual order from a control plane), set feed_ts_ns = 0.
Reading back¶
The reader returns parsed records in chronological order. If a run never wrote a given record kind, its accessor returns an empty list; the format does not require every segment file to exist.
reader = TraceReader("run.lrvxrun")
print("strategy:", reader.strategy_id)
print("tapes:", [t["path"] for t in reader.tape_refs])
for s in reader.read_all_signals():
print(s["run_ts_ns"], s["name"], s["symbol_ids"])
for e in reader.read_all_order_events():
print(e["run_ts_ns"], e["order_id"], e["event_kind"])
for f in reader.read_all_fills():
print(f["run_ts_ns"], f["order_id"], f["price_raw"], f["qty_raw"])
What is in a .lrvxrun directory¶
run.lrvxrun/
├── manifest.json strategy id, hash, tape refs, segment index
├── signals-000000.bin SignalRecord frames
├── orders-000000.bin OrderEventRecord frames
└── fills-000000.bin FillRecord frames
manifest.json is JSON; the segment files are little-endian binary with the same FrameHeader and CRC layout as .lrvx. Segment files only exist for kinds the recorder actually wrote; an empty run produces only manifest.json.
Reading a bundle you did not write¶
A .lrvxrun is meant to travel, so the reader treats one as untrusted input.
It raises std::runtime_error (RuntimeError in Python) rather than reading
past the end of a segment or outside the bundle directory:
- A record whose declared name, symbol-list or payload length runs past its own frame is rejected. Those lengths live inside the bytes the frame CRC covers, so a checksum that matches proves nothing about them.
- A segment name in the manifest has to be a relative path inside the bundle.
An absolute path, or one that climbs out with
.., is refused.
A bundle written by the recorder never hits either case.
See also¶
- lrvxrun spec v1.0. Wire layout, manifest schema, frame types.
- lrvx spec v1.0. The companion market-data format.
- Record and replay tapes. The CLI surface for the input side.