Skip to main content

nautilus_plugin/bridge/
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 `HostVTable` that routes plug-in callbacks through live adapters.
17//!
18//! The thunks resolve the calling adapter from the per-instance
19//! [`HostContextInner`] payload, then route cache reads, subscription changes,
20//! msgbus publishes, timers, and order commands through the same
21//! [`DataActor`] / [`Strategy`] paths as
22//! compiled-in components.
23
24#![allow(unsafe_code)]
25#![allow(
26    clippy::multiple_unsafe_ops_per_block,
27    reason = "vtable deref and FFI call form a single boundary callback; \
28              SAFETY comments cover both ops together"
29)]
30
31use std::{num::NonZeroUsize, str::FromStr, sync::OnceLock};
32
33use nautilus_common::{
34    actor::{DataActor, registry::try_get_actor_unchecked},
35    cache::Cache,
36    msgbus,
37};
38use nautilus_core::{Params, UnixNanos, time::duration_since_unix_epoch};
39use nautilus_model::{
40    data::BarType,
41    enums::{BookType, FromU8},
42    identifiers::{AccountId, ClientId, ClientOrderId, InstrumentId, PositionId, StrategyId},
43};
44use nautilus_trading::strategy::Strategy;
45use serde::Serialize;
46
47use crate::{
48    NAUTILUS_PLUGIN_ABI_VERSION,
49    boundary::{BorrowedStr, OwnedBytes, PluginError, PluginErrorCode, PluginResult, Slice},
50    bridge::{
51        actor::PluginActorAdapter,
52        registry::{HostContextInner, controller_host_context_inner, host_context_inner},
53        strategy::PluginStrategyAdapter,
54    },
55    host::{ControllerHostContext, ControllerHostVTable, HostContext, HostLogLevel, HostVTable},
56    loader::PluginLoader,
57    normalize::BoundaryCommandHandle,
58    surfaces::commands::{
59        CancelAllOrdersHandle, CancelOrderHandle, CancelOrdersHandle, CloseAllPositionsHandle,
60        ClosePositionHandle, ModifyOrderHandle, QueryAccountHandle, QueryOrderHandle,
61        SubmitOrderHandle, SubmitOrderListHandle,
62    },
63};
64
65/// Returns the process-wide `HostVTable` configured for the live node.
66///
67/// One static vtable is enough because plug-ins never compare vtables; they
68/// only call through the function pointers. The vtable bundles the order
69/// command thunks that route through [`Strategy`].
70#[must_use]
71pub fn host_vtable() -> *const HostVTable {
72    static HOST: OnceLock<HostVTable> = OnceLock::new();
73    std::ptr::from_ref(HOST.get_or_init(|| HostVTable {
74        abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
75        clock_now_ns: host_clock_now_ns,
76        log: host_log,
77        cache_instrument: host_cache_instrument,
78        cache_account: host_cache_account,
79        cache_order: host_cache_order,
80        cache_position: host_cache_position,
81        cache_orders_for_strategy: host_cache_orders_for_strategy,
82        cache_positions_for_strategy: host_cache_positions_for_strategy,
83        subscribe_quotes: host_subscribe_quotes,
84        unsubscribe_quotes: host_unsubscribe_quotes,
85        subscribe_trades: host_subscribe_trades,
86        unsubscribe_trades: host_unsubscribe_trades,
87        subscribe_bars: host_subscribe_bars,
88        unsubscribe_bars: host_unsubscribe_bars,
89        subscribe_book_deltas: host_subscribe_book_deltas,
90        unsubscribe_book_deltas: host_unsubscribe_book_deltas,
91        subscribe_book_at_interval: host_subscribe_book_at_interval,
92        unsubscribe_book_at_interval: host_unsubscribe_book_at_interval,
93        msgbus_publish: host_msgbus_publish,
94        set_time_alert: host_set_time_alert,
95        set_timer: host_set_timer,
96        cancel_timer: host_cancel_timer,
97        submit_order: host_submit_order,
98        cancel_order: host_cancel_order,
99        modify_order: host_modify_order,
100        submit_order_list: host_submit_order_list,
101        cancel_orders: host_cancel_orders,
102        cancel_all_orders: host_cancel_all_orders,
103        close_position: host_close_position,
104        close_all_positions: host_close_all_positions,
105        query_account: host_query_account,
106        query_order: host_query_order,
107    }))
108}
109
110/// Returns the process-wide `ControllerHostVTable` for plug-in controllers.
111#[must_use]
112pub fn controller_host_vtable() -> *const ControllerHostVTable {
113    static HOST: OnceLock<ControllerHostVTable> = OnceLock::new();
114    std::ptr::from_ref(HOST.get_or_init(|| ControllerHostVTable {
115        abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
116        create_plugin_strategy: controller_host_not_implemented,
117        start_strategy: controller_host_not_implemented,
118        stop_strategy: controller_host_not_implemented,
119        exit_market: controller_host_not_implemented,
120        remove_strategy: controller_host_not_implemented,
121        instrument_exists: controller_host_not_implemented,
122        log: controller_host_log,
123        clock_now_ns: controller_host_clock_now_ns,
124    }))
125}
126
127/// Returns a [`PluginLoader`] pre-bound to the host vtable from
128/// [`host_vtable`].
129///
130/// The loader hands every plug-in cdylib the live-node vtable so order
131/// stateful callbacks route through live adapters instead of returning
132/// `NotImplemented`.
133#[must_use]
134pub fn plugin_loader() -> PluginLoader {
135    PluginLoader::with_host(host_vtable())
136}
137
138unsafe extern "C" fn host_clock_now_ns() -> u64 {
139    u64::try_from(duration_since_unix_epoch().as_nanos()).unwrap_or(u64::MAX)
140}
141
142unsafe extern "C" fn controller_host_not_implemented(
143    ctx: *const ControllerHostContext,
144    _request_json: BorrowedStr<'_>,
145) -> PluginResult<OwnedBytes> {
146    let context = controller_context_label(ctx);
147    PluginResult::Err(PluginError::new(
148        PluginErrorCode::NotImplemented,
149        format!("{context} controller host service is not implemented"),
150    ))
151}
152
153unsafe extern "C" fn controller_host_log(
154    ctx: *const ControllerHostContext,
155    request_json: BorrowedStr<'_>,
156) -> PluginResult<OwnedBytes> {
157    let context = controller_context_label(ctx);
158    // SAFETY: producer holds the storage live across the call.
159    let request = unsafe { request_json.as_str() };
160    log::info!(target: "nautilus_plugin", "[{context}] {request}");
161    PluginResult::Ok(OwnedBytes::empty())
162}
163
164unsafe extern "C" fn controller_host_clock_now_ns(
165    _ctx: *const ControllerHostContext,
166    _request_json: BorrowedStr<'_>,
167) -> PluginResult<OwnedBytes> {
168    json_bytes(&serde_json::json!({
169        "unix_nanos": u64::try_from(duration_since_unix_epoch().as_nanos()).unwrap_or(u64::MAX),
170    }))
171}
172
173fn controller_context_label(ctx: *const ControllerHostContext) -> String {
174    // SAFETY: plug-ins round-trip the context pointer supplied at create time.
175    let Some(inner) = (unsafe { controller_host_context_inner(ctx) }) else {
176        return "unknown-controller".to_string();
177    };
178    format!("{}:{}", inner.plugin_name, inner.type_name)
179}
180
181unsafe extern "C" fn host_log(
182    level: HostLogLevel,
183    target: BorrowedStr<'_>,
184    message: BorrowedStr<'_>,
185) {
186    // SAFETY: producer holds the storage live across the call.
187    let target = unsafe { target.as_str() };
188    // SAFETY: see above.
189    let message = unsafe { message.as_str() };
190    match level {
191        HostLogLevel::Error => log::error!(target: "nautilus_plugin", "[{target}] {message}"),
192        HostLogLevel::Warn => log::warn!(target: "nautilus_plugin", "[{target}] {message}"),
193        HostLogLevel::Info => log::info!(target: "nautilus_plugin", "[{target}] {message}"),
194        HostLogLevel::Debug => log::debug!(target: "nautilus_plugin", "[{target}] {message}"),
195        HostLogLevel::Trace => log::trace!(target: "nautilus_plugin", "[{target}] {message}"),
196    }
197}
198
199unsafe extern "C" fn host_cache_instrument(
200    ctx: *const HostContext,
201    instrument_id: BorrowedStr<'_>,
202) -> PluginResult<OwnedBytes> {
203    let instrument_id = match parse_instrument_id(instrument_id, "instrument_id") {
204        Ok(id) => id,
205        Err(e) => return PluginResult::Err(e),
206    };
207
208    dispatch_cache_query(ctx, "cache_instrument", |cache, _| {
209        json_optional(cache.instrument(&instrument_id))
210    })
211}
212
213unsafe extern "C" fn host_cache_account(
214    ctx: *const HostContext,
215    account_id: BorrowedStr<'_>,
216) -> PluginResult<OwnedBytes> {
217    let account_id = match parse_account_id(account_id, "account_id") {
218        Ok(id) => id,
219        Err(e) => return PluginResult::Err(e),
220    };
221
222    dispatch_cache_query(ctx, "cache_account", |cache, _| {
223        let account = cache.account(&account_id).map(|account| account.cloned());
224        json_optional(account.as_ref())
225    })
226}
227
228unsafe extern "C" fn host_cache_order(
229    ctx: *const HostContext,
230    client_order_id: BorrowedStr<'_>,
231) -> PluginResult<OwnedBytes> {
232    let client_order_id = match parse_client_order_id(client_order_id, "client_order_id") {
233        Ok(id) => id,
234        Err(e) => return PluginResult::Err(e),
235    };
236
237    dispatch_cache_query(ctx, "cache_order", |cache, _| {
238        let order = cache.order(&client_order_id).map(|order| order.cloned());
239        json_optional(order.as_ref())
240    })
241}
242
243unsafe extern "C" fn host_cache_position(
244    ctx: *const HostContext,
245    position_id: BorrowedStr<'_>,
246) -> PluginResult<OwnedBytes> {
247    let position_id = match parse_position_id(position_id, "position_id") {
248        Ok(id) => id,
249        Err(e) => return PluginResult::Err(e),
250    };
251
252    dispatch_cache_query(ctx, "cache_position", |cache, _| {
253        let position = cache
254            .position(&position_id)
255            .map(|position| position.cloned());
256        json_optional(position.as_ref())
257    })
258}
259
260unsafe extern "C" fn host_cache_orders_for_strategy(
261    ctx: *const HostContext,
262    strategy_id: BorrowedStr<'_>,
263) -> PluginResult<OwnedBytes> {
264    dispatch_cache_query(ctx, "cache_orders_for_strategy", |cache, inner| {
265        let strategy_id = match parse_strategy_id_for_context(strategy_id, inner) {
266            Ok(id) => id,
267            Err(e) => return PluginResult::Err(e),
268        };
269        let orders = cache
270            .orders(None, None, Some(&strategy_id), None, None)
271            .into_iter()
272            .map(|order| order.cloned())
273            .collect::<Vec<_>>();
274        json_bytes(&orders)
275    })
276}
277
278unsafe extern "C" fn host_cache_positions_for_strategy(
279    ctx: *const HostContext,
280    strategy_id: BorrowedStr<'_>,
281) -> PluginResult<OwnedBytes> {
282    dispatch_cache_query(ctx, "cache_positions_for_strategy", |cache, inner| {
283        let strategy_id = match parse_strategy_id_for_context(strategy_id, inner) {
284            Ok(id) => id,
285            Err(e) => return PluginResult::Err(e),
286        };
287        let positions = cache
288            .positions(None, None, Some(&strategy_id), None, None)
289            .into_iter()
290            .map(|position| position.cloned())
291            .collect::<Vec<_>>();
292        json_bytes(&positions)
293    })
294}
295
296unsafe extern "C" fn host_subscribe_quotes(
297    ctx: *const HostContext,
298    instrument_id: BorrowedStr<'_>,
299    client_id: BorrowedStr<'_>,
300    params_json: BorrowedStr<'_>,
301) -> PluginResult<()> {
302    let args = match parse_instrument_subscription(instrument_id, client_id, params_json) {
303        Ok(args) => args,
304        Err(e) => return PluginResult::Err(e),
305    };
306    let actor_args = args.clone();
307    let strategy_args = args;
308
309    dispatch_actor_action(
310        ctx,
311        "subscribe_quotes",
312        |actor| {
313            DataActor::subscribe_quotes(
314                actor,
315                actor_args.instrument_id,
316                actor_args.client_id,
317                actor_args.params,
318            );
319            Ok(())
320        },
321        |strategy| {
322            DataActor::subscribe_quotes(
323                strategy,
324                strategy_args.instrument_id,
325                strategy_args.client_id,
326                strategy_args.params,
327            );
328            Ok(())
329        },
330    )
331}
332
333unsafe extern "C" fn host_unsubscribe_quotes(
334    ctx: *const HostContext,
335    instrument_id: BorrowedStr<'_>,
336    client_id: BorrowedStr<'_>,
337    params_json: BorrowedStr<'_>,
338) -> PluginResult<()> {
339    let args = match parse_instrument_subscription(instrument_id, client_id, params_json) {
340        Ok(args) => args,
341        Err(e) => return PluginResult::Err(e),
342    };
343    let actor_args = args.clone();
344    let strategy_args = args;
345
346    dispatch_actor_action(
347        ctx,
348        "unsubscribe_quotes",
349        |actor| {
350            DataActor::unsubscribe_quotes(
351                actor,
352                actor_args.instrument_id,
353                actor_args.client_id,
354                actor_args.params,
355            );
356            Ok(())
357        },
358        |strategy| {
359            DataActor::unsubscribe_quotes(
360                strategy,
361                strategy_args.instrument_id,
362                strategy_args.client_id,
363                strategy_args.params,
364            );
365            Ok(())
366        },
367    )
368}
369
370unsafe extern "C" fn host_subscribe_trades(
371    ctx: *const HostContext,
372    instrument_id: BorrowedStr<'_>,
373    client_id: BorrowedStr<'_>,
374    params_json: BorrowedStr<'_>,
375) -> PluginResult<()> {
376    let args = match parse_instrument_subscription(instrument_id, client_id, params_json) {
377        Ok(args) => args,
378        Err(e) => return PluginResult::Err(e),
379    };
380    let actor_args = args.clone();
381    let strategy_args = args;
382
383    dispatch_actor_action(
384        ctx,
385        "subscribe_trades",
386        |actor| {
387            DataActor::subscribe_trades(
388                actor,
389                actor_args.instrument_id,
390                actor_args.client_id,
391                actor_args.params,
392            );
393            Ok(())
394        },
395        |strategy| {
396            DataActor::subscribe_trades(
397                strategy,
398                strategy_args.instrument_id,
399                strategy_args.client_id,
400                strategy_args.params,
401            );
402            Ok(())
403        },
404    )
405}
406
407unsafe extern "C" fn host_unsubscribe_trades(
408    ctx: *const HostContext,
409    instrument_id: BorrowedStr<'_>,
410    client_id: BorrowedStr<'_>,
411    params_json: BorrowedStr<'_>,
412) -> PluginResult<()> {
413    let args = match parse_instrument_subscription(instrument_id, client_id, params_json) {
414        Ok(args) => args,
415        Err(e) => return PluginResult::Err(e),
416    };
417    let actor_args = args.clone();
418    let strategy_args = args;
419
420    dispatch_actor_action(
421        ctx,
422        "unsubscribe_trades",
423        |actor| {
424            DataActor::unsubscribe_trades(
425                actor,
426                actor_args.instrument_id,
427                actor_args.client_id,
428                actor_args.params,
429            );
430            Ok(())
431        },
432        |strategy| {
433            DataActor::unsubscribe_trades(
434                strategy,
435                strategy_args.instrument_id,
436                strategy_args.client_id,
437                strategy_args.params,
438            );
439            Ok(())
440        },
441    )
442}
443
444unsafe extern "C" fn host_subscribe_bars(
445    ctx: *const HostContext,
446    bar_type: BorrowedStr<'_>,
447    client_id: BorrowedStr<'_>,
448    params_json: BorrowedStr<'_>,
449) -> PluginResult<()> {
450    let args = match parse_bar_subscription(bar_type, client_id, params_json) {
451        Ok(args) => args,
452        Err(e) => return PluginResult::Err(e),
453    };
454    let actor_args = args.clone();
455    let strategy_args = args;
456
457    dispatch_actor_action(
458        ctx,
459        "subscribe_bars",
460        |actor| {
461            DataActor::subscribe_bars(
462                actor,
463                actor_args.bar_type,
464                actor_args.client_id,
465                actor_args.params,
466            );
467            Ok(())
468        },
469        |strategy| {
470            DataActor::subscribe_bars(
471                strategy,
472                strategy_args.bar_type,
473                strategy_args.client_id,
474                strategy_args.params,
475            );
476            Ok(())
477        },
478    )
479}
480
481unsafe extern "C" fn host_unsubscribe_bars(
482    ctx: *const HostContext,
483    bar_type: BorrowedStr<'_>,
484    client_id: BorrowedStr<'_>,
485    params_json: BorrowedStr<'_>,
486) -> PluginResult<()> {
487    let args = match parse_bar_subscription(bar_type, client_id, params_json) {
488        Ok(args) => args,
489        Err(e) => return PluginResult::Err(e),
490    };
491    let actor_args = args.clone();
492    let strategy_args = args;
493
494    dispatch_actor_action(
495        ctx,
496        "unsubscribe_bars",
497        |actor| {
498            DataActor::unsubscribe_bars(
499                actor,
500                actor_args.bar_type,
501                actor_args.client_id,
502                actor_args.params,
503            );
504            Ok(())
505        },
506        |strategy| {
507            DataActor::unsubscribe_bars(
508                strategy,
509                strategy_args.bar_type,
510                strategy_args.client_id,
511                strategy_args.params,
512            );
513            Ok(())
514        },
515    )
516}
517
518unsafe extern "C" fn host_subscribe_book_deltas(
519    ctx: *const HostContext,
520    instrument_id: BorrowedStr<'_>,
521    book_type: u8,
522    depth: usize,
523    client_id: BorrowedStr<'_>,
524    managed: u8,
525    params_json: BorrowedStr<'_>,
526) -> PluginResult<()> {
527    let args =
528        match parse_book_subscription(instrument_id, book_type, depth, client_id, params_json) {
529            Ok(args) => args,
530            Err(e) => return PluginResult::Err(e),
531        };
532    let actor_args = args.clone();
533    let strategy_args = args;
534    let managed = managed != 0;
535
536    dispatch_actor_action(
537        ctx,
538        "subscribe_book_deltas",
539        |actor| {
540            DataActor::subscribe_book_deltas(
541                actor,
542                actor_args.instrument_id,
543                actor_args.book_type,
544                actor_args.depth,
545                actor_args.client_id,
546                managed,
547                actor_args.params,
548            );
549            Ok(())
550        },
551        |strategy| {
552            DataActor::subscribe_book_deltas(
553                strategy,
554                strategy_args.instrument_id,
555                strategy_args.book_type,
556                strategy_args.depth,
557                strategy_args.client_id,
558                managed,
559                strategy_args.params,
560            );
561            Ok(())
562        },
563    )
564}
565
566unsafe extern "C" fn host_unsubscribe_book_deltas(
567    ctx: *const HostContext,
568    instrument_id: BorrowedStr<'_>,
569    client_id: BorrowedStr<'_>,
570    params_json: BorrowedStr<'_>,
571) -> PluginResult<()> {
572    let args = match parse_instrument_subscription(instrument_id, client_id, params_json) {
573        Ok(args) => args,
574        Err(e) => return PluginResult::Err(e),
575    };
576    let actor_args = args.clone();
577    let strategy_args = args;
578
579    dispatch_actor_action(
580        ctx,
581        "unsubscribe_book_deltas",
582        |actor| {
583            DataActor::unsubscribe_book_deltas(
584                actor,
585                actor_args.instrument_id,
586                actor_args.client_id,
587                actor_args.params,
588            );
589            Ok(())
590        },
591        |strategy| {
592            DataActor::unsubscribe_book_deltas(
593                strategy,
594                strategy_args.instrument_id,
595                strategy_args.client_id,
596                strategy_args.params,
597            );
598            Ok(())
599        },
600    )
601}
602
603unsafe extern "C" fn host_subscribe_book_at_interval(
604    ctx: *const HostContext,
605    instrument_id: BorrowedStr<'_>,
606    book_type: u8,
607    depth: usize,
608    interval_ms: usize,
609    client_id: BorrowedStr<'_>,
610    params_json: BorrowedStr<'_>,
611) -> PluginResult<()> {
612    let args =
613        match parse_book_subscription(instrument_id, book_type, depth, client_id, params_json) {
614            Ok(args) => args,
615            Err(e) => return PluginResult::Err(e),
616        };
617    let actor_args = args.clone();
618    let strategy_args = args;
619    let interval_ms = match NonZeroUsize::new(interval_ms) {
620        Some(value) => value,
621        None => {
622            return PluginResult::Err(PluginError::new(
623                PluginErrorCode::InvalidArgument,
624                "interval_ms must be greater than zero",
625            ));
626        }
627    };
628
629    dispatch_actor_action(
630        ctx,
631        "subscribe_book_at_interval",
632        |actor| {
633            DataActor::subscribe_book_at_interval(
634                actor,
635                actor_args.instrument_id,
636                actor_args.book_type,
637                actor_args.depth,
638                interval_ms,
639                actor_args.client_id,
640                actor_args.params,
641            );
642            Ok(())
643        },
644        |strategy| {
645            DataActor::subscribe_book_at_interval(
646                strategy,
647                strategy_args.instrument_id,
648                strategy_args.book_type,
649                strategy_args.depth,
650                interval_ms,
651                strategy_args.client_id,
652                strategy_args.params,
653            );
654            Ok(())
655        },
656    )
657}
658
659unsafe extern "C" fn host_unsubscribe_book_at_interval(
660    ctx: *const HostContext,
661    instrument_id: BorrowedStr<'_>,
662    interval_ms: usize,
663    client_id: BorrowedStr<'_>,
664    params_json: BorrowedStr<'_>,
665) -> PluginResult<()> {
666    let args = match parse_instrument_subscription(instrument_id, client_id, params_json) {
667        Ok(args) => args,
668        Err(e) => return PluginResult::Err(e),
669    };
670    let actor_args = args.clone();
671    let strategy_args = args;
672    let interval_ms = match NonZeroUsize::new(interval_ms) {
673        Some(value) => value,
674        None => {
675            return PluginResult::Err(PluginError::new(
676                PluginErrorCode::InvalidArgument,
677                "interval_ms must be greater than zero",
678            ));
679        }
680    };
681
682    dispatch_actor_action(
683        ctx,
684        "unsubscribe_book_at_interval",
685        |actor| {
686            DataActor::unsubscribe_book_at_interval(
687                actor,
688                actor_args.instrument_id,
689                interval_ms,
690                actor_args.client_id,
691                actor_args.params,
692            );
693            Ok(())
694        },
695        |strategy| {
696            DataActor::unsubscribe_book_at_interval(
697                strategy,
698                strategy_args.instrument_id,
699                interval_ms,
700                strategy_args.client_id,
701                strategy_args.params,
702            );
703            Ok(())
704        },
705    )
706}
707
708unsafe extern "C" fn host_msgbus_publish(
709    ctx: *const HostContext,
710    topic: BorrowedStr<'_>,
711    payload: Slice<'_, u8>,
712) -> PluginResult<()> {
713    let inner = match resolve_context(ctx, "msgbus_publish") {
714        Ok(inner) => inner,
715        Err(e) => return PluginResult::Err(e),
716    };
717
718    if let Err(e) = ensure_adapter_registered("msgbus_publish", inner) {
719        return PluginResult::Err(e);
720    }
721
722    // SAFETY: topic and payload borrow storage live across this call.
723    let topic = unsafe { topic.as_str() };
724    // SAFETY: see above.
725    let payload = unsafe { payload.as_slice() }.to_vec();
726    msgbus::publish_any(topic.into(), &payload);
727    PluginResult::Ok(())
728}
729
730unsafe extern "C" fn host_set_time_alert(
731    ctx: *const HostContext,
732    name: BorrowedStr<'_>,
733    alert_time_ns: u64,
734    allow_past: u8,
735) -> PluginResult<()> {
736    // SAFETY: name borrows storage live across this call.
737    let name = unsafe { name.as_str() }.to_string();
738    dispatch_actor_action(
739        ctx,
740        "set_time_alert",
741        |actor| {
742            actor.clock().set_time_alert_ns(
743                &name,
744                UnixNanos::from(alert_time_ns),
745                None,
746                Some(allow_past != 0),
747            )
748        },
749        |strategy| {
750            strategy.clock().set_time_alert_ns(
751                &name,
752                UnixNanos::from(alert_time_ns),
753                None,
754                Some(allow_past != 0),
755            )
756        },
757    )
758}
759
760unsafe extern "C" fn host_set_timer(
761    ctx: *const HostContext,
762    name: BorrowedStr<'_>,
763    interval_ns: u64,
764    start_time_ns: u64,
765    stop_time_ns: u64,
766    allow_past: u8,
767    fire_immediately: u8,
768) -> PluginResult<()> {
769    // SAFETY: name borrows storage live across this call.
770    let name = unsafe { name.as_str() }.to_string();
771    let start_time_ns = nonzero_unix_nanos(start_time_ns);
772    let stop_time_ns = nonzero_unix_nanos(stop_time_ns);
773
774    dispatch_actor_action(
775        ctx,
776        "set_timer",
777        |actor| {
778            actor.clock().set_timer_ns(
779                &name,
780                interval_ns,
781                start_time_ns,
782                stop_time_ns,
783                None,
784                Some(allow_past != 0),
785                Some(fire_immediately != 0),
786            )
787        },
788        |strategy| {
789            strategy.clock().set_timer_ns(
790                &name,
791                interval_ns,
792                start_time_ns,
793                stop_time_ns,
794                None,
795                Some(allow_past != 0),
796                Some(fire_immediately != 0),
797            )
798        },
799    )
800}
801
802unsafe extern "C" fn host_cancel_timer(
803    ctx: *const HostContext,
804    name: BorrowedStr<'_>,
805) -> PluginResult<()> {
806    // SAFETY: name borrows storage live across this call.
807    let name = unsafe { name.as_str() }.to_string();
808    dispatch_actor_action(
809        ctx,
810        "cancel_timer",
811        |actor| {
812            actor.clock().cancel_timer(&name);
813            Ok(())
814        },
815        |strategy| {
816            strategy.clock().cancel_timer(&name);
817            Ok(())
818        },
819    )
820}
821
822unsafe extern "C" fn host_submit_order(
823    ctx: *const HostContext,
824    command: *const SubmitOrderHandle,
825) -> PluginResult<()> {
826    // SAFETY: plug-in keeps the handle alive for the duration of the call.
827    unsafe {
828        dispatch_handle(ctx, command, "submit_order", |adapter, cmd| {
829            Strategy::submit_order(
830                adapter,
831                cmd.order,
832                cmd.position_id,
833                cmd.client_id,
834                cmd.params,
835            )
836        })
837    }
838}
839
840unsafe extern "C" fn host_cancel_order(
841    ctx: *const HostContext,
842    command: *const CancelOrderHandle,
843) -> PluginResult<()> {
844    // SAFETY: plug-in keeps the handle alive for the duration of the call.
845    unsafe {
846        dispatch_handle(ctx, command, "cancel_order", |adapter, cmd| {
847            Strategy::cancel_order(adapter, cmd.client_order_id, cmd.client_id, cmd.params)
848        })
849    }
850}
851
852unsafe extern "C" fn host_modify_order(
853    ctx: *const HostContext,
854    command: *const ModifyOrderHandle,
855) -> PluginResult<()> {
856    // SAFETY: plug-in keeps the handle alive for the duration of the call.
857    unsafe {
858        dispatch_handle(ctx, command, "modify_order", |adapter, cmd| {
859            Strategy::modify_order(
860                adapter,
861                cmd.client_order_id,
862                cmd.quantity,
863                cmd.price,
864                cmd.trigger_price,
865                cmd.client_id,
866                cmd.params,
867            )
868        })
869    }
870}
871
872unsafe extern "C" fn host_submit_order_list(
873    ctx: *const HostContext,
874    command: *const SubmitOrderListHandle,
875) -> PluginResult<()> {
876    // SAFETY: plug-in keeps the handle alive for the duration of the call.
877    unsafe {
878        dispatch_handle(ctx, command, "submit_order_list", |adapter, cmd| {
879            Strategy::submit_order_list(
880                adapter,
881                cmd.orders,
882                cmd.position_id,
883                cmd.client_id,
884                cmd.params,
885            )
886        })
887    }
888}
889
890unsafe extern "C" fn host_cancel_orders(
891    ctx: *const HostContext,
892    command: *const CancelOrdersHandle,
893) -> PluginResult<()> {
894    // SAFETY: plug-in keeps the handle alive for the duration of the call.
895    unsafe {
896        dispatch_handle(ctx, command, "cancel_orders", |adapter, cmd| {
897            Strategy::cancel_orders(adapter, cmd.client_order_ids, cmd.client_id, cmd.params)
898        })
899    }
900}
901
902unsafe extern "C" fn host_cancel_all_orders(
903    ctx: *const HostContext,
904    command: *const CancelAllOrdersHandle,
905) -> PluginResult<()> {
906    // SAFETY: plug-in keeps the handle alive for the duration of the call.
907    unsafe {
908        dispatch_handle(ctx, command, "cancel_all_orders", |adapter, cmd| {
909            Strategy::cancel_all_orders(
910                adapter,
911                cmd.instrument_id,
912                cmd.order_side,
913                cmd.client_id,
914                cmd.params,
915            )
916        })
917    }
918}
919
920unsafe extern "C" fn host_close_position(
921    ctx: *const HostContext,
922    command: *const ClosePositionHandle,
923) -> PluginResult<()> {
924    // SAFETY: plug-in keeps the handle alive for the duration of the call.
925    unsafe {
926        dispatch_handle(ctx, command, "close_position", |adapter, cmd| {
927            let position = {
928                let cache = adapter.cache();
929                cache.position(&cmd.position_id).map(|p| p.cloned())
930            };
931            let position = position.ok_or_else(|| {
932                anyhow::anyhow!("position '{}' not found in cache", cmd.position_id)
933            })?;
934            Strategy::close_position(
935                adapter,
936                &position,
937                cmd.client_id,
938                cmd.tags,
939                cmd.time_in_force,
940                cmd.reduce_only,
941                cmd.quote_quantity,
942            )
943        })
944    }
945}
946
947unsafe extern "C" fn host_close_all_positions(
948    ctx: *const HostContext,
949    command: *const CloseAllPositionsHandle,
950) -> PluginResult<()> {
951    // SAFETY: plug-in keeps the handle alive for the duration of the call.
952    unsafe {
953        dispatch_handle(ctx, command, "close_all_positions", |adapter, cmd| {
954            Strategy::close_all_positions(
955                adapter,
956                cmd.instrument_id,
957                cmd.position_side,
958                cmd.client_id,
959                cmd.tags,
960                cmd.time_in_force,
961                cmd.reduce_only,
962                cmd.quote_quantity,
963            )
964        })
965    }
966}
967
968unsafe extern "C" fn host_query_account(
969    ctx: *const HostContext,
970    command: *const QueryAccountHandle,
971) -> PluginResult<()> {
972    // SAFETY: plug-in keeps the handle alive for the duration of the call.
973    unsafe {
974        dispatch_handle(ctx, command, "query_account", |adapter, cmd| {
975            Strategy::query_account(adapter, cmd.account_id, cmd.client_id, cmd.params)
976        })
977    }
978}
979
980unsafe extern "C" fn host_query_order(
981    ctx: *const HostContext,
982    command: *const QueryOrderHandle,
983) -> PluginResult<()> {
984    // SAFETY: plug-in keeps the handle alive for the duration of the call.
985    unsafe {
986        dispatch_handle(ctx, command, "query_order", |adapter, cmd| {
987            let order = {
988                let cache = adapter.cache();
989                cache.order(&cmd.client_order_id).map(|o| o.cloned())
990            };
991            let order = order.ok_or_else(|| {
992                anyhow::anyhow!("order '{}' not found in cache", cmd.client_order_id)
993            })?;
994            Strategy::query_order(adapter, &order, cmd.client_id, cmd.params)
995        })
996    }
997}
998
999// Resolves the calling strategy adapter from `ctx` and invokes `f` with a
1000// borrowed reference to the plug-in-owned handle. Used by the boundary-owned
1001// command slots (`cancel_order`, `modify_order`, etc.) that take
1002// `*const XHandle` rather than JSON. The handle stays owned by the plug-in
1003// for the duration of the call; the host only borrows it.
1004//
1005// SAFETY contract for callers: `command` must be a non-null pointer to a
1006// live handle whose layout matches the host's view of `H`. v1 relies on
1007// operator-side pinning (plug-in cdylibs rebuilt to match each Nautilus
1008// version) to enforce this; `PluginBuildId` is recorded as a diagnostic
1009// in load errors but is not enforced by the loader. A plug-in built
1010// against a mismatched toolchain that still advertises the right ABI
1011// version will deref through this path with whatever layout it
1012// happened to compile against.
1013unsafe fn dispatch_handle<H>(
1014    ctx: *const HostContext,
1015    command: *const H,
1016    method: &'static str,
1017    f: impl FnOnce(&mut PluginStrategyAdapter, H::Command) -> anyhow::Result<()>,
1018) -> PluginResult<()>
1019where
1020    H: BoundaryCommandHandle,
1021{
1022    if command.is_null() {
1023        return PluginResult::Err(PluginError::new(
1024            PluginErrorCode::InvalidArgument,
1025            format!("{method} called with null command handle"),
1026        ));
1027    }
1028
1029    // SAFETY: caller (the plug-in) round-trips the same ctx the host handed
1030    // back from `PluginStrategyAdapter::new`.
1031    let inner = match unsafe { host_context_inner(ctx) } {
1032        Some(inner) => inner,
1033        None => {
1034            return PluginResult::Err(PluginError::new(
1035                PluginErrorCode::InvalidArgument,
1036                format!("{method} called with null HostContext"),
1037            ));
1038        }
1039    };
1040
1041    if !inner.is_strategy {
1042        return PluginResult::Err(PluginError::new(
1043            PluginErrorCode::InvalidArgument,
1044            format!(
1045                "{method} called from a non-strategy plug-in context (actor_id={})",
1046                inner.actor_id
1047            ),
1048        ));
1049    }
1050
1051    let actor_id = inner.actor_id.inner();
1052    let Some(mut adapter_ref) = try_get_actor_unchecked::<PluginStrategyAdapter>(&actor_id) else {
1053        return PluginResult::Err(PluginError::new(
1054            PluginErrorCode::Generic,
1055            format!(
1056                "{method} could not resolve strategy adapter for actor_id={}",
1057                inner.actor_id
1058            ),
1059        ));
1060    };
1061
1062    // SAFETY: command is non-null (checked above) and the plug-in commits
1063    // to keeping the handle live for the duration of this call.
1064    let handle = unsafe { &*command };
1065    let command = handle.boundary_normalized_command();
1066    match f(&mut adapter_ref, command) {
1067        Ok(()) => PluginResult::Ok(()),
1068        Err(e) => PluginResult::Err(PluginError::new(PluginErrorCode::Generic, e.to_string())),
1069    }
1070}
1071
1072fn dispatch_actor_action(
1073    ctx: *const HostContext,
1074    method: &'static str,
1075    actor_fn: impl FnOnce(&mut PluginActorAdapter) -> anyhow::Result<()>,
1076    strategy_fn: impl FnOnce(&mut PluginStrategyAdapter) -> anyhow::Result<()>,
1077) -> PluginResult<()> {
1078    let inner = match resolve_context(ctx, method) {
1079        Ok(inner) => inner,
1080        Err(e) => return PluginResult::Err(e),
1081    };
1082
1083    let actor_id = inner.actor_id.inner();
1084    let result = if inner.is_strategy {
1085        let Some(mut adapter_ref) = try_get_actor_unchecked::<PluginStrategyAdapter>(&actor_id)
1086        else {
1087            return PluginResult::Err(resolve_adapter_error(method, inner));
1088        };
1089        strategy_fn(&mut adapter_ref)
1090    } else {
1091        let Some(mut adapter_ref) = try_get_actor_unchecked::<PluginActorAdapter>(&actor_id) else {
1092            return PluginResult::Err(resolve_adapter_error(method, inner));
1093        };
1094        actor_fn(&mut adapter_ref)
1095    };
1096
1097    match result {
1098        Ok(()) => PluginResult::Ok(()),
1099        Err(e) => PluginResult::Err(PluginError::new(PluginErrorCode::Generic, e.to_string())),
1100    }
1101}
1102
1103fn dispatch_cache_query(
1104    ctx: *const HostContext,
1105    method: &'static str,
1106    f: impl FnOnce(&Cache, &HostContextInner) -> PluginResult<OwnedBytes>,
1107) -> PluginResult<OwnedBytes> {
1108    let inner = match resolve_context(ctx, method) {
1109        Ok(inner) => inner,
1110        Err(e) => return PluginResult::Err(e),
1111    };
1112
1113    let actor_id = inner.actor_id.inner();
1114    if inner.is_strategy {
1115        let Some(adapter_ref) = try_get_actor_unchecked::<PluginStrategyAdapter>(&actor_id) else {
1116            return PluginResult::Err(resolve_adapter_error(method, inner));
1117        };
1118        let cache = adapter_ref.cache();
1119        f(&cache, inner)
1120    } else {
1121        let Some(adapter_ref) = try_get_actor_unchecked::<PluginActorAdapter>(&actor_id) else {
1122            return PluginResult::Err(resolve_adapter_error(method, inner));
1123        };
1124        let cache = adapter_ref.cache();
1125        f(&cache, inner)
1126    }
1127}
1128
1129fn resolve_context(
1130    ctx: *const HostContext,
1131    method: &'static str,
1132) -> Result<&'static HostContextInner, PluginError> {
1133    // SAFETY: plug-ins round-trip the context pointer supplied at create time.
1134    unsafe { host_context_inner(ctx) }.ok_or_else(|| {
1135        PluginError::new(
1136            PluginErrorCode::InvalidArgument,
1137            format!("{method} called with null HostContext"),
1138        )
1139    })
1140}
1141
1142fn resolve_adapter_error(method: &str, inner: &HostContextInner) -> PluginError {
1143    let kind = if inner.is_strategy {
1144        "strategy"
1145    } else {
1146        "actor"
1147    };
1148    PluginError::new(
1149        PluginErrorCode::Generic,
1150        format!(
1151            "{method} could not resolve {kind} adapter for actor_id={}",
1152            inner.actor_id
1153        ),
1154    )
1155}
1156
1157fn ensure_adapter_registered(method: &str, inner: &HostContextInner) -> Result<(), PluginError> {
1158    let actor_id = inner.actor_id.inner();
1159    let found = if inner.is_strategy {
1160        try_get_actor_unchecked::<PluginStrategyAdapter>(&actor_id).is_some()
1161    } else {
1162        try_get_actor_unchecked::<PluginActorAdapter>(&actor_id).is_some()
1163    };
1164
1165    if found {
1166        Ok(())
1167    } else {
1168        Err(resolve_adapter_error(method, inner))
1169    }
1170}
1171
1172fn json_optional<T>(value: Option<&T>) -> PluginResult<OwnedBytes>
1173where
1174    T: Serialize,
1175{
1176    match value {
1177        Some(value) => json_bytes(value),
1178        None => PluginResult::Ok(OwnedBytes::empty()),
1179    }
1180}
1181
1182fn json_bytes<T>(value: &T) -> PluginResult<OwnedBytes>
1183where
1184    T: Serialize,
1185{
1186    match serde_json::to_vec(value) {
1187        Ok(bytes) => PluginResult::Ok(OwnedBytes::from_vec(bytes)),
1188        Err(e) => PluginResult::Err(PluginError::new(
1189            PluginErrorCode::SerializationFailed,
1190            e.to_string(),
1191        )),
1192    }
1193}
1194
1195#[derive(Clone)]
1196struct InstrumentSubscriptionArgs {
1197    instrument_id: InstrumentId,
1198    client_id: Option<ClientId>,
1199    params: Option<Params>,
1200}
1201
1202#[derive(Clone)]
1203struct BarSubscriptionArgs {
1204    bar_type: BarType,
1205    client_id: Option<ClientId>,
1206    params: Option<Params>,
1207}
1208
1209#[derive(Clone)]
1210struct BookSubscriptionArgs {
1211    instrument_id: InstrumentId,
1212    book_type: BookType,
1213    depth: Option<NonZeroUsize>,
1214    client_id: Option<ClientId>,
1215    params: Option<Params>,
1216}
1217
1218fn parse_instrument_subscription(
1219    instrument_id: BorrowedStr<'_>,
1220    client_id: BorrowedStr<'_>,
1221    params_json: BorrowedStr<'_>,
1222) -> Result<InstrumentSubscriptionArgs, PluginError> {
1223    Ok(InstrumentSubscriptionArgs {
1224        instrument_id: parse_instrument_id(instrument_id, "instrument_id")?,
1225        client_id: parse_optional_client_id(client_id)?,
1226        params: parse_optional_params(params_json)?,
1227    })
1228}
1229
1230fn parse_bar_subscription(
1231    bar_type: BorrowedStr<'_>,
1232    client_id: BorrowedStr<'_>,
1233    params_json: BorrowedStr<'_>,
1234) -> Result<BarSubscriptionArgs, PluginError> {
1235    // SAFETY: bar_type borrows storage live across this call.
1236    let raw = unsafe { bar_type.as_str() };
1237    let bar_type = BarType::from_str(raw).map_err(|e| {
1238        PluginError::new(
1239            PluginErrorCode::InvalidArgument,
1240            format!("invalid bar_type '{raw}': {e}"),
1241        )
1242    })?;
1243    Ok(BarSubscriptionArgs {
1244        bar_type,
1245        client_id: parse_optional_client_id(client_id)?,
1246        params: parse_optional_params(params_json)?,
1247    })
1248}
1249
1250fn parse_book_subscription(
1251    instrument_id: BorrowedStr<'_>,
1252    book_type: u8,
1253    depth: usize,
1254    client_id: BorrowedStr<'_>,
1255    params_json: BorrowedStr<'_>,
1256) -> Result<BookSubscriptionArgs, PluginError> {
1257    let book_type = BookType::from_u8(book_type).ok_or_else(|| {
1258        PluginError::new(
1259            PluginErrorCode::InvalidArgument,
1260            format!("invalid book_type discriminant {book_type}"),
1261        )
1262    })?;
1263    Ok(BookSubscriptionArgs {
1264        instrument_id: parse_instrument_id(instrument_id, "instrument_id")?,
1265        book_type,
1266        depth: NonZeroUsize::new(depth),
1267        client_id: parse_optional_client_id(client_id)?,
1268        params: parse_optional_params(params_json)?,
1269    })
1270}
1271
1272fn parse_instrument_id(
1273    value: BorrowedStr<'_>,
1274    label: &'static str,
1275) -> Result<InstrumentId, PluginError> {
1276    // SAFETY: value borrows storage live across this call.
1277    let raw = unsafe { value.as_str() };
1278    InstrumentId::from_str(raw).map_err(|e| {
1279        PluginError::new(
1280            PluginErrorCode::InvalidArgument,
1281            format!("invalid {label} '{raw}': {e}"),
1282        )
1283    })
1284}
1285
1286fn parse_account_id(value: BorrowedStr<'_>, label: &'static str) -> Result<AccountId, PluginError> {
1287    // SAFETY: value borrows storage live across this call.
1288    let raw = unsafe { value.as_str() };
1289    AccountId::new_checked(raw).map_err(|e| {
1290        PluginError::new(
1291            PluginErrorCode::InvalidArgument,
1292            format!("invalid {label} '{raw}': {e}"),
1293        )
1294    })
1295}
1296
1297fn parse_client_order_id(
1298    value: BorrowedStr<'_>,
1299    label: &'static str,
1300) -> Result<ClientOrderId, PluginError> {
1301    // SAFETY: value borrows storage live across this call.
1302    let raw = unsafe { value.as_str() };
1303    ClientOrderId::new_checked(raw).map_err(|e| {
1304        PluginError::new(
1305            PluginErrorCode::InvalidArgument,
1306            format!("invalid {label} '{raw}': {e}"),
1307        )
1308    })
1309}
1310
1311fn parse_position_id(
1312    value: BorrowedStr<'_>,
1313    label: &'static str,
1314) -> Result<PositionId, PluginError> {
1315    // SAFETY: value borrows storage live across this call.
1316    let raw = unsafe { value.as_str() };
1317    PositionId::new_checked(raw).map_err(|e| {
1318        PluginError::new(
1319            PluginErrorCode::InvalidArgument,
1320            format!("invalid {label} '{raw}': {e}"),
1321        )
1322    })
1323}
1324
1325fn parse_strategy_id_for_context(
1326    value: BorrowedStr<'_>,
1327    inner: &HostContextInner,
1328) -> Result<StrategyId, PluginError> {
1329    // SAFETY: value borrows storage live across this call.
1330    let raw = unsafe { value.as_str() };
1331    if !raw.is_empty() {
1332        return StrategyId::new_checked(raw).map_err(|e| {
1333            PluginError::new(
1334                PluginErrorCode::InvalidArgument,
1335                format!("invalid strategy_id '{raw}': {e}"),
1336            )
1337        });
1338    }
1339
1340    if !inner.is_strategy {
1341        return Err(PluginError::new(
1342            PluginErrorCode::InvalidArgument,
1343            "empty strategy_id is only valid for strategy plug-in contexts",
1344        ));
1345    }
1346
1347    StrategyId::new_checked(inner.actor_id.inner().as_str()).map_err(|e| {
1348        PluginError::new(
1349            PluginErrorCode::InvalidArgument,
1350            format!("invalid calling strategy_id '{}': {e}", inner.actor_id),
1351        )
1352    })
1353}
1354
1355fn parse_optional_client_id(value: BorrowedStr<'_>) -> Result<Option<ClientId>, PluginError> {
1356    // SAFETY: value borrows storage live across this call.
1357    let raw = unsafe { value.as_str() };
1358    if raw.is_empty() {
1359        return Ok(None);
1360    }
1361    ClientId::new_checked(raw)
1362        .map(Some)
1363        .map_err(|e| PluginError::new(PluginErrorCode::InvalidArgument, e.to_string()))
1364}
1365
1366fn parse_optional_params(value: BorrowedStr<'_>) -> Result<Option<Params>, PluginError> {
1367    // SAFETY: value borrows storage live across this call.
1368    let raw = unsafe { value.as_str() };
1369    if raw.trim().is_empty() {
1370        return Ok(None);
1371    }
1372    serde_json::from_str(raw).map(Some).map_err(|e| {
1373        PluginError::new(
1374            PluginErrorCode::InvalidArgument,
1375            format!("invalid params_json: {e}"),
1376        )
1377    })
1378}
1379
1380fn nonzero_unix_nanos(value: u64) -> Option<UnixNanos> {
1381    (value != 0).then_some(UnixNanos::from(value))
1382}
1383
1384#[cfg(test)]
1385mod tests {
1386    use nautilus_core::{UUID4, UnixNanos};
1387    use nautilus_model::{
1388        enums::{OrderSide, TimeInForce},
1389        identifiers::{
1390            ClientOrderId as TestClientOrderId, InstrumentId as TestInstrumentId, StrategyId,
1391            TraderId,
1392        },
1393        orders::{MarketOrder, OrderAny},
1394        types::Quantity,
1395    };
1396    use rstest::rstest;
1397
1398    use super::*;
1399    use crate::surfaces::commands::{CancelOrderCommand, ModifyOrderCommand, SubmitOrderCommand};
1400
1401    fn make_market_submit_command() -> SubmitOrderCommand {
1402        let order = OrderAny::Market(MarketOrder::new(
1403            TraderId::from("TRADER-001"),
1404            StrategyId::from("S-001"),
1405            TestInstrumentId::from("ETH-USDT.BINANCE"),
1406            TestClientOrderId::from("O-1"),
1407            OrderSide::Buy,
1408            Quantity::from("1.0"),
1409            TimeInForce::Gtc,
1410            UUID4::new(),
1411            UnixNanos::default(),
1412            false,
1413            false,
1414            None,
1415            None,
1416            None,
1417            None,
1418            None,
1419            None,
1420            None,
1421            None,
1422        ));
1423        SubmitOrderCommand::new(order, None, None, None)
1424    }
1425
1426    #[rstest]
1427    fn host_vtable_carries_compiled_abi() {
1428        let p = host_vtable();
1429        assert!(!p.is_null());
1430        // SAFETY: pointer is to a static OnceLock-backed HostVTable.
1431        let v = unsafe { &*p };
1432        assert_eq!(v.abi_version, NAUTILUS_PLUGIN_ABI_VERSION);
1433    }
1434
1435    #[rstest]
1436    fn host_vtable_binds_live_node_callbacks() {
1437        // Locks in that the live-node host vtable installs the routing thunks
1438        // defined in this module, not the loader.rs NotImplemented stubs.
1439        let p = host_vtable();
1440        // SAFETY: pointer is to a static OnceLock-backed HostVTable.
1441        let v = unsafe { &*p };
1442        assert_eq!(
1443            v.cache_order as *const () as usize,
1444            host_cache_order as *const () as usize,
1445        );
1446        assert_eq!(
1447            v.subscribe_quotes as *const () as usize,
1448            host_subscribe_quotes as *const () as usize,
1449        );
1450        assert_eq!(
1451            v.msgbus_publish as *const () as usize,
1452            host_msgbus_publish as *const () as usize,
1453        );
1454        assert_eq!(
1455            v.set_timer as *const () as usize,
1456            host_set_timer as *const () as usize,
1457        );
1458        assert_eq!(
1459            v.submit_order as *const () as usize,
1460            host_submit_order as *const () as usize,
1461        );
1462        assert_eq!(
1463            v.cancel_order as *const () as usize,
1464            host_cancel_order as *const () as usize,
1465        );
1466        assert_eq!(
1467            v.modify_order as *const () as usize,
1468            host_modify_order as *const () as usize,
1469        );
1470        assert_eq!(
1471            v.clock_now_ns as *const () as usize,
1472            host_clock_now_ns as *const () as usize,
1473        );
1474        assert_eq!(v.log as *const () as usize, host_log as *const () as usize);
1475    }
1476
1477    #[rstest]
1478    fn host_clock_now_ns_returns_unix_nanos_after_2020() {
1479        let p = host_vtable();
1480        // SAFETY: pointer is to a static OnceLock-backed HostVTable.
1481        let v = unsafe { &*p };
1482        // SAFETY: fn pointer is non-null; clock_now_ns dereferences no input.
1483        let now = unsafe { (v.clock_now_ns)() };
1484        // Any time after 2020-01-01 in UNIX nanoseconds.
1485        assert!(now > 1_577_836_800_000_000_000u64);
1486    }
1487
1488    #[rstest]
1489    fn host_submit_order_rejects_null_ctx() {
1490        let p = host_vtable();
1491        // SAFETY: pointer is to a static OnceLock-backed HostVTable.
1492        let v = unsafe { &*p };
1493        let handle = SubmitOrderHandle::new(make_market_submit_command());
1494        // SAFETY: passes null ctx; handle is live.
1495        let r = unsafe { (v.submit_order)(std::ptr::null(), &raw const handle) };
1496        let err = r.into_result().unwrap_err();
1497        assert_eq!(err.code, PluginErrorCode::InvalidArgument);
1498        assert!(err.message_string().contains("null HostContext"));
1499    }
1500
1501    #[rstest]
1502    fn host_cancel_order_rejects_null_ctx() {
1503        use nautilus_model::identifiers::ClientOrderId;
1504
1505        let p = host_vtable();
1506        // SAFETY: see above.
1507        let v = unsafe { &*p };
1508        let handle = CancelOrderHandle::new(CancelOrderCommand::new(
1509            ClientOrderId::from("O-1"),
1510            None,
1511            None,
1512        ));
1513        // SAFETY: passes null ctx; handle is live.
1514        let r = unsafe { (v.cancel_order)(std::ptr::null(), &raw const handle) };
1515        let err = r.into_result().unwrap_err();
1516        assert_eq!(err.code, PluginErrorCode::InvalidArgument);
1517    }
1518
1519    #[rstest]
1520    fn host_modify_order_rejects_null_ctx() {
1521        use nautilus_model::identifiers::ClientOrderId;
1522
1523        let p = host_vtable();
1524        // SAFETY: see above.
1525        let v = unsafe { &*p };
1526        let handle = ModifyOrderHandle::new(ModifyOrderCommand::new(
1527            ClientOrderId::from("O-1"),
1528            None,
1529            None,
1530            None,
1531            None,
1532            None,
1533        ));
1534        // SAFETY: passes null ctx; handle is live.
1535        let r = unsafe { (v.modify_order)(std::ptr::null(), &raw const handle) };
1536        let err = r.into_result().unwrap_err();
1537        assert_eq!(err.code, PluginErrorCode::InvalidArgument);
1538    }
1539
1540    #[rstest]
1541    fn host_cancel_order_rejects_null_command() {
1542        let p = host_vtable();
1543        // SAFETY: see above.
1544        let v = unsafe { &*p };
1545        // SAFETY: passes null command handle; ctx irrelevant.
1546        let r = unsafe { (v.cancel_order)(std::ptr::null(), std::ptr::null()) };
1547        let err = r.into_result().unwrap_err();
1548        assert_eq!(err.code, PluginErrorCode::InvalidArgument);
1549        assert!(err.message_string().contains("null command handle"));
1550    }
1551
1552    #[rstest]
1553    fn host_modify_order_rejects_null_command() {
1554        let p = host_vtable();
1555        // SAFETY: see above.
1556        let v = unsafe { &*p };
1557        // SAFETY: passes null command handle; ctx irrelevant.
1558        let r = unsafe { (v.modify_order)(std::ptr::null(), std::ptr::null()) };
1559        let err = r.into_result().unwrap_err();
1560        assert_eq!(err.code, PluginErrorCode::InvalidArgument);
1561        assert!(err.message_string().contains("null command handle"));
1562    }
1563
1564    #[rstest]
1565    fn host_submit_order_rejects_non_strategy_context() {
1566        // Plug-in actors must not submit orders. The host vtable thunk
1567        // inspects HostContextInner::is_strategy and rejects calls from
1568        // actor contexts with InvalidArgument.
1569        use nautilus_model::identifiers::ActorId;
1570
1571        use crate::bridge::registry::{
1572            HostContextInner, drop_host_context, host_context_test_lock, leak_host_context,
1573        };
1574
1575        let _guard = host_context_test_lock();
1576        let ctx = leak_host_context(HostContextInner {
1577            actor_id: ActorId::from("ActorContextProbe"),
1578            is_strategy: false,
1579        });
1580        let p = host_vtable();
1581        // SAFETY: pointer is to a static OnceLock-backed HostVTable.
1582        let v = unsafe { &*p };
1583        let handle = SubmitOrderHandle::new(make_market_submit_command());
1584        // SAFETY: ctx was leaked above and is live; handle outlives the call.
1585        let r = unsafe { (v.submit_order)(ctx, &raw const handle) };
1586        let err = r.into_result().unwrap_err();
1587        assert_eq!(err.code, PluginErrorCode::InvalidArgument);
1588        assert!(
1589            err.message_string().contains("non-strategy"),
1590            "expected non-strategy rejection, was: {}",
1591            err.message_string(),
1592        );
1593        // SAFETY: ctx came from leak_host_context above.
1594        unsafe { drop_host_context(ctx) };
1595    }
1596
1597    #[rstest]
1598    fn host_submit_order_rejects_unregistered_actor_id() {
1599        // ctx points to a strategy actor_id that no PluginStrategyAdapter
1600        // has registered into the thread-local actor registry, so the
1601        // host vtable's try_get_actor_unchecked lookup returns None.
1602        use nautilus_model::identifiers::ActorId;
1603
1604        use crate::bridge::registry::{
1605            HostContextInner, drop_host_context, host_context_test_lock, leak_host_context,
1606        };
1607
1608        let _guard = host_context_test_lock();
1609        let ctx = leak_host_context(HostContextInner {
1610            actor_id: ActorId::from("UnregisteredStrategyAdapter"),
1611            is_strategy: true,
1612        });
1613        let p = host_vtable();
1614        // SAFETY: pointer is to a static OnceLock-backed HostVTable.
1615        let v = unsafe { &*p };
1616        let handle = SubmitOrderHandle::new(make_market_submit_command());
1617        // SAFETY: ctx was leaked above and is live; handle outlives the call.
1618        let r = unsafe { (v.submit_order)(ctx, &raw const handle) };
1619        let err = r.into_result().unwrap_err();
1620        assert_eq!(err.code, PluginErrorCode::Generic);
1621        assert!(
1622            err.message_string().contains("could not resolve"),
1623            "expected unresolved-adapter rejection, was: {}",
1624            err.message_string(),
1625        );
1626        // SAFETY: ctx came from leak_host_context above.
1627        unsafe { drop_host_context(ctx) };
1628    }
1629}