Plugins
The plug-in system extends a Nautilus live node with independently compiled Rust cdylibs. The host loads each cdylib at process startup and runs its actors, strategies, and custom-data types alongside compiled-in components. The host owns the C-ABI boundary; plug-in authors write standard Rust traits, and a macro emits the boundary glue.
The plug-in system is supported on Linux only.
The core philosophy:
- The boundary is C ABI, because Rust's
#[repr(Rust)]layout is unstable across compilations. - Authors write normal Rust traits; macros generate the
extern "C"thunks and#[repr(C)]vtables. - Plug-ins load at process startup, register through a validated manifest, and live for the process lifetime.
- The host adapts each plug-in instance into a
DataActororStrategyso the live engine sees no FFI. - Callbacks from a plug-in back into the host route through a single static
HostVTableof function pointers. - Every plug-in callback runs under
catch_unwind. A panic in a fallible plug-in thunk surfaces as aPluginError; a panic in an infallible plug-in thunk (create,drop_handle, custom-datats_event/ts_init/clone_handle/drop_handle/eq_handles) aborts the process. Neither path unwinds across the FFI boundary.
The plug-in ABI and LiveNodeConfig wiring are early alpha. NAUTILUS_PLUGIN_ABI_VERSION tracks
the current boundary layout and does not promise compatibility between Nautilus versions. Pin
plug-in builds to the matching host version, and treat the concepts here as the design contract
for current development.
Terms
- Plug-in: a Rust cdylib that exports a single
nautilus_plugin_initsymbol. - Plug point: one trait surface a plug-in can contribute to (custom data, actor, strategy).
- Manifest: a
'static PluginManifestreturned fromnautilus_plugin_initenumerating contributions. - VTable: a
#[repr(C)]struct of function pointers the host calls for one plug point on one type. HostVTable: the function-pointer table the host hands every plug-in for re-entrant callbacks.HostContext: an opaque boundary pointer that lets host thunks attribute callbacks to the calling adapter. On the host side it points to aHostContextInnerallocation carrying the adapter's actor ID and whether the caller is a strategy.- Adapter: the host-side
PluginActorAdapterorPluginStrategyAdapterthat wraps a plug-in handle.
What a plug-in contributes
A plug-in cdylib can publish three families of contributions through its manifest:
- Custom-data types via
PluginCustomData(surfaces::custom_data). - Plug-in actors via
PluginActor(surfaces::actor). - Plug-in strategies via
PluginStrategy(surfaces::strategy).
Each family has its own #[repr(C)] vtable struct, an author-facing trait, and a registration entry
the manifest lists in a Slice<'static, Registration>. Adding a future plug point means adding one
module and one slice field, then bumping the ABI version.
Each plug point family carries a fixed callback set. The actor surface today covers:
- Lifecycle hooks.
- Market-data callbacks for instruments, order books, book deltas, quotes, trades, bars, mark/index/funding prices, option greeks, option chain snapshots, instrument status, and instrument close.
- Order filled and canceled events.
- Signals and time events.
- Custom data values registered through
PluginCustomData.
The strategy surface adds the order lifecycle and position event callbacks on top of the actor surface.
Boundaries
The plug-in system is intentionally narrow. Out of scope today:
- Async client adapters for data and execution.
- Catalog, cache, and event-store backends as plug-ins.
- Pre-trade risk gating as a plug-in.
- Hot reload (plug-ins load at process startup and stay loaded).
- Mutable host
OrderBookstate and native or PythonCustomDataon the actor or strategy callback surface. Order book callbacks receive cloned snapshots, and non-plug-in custom data has no plug-in vtable and handle to downcast through.
ABI boundary
Only #[repr(C)] types may cross between an independently compiled plug-in and the host. The
following patterns cover the current surface:
- Events flow into the plug-in as borrowed
*const Tpointers into the host's already-#[repr(C)]model types. No serialisation, no per-event allocation. - Non-
#[repr(C)]inbound payloads flow into the plug-in as borrowed handles:InstrumentAnyHandle,OrderBookHandle,OrderBookDeltasHandle, andOptionChainSliceHandle. The host owns each handle for the callback duration.OrderBookHandlewraps a cloned book snapshot, so the plug-in never receives mutable host book state. - Order commands flow out of the plug-in as boundary-owned
*const XHandlepointers (SubmitOrderHandle,CancelOrderHandle,ModifyOrderHandle,SubmitOrderListHandle,CancelOrdersHandle,CancelAllOrdersHandle,ClosePositionHandle,CloseAllPositionsHandle,QueryAccountHandle,QueryOrderHandle) into command structs the plug-in owns for the duration of the call. The host derefs the handle and dispatches into the matchingStrategycommand, leaving the in-engineTradingCommandshape untouched. No JSON crosses the boundary on any per-call command path. - Plug-in custom data flows into actor and strategy
on_datacallbacks as a borrowedPluginCustomDataRef. The host only dispatches custom data values that came from aPluginCustomDataregistration in a loaded manifest, because that wrapper carries the plug-in vtable and opaque handle needed for a local downcast inside the cdylib. - Historical plug-in custom-data responses use the same boundary only when the value came from a
PluginCustomDataregistration. The host inspects&dyn Anyonly inside the adapter, extracts registered plug-inCustomData, and calls the existingon_dataslot withPluginCustomDataRef. No&dyn Anyvalue crosses the cdylib boundary.
The boundary primitives (BorrowedStr, Slice, OwnedBytes, PluginError, PluginResult) are
documented in nautilus_plugin::boundary.
Identifier interning
Nautilus identifiers such as ClientOrderId, InstrumentId, ClientId, AccountId,
PositionId, StrategyId, and TraderId wrap Ustr. A Rust cdylib has its own ustr
global string cache, so equal text can have different Ustr pointers on the host and
plug-in sides. The boundary treats Ustr values as receiver-local:
- Host command dispatch re-interns every identifier in boundary-owned command handles before
calling the matching
Strategy::*method. - Plug-in event thunks re-intern identifiers in inbound event payloads before calling
PluginActororPluginStrategytrait methods. - Plug-in authors can compare and store identifiers received through trait callbacks normally.
Code that bypasses the macro-generated thunks must re-intern copied identifiers with
Ustr::from(value.as_str()).
The policy also covers nested identifiers such as Symbol, Venue, OrderListId,
ExecAlgorithmId, VenueOrderId, OptionSeriesId, raw Ustr tags and names, and
currency codes carried inside command or event payloads. This does not change any vtable
or handle layout, so it does not require an ABI version bump.
Manifest
The manifest is process-lifetime static data the plug-in returns from nautilus_plugin_init. It
identifies the build and enumerates every plug point contribution:
abi_version: must equalNAUTILUS_PLUGIN_ABI_VERSIONor the host refuses to load.plugin_name,plugin_vendor,plugin_version: identifier strings.build_id: a versionedPluginBuildIdcarryingnautilus-plugincrate version,rustcversion, target triple, and build profile.custom_data,actors,strategies: registration slices, one per plug point.
The loader runs ValidatedPluginManifest::new on the manifest before exposing it to the live node.
Validation checks identifier strings, the build-id schema version, every registration vtable
pointer, every required vtable slot, and uniqueness of type names across all plug points. The build
identifier itself stays diagnostic: empty rustc_version, target_triple, or build_profile
strings do not make a manifest invalid.
Load flow
The operational steps are:
- The node clones the configured plug-in entries and refuses to load while it is not
Idle. - For each path, the node verifies the optional SHA-256 digest in
LiveNode::load_configured_plugins, then asks the loader todlopenthe cdylib and resolvenautilus_plugin_init.PluginLoaderitself does not hash the file. - The plug-in's init thunk receives the host's
HostVTablepointer and returns its static manifest. - The loader runs structural validation. Failure produces a
LoadErrorwhose diagnostics include the plug-in name, version, and fullPluginBuildId. - The node walks every loaded manifest once to register custom-data deserializers with
nautilus_model::data::registry. - The node walks the configured entries again, resolves each
type_nameto either an actor or strategy registration, and instantiates an adapter through the plug-in'screatethunk. - The adapter is added to the trader, after which the live engine drives it like any compiled-in component.
The loader stops on the first error and leaks every successfully opened Library for the process
lifetime, because manifest, vtable, and drop_fn pointers the host has copied into its registries
must outlive the loader.
Adapter routing
Once an adapter is registered, callbacks flow in both directions through stable function pointers:
- Forward calls (engine to plug-in) go through the adapter's validated vtable, with two layers
of
catch_unwindguarding the FFI call so a plug-in panic surfaces as aPluginErrorrather than unwinding across the boundary. - Reverse calls (plug-in to host) go through
HostVTable. The host attributes each call to the caller via the per-instanceHostContextpointer it handed the plug-in at create time and routes through the engine's cache, msgbus, clock, timer, and order pipelines. - Order-command slots reject calls from actor contexts; actors cannot submit orders.
- The default
HostVTablereturnsNotImplementedfor stateful callbacks. Engines install a populated vtable viaplugin_loader()so plug-ins reach the real execution paths.
Lifecycle
A plug-in instance follows the same lifecycle as a compiled-in actor or strategy:
Key points:
createruns once per configured instance. The adapter passes the plug-in itsHostVTablepointer, itsHostContextInnerpointer, and the verbatim JSON config payload.- Adapter drop runs the plug-in's
drop_handlethunk and releases the heap-allocatedHostContextInnerallocation. dlcloseis intentionally never called. TheLoadedPluginwraps itslibloading::LibraryinManuallyDropso manifest and vtable pointers copied into the host's registries never dangle.
Configuration
Plug-in instances are declared on LiveNodeConfig.plugins as a list of PluginConfig entries:
[[plugins]]
path = "./target/debug/examples/libcustom_data_plugin.so"
type_name = "ExampleStrategy"
sha256 = "<optional 64-char hex digest>"
[plugins.config]
strategy_id = "STRAT-001"
order_id_tag = "001"
threshold = 10Each entry binds one plug-in instance:
path: absolute or working-directory-relative path to the cdylib. Repeated paths are loaded once and shared across entries.type_name: the canonical type name from the plug-in manifest. The host rejects the entry if the manifest exposes the name as both an actor and a strategy.sha256: optional lowercase hex SHA-256 digest of the cdylib. If set, the node hashes the file before loading and aborts on mismatch.config: a free-form JSON object serialised verbatim into theconfig_jsonargument the plug-in'screatethunk receives.
The node interprets a few well-known keys inside config when instantiating an entry:
actor_id: identifier assigned to the adapter'sActorId. Defaults to the manifesttype_name.strategy_id: identifier assigned to the adapter'sStrategyId. Defaults to<type_name>-001.order_id_tag: optional order ID tag forwarded into the strategy'sStrategyConfig.strategy_config: optional fully-formedStrategyConfigJSON value, used for strategy plug-ins that need more than the three keys above.
Plug-in support is gated behind the plugin Cargo feature on the live crate, which is on by
default. A build compiled with --no-default-features (or any feature set that omits plugin)
rejects a non-empty plugins list with a clear error so plug-in users cannot accidentally run
without host-side support compiled in.
Author API
Plug-in authors implement one trait per plug point family and call the nautilus_plugin! macro:
use nautilus_model::data::QuoteTick;
use nautilus_plugin::prelude::*;
#[derive(Default)]
pub struct ExampleActor {
quotes_seen: u64,
}
impl PluginActor for ExampleActor {
const TYPE_NAME: &'static str = "ExampleActor";
fn new(_host: *const HostVTable, _ctx: *const HostContext, _config_json: &str) -> Self {
Self::default()
}
fn on_quote(&mut self, _quote: &QuoteTick) -> anyhow::Result<()> {
self.quotes_seen += 1;
Ok(())
}
}
nautilus_plugin::nautilus_plugin! {
name: "example-actor-plugin",
vendor: "Nautech",
version: env!("CARGO_PKG_VERSION"),
actors: [ExampleActor],
}The macro emits nautilus_plugin_init, the 'static PluginManifest, and the vtables for each plug
point. Fallible thunks forward through panic::guard; the heavier infallible thunks
(create, drop_handle, and custom-data
ts_event/ts_init/clone_handle/drop_handle/eq_handles) forward through
guard_infallible; trivial slots that cannot panic (the type_name thunks, which just return a
BorrowedStr over a &'static str constant) carry no guard at all.
Authors never write extern "C" or #[repr(C)]. unsafe requirements depend on what the plug-in
holds. The example actor in crates/plugin/examples/custom_data_plugin.rs discards the
*const HostVTable and *const HostContext pointers that PluginActor::new receives, so it
needs no unsafe. Plug-ins that store those pointers (whether actor or strategy) need an
unsafe impl Send on the struct, and any direct call into a HostVTable slot is
unsafe extern "C" and therefore unsafe to invoke.
Cargo.toml for the cdylib needs crate-type = ["cdylib"] and a dependency on the matching
nautilus-plugin version. The artifact lands at
target/<profile>/<libname>.<so|dylib|dll> depending on the host platform.
Build a cdylib example shipped with the crate:
cargo build -p nautilus-plugin --example custom_data_pluginOperating notes
- Pin every plug-in build to the host's Nautilus version. The loader checks
abi_versionand the build-id schema only; crate version,rustc, target triple, and build profile travel as diagnostics in load-error output. - Use the optional
sha256field on aPluginConfigentry as a deployment-time integrity check. - The node refuses to load plug-ins once it has left the
Idlestate, so anyLoadErrorsurfaces during startup, before client connections. - A plug-in panic in a fallible callback surfaces as
PluginError::Panic. A panic in an infallible callback (e.g.create,drop_handle) aborts the process; seenautilus_plugin::panicfor the rationale. - Loader activity logs under the
nautilus_plugintarget.
Relationship to compiled-in components
Plug-in actors and strategies behave like any other DataActor / Strategy once the adapter is
registered:
- Same trader registration APIs.
- Same risk, OMS, and event-store paths for order commands routed through the adapter.
- Cache reads, msgbus publishes, and timer callbacks bypass the
Strategylayer by design and go through the engine services directly.
The only difference is structural: plug-ins ship as separate cdylibs with their own manifest, in exchange for being deployable out-of-tree without recompiling the host.