WebAssembly Plugins — Building & Running (Rust)
Rust analogue of the C++ guest SDK — same wire contract, same host (nanoconda-cli -a pluginloader), same dmasession/listener model. The crate ships in the rust/ directory of the downloaded package, and its public API is the nanoconda module, matching nanoconda.h field, type, and method names verbatim (order.symbolId, security, session.newOrder(&mut o), listener.onbook(...)) — porting a strategy between the two SDKs is close to a mechanical find-and-replace.
1. Install the Rust wasm32-wasip1 target
No separate SDK download needed here — unlike the C++ side (which needs wasi-sdk), the Rust standard library ships wasm32-wasip1 support directly through rustup. If rustup itself isn't installed: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh, then source "$HOME/.cargo/env".
2. Write a minimal strategy
This is a complete, working example — a strategy that logs book/trade/fill events, places a limit buy at the best bid the first time it sees one, tracks fill count and last fill price, and persists that state across restarts:
mod nanoconda;
use nanoconda::{book, dmasession, error, listener, loadState, logger, order, orderType, saveState, security, trade};
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct PersistedState {
fillCount: i64,
lastFillPrice: i64,
}
impl PersistedState {
fn as_bytes(&self) -> &[u8] {
unsafe { core::slice::from_raw_parts((self as *const Self) as *const u8, core::mem::size_of::<Self>()) }
}
fn as_bytes_mut(&mut self) -> &mut [u8] {
unsafe { core::slice::from_raw_parts_mut((self as *mut Self) as *mut u8, core::mem::size_of::<Self>()) }
}
}
struct MyStrategy {
session: dmasession,
logger: logger,
state: PersistedState,
sentOrder: bool,
}
impl listener for MyStrategy {
fn onsecurity(&mut self, s: &security) {
self.logger.info(&format!("security {} tick={}", s.symbol(), s.tickSize));
}
fn onbook(&mut self, b: &book) {
if b.buys[0].qty != 0 && b.sells[0].qty != 0 {
self.logger.info(&format!("book symbol={} bid={} ask={}", b.symbolId, b.buys[0].price, b.sells[0].price));
// API example: place a limit buy at the best bid, once.
if !self.sentOrder {
let mut o = order::default();
o.symbolId = b.symbolId;
o.side = b'B';
o.type_ = orderType::LMT.into();
o.price = b.buys[0].price;
o.quantity = 1;
let r = self.session.newOrder(&mut o);
self.logger.info(&format!("newOrder result={r}"));
self.sentOrder = true;
}
}
}
fn ontrade(&mut self, t: &trade) {
self.logger.info(&format!("trade symbol={} px={} sz={}", t.symbolId, t.lastPrice, t.lastSize));
}
fn onorderack(&mut self, o: &order) { self.logger.info(&format!("order ack id={}", o.orderId)); }
fn oncancelack(&mut self, _o: &order) {}
fn onmodifyack(&mut self, _o: &order) {}
fn onorderreject(&mut self, o: &order) { self.logger.info(&format!("order reject {}", o.code())); }
fn onmodifyreject(&mut self, _o: &order) {}
fn oncancelreject(&mut self, _o: &order) {}
fn onfill(&mut self, o: &order) {
self.logger.info(&format!("fill id={} qty={}", o.orderId, o.quantityLast));
self.state.fillCount += 1;
self.state.lastFillPrice = o.priceLast;
}
fn onout(&mut self, _o: &order) {}
fn onorderstatus(&mut self, _o: &order) {}
fn onerror(&mut self, e: &error) { self.logger.error(&format!("error {}", e.message())); }
}
plugin_entry!(
|session: dmasession, args: Vec<String>| -> Box<dyn listener> {
let log = logger::default(); // routes to the host's single log file (nanoconda-cli's -l/--log)
// OPTIONAL: plugin-specific CLI args, e.g. `... -- --threshold 5` on the
// run command below. `args` is exactly what followed the second `--`, in order.
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if arg == "--threshold" {
if let Some(v) = iter.next() { log.info(&format!("threshold arg = {v}")); }
}
}
// OPTIONAL: restore state from the previous run, if any.
let mut state = PersistedState::default();
let n = loadState(0, state.as_bytes_mut());
log.info(&format!("loaded state: {n} bytes, fillCount={} lastFillPrice={}", state.fillCount, state.lastFillPrice));
Box::new(MyStrategy { session, logger: log, state, sentOrder: false })
},
|strategy: &mut dyn listener| { let _ = strategy; }
);
impl Drop for MyStrategy {
fn drop(&mut self) {
// OPTIONAL: save state to disk on shutdown.
let r = saveState(0, self.state.as_bytes());
self.logger.info(&format!("saved state on shutdown: result={r}"));
}
}
plugin_entry! is what generates every wasm export the host requires (starting, stopping, nc_abi_version, nc_cb_alloc/nc_cb_free, and all nc_cb_* callback dispatchers) and wires them to the init closure above — it's the Rust equivalent of the C++ SDK handling everything invisibly once you define init_nanoconda_plugin/plugin_stop. nanoconda::saveState/loadState persist to the file given by nanoconda-cli -a pluginloader's -f/--statefile argument, same as the C++ side.
3. Build the guest
From rust/:
Output: strategy_example.wasm, right in that directory. (make just runs cargo build --release then copies the result out of Cargo's target-triple build directory; run cargo build --release directly instead if you'd rather — output then lands at target/wasm32-wasip1/release/strategy_example.wasm.) The crate's .cargo/config.toml sets wasm32-wasip1 as the default build target, so plain cargo build is enough — no --target flag needed.
The strategy crate is built with crate-type = ["cdylib"], which produces a WASI reactor module (a library-style wasm binary that exports functions the host calls, with no _start/exit entry point) — the same execution model the C++ side gets from -mexec-model=reactor. The crate also passes -C link-args=--no-entry for this target, so the build stays reactor-shaped even on a toolchain where cdylib alone doesn't already default to it.
4. Run it
No separate host to build — same as the C++ side, nanoconda-cli -a pluginloader already has the wasm loader built in:
nanoconda-cli -a pluginloader -i strategy_example.wasm \
-- wasmplugin -e CME -s ESZ6,NQZ6 -u myuser -p mypass -a myaccount -c 0 -l strategy.log -f strategy.state \
-- --threshold 5
Same nanoconda-cli session arguments as the C++ side.
Differences from the C++ SDK
| C++ | Rust | Why |
|---|---|---|
order.type |
order.type_, or order.kind() for the typed value |
type is a reserved keyword in Rust. |
dmasession::cancelAllOrders(symbolId) / cancelAllOrders(symbolId, userId) (overloaded) |
cancelAllOrders(symbolId) / cancelAllOrdersForUser(symbolId, userId) (distinct names) |
Rust doesn't support overloading on arity. Same split for flatten/flattenForUser. |
customField::_c() / _c(value) |
customField::_c() / set_c(value) (same for every _x/set_x) |
Rust doesn't overload on argument count either. |
logM(format, ...) / logL(level, format, ...) (C preprocessor macros, real varargs) |
logger.logMessage(&format!(...)), or this crate's logM!/logL! macros |
Rust has no C-style varargs; format!() is the closest equivalent. |
datainput.symbolId (inherited) |
datainput.header.symbolId |
Rust has no struct inheritance; dataheader is a named field instead. |
Entry point: define init_nanoconda_plugin/plugin_stop, header handles the rest invisibly |
Entry point: invoke plugin_entry! once at your crate root |
#[no_mangle]/#[export_name] symbols only survive into the final .wasm when defined in the crate actually compiled as the cdylib — a dependency crate can't inject them the way a C++ header can, so the macro generates them in your crate instead. |
Everything else — struct layouts, enum values, the listener callback set, dmasession's method set, and every type/field name — is a direct, same-case port; a strategy written against one SDK's naming reads almost unchanged against the other. See rust/README.md for the full reference, including the ABI reference shared with the C++ SDK (byte-identical wire structs either way).
Writing a second strategy
Copy the whole rust/ directory to a new location and edit its src/lib.rs. Don't edit src/nanoconda.rs (the SDK) or src/nc_abi_generated.rs (generated, maintained by Nanoconda).