Skip to main content

nautilus_plugin/
host.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Host-side function table given to plug-ins for re-entrant callbacks.
17//!
18//! The surface stays explicit: every host service is a concrete function
19//! pointer. During alpha, added methods require rebuilding plug-ins to match
20//! the host rather than changing the ABI version. This avoids exposing
21//! `Arc<MessageBus>` or any `dyn Trait` across the boundary.
22
23#![allow(unsafe_code)]
24
25use crate::{
26    NAUTILUS_PLUGIN_ABI_VERSION,
27    boundary::{BorrowedStr, OwnedBytes, PluginResult, Slice},
28    surfaces::commands::{
29        CancelAllOrdersHandle, CancelOrderHandle, CancelOrdersHandle, CloseAllPositionsHandle,
30        ClosePositionHandle, ModifyOrderHandle, QueryAccountHandle, QueryOrderHandle,
31        SubmitOrderHandle, SubmitOrderListHandle,
32    },
33};
34
35/// Log levels mirrored from the host's `log` crate without dragging the
36/// crate into the boundary type.
37#[repr(u8)]
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum HostLogLevel {
40    Error = 1,
41    Warn = 2,
42    Info = 3,
43    Debug = 4,
44    Trace = 5,
45}
46
47/// Opaque per-instance context the host supplies at plug-in creation.
48///
49/// The host attaches a unique context to each actor or strategy instance so
50/// that host services that need attribution (logging targets, order
51/// commands, cache scoping) can resolve back to the correct caller without
52/// the plug-in needing to know any host identifiers. The plug-in only ever
53/// passes the pointer through to the relevant [`HostVTable`] entry.
54#[repr(C)]
55pub struct HostContext {
56    _opaque: [u8; 0],
57}
58
59/// Opaque per-instance context the host supplies to controller plug-ins.
60///
61/// Controller host services use this context to attribute runtime-created
62/// strategies and lifecycle commands to the calling controller instance.
63#[repr(C)]
64pub struct ControllerHostContext {
65    _opaque: [u8; 0],
66}
67
68/// Function table the host passes to every controller plug-in instance.
69///
70/// Each service accepts a JSON request envelope and returns a JSON response
71/// envelope as [`OwnedBytes`]. The producing side owns and frees returned
72/// allocations so host and plug-in allocators remain isolated.
73#[repr(C)]
74pub struct ControllerHostVTable {
75    /// ABI version of this vtable. Must equal [`NAUTILUS_PLUGIN_ABI_VERSION`].
76    pub abi_version: u32,
77
78    /// Creates a plug-in strategy through the host's registered controller.
79    pub create_plugin_strategy: unsafe extern "C" fn(
80        ctx: *const ControllerHostContext,
81        request_json: BorrowedStr<'_>,
82    ) -> PluginResult<OwnedBytes>,
83
84    /// Starts a strategy through the host's system controller command path.
85    pub start_strategy: unsafe extern "C" fn(
86        ctx: *const ControllerHostContext,
87        request_json: BorrowedStr<'_>,
88    ) -> PluginResult<OwnedBytes>,
89
90    /// Stops a strategy through the host's system controller command path.
91    pub stop_strategy: unsafe extern "C" fn(
92        ctx: *const ControllerHostContext,
93        request_json: BorrowedStr<'_>,
94    ) -> PluginResult<OwnedBytes>,
95
96    /// Exits the market for a strategy through the system controller.
97    pub exit_market: unsafe extern "C" fn(
98        ctx: *const ControllerHostContext,
99        request_json: BorrowedStr<'_>,
100    ) -> PluginResult<OwnedBytes>,
101
102    /// Removes a strategy through the system controller.
103    pub remove_strategy: unsafe extern "C" fn(
104        ctx: *const ControllerHostContext,
105        request_json: BorrowedStr<'_>,
106    ) -> PluginResult<OwnedBytes>,
107
108    /// Checks whether an instrument is present in the host cache.
109    pub instrument_exists: unsafe extern "C" fn(
110        ctx: *const ControllerHostContext,
111        request_json: BorrowedStr<'_>,
112    ) -> PluginResult<OwnedBytes>,
113
114    /// Emits a structured log record through the host logger.
115    pub log: unsafe extern "C" fn(
116        ctx: *const ControllerHostContext,
117        request_json: BorrowedStr<'_>,
118    ) -> PluginResult<OwnedBytes>,
119
120    /// Returns the host clock reading in a JSON response envelope.
121    pub clock_now_ns: unsafe extern "C" fn(
122        ctx: *const ControllerHostContext,
123        request_json: BorrowedStr<'_>,
124    ) -> PluginResult<OwnedBytes>,
125}
126
127impl ControllerHostVTable {
128    /// Asserts that the embedded ABI version matches the compiled-in constant.
129    #[must_use]
130    pub fn matches_compiled_abi(&self) -> bool {
131        self.abi_version == NAUTILUS_PLUGIN_ABI_VERSION
132    }
133}
134
135/// SAFETY: function pointers are thread-safe by construction; the host
136/// guarantees the underlying implementations are `Sync`.
137unsafe impl Send for ControllerHostVTable {}
138/// SAFETY: see above.
139unsafe impl Sync for ControllerHostVTable {}
140
141/// Function table the host passes to every plug-in at load time.
142///
143/// All function pointers are non-null and stable for the process lifetime.
144/// Plug-ins stash the pointer and call back through it whenever they need
145/// host services. Adding a method is a breaking ABI change and requires
146/// rebuilding plug-ins to match the host during alpha.
147#[repr(C)]
148pub struct HostVTable {
149    /// ABI version of this vtable. Must equal [`NAUTILUS_PLUGIN_ABI_VERSION`].
150    pub abi_version: u32,
151
152    /// Returns the host's monotonic clock reading in UNIX nanoseconds.
153    pub clock_now_ns: unsafe extern "C" fn() -> u64,
154
155    /// Emits a log line to the host's logger.
156    ///
157    /// `target` is the log target (e.g. plug-in name), `message` is the body.
158    pub log: unsafe extern "C" fn(
159        level: HostLogLevel,
160        target: BorrowedStr<'_>,
161        message: BorrowedStr<'_>,
162    ),
163
164    /// Returns the JSON-encoded instrument snapshot for `instrument_id`.
165    ///
166    /// Empty bytes mean the cache had no matching instrument.
167    pub cache_instrument: unsafe extern "C" fn(
168        ctx: *const HostContext,
169        instrument_id: BorrowedStr<'_>,
170    ) -> PluginResult<OwnedBytes>,
171
172    /// Returns the JSON-encoded account snapshot for `account_id`.
173    ///
174    /// Empty bytes mean the cache had no matching account.
175    pub cache_account: unsafe extern "C" fn(
176        ctx: *const HostContext,
177        account_id: BorrowedStr<'_>,
178    ) -> PluginResult<OwnedBytes>,
179
180    /// Returns the JSON-encoded order snapshot for `client_order_id`.
181    ///
182    /// Empty bytes mean the cache had no matching order.
183    pub cache_order: unsafe extern "C" fn(
184        ctx: *const HostContext,
185        client_order_id: BorrowedStr<'_>,
186    ) -> PluginResult<OwnedBytes>,
187
188    /// Returns the JSON-encoded position snapshot for `position_id`.
189    ///
190    /// Empty bytes mean the cache had no matching position.
191    pub cache_position: unsafe extern "C" fn(
192        ctx: *const HostContext,
193        position_id: BorrowedStr<'_>,
194    ) -> PluginResult<OwnedBytes>,
195
196    /// Returns JSON-encoded order snapshots for the requested strategy.
197    ///
198    /// Passing an empty `strategy_id` uses the calling strategy's own ID.
199    pub cache_orders_for_strategy: unsafe extern "C" fn(
200        ctx: *const HostContext,
201        strategy_id: BorrowedStr<'_>,
202    ) -> PluginResult<OwnedBytes>,
203
204    /// Returns JSON-encoded position snapshots for the requested strategy.
205    ///
206    /// Passing an empty `strategy_id` uses the calling strategy's own ID.
207    pub cache_positions_for_strategy: unsafe extern "C" fn(
208        ctx: *const HostContext,
209        strategy_id: BorrowedStr<'_>,
210    ) -> PluginResult<OwnedBytes>,
211
212    /// Subscribes the calling actor or strategy to quote ticks.
213    pub subscribe_quotes: unsafe extern "C" fn(
214        ctx: *const HostContext,
215        instrument_id: BorrowedStr<'_>,
216        client_id: BorrowedStr<'_>,
217        params_json: BorrowedStr<'_>,
218    ) -> PluginResult<()>,
219
220    /// Unsubscribes the calling actor or strategy from quote ticks.
221    pub unsubscribe_quotes: unsafe extern "C" fn(
222        ctx: *const HostContext,
223        instrument_id: BorrowedStr<'_>,
224        client_id: BorrowedStr<'_>,
225        params_json: BorrowedStr<'_>,
226    ) -> PluginResult<()>,
227
228    /// Subscribes the calling actor or strategy to trade ticks.
229    pub subscribe_trades: unsafe extern "C" fn(
230        ctx: *const HostContext,
231        instrument_id: BorrowedStr<'_>,
232        client_id: BorrowedStr<'_>,
233        params_json: BorrowedStr<'_>,
234    ) -> PluginResult<()>,
235
236    /// Unsubscribes the calling actor or strategy from trade ticks.
237    pub unsubscribe_trades: unsafe extern "C" fn(
238        ctx: *const HostContext,
239        instrument_id: BorrowedStr<'_>,
240        client_id: BorrowedStr<'_>,
241        params_json: BorrowedStr<'_>,
242    ) -> PluginResult<()>,
243
244    /// Subscribes the calling actor or strategy to bars.
245    pub subscribe_bars: unsafe extern "C" fn(
246        ctx: *const HostContext,
247        bar_type: BorrowedStr<'_>,
248        client_id: BorrowedStr<'_>,
249        params_json: BorrowedStr<'_>,
250    ) -> PluginResult<()>,
251
252    /// Unsubscribes the calling actor or strategy from bars.
253    pub unsubscribe_bars: unsafe extern "C" fn(
254        ctx: *const HostContext,
255        bar_type: BorrowedStr<'_>,
256        client_id: BorrowedStr<'_>,
257        params_json: BorrowedStr<'_>,
258    ) -> PluginResult<()>,
259
260    /// Subscribes the calling actor or strategy to order book deltas.
261    ///
262    /// `book_type` uses the `BookType` discriminant. `depth == 0` means no
263    /// depth limit. `managed != 0` requests a managed book subscription.
264    pub subscribe_book_deltas: unsafe extern "C" fn(
265        ctx: *const HostContext,
266        instrument_id: BorrowedStr<'_>,
267        book_type: u8,
268        depth: usize,
269        client_id: BorrowedStr<'_>,
270        managed: u8,
271        params_json: BorrowedStr<'_>,
272    ) -> PluginResult<()>,
273
274    /// Unsubscribes the calling actor or strategy from order book deltas.
275    pub unsubscribe_book_deltas: unsafe extern "C" fn(
276        ctx: *const HostContext,
277        instrument_id: BorrowedStr<'_>,
278        client_id: BorrowedStr<'_>,
279        params_json: BorrowedStr<'_>,
280    ) -> PluginResult<()>,
281
282    /// Subscribes the calling actor or strategy to periodic order book snapshots.
283    ///
284    /// `book_type` uses the `BookType` discriminant. `depth == 0` means no
285    /// depth limit. `interval_ms` must be greater than zero.
286    pub subscribe_book_at_interval: unsafe extern "C" fn(
287        ctx: *const HostContext,
288        instrument_id: BorrowedStr<'_>,
289        book_type: u8,
290        depth: usize,
291        interval_ms: usize,
292        client_id: BorrowedStr<'_>,
293        params_json: BorrowedStr<'_>,
294    ) -> PluginResult<()>,
295
296    /// Unsubscribes the calling actor or strategy from periodic order book snapshots.
297    ///
298    /// `interval_ms` must be greater than zero.
299    pub unsubscribe_book_at_interval: unsafe extern "C" fn(
300        ctx: *const HostContext,
301        instrument_id: BorrowedStr<'_>,
302        interval_ms: usize,
303        client_id: BorrowedStr<'_>,
304        params_json: BorrowedStr<'_>,
305    ) -> PluginResult<()>,
306
307    /// Publishes an arbitrary byte payload on the host message bus.
308    ///
309    /// The payload is delivered as a `Vec<u8>` to subscribers of `topic`.
310    pub msgbus_publish: unsafe extern "C" fn(
311        ctx: *const HostContext,
312        topic: BorrowedStr<'_>,
313        payload: Slice<'_, u8>,
314    ) -> PluginResult<()>,
315
316    /// Registers a one-shot time alert on the calling actor or strategy clock.
317    pub set_time_alert: unsafe extern "C" fn(
318        ctx: *const HostContext,
319        name: BorrowedStr<'_>,
320        alert_time_ns: u64,
321        allow_past: u8,
322    ) -> PluginResult<()>,
323
324    /// Registers an interval timer on the calling actor or strategy clock.
325    ///
326    /// `start_time_ns == 0` and `stop_time_ns == 0` mean no explicit bound.
327    pub set_timer: unsafe extern "C" fn(
328        ctx: *const HostContext,
329        name: BorrowedStr<'_>,
330        interval_ns: u64,
331        start_time_ns: u64,
332        stop_time_ns: u64,
333        allow_past: u8,
334        fire_immediately: u8,
335    ) -> PluginResult<()>,
336
337    /// Cancels a timer on the calling actor or strategy clock.
338    pub cancel_timer:
339        unsafe extern "C" fn(ctx: *const HostContext, name: BorrowedStr<'_>) -> PluginResult<()>,
340
341    /// Submits an order on behalf of the calling strategy.
342    ///
343    /// `ctx` is the [`HostContext`] the host passed into the strategy's
344    /// `create`. `command` is a boundary-owned [`SubmitOrderHandle`] the
345    /// plug-in constructs around the order and its routing/position
346    /// metadata. The plug-in owns the box and frees it when this call
347    /// returns; the host only borrows the handle for the duration of the
348    /// call.
349    pub submit_order: unsafe extern "C" fn(
350        ctx: *const HostContext,
351        command: *const SubmitOrderHandle,
352    ) -> PluginResult<()>,
353
354    /// Cancels an in-flight order on behalf of the calling strategy.
355    ///
356    /// `command` is a boundary-owned [`CancelOrderHandle`] the plug-in
357    /// constructs around the cancel parameters (typically `client_order_id`,
358    /// optional `client_id`, optional venue params). The plug-in owns the
359    /// box and frees it when this call returns; the host only borrows the
360    /// handle for the duration of the call.
361    pub cancel_order: unsafe extern "C" fn(
362        ctx: *const HostContext,
363        command: *const CancelOrderHandle,
364    ) -> PluginResult<()>,
365
366    /// Modifies an in-flight order on behalf of the calling strategy.
367    ///
368    /// `command` is a boundary-owned [`ModifyOrderHandle`] the plug-in
369    /// constructs around the modify parameters (new quantity, price,
370    /// trigger price, etc.). The plug-in owns the box and frees it when
371    /// this call returns; the host only borrows the handle for the
372    /// duration of the call.
373    pub modify_order: unsafe extern "C" fn(
374        ctx: *const HostContext,
375        command: *const ModifyOrderHandle,
376    ) -> PluginResult<()>,
377
378    /// Submits a list of orders as a single batch on behalf of the calling
379    /// strategy.
380    ///
381    /// `command` is a boundary-owned [`SubmitOrderListHandle`] the plug-in
382    /// constructs around the order list and optional position id, client
383    /// id, and routing params. The host dispatches the batch atomically
384    /// through the execution engine.
385    pub submit_order_list: unsafe extern "C" fn(
386        ctx: *const HostContext,
387        command: *const SubmitOrderListHandle,
388    ) -> PluginResult<()>,
389
390    /// Cancels every order named in the supplied list on behalf of the
391    /// calling strategy.
392    ///
393    /// `command` is a boundary-owned [`CancelOrdersHandle`] carrying the
394    /// `client_order_id` list plus optional client id and routing params.
395    pub cancel_orders: unsafe extern "C" fn(
396        ctx: *const HostContext,
397        command: *const CancelOrdersHandle,
398    ) -> PluginResult<()>,
399
400    /// Cancels every open order matching the supplied filter on behalf of
401    /// the calling strategy.
402    ///
403    /// `command` is a boundary-owned [`CancelAllOrdersHandle`] carrying the
404    /// `instrument_id` and optional `order_side`, `client_id`, and routing
405    /// params. The host scans its cache for matching open orders and
406    /// issues the cancels.
407    pub cancel_all_orders: unsafe extern "C" fn(
408        ctx: *const HostContext,
409        command: *const CancelAllOrdersHandle,
410    ) -> PluginResult<()>,
411
412    /// Closes the position identified by the command on behalf of the
413    /// calling strategy.
414    ///
415    /// `command` is a boundary-owned [`ClosePositionHandle`] carrying the
416    /// `position_id` plus optional `client_id`, `tags`, `time_in_force`,
417    /// `reduce_only`, and `quote_quantity`. The host reads the position
418    /// from its cache and submits a closing market order through the
419    /// strategy's order factory.
420    pub close_position: unsafe extern "C" fn(
421        ctx: *const HostContext,
422        command: *const ClosePositionHandle,
423    ) -> PluginResult<()>,
424
425    /// Closes every open position matching the supplied filter on behalf
426    /// of the calling strategy.
427    ///
428    /// `command` is a boundary-owned [`CloseAllPositionsHandle`] carrying
429    /// the `instrument_id` plus optional `position_side`, `client_id`,
430    /// `tags`, `time_in_force`, `reduce_only`, and `quote_quantity`. The
431    /// host scans its cache for matching open positions and submits
432    /// closing market orders.
433    pub close_all_positions: unsafe extern "C" fn(
434        ctx: *const HostContext,
435        command: *const CloseAllPositionsHandle,
436    ) -> PluginResult<()>,
437
438    /// Queries the venue for the latest snapshot of `account_id` on
439    /// behalf of the calling strategy.
440    ///
441    /// `command` is a boundary-owned [`QueryAccountHandle`] carrying the
442    /// `account_id` plus optional `client_id` and routing params. The
443    /// result is delivered asynchronously through the host's normal
444    /// account-state event flow; this call only fires the query, it does
445    /// not return the snapshot inline.
446    pub query_account: unsafe extern "C" fn(
447        ctx: *const HostContext,
448        command: *const QueryAccountHandle,
449    ) -> PluginResult<()>,
450
451    /// Queries the venue for the latest snapshot of `client_order_id` on
452    /// behalf of the calling strategy.
453    ///
454    /// `command` is a boundary-owned [`QueryOrderHandle`] carrying the
455    /// `client_order_id` plus optional `client_id` and routing params.
456    /// The result is delivered asynchronously through the host's normal
457    /// order-status event flow; this call only fires the query, it does
458    /// not return the snapshot inline.
459    pub query_order: unsafe extern "C" fn(
460        ctx: *const HostContext,
461        command: *const QueryOrderHandle,
462    ) -> PluginResult<()>,
463}
464
465impl HostVTable {
466    /// Asserts that the embedded ABI version matches the compiled-in constant.
467    ///
468    /// Plug-ins should call this in their `nautilus_plugin_init` body before
469    /// trusting any function pointer from the table.
470    #[must_use]
471    pub fn matches_compiled_abi(&self) -> bool {
472        self.abi_version == NAUTILUS_PLUGIN_ABI_VERSION
473    }
474
475    /// Reads the clock through the vtable.
476    ///
477    /// # Safety
478    ///
479    /// The vtable pointer must originate from the host's `nautilus_plugin_init`
480    /// call and the host's library must still be live.
481    pub unsafe fn now_ns(&self) -> u64 {
482        // SAFETY: caller upholds liveness of the host.
483        unsafe { (self.clock_now_ns)() }
484    }
485
486    /// Logs `message` at `level` through the vtable.
487    ///
488    /// # Safety
489    ///
490    /// See [`HostVTable::now_ns`].
491    pub unsafe fn log_message(&self, level: HostLogLevel, target: &str, message: &str) {
492        // SAFETY: BorrowedStr is `'a` and outlives this call.
493        unsafe {
494            (self.log)(
495                level,
496                BorrowedStr::from_str(target),
497                BorrowedStr::from_str(message),
498            );
499        }
500    }
501}
502
503/// SAFETY: function pointers are thread-safe by construction; the host
504/// guarantees the underlying implementations are `Sync`.
505unsafe impl Send for HostVTable {}
506/// SAFETY: see above.
507unsafe impl Sync for HostVTable {}
508
509#[cfg(test)]
510mod tests {
511    use std::sync::{
512        Mutex, MutexGuard, OnceLock,
513        atomic::{AtomicU8, AtomicU64, Ordering},
514    };
515
516    use rstest::rstest;
517
518    use super::*;
519    use crate::boundary::{OwnedBytes, PluginResult, Slice};
520
521    static CLOCK_VALUE: AtomicU64 = AtomicU64::new(0);
522    static LOG_LEVEL_OBSERVED: AtomicU8 = AtomicU8::new(0);
523
524    // Serialises tests that mutate and observe `CLOCK_VALUE` /
525    // `LOG_LEVEL_OBSERVED`. cargo test runs cases in parallel by
526    // default, so without this lock a parametrised case can overwrite
527    // the static after another case's reset but before its assertion.
528    fn shared_state_lock() -> MutexGuard<'static, ()> {
529        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
530        LOCK.get_or_init(|| Mutex::new(()))
531            .lock()
532            .unwrap_or_else(|p| p.into_inner())
533    }
534
535    unsafe extern "C" fn fixed_clock_now_ns() -> u64 {
536        CLOCK_VALUE.load(Ordering::SeqCst)
537    }
538
539    unsafe extern "C" fn recording_log(
540        level: HostLogLevel,
541        _target: BorrowedStr<'_>,
542        _message: BorrowedStr<'_>,
543    ) {
544        LOG_LEVEL_OBSERVED.store(level as u8, Ordering::SeqCst);
545    }
546
547    macro_rules! stub_bytes {
548        ($name:ident) => {
549            unsafe extern "C" fn $name(
550                _ctx: *const HostContext,
551                _a: BorrowedStr<'_>,
552            ) -> PluginResult<OwnedBytes> {
553                PluginResult::Ok(OwnedBytes::empty())
554            }
555        };
556    }
557
558    macro_rules! stub_controller_bytes {
559        ($name:ident) => {
560            unsafe extern "C" fn $name(
561                _ctx: *const ControllerHostContext,
562                _a: BorrowedStr<'_>,
563            ) -> PluginResult<OwnedBytes> {
564                PluginResult::Ok(OwnedBytes::empty())
565            }
566        };
567    }
568
569    macro_rules! stub_unit {
570        ($name:ident, ($($arg:ident : $ty:ty),* $(,)?)) => {
571            unsafe extern "C" fn $name($($arg: $ty),*) -> PluginResult<()> {
572                $(let _ = $arg;)*
573                PluginResult::Ok(())
574            }
575        };
576    }
577
578    stub_bytes!(stub_cache_instrument);
579    stub_bytes!(stub_cache_account);
580    stub_bytes!(stub_cache_order);
581    stub_bytes!(stub_cache_position);
582    stub_bytes!(stub_cache_orders_for_strategy);
583    stub_bytes!(stub_cache_positions_for_strategy);
584    stub_controller_bytes!(stub_controller_create_plugin_strategy);
585    stub_controller_bytes!(stub_controller_start_strategy);
586    stub_controller_bytes!(stub_controller_stop_strategy);
587    stub_controller_bytes!(stub_controller_exit_market);
588    stub_controller_bytes!(stub_controller_remove_strategy);
589    stub_controller_bytes!(stub_controller_instrument_exists);
590    stub_controller_bytes!(stub_controller_log);
591    stub_controller_bytes!(stub_controller_clock_now_ns);
592
593    stub_unit!(
594        stub_subscribe,
595        (
596            ctx: *const HostContext,
597            a: BorrowedStr<'_>,
598            b: BorrowedStr<'_>,
599            c: BorrowedStr<'_>,
600        )
601    );
602    stub_unit!(
603        stub_subscribe_book_deltas,
604        (
605            ctx: *const HostContext,
606            a: BorrowedStr<'_>,
607            t: u8,
608            d: usize,
609            b: BorrowedStr<'_>,
610            m: u8,
611            c: BorrowedStr<'_>,
612        )
613    );
614    stub_unit!(
615        stub_subscribe_book_at_interval,
616        (
617            ctx: *const HostContext,
618            a: BorrowedStr<'_>,
619            t: u8,
620            d: usize,
621            i: usize,
622            b: BorrowedStr<'_>,
623            c: BorrowedStr<'_>,
624        )
625    );
626    stub_unit!(
627        stub_unsubscribe_book_at_interval,
628        (
629            ctx: *const HostContext,
630            a: BorrowedStr<'_>,
631            i: usize,
632            b: BorrowedStr<'_>,
633            c: BorrowedStr<'_>,
634        )
635    );
636    stub_unit!(
637        stub_msgbus_publish,
638        (
639            ctx: *const HostContext,
640            t: BorrowedStr<'_>,
641            p: Slice<'_, u8>,
642        )
643    );
644    stub_unit!(
645        stub_set_time_alert,
646        (
647            ctx: *const HostContext,
648            n: BorrowedStr<'_>,
649            a: u64,
650            p: u8,
651        )
652    );
653    stub_unit!(
654        stub_set_timer,
655        (
656            ctx: *const HostContext,
657            n: BorrowedStr<'_>,
658            i: u64,
659            s: u64,
660            e: u64,
661            p: u8,
662            f: u8,
663        )
664    );
665    stub_unit!(stub_cancel_timer, (ctx: *const HostContext, n: BorrowedStr<'_>));
666    stub_unit!(
667        stub_submit_order,
668        (ctx: *const HostContext, c: *const SubmitOrderHandle)
669    );
670    stub_unit!(
671        stub_cancel_order,
672        (ctx: *const HostContext, c: *const CancelOrderHandle)
673    );
674    stub_unit!(
675        stub_modify_order,
676        (ctx: *const HostContext, c: *const ModifyOrderHandle)
677    );
678    stub_unit!(
679        stub_submit_order_list,
680        (ctx: *const HostContext, c: *const SubmitOrderListHandle)
681    );
682    stub_unit!(
683        stub_cancel_orders,
684        (ctx: *const HostContext, c: *const CancelOrdersHandle)
685    );
686    stub_unit!(
687        stub_cancel_all_orders,
688        (ctx: *const HostContext, c: *const CancelAllOrdersHandle)
689    );
690    stub_unit!(
691        stub_close_position,
692        (ctx: *const HostContext, c: *const ClosePositionHandle)
693    );
694    stub_unit!(
695        stub_close_all_positions,
696        (ctx: *const HostContext, c: *const CloseAllPositionsHandle)
697    );
698    stub_unit!(
699        stub_query_account,
700        (ctx: *const HostContext, c: *const QueryAccountHandle)
701    );
702    stub_unit!(
703        stub_query_order,
704        (ctx: *const HostContext, c: *const QueryOrderHandle)
705    );
706
707    fn build_test_host(abi: u32) -> HostVTable {
708        HostVTable {
709            abi_version: abi,
710            clock_now_ns: fixed_clock_now_ns,
711            log: recording_log,
712            cache_instrument: stub_cache_instrument,
713            cache_account: stub_cache_account,
714            cache_order: stub_cache_order,
715            cache_position: stub_cache_position,
716            cache_orders_for_strategy: stub_cache_orders_for_strategy,
717            cache_positions_for_strategy: stub_cache_positions_for_strategy,
718            subscribe_quotes: stub_subscribe,
719            unsubscribe_quotes: stub_subscribe,
720            subscribe_trades: stub_subscribe,
721            unsubscribe_trades: stub_subscribe,
722            subscribe_bars: stub_subscribe,
723            unsubscribe_bars: stub_subscribe,
724            subscribe_book_deltas: stub_subscribe_book_deltas,
725            unsubscribe_book_deltas: stub_subscribe,
726            subscribe_book_at_interval: stub_subscribe_book_at_interval,
727            unsubscribe_book_at_interval: stub_unsubscribe_book_at_interval,
728            msgbus_publish: stub_msgbus_publish,
729            set_time_alert: stub_set_time_alert,
730            set_timer: stub_set_timer,
731            cancel_timer: stub_cancel_timer,
732            submit_order: stub_submit_order,
733            cancel_order: stub_cancel_order,
734            modify_order: stub_modify_order,
735            submit_order_list: stub_submit_order_list,
736            cancel_orders: stub_cancel_orders,
737            cancel_all_orders: stub_cancel_all_orders,
738            close_position: stub_close_position,
739            close_all_positions: stub_close_all_positions,
740            query_account: stub_query_account,
741            query_order: stub_query_order,
742        }
743    }
744
745    fn build_controller_test_host(abi: u32) -> ControllerHostVTable {
746        ControllerHostVTable {
747            abi_version: abi,
748            create_plugin_strategy: stub_controller_create_plugin_strategy,
749            start_strategy: stub_controller_start_strategy,
750            stop_strategy: stub_controller_stop_strategy,
751            exit_market: stub_controller_exit_market,
752            remove_strategy: stub_controller_remove_strategy,
753            instrument_exists: stub_controller_instrument_exists,
754            log: stub_controller_log,
755            clock_now_ns: stub_controller_clock_now_ns,
756        }
757    }
758
759    #[rstest]
760    fn matches_compiled_abi_accepts_compiled_version() {
761        let host = build_test_host(NAUTILUS_PLUGIN_ABI_VERSION);
762        assert!(host.matches_compiled_abi());
763    }
764
765    #[rstest]
766    fn controller_matches_compiled_abi_accepts_compiled_version() {
767        let host = build_controller_test_host(NAUTILUS_PLUGIN_ABI_VERSION);
768        assert!(host.matches_compiled_abi());
769    }
770
771    #[rstest]
772    #[case::off_by_one(NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1))]
773    #[case::zero(0)]
774    #[case::max(u32::MAX)]
775    fn matches_compiled_abi_rejects_mismatch(#[case] abi: u32) {
776        let host = build_test_host(abi);
777        assert!(!host.matches_compiled_abi());
778    }
779
780    #[rstest]
781    #[case::off_by_one(NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1))]
782    #[case::zero(0)]
783    #[case::max(u32::MAX)]
784    fn controller_matches_compiled_abi_rejects_mismatch(#[case] abi: u32) {
785        let host = build_controller_test_host(abi);
786        assert!(!host.matches_compiled_abi());
787    }
788
789    #[rstest]
790    fn now_ns_calls_clock_function_pointer() {
791        let _g = shared_state_lock();
792        CLOCK_VALUE.store(42_424_242, Ordering::SeqCst);
793        let host = build_test_host(NAUTILUS_PLUGIN_ABI_VERSION);
794        // SAFETY: clock_now_ns function pointer is non-null and lives for
795        // the test scope.
796        let n = unsafe { host.now_ns() };
797        assert_eq!(n, 42_424_242);
798    }
799
800    #[rstest]
801    #[case::error(HostLogLevel::Error, 1u8)]
802    #[case::warn(HostLogLevel::Warn, 2)]
803    #[case::info(HostLogLevel::Info, 3)]
804    #[case::debug(HostLogLevel::Debug, 4)]
805    #[case::trace(HostLogLevel::Trace, 5)]
806    fn log_message_invokes_log_with_the_right_level(
807        #[case] level: HostLogLevel,
808        #[case] expected_discriminant: u8,
809    ) {
810        let _g = shared_state_lock();
811        LOG_LEVEL_OBSERVED.store(0, Ordering::SeqCst);
812        let host = build_test_host(NAUTILUS_PLUGIN_ABI_VERSION);
813        // SAFETY: log fn pointer is non-null and lives for the test scope.
814        unsafe { host.log_message(level, "target", "message") };
815        assert_eq!(
816            LOG_LEVEL_OBSERVED.load(Ordering::SeqCst),
817            expected_discriminant
818        );
819    }
820}