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 aborts the process. Neither path unwinds across the FFI boundary. Infallible thunks include:createdrop_handle- custom-data
ts_event,ts_init,clone_handle,drop_handle, andeq_handles
The plug-in ABI and LiveNodeConfig wiring are early alpha. NAUTILUS_PLUGIN_ABI_VERSION and
PLUGIN_BUILD_ID_VERSION remain 1 during this phase, even when the boundary changes. Pin
plug-in builds to the matching host version, and treat the concepts here as the design contract
for current development rather than a stable compatibility promise.
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 rebuilding plug-ins to match the host.
Each plug point family carries a fixed callback set. The actor surface today covers:
- Lifecycle hooks.
- Market-data callbacks for:
- instruments
- order books and book deltas
- quotes, trades, and bars
- mark, index, and funding prices
- option greeks and 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:InstrumentAnyHandleOrderBookHandleOrderBookDeltasHandleOptionChainSliceHandleThe 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:SubmitOrderHandleSubmitOrderListHandleCancelOrderHandleCancelOrdersHandleCancelAllOrdersHandleModifyOrderHandleClosePositionHandleCloseAllPositionsHandleQueryAccountHandleQueryOrderHandle
The plug-in owns the command structs for the duration of the call. The host derefs the handle and dispatches into the matching
Strategycommand, 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 are documented in nautilus_plugin::boundary:
BorrowedStrSliceOwnedBytesPluginErrorPluginResult
Identifier interning
Nautilus identifiers wrap Ustr, including:
ClientOrderIdInstrumentIdClientIdAccountIdPositionIdStrategyIdTraderId
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 carried inside command or event payloads:
SymbolVenueOrderListIdExecAlgorithmIdVenueOrderIdOptionSeriesId- raw
Ustrtags and names - currency codes
This does not change any vtable or handle layout, so it does not require a plug-in rebuild.
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 versionedPluginBuildIdcarrying:nautilus-plugincrate versionrustcversion- target triple
- build profile
- precision mode
- fixed precision
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. It also
checks the plug-in precision mode and fixed precision against the host, because standard-precision
and high-precision builds use different model layouts at the boundary. The specific crate version,
rustc, target triple, and build profile values stay diagnostic.
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
forward through guard_infallible:
createdrop_handle- custom-data
ts_event,ts_init,clone_handle,drop_handle, andeq_handles
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, and rejects plug-ins built with a different precision mode or fixed precision. 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.