Skip to content

Extension Hook Pattern

How to add a binding-supplied callback hook to lrvx (PnLTracker, RiskManager, Executor, etc.) and wire it through every language binding.

If you're consuming a hook (writing user code in Python or Node), see the binding API docs instead. This guide is for adding the hook itself.

What a hook is

A hook is a struct of C function pointers that the engine invokes at a specific point. The binding-side code wraps a user-supplied class / object behind those pointers. As of today lrvx has nine hooks:

Hook What it does Group
PnLTracker Observes every emitted signal, computes PnL metrics
StorageSink Persists every emitted signal storage
RiskManager Pre-trade gate; can drop a signal risk
KillSwitch Halts trading globally risk
OrderValidator Per-order validation risk
MarketDataRecorderHook Receives every trade/book update fed in recorder
ReplaySource Custom event source for BacktestRunner replay
Executor Replaces SimulatedExecutor with a real broker / paper-trading execution
ExecutionListener Observes order lifecycle (fills / rejects / etc.) execution

Plus set_log_callback (single function, not a class).

The four layers

For each hook, four things exist in lock-step:

1. C ABI declaration (IDL → lrvx_capi.h)
2. C++ implementation (src/capi/lrvx_capi.cpp)
3. pybind11 wrapper (python/hook_bindings.h)
4. NAPI wrapper (node/src/hooks.h)

If any one of those four is missing or out of sync, the parity gate catches it in CI.

Layer 1: IDL declaration

In include/lrvx/capi/lrvx_capi_spec.hpp:

typedef void (*LrvxXxxOnEventFn)(void* user_data, const LrvxSignal* sig);

typedef struct {
  LrvxXxxOnEventFn on_event;
  void* user_data;
} LrvxXxxCallbacks;

typedef void* LrvxXxxHandle;

LRVX_EXPORT(group = "your_group_name")
LrvxXxxHandle lrvx_xxx_create(LrvxXxxCallbacks cb);
LRVX_EXPORT(group = "your_group_name")
void lrvx_xxx_destroy(LrvxXxxHandle xxx);

Plus a setter on Runner / LiveEngine / BacktestRunner:

LRVX_EXPORT(group = "your_group_name")
void lrvx_runner_set_xxx(LrvxRunnerHandle runner, LrvxXxxHandle xxx);

After editing the spec, run:

bash tools/codegen/scripts/regenerate.sh

This regenerates lrvx_capi.h, the Codon golden file, the Markdown reference, and the ABI snapshot.

Layer 2: C++ implementation

In src/capi/lrvx_capi.cpp, create and destroy go through TrackerRegistry, not new/delete directly:

struct LrvxXxxImpl {
  LrvxXxxCallbacks cb;
};

LrvxXxxHandle lrvx_xxx_create(LrvxXxxCallbacks cb) {
  return static_cast<LrvxXxxHandle>(TrackerRegistry<LrvxXxxImpl>::create(LrvxXxxImpl{cb}));
}
void lrvx_xxx_destroy(LrvxXxxHandle h) {
  TrackerRegistry<LrvxXxxImpl>::destroy(static_cast<LrvxXxxImpl*>(h));
}

Then wire the hook into the place that fires it. For a post-emission observer like PnLTracker, that means storing a slot on the signal handler and calling its on_event after the user callback. For a pre-trade gate like RiskManager, that means evaluating it inside RunnerSignalHandler::onSignal before the user callback. For an executor, that means routing emitted signals to it instead of SimulatedExecutor.

Ownership: why a bare atomic pointer is not enough

A hook fired from RunnerSignalHandler::onSignal is reachable from the live engine's bus consumer threads. lrvx_xxx_destroy() can run on a completely different thread, at any moment, including the moment a consumer thread has already loaded the hook's pointer and is in the middle of calling into it. Detaching first (lrvx_runner_set_xxx(runner, NULL)) only stops future dispatches from picking the pointer up; it does nothing for one already in flight. new/delete on a bare std::atomic<LrvxXxxImpl*> frees the object out from under that in-flight call — see tracker_ownership.h for the full writeup, and the live-engine + Node channel lifetime fixes that motivated it.

The fix is shared ownership instead of a bare pointer, via TrackerRegistry<T> (create/destroy, keyed by the handle address) and TrackerSlot<T> (what the consumer actually holds):

// On the consumer (RunnerSignalHandler / LiveEngineImpl):
TrackerSlot<LrvxXxxImpl> _xxx;

void setXxx(LrvxXxxImpl* x) {
  _xxx.set(TrackerRegistry<LrvxXxxImpl>::lookup(x));
}

void onSomething(...) {
  // get() returns a strong reference that keeps the object alive for the
  // rest of this scope, no matter what destroy() does concurrently.
  if (auto x = _xxx.get(); x && x->cb.on_event != nullptr) {
    x->cb.on_event(x->cb.user_data, &payload);
  }
}

Lifecycle (on_start / on_stop) follows the same pattern, balanced against engine start() / stop() so attaching mid-run fires on_start immediately. Anything that calls straight into prev->cb.on_stop(...) synchronously inside a setter (rather than from a background dispatch thread) does not need this treatment — the caller's own thread already serializes that access against a later destroy() it makes itself.

Layer 3: pybind11 wrapper

In python/hook_bindings.h:

class PyXxx {
 public:
  virtual ~PyXxx() = default;
  virtual void on_event(const PySignal& /*sig*/) {}
};

class PyXxxTrampoline : public PyXxx {
 public:
  using PyXxx::PyXxx;
  void on_event(const PySignal& sig) override {
    PYBIND11_OVERRIDE(void, PyXxx, on_event, sig);
  }
};

// C-ABI bridge: acquires GIL, swallows Python exceptions.
inline void xxxOnEventBridge(void* ud, const LrvxSignal* sig) {
  auto* py = static_cast<PyXxx*>(ud);
  invokeUnderGil([&] { py->on_event(pySignalFromC(sig)); });
}

// RAII over the C ABI handle.
class PyXxxOwner {
  std::shared_ptr<PyXxx> _delegate;
  LrvxXxxHandle _handle{nullptr};
 public:
  PyXxxOwner(std::shared_ptr<PyXxx> d) : _delegate(std::move(d)) {
    LrvxXxxCallbacks cb{};
    cb.on_event = xxxOnEventBridge;
    cb.user_data = _delegate.get();
    _handle = lrvx_xxx_create(cb);
  }
  ~PyXxxOwner() { if (_handle) lrvx_xxx_destroy(_handle); }
  LrvxXxxHandle handle() const noexcept { return _handle; }
};

Then add a set_xxx method to PyStrategyRunner, PyLiveEngine, PyBacktestRunner that owns the PyXxxOwner and calls the C-ABI setter.

Register the class in lrvx.cpp (or in the registration block at the bottom of strategy_bindings.h):

py::class_<PyXxx, PyXxxTrampoline, std::shared_ptr<PyXxx>>(m, "Xxx")
    .def(py::init<>())
    .def("on_event", &PyXxx::on_event, py::arg("signal"));

User code:

class MyXxx(lrvx.Xxx):
    def on_event(self, sig):
        ...

runner.set_xxx(MyXxx())

Important rules

  1. Acquire the GIL. Use the invokeUnderGil helper. Live engine callbacks fire from C++ consumer threads where the GIL is not held.
  2. Never propagate Python exceptions across the C ABI boundary. The helper catches py::error_already_set and prints the traceback. Letting an exception unwind through C is undefined behaviour.
  3. Hold a shared_ptr to the user object. The Owner holds a non-owning C ABI handle; the user's Python class must outlive that handle. std::shared_ptr<PyXxx> propagates to pybind11's class registration.

Layer 4: NAPI wrapper

In node/src/hooks.h:

struct XxxHost {
  Napi::FunctionReference on_event_fn;
  Napi::ThreadSafeFunction tsfn;
  HookMode mode;
  Napi::Env env;
  LrvxXxxHandle handle{nullptr};

  XxxHost(Napi::Env env_, Napi::Object obj, HookMode m = HookMode::Sync)
      : on_event_fn(takeFn(obj, "onEvent")), mode(m), env(env_) {
    if (mode == HookMode::Threaded) {
      auto noop = Napi::Function::New(env, [](const Napi::CallbackInfo&) {});
      tsfn = Napi::ThreadSafeFunction::New(env, noop, "lrvx_xxx_cb", 0, 1);
    }
    LrvxXxxCallbacks cb{};
    cb.on_event = &XxxHost::onEventBridge;
    cb.user_data = this;
    handle = lrvx_xxx_create(cb);
  }
  ~XxxHost() {
    if (handle) lrvx_xxx_destroy(handle);
    if (mode == HookMode::Threaded) tsfn.Release();
  }

  static void onEventBridge(void* ud, const LrvxSignal* sig) {
    auto* self = static_cast<XxxHost*>(ud);
    if (self->on_event_fn.IsEmpty()) return;
    if (self->mode == HookMode::Sync) {
      // We're already on the JS thread — direct call. The result is
      // observable immediately on return from runner.onTrade etc.
      self->on_event_fn.Call({signalToJs(self->env, sig)});
      return;
    }
    // We're on a C++ consumer thread (LiveEngine). Queue via TSFN so
    // the JS handler runs at the next Node event loop tick.
    auto* copy = new LrvxSignal(*sig);
    self->tsfn.NonBlockingCall(copy, [self](Napi::Env env, Napi::Function, LrvxSignal* s) {
      self->on_event_fn.Call({signalToJs(env, s)});
      delete s;
    });
  }
};

Then add a setXxx method to RunnerNode / BacktestRunnerNode (node/src/strategy.h):

Napi::Value setXxx(const Napi::CallbackInfo& info) {
  auto env = info.Env();
  if (info.Length() == 0 || info[0].IsNull() || info[0].IsUndefined()) {
    _xxx_host.reset();
    lrvx_runner_set_xxx(_runner, nullptr);
    return env.Undefined();
  }
  _xxx_host = std::make_unique<lrvx_node::XxxHost>(env, info[0].As<Napi::Object>());
  lrvx_runner_set_xxx(_runner, _xxx_host->handle);
  return env.Undefined();
}

User code:

runner.setXxx({
  onEvent(sig) { ... }
});

Why two modes (Sync / Threaded)

The synchronous Runner / BacktestRunner fires hooks from the JS thread (the same thread runner.onTrade(...) was called from). Going through Napi::ThreadSafeFunction::NonBlockingCall from there would queue the JS handler for the next event loop tick — meaning a test that does runner.onTrade(...) and then immediately checks seen.length === 1 would see 0, because the callback hasn't run yet.

Direct FunctionReference::Call from the JS thread is what users actually want: the hook fires synchronously, and await runner.something() semantics are preserved.

For LiveEngine (threaded: true), callbacks fire from a C++ Disruptor consumer thread. Touching V8 from there would crash. ThreadSafeFunction is mandatory.

The HookMode flag picks the right path. Pre-trade gates (RiskManager.allow, KillSwitch.check, OrderValidator.validate) and Executor.capabilities are Sync-only — the engine reads the return value inline.

Layer 5: TypeScript declaration

In node/index.d.ts, add an interface (not a class) for the user-supplied object:

export interface Xxx {
  onEvent?(signal: Signal): void;
}

The parity gate accepts interface declarations as fulfilling the same role as a class.

Tests

Mirror the existing patterns:

A test should: subclass / instantiate the hook → attach to runner → fire a scenario → assert the callback ran the expected number of times with the expected payload.

Coverage gate

Open tools/codegen/binding_parity.yaml and add the new group with required status:

your_group_name:
  pybind11: { status: required, classes: [Xxx] }
  napi: { status: required, classes: [Xxx] }  # interface name
  codon: { status: required }

Run python3 scripts/check_binding_parity.py to verify.

Checklist

  • [ ] IDL spec updated, regenerate.sh ran clean
  • [ ] C++ impl wired into the right firing point, through TrackerRegistry / TrackerSlot if the firing point is a live-engine dispatch thread
  • [ ] pybind11 trampoline + Owner + class registration + setter on Runner/Engine/BacktestRunner
  • [ ] NAPI Host (with Sync/Threaded modes) + setter on RunnerNode/BacktestRunnerNode
  • [ ] node/index.d.ts interface added
  • [ ] Tests on both pybind11 and NAPI sides
  • [ ] binding_parity.yaml updated to required
  • [ ] scripts/check_binding_parity.py passes
  • [ ] scripts/check_dts_exports.py passes
  • [ ] scripts/gen_pyi_stubs.py --check passes (regenerated .pyi)

If all of the above pass locally, CI will be green.