1#![allow(unsafe_code)]
23
24use std::{
25 ffi::OsStr,
26 fmt::{Debug, Display},
27 mem::ManuallyDrop,
28 path::{Path, PathBuf},
29 slice,
30 sync::OnceLock,
31};
32
33use libloading::{Library, Symbol};
34
35use crate::{
36 NAUTILUS_PLUGIN_ABI_VERSION, NAUTILUS_PLUGIN_INIT_SYMBOL, PLUGIN_BUILD_ID_VERSION,
37 boundary::{BorrowedStr, PluginError, PluginErrorCode, PluginResult},
38 host::{HostContext, HostLogLevel, HostVTable},
39 manifest::{
40 PluginBuildId, PluginInitFn, PluginManifest, PluginManifestValidationErrors,
41 ValidatedPluginManifest,
42 },
43 surfaces::commands::{
44 CancelAllOrdersHandle, CancelOrderHandle, CancelOrdersHandle, CloseAllPositionsHandle,
45 ClosePositionHandle, ModifyOrderHandle, QueryAccountHandle, QueryOrderHandle,
46 SubmitOrderHandle, SubmitOrderListHandle,
47 },
48};
49
50#[derive(Debug, thiserror::Error)]
52pub enum LoadError {
53 #[error("failed to open plug-in '{path}': {source}")]
54 Open {
55 path: PathBuf,
56 #[source]
57 source: libloading::Error,
58 },
59
60 #[error("plug-in '{path}' is missing the `nautilus_plugin_init` symbol: {source}")]
61 MissingSymbol {
62 path: PathBuf,
63 #[source]
64 source: libloading::Error,
65 },
66
67 #[error("plug-in '{path}' returned a null manifest from `nautilus_plugin_init`")]
68 NullManifest { path: PathBuf },
69
70 #[error("plug-in '{path}' ABI mismatch: host = {expected}, plug-in = {actual}; {diagnostics}")]
71 AbiMismatch {
72 path: PathBuf,
73 expected: u32,
74 actual: u32,
75 diagnostics: Box<PluginManifestDiagnostics>,
76 },
77
78 #[error("plug-in '{path}' manifest validation failed: {diagnostics}; {errors}")]
79 InvalidManifest {
80 path: PathBuf,
81 diagnostics: Box<PluginManifestDiagnostics>,
82 #[source]
83 errors: PluginManifestValidationErrors,
84 },
85
86 #[error(
87 "plug-in '{path}' redeclares custom-data type '{type_name}' already provided by '{existing_path}'"
88 )]
89 DuplicateCustomDataType {
90 path: PathBuf,
91 type_name: String,
92 existing_path: PathBuf,
93 },
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct PluginManifestDiagnostics {
99 pub plugin_name: String,
101 pub plugin_version: String,
103 pub build_id: PluginBuildIdDiagnostics,
105}
106
107impl PluginManifestDiagnostics {
108 fn from_manifest(manifest: &PluginManifest) -> Self {
109 Self {
110 plugin_name: borrowed_str_diagnostic(manifest.plugin_name),
111 plugin_version: borrowed_str_diagnostic(manifest.plugin_version),
112 build_id: PluginBuildIdDiagnostics::from_build_id(&manifest.build_id),
113 }
114 }
115
116 fn from_abi_mismatch_manifest(manifest: &PluginManifest) -> Self {
117 let build_id = if manifest.build_id.schema_version == PLUGIN_BUILD_ID_VERSION {
118 PluginBuildIdDiagnostics::from_build_id(&manifest.build_id)
119 } else {
120 PluginBuildIdDiagnostics::schema_only(manifest.build_id.schema_version)
121 };
122 Self {
123 plugin_name: borrowed_str_diagnostic(manifest.plugin_name),
124 plugin_version: borrowed_str_diagnostic(manifest.plugin_version),
125 build_id,
126 }
127 }
128}
129
130impl Display for PluginManifestDiagnostics {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 let plugin_name = unknown_if_empty(&self.plugin_name);
133 let plugin_version = unknown_if_empty(&self.plugin_version);
134 let build_id = &self.build_id;
135 write!(
136 f,
137 "manifest name='{plugin_name}', version='{plugin_version}', {build_id}"
138 )
139 }
140}
141
142#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct PluginBuildIdDiagnostics {
145 pub schema_version: u32,
147 pub nautilus_plugin_version: String,
149 pub rustc_version: String,
151 pub target_triple: String,
153 pub build_profile: String,
155 pub precision_mode: String,
157 pub fixed_precision: Option<u8>,
159}
160
161impl PluginBuildIdDiagnostics {
162 fn from_build_id(build_id: &PluginBuildId) -> Self {
163 Self {
164 schema_version: build_id.schema_version,
165 nautilus_plugin_version: borrowed_str_diagnostic(build_id.nautilus_plugin_version),
166 rustc_version: borrowed_str_diagnostic(build_id.rustc_version),
167 target_triple: borrowed_str_diagnostic(build_id.target_triple),
168 build_profile: borrowed_str_diagnostic(build_id.build_profile),
169 precision_mode: borrowed_str_diagnostic(build_id.precision_mode),
170 fixed_precision: Some(build_id.fixed_precision),
171 }
172 }
173
174 fn schema_only(schema_version: u32) -> Self {
175 Self {
176 schema_version,
177 nautilus_plugin_version: String::new(),
178 rustc_version: String::new(),
179 target_triple: String::new(),
180 build_profile: String::new(),
181 precision_mode: String::new(),
182 fixed_precision: None,
183 }
184 }
185}
186
187impl Display for PluginBuildIdDiagnostics {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 let schema_version = self.schema_version;
190 let nautilus_plugin_version = unknown_if_empty(&self.nautilus_plugin_version);
191 let rustc_version = unknown_if_empty(&self.rustc_version);
192 let target_triple = unknown_if_empty(&self.target_triple);
193 let build_profile = unknown_if_empty(&self.build_profile);
194 let precision_mode = unknown_if_empty(&self.precision_mode);
195 let fixed_precision = self
196 .fixed_precision
197 .map_or_else(|| "<unknown>".to_string(), |value| value.to_string());
198 write!(f, "build_id(schema={schema_version}, ")?;
199 write!(f, "nautilus_plugin_version='{nautilus_plugin_version}', ")?;
200 write!(f, "rustc='{rustc_version}', target='{target_triple}', ")?;
201 write!(
202 f,
203 "profile='{build_profile}', precision_mode='{precision_mode}', "
204 )?;
205 write!(f, "fixed_precision={fixed_precision})")
206 }
207}
208
209fn unknown_if_empty(value: &str) -> &str {
210 if value.is_empty() { "<unknown>" } else { value }
211}
212
213fn borrowed_str_diagnostic(value: BorrowedStr<'_>) -> String {
214 if value.ptr.is_null() || value.len == 0 {
215 return String::new();
216 }
217
218 let bytes = unsafe { slice::from_raw_parts(value.ptr, value.len) };
221 String::from_utf8_lossy(bytes).into_owned()
222}
223
224pub struct LoadedPlugin {
234 path: PathBuf,
235 _library: ManuallyDrop<Library>,
236 manifest: ValidatedPluginManifest<'static>,
237}
238
239impl Debug for LoadedPlugin {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 f.debug_struct(stringify!(LoadedPlugin))
242 .field("path", &self.path)
243 .finish()
244 }
245}
246
247unsafe impl Send for LoadedPlugin {}
250unsafe impl Sync for LoadedPlugin {}
252
253impl LoadedPlugin {
254 #[must_use]
256 pub fn path(&self) -> &Path {
257 &self.path
258 }
259
260 #[must_use]
262 pub fn manifest(&self) -> &PluginManifest {
263 self.manifest.manifest()
264 }
265
266 #[must_use]
268 pub fn validated_manifest(&self) -> ValidatedPluginManifest<'static> {
269 self.manifest
270 }
271}
272
273#[derive(Debug, Default)]
279pub struct PluginLoader {
280 loaded: Vec<LoadedPlugin>,
281 host: Option<*const HostVTable>,
282}
283
284unsafe impl Send for PluginLoader {}
288unsafe impl Sync for PluginLoader {}
290
291impl PluginLoader {
292 #[must_use]
297 pub fn new() -> Self {
298 Self {
299 loaded: Vec::new(),
300 host: None,
301 }
302 }
303
304 #[must_use]
310 pub fn with_host(host: *const HostVTable) -> Self {
311 Self {
312 loaded: Vec::new(),
313 host: Some(host),
314 }
315 }
316
317 pub fn load_all<P>(&mut self, paths: impl IntoIterator<Item = P>) -> Result<(), LoadError>
319 where
320 P: AsRef<OsStr>,
321 {
322 for p in paths {
323 self.load(p.as_ref())?;
324 }
325 Ok(())
326 }
327
328 pub fn load(&mut self, path: impl AsRef<OsStr>) -> Result<&LoadedPlugin, LoadError> {
330 let path_buf = PathBuf::from(path.as_ref());
331
332 let library = unsafe { Library::new(path.as_ref()) }.map_err(|e| LoadError::Open {
336 path: path_buf.clone(),
337 source: e,
338 })?;
339
340 let manifest_ptr = {
341 let init: Symbol<PluginInitFn> = unsafe { library.get(NAUTILUS_PLUGIN_INIT_SYMBOL) }
343 .map_err(|e| LoadError::MissingSymbol {
344 path: path_buf.clone(),
345 source: e,
346 })?;
347 let host = self.host.unwrap_or_else(host_vtable);
348 unsafe { init(host) }
352 };
353
354 let manifest = validate_manifest_ptr(manifest_ptr, &path_buf)?;
355
356 let collision = {
357 let new_types: Vec<&str> = manifest.custom_data().map(|e| e.type_name()).collect();
358 let existing: Vec<(&str, &Path)> = self
359 .loaded
360 .iter()
361 .flat_map(|loaded| {
362 let loaded_path = loaded.path();
363 loaded
364 .validated_manifest()
365 .custom_data()
366 .map(move |entry| (entry.type_name(), loaded_path))
367 })
368 .collect();
369 first_duplicate_custom_data_type(&new_types, &existing).map(
370 |(type_name, existing_path)| (type_name.to_string(), existing_path.to_path_buf()),
371 )
372 };
373
374 if let Some((type_name, existing_path)) = collision {
375 return Err(LoadError::DuplicateCustomDataType {
376 path: path_buf,
377 type_name,
378 existing_path,
379 });
380 }
381
382 let manifest_ref = manifest.manifest();
383 let abi = manifest_ref.abi_version;
384 let custom_data_count = manifest.custom_data().len();
385 let actor_count = manifest.actors().len();
386 let strategy_count = manifest.strategies().len();
387 let controller_count = manifest.controllers().len();
388 let build_id = PluginBuildIdDiagnostics::from_build_id(&manifest_ref.build_id);
389 log::info!(
390 target: "nautilus_plugin",
391 "Loaded plug-in '{}' (abi={abi}, {build_id}, custom_data={custom_data_count}, actors={actor_count}, strategies={strategy_count}, controllers={controller_count}) from {}",
392 manifest.plugin_name(),
393 path_buf.display(),
394 );
395
396 self.loaded.push(LoadedPlugin {
397 path: path_buf,
398 _library: ManuallyDrop::new(library),
399 manifest,
400 });
401 Ok(self.loaded.last().expect("just pushed"))
402 }
403
404 #[must_use]
406 pub fn loaded(&self) -> &[LoadedPlugin] {
407 &self.loaded
408 }
409
410 #[must_use]
412 pub fn len(&self) -> usize {
413 self.loaded.len()
414 }
415
416 #[must_use]
418 pub fn is_empty(&self) -> bool {
419 self.loaded.is_empty()
420 }
421}
422
423fn validate_manifest_ptr(
429 manifest_ptr: *const PluginManifest,
430 path: &Path,
431) -> Result<ValidatedPluginManifest<'static>, LoadError> {
432 if manifest_ptr.is_null() {
433 return Err(LoadError::NullManifest {
434 path: path.to_path_buf(),
435 });
436 }
437 let manifest = unsafe { &*manifest_ptr };
439 let abi = manifest.abi_version;
440 if abi != NAUTILUS_PLUGIN_ABI_VERSION {
441 return Err(LoadError::AbiMismatch {
442 path: path.to_path_buf(),
443 expected: NAUTILUS_PLUGIN_ABI_VERSION,
444 actual: abi,
445 diagnostics: Box::new(PluginManifestDiagnostics::from_abi_mismatch_manifest(
446 manifest,
447 )),
448 });
449 }
450
451 match ValidatedPluginManifest::new(manifest) {
452 Ok(manifest) => Ok(manifest),
453 Err(errors) => Err(LoadError::InvalidManifest {
454 path: path.to_path_buf(),
455 diagnostics: Box::new(PluginManifestDiagnostics::from_manifest(manifest)),
456 errors,
457 }),
458 }
459}
460
461fn first_duplicate_custom_data_type<'a>(
473 new_types: &[&'a str],
474 existing: &[(&'a str, &'a Path)],
475) -> Option<(&'a str, &'a Path)> {
476 new_types.iter().find_map(|&new_type| {
477 existing
478 .iter()
479 .find(|(existing_type, _)| *existing_type == new_type)
480 .map(|&(_, path)| (new_type, path))
481 })
482}
483
484fn host_vtable() -> *const HostVTable {
490 static HOST: OnceLock<HostVTable> = OnceLock::new();
491 std::ptr::from_ref(HOST.get_or_init(|| HostVTable {
492 abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
493 clock_now_ns: host_clock_now_ns,
494 log: host_log,
495 cache_instrument: host_cache_instrument_unbound,
496 cache_account: host_cache_account_unbound,
497 cache_order: host_cache_order_unbound,
498 cache_position: host_cache_position_unbound,
499 cache_orders_for_strategy: host_cache_orders_for_strategy_unbound,
500 cache_positions_for_strategy: host_cache_positions_for_strategy_unbound,
501 subscribe_quotes: host_subscribe_quotes_unbound,
502 unsubscribe_quotes: host_unsubscribe_quotes_unbound,
503 subscribe_trades: host_subscribe_trades_unbound,
504 unsubscribe_trades: host_unsubscribe_trades_unbound,
505 subscribe_bars: host_subscribe_bars_unbound,
506 unsubscribe_bars: host_unsubscribe_bars_unbound,
507 subscribe_book_deltas: host_subscribe_book_deltas_unbound,
508 unsubscribe_book_deltas: host_unsubscribe_book_deltas_unbound,
509 subscribe_book_at_interval: host_subscribe_book_at_interval_unbound,
510 unsubscribe_book_at_interval: host_unsubscribe_book_at_interval_unbound,
511 msgbus_publish: host_msgbus_publish_unbound,
512 set_time_alert: host_set_time_alert_unbound,
513 set_timer: host_set_timer_unbound,
514 cancel_timer: host_cancel_timer_unbound,
515 submit_order: host_submit_order_unbound,
516 cancel_order: host_cancel_order_unbound,
517 modify_order: host_modify_order_unbound,
518 submit_order_list: host_submit_order_list_unbound,
519 cancel_orders: host_cancel_orders_unbound,
520 cancel_all_orders: host_cancel_all_orders_unbound,
521 close_position: host_close_position_unbound,
522 close_all_positions: host_close_all_positions_unbound,
523 query_account: host_query_account_unbound,
524 query_order: host_query_order_unbound,
525 }))
526}
527
528unsafe extern "C" fn host_clock_now_ns() -> u64 {
529 use std::time::{SystemTime, UNIX_EPOCH};
530
531 SystemTime::now()
532 .duration_since(UNIX_EPOCH)
533 .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
534}
535
536macro_rules! unbound_bytes_fn {
537 ($name:ident, $message:literal, ($($arg:ident : $ty:ty),* $(,)?)) => {
538 unsafe extern "C" fn $name($($arg: $ty),*) -> PluginResult<crate::OwnedBytes> {
539 $(let _ = $arg;)*
540 PluginResult::Err(PluginError::new(PluginErrorCode::NotImplemented, $message))
541 }
542 };
543}
544
545macro_rules! unbound_unit_fn {
546 ($name:ident, $message:literal, ($($arg:ident : $ty:ty),* $(,)?)) => {
547 unsafe extern "C" fn $name($($arg: $ty),*) -> PluginResult<()> {
548 $(let _ = $arg;)*
549 PluginResult::Err(PluginError::new(PluginErrorCode::NotImplemented, $message))
550 }
551 };
552}
553
554unbound_bytes_fn!(
555 host_cache_instrument_unbound,
556 "cache_instrument is not wired into this host vtable",
557 (ctx: *const HostContext, instrument_id: BorrowedStr<'_>)
558);
559unbound_bytes_fn!(
560 host_cache_account_unbound,
561 "cache_account is not wired into this host vtable",
562 (ctx: *const HostContext, account_id: BorrowedStr<'_>)
563);
564unbound_bytes_fn!(
565 host_cache_order_unbound,
566 "cache_order is not wired into this host vtable",
567 (ctx: *const HostContext, client_order_id: BorrowedStr<'_>)
568);
569unbound_bytes_fn!(
570 host_cache_position_unbound,
571 "cache_position is not wired into this host vtable",
572 (ctx: *const HostContext, position_id: BorrowedStr<'_>)
573);
574unbound_bytes_fn!(
575 host_cache_orders_for_strategy_unbound,
576 "cache_orders_for_strategy is not wired into this host vtable",
577 (ctx: *const HostContext, strategy_id: BorrowedStr<'_>)
578);
579unbound_bytes_fn!(
580 host_cache_positions_for_strategy_unbound,
581 "cache_positions_for_strategy is not wired into this host vtable",
582 (ctx: *const HostContext, strategy_id: BorrowedStr<'_>)
583);
584
585unbound_unit_fn!(
586 host_subscribe_quotes_unbound,
587 "subscribe_quotes is not wired into this host vtable",
588 (
589 ctx: *const HostContext,
590 instrument_id: BorrowedStr<'_>,
591 client_id: BorrowedStr<'_>,
592 params_json: BorrowedStr<'_>,
593 )
594);
595unbound_unit_fn!(
596 host_unsubscribe_quotes_unbound,
597 "unsubscribe_quotes is not wired into this host vtable",
598 (
599 ctx: *const HostContext,
600 instrument_id: BorrowedStr<'_>,
601 client_id: BorrowedStr<'_>,
602 params_json: BorrowedStr<'_>,
603 )
604);
605unbound_unit_fn!(
606 host_subscribe_trades_unbound,
607 "subscribe_trades is not wired into this host vtable",
608 (
609 ctx: *const HostContext,
610 instrument_id: BorrowedStr<'_>,
611 client_id: BorrowedStr<'_>,
612 params_json: BorrowedStr<'_>,
613 )
614);
615unbound_unit_fn!(
616 host_unsubscribe_trades_unbound,
617 "unsubscribe_trades is not wired into this host vtable",
618 (
619 ctx: *const HostContext,
620 instrument_id: BorrowedStr<'_>,
621 client_id: BorrowedStr<'_>,
622 params_json: BorrowedStr<'_>,
623 )
624);
625unbound_unit_fn!(
626 host_subscribe_bars_unbound,
627 "subscribe_bars is not wired into this host vtable",
628 (
629 ctx: *const HostContext,
630 bar_type: BorrowedStr<'_>,
631 client_id: BorrowedStr<'_>,
632 params_json: BorrowedStr<'_>,
633 )
634);
635unbound_unit_fn!(
636 host_unsubscribe_bars_unbound,
637 "unsubscribe_bars is not wired into this host vtable",
638 (
639 ctx: *const HostContext,
640 bar_type: BorrowedStr<'_>,
641 client_id: BorrowedStr<'_>,
642 params_json: BorrowedStr<'_>,
643 )
644);
645unbound_unit_fn!(
646 host_subscribe_book_deltas_unbound,
647 "subscribe_book_deltas is not wired into this host vtable",
648 (
649 ctx: *const HostContext,
650 instrument_id: BorrowedStr<'_>,
651 book_type: u8,
652 depth: usize,
653 client_id: BorrowedStr<'_>,
654 managed: u8,
655 params_json: BorrowedStr<'_>,
656 )
657);
658unbound_unit_fn!(
659 host_unsubscribe_book_deltas_unbound,
660 "unsubscribe_book_deltas is not wired into this host vtable",
661 (
662 ctx: *const HostContext,
663 instrument_id: BorrowedStr<'_>,
664 client_id: BorrowedStr<'_>,
665 params_json: BorrowedStr<'_>,
666 )
667);
668unbound_unit_fn!(
669 host_subscribe_book_at_interval_unbound,
670 "subscribe_book_at_interval is not wired into this host vtable",
671 (
672 ctx: *const HostContext,
673 instrument_id: BorrowedStr<'_>,
674 book_type: u8,
675 depth: usize,
676 interval_ms: usize,
677 client_id: BorrowedStr<'_>,
678 params_json: BorrowedStr<'_>,
679 )
680);
681unbound_unit_fn!(
682 host_unsubscribe_book_at_interval_unbound,
683 "unsubscribe_book_at_interval is not wired into this host vtable",
684 (
685 ctx: *const HostContext,
686 instrument_id: BorrowedStr<'_>,
687 interval_ms: usize,
688 client_id: BorrowedStr<'_>,
689 params_json: BorrowedStr<'_>,
690 )
691);
692unbound_unit_fn!(
693 host_msgbus_publish_unbound,
694 "msgbus_publish is not wired into this host vtable",
695 (
696 ctx: *const HostContext,
697 topic: BorrowedStr<'_>,
698 payload: crate::Slice<'_, u8>,
699 )
700);
701unbound_unit_fn!(
702 host_set_time_alert_unbound,
703 "set_time_alert is not wired into this host vtable",
704 (
705 ctx: *const HostContext,
706 name: BorrowedStr<'_>,
707 alert_time_ns: u64,
708 allow_past: u8,
709 )
710);
711unbound_unit_fn!(
712 host_set_timer_unbound,
713 "set_timer is not wired into this host vtable",
714 (
715 ctx: *const HostContext,
716 name: BorrowedStr<'_>,
717 interval_ns: u64,
718 start_time_ns: u64,
719 stop_time_ns: u64,
720 allow_past: u8,
721 fire_immediately: u8,
722 )
723);
724unbound_unit_fn!(
725 host_cancel_timer_unbound,
726 "cancel_timer is not wired into this host vtable",
727 (ctx: *const HostContext, name: BorrowedStr<'_>)
728);
729
730unsafe extern "C" fn host_submit_order_unbound(
731 _ctx: *const HostContext,
732 _command: *const SubmitOrderHandle,
733) -> PluginResult<()> {
734 PluginResult::Err(PluginError::new(
735 PluginErrorCode::NotImplemented,
736 "submit_order is not wired into this host vtable",
737 ))
738}
739
740unsafe extern "C" fn host_cancel_order_unbound(
741 _ctx: *const HostContext,
742 _command: *const CancelOrderHandle,
743) -> PluginResult<()> {
744 PluginResult::Err(PluginError::new(
745 PluginErrorCode::NotImplemented,
746 "cancel_order is not wired into this host vtable",
747 ))
748}
749
750unsafe extern "C" fn host_modify_order_unbound(
751 _ctx: *const HostContext,
752 _command: *const ModifyOrderHandle,
753) -> PluginResult<()> {
754 PluginResult::Err(PluginError::new(
755 PluginErrorCode::NotImplemented,
756 "modify_order is not wired into this host vtable",
757 ))
758}
759
760unsafe extern "C" fn host_submit_order_list_unbound(
761 _ctx: *const HostContext,
762 _command: *const SubmitOrderListHandle,
763) -> PluginResult<()> {
764 PluginResult::Err(PluginError::new(
765 PluginErrorCode::NotImplemented,
766 "submit_order_list is not wired into this host vtable",
767 ))
768}
769
770unsafe extern "C" fn host_cancel_orders_unbound(
771 _ctx: *const HostContext,
772 _command: *const CancelOrdersHandle,
773) -> PluginResult<()> {
774 PluginResult::Err(PluginError::new(
775 PluginErrorCode::NotImplemented,
776 "cancel_orders is not wired into this host vtable",
777 ))
778}
779
780unsafe extern "C" fn host_cancel_all_orders_unbound(
781 _ctx: *const HostContext,
782 _command: *const CancelAllOrdersHandle,
783) -> PluginResult<()> {
784 PluginResult::Err(PluginError::new(
785 PluginErrorCode::NotImplemented,
786 "cancel_all_orders is not wired into this host vtable",
787 ))
788}
789
790unsafe extern "C" fn host_close_position_unbound(
791 _ctx: *const HostContext,
792 _command: *const ClosePositionHandle,
793) -> PluginResult<()> {
794 PluginResult::Err(PluginError::new(
795 PluginErrorCode::NotImplemented,
796 "close_position is not wired into this host vtable",
797 ))
798}
799
800unsafe extern "C" fn host_close_all_positions_unbound(
801 _ctx: *const HostContext,
802 _command: *const CloseAllPositionsHandle,
803) -> PluginResult<()> {
804 PluginResult::Err(PluginError::new(
805 PluginErrorCode::NotImplemented,
806 "close_all_positions is not wired into this host vtable",
807 ))
808}
809
810unsafe extern "C" fn host_query_account_unbound(
811 _ctx: *const HostContext,
812 _command: *const QueryAccountHandle,
813) -> PluginResult<()> {
814 PluginResult::Err(PluginError::new(
815 PluginErrorCode::NotImplemented,
816 "query_account is not wired into this host vtable",
817 ))
818}
819
820unsafe extern "C" fn host_query_order_unbound(
821 _ctx: *const HostContext,
822 _command: *const QueryOrderHandle,
823) -> PluginResult<()> {
824 PluginResult::Err(PluginError::new(
825 PluginErrorCode::NotImplemented,
826 "query_order is not wired into this host vtable",
827 ))
828}
829
830unsafe extern "C" fn host_log(
831 level: HostLogLevel,
832 target: BorrowedStr<'_>,
833 message: BorrowedStr<'_>,
834) {
835 let target = unsafe { target.as_str() };
837 let message = unsafe { message.as_str() };
839 match level {
840 HostLogLevel::Error => log::error!(target: "nautilus_plugin", "[{target}] {message}"),
841 HostLogLevel::Warn => log::warn!(target: "nautilus_plugin", "[{target}] {message}"),
842 HostLogLevel::Info => log::info!(target: "nautilus_plugin", "[{target}] {message}"),
843 HostLogLevel::Debug => log::debug!(target: "nautilus_plugin", "[{target}] {message}"),
844 HostLogLevel::Trace => log::trace!(target: "nautilus_plugin", "[{target}] {message}"),
845 }
846}
847
848#[cfg(test)]
849mod tests {
850 use nautilus_model::types::fixed::FIXED_PRECISION;
851 use rstest::rstest;
852
853 use super::*;
854 use crate::{
855 boundary::Slice,
856 manifest::{CustomDataRegistration, PluginBuildId},
857 surfaces::custom_data::{CustomDataVTable, PluginCustomData, custom_data_vtable},
858 };
859
860 #[derive(Clone, PartialEq)]
861 struct LoaderTestTick;
862
863 impl PluginCustomData for LoaderTestTick {
864 const TYPE_NAME: &'static str = "LoaderTestTick";
865
866 fn ts_event(&self) -> u64 {
867 0
868 }
869
870 fn ts_init(&self) -> u64 {
871 0
872 }
873
874 fn to_json(&self) -> anyhow::Result<Vec<u8>> {
875 Ok(Vec::new())
876 }
877
878 fn from_json(_payload: &[u8]) -> anyhow::Result<Self> {
879 Ok(Self)
880 }
881
882 fn schema_ipc() -> anyhow::Result<Vec<u8>> {
883 Ok(Vec::new())
884 }
885
886 fn encode_batch(_items: &[&Self]) -> anyhow::Result<Vec<u8>> {
887 Ok(Vec::new())
888 }
889
890 fn decode_batch(
891 _ipc_bytes: &[u8],
892 _metadata: &[(String, String)],
893 ) -> anyhow::Result<Vec<Self>> {
894 Ok(Vec::new())
895 }
896 }
897
898 fn custom_data_vtable_missing_to_json() -> *const CustomDataVTable {
899 let valid = custom_data_vtable::<LoaderTestTick>();
900 let valid = unsafe { &*valid };
902 let vtable = Box::leak(Box::new(CustomDataVTable {
903 type_name: valid.type_name,
904 schema_ipc: valid.schema_ipc,
905 from_json: valid.from_json,
906 encode_batch: valid.encode_batch,
907 decode_batch: valid.decode_batch,
908 ts_event: valid.ts_event,
909 ts_init: valid.ts_init,
910 to_json: None,
911 clone_handle: valid.clone_handle,
912 drop_handle: valid.drop_handle,
913 eq_handles: valid.eq_handles,
914 }));
915 std::ptr::from_ref(&*vtable)
916 }
917
918 #[rstest]
919 fn empty_loader_is_empty() {
920 let loader = PluginLoader::new();
921 assert!(loader.is_empty());
922 assert_eq!(loader.len(), 0);
923 assert!(loader.loaded().is_empty());
924 }
925
926 #[rstest]
927 fn first_duplicate_custom_data_type_finds_cross_plugin_collision() {
928 let path_a = Path::new("/plugins/a.so");
929 let path_b = Path::new("/plugins/b.so");
930 let existing = [
931 ("AlphaTick", path_a),
932 ("BetaTick", path_a),
933 ("GammaTick", path_b),
934 ];
935 let new_types = ["DeltaTick", "BetaTick"];
936
937 let hit = first_duplicate_custom_data_type(&new_types, &existing);
938
939 assert_eq!(hit, Some(("BetaTick", path_a)));
940 }
941
942 #[rstest]
943 fn first_duplicate_custom_data_type_returns_none_when_disjoint() {
944 let path_a = Path::new("/plugins/a.so");
945 let existing = [("AlphaTick", path_a)];
946 let new_types = ["BetaTick", "GammaTick"];
947
948 assert_eq!(
949 first_duplicate_custom_data_type(&new_types, &existing),
950 None
951 );
952 }
953
954 #[rstest]
955 fn first_duplicate_custom_data_type_handles_empty_inputs() {
956 let path_a = Path::new("/plugins/a.so");
957 let existing = [("AlphaTick", path_a)];
958
959 assert_eq!(first_duplicate_custom_data_type(&[], &existing), None);
960 assert_eq!(first_duplicate_custom_data_type(&["AlphaTick"], &[]), None);
961 }
962
963 #[rstest]
964 fn missing_file_reports_open_error_with_path_and_source() {
965 let mut loader = PluginLoader::new();
966 let path = "/nonexistent/path/to/plugin.so";
967 let err = loader.load(path).expect_err("should fail to open");
968 match &err {
969 LoadError::Open { path: p, source: _ } => {
970 assert_eq!(p.as_os_str(), path);
971 }
972 other => panic!("expected Open, was {other:?}"),
973 }
974 let rendered = format!("{err}");
975 assert!(
976 rendered.contains(path),
977 "rendered error should include the path, was: {rendered}",
978 );
979 }
980
981 #[rstest]
982 fn host_vtable_singleton_matches_abi() {
983 let p = host_vtable();
984 assert!(!p.is_null());
985 let v = unsafe { &*p };
987 assert_eq!(v.abi_version, NAUTILUS_PLUGIN_ABI_VERSION);
988 }
989
990 #[rstest]
991 fn host_vtable_clock_now_ns_returns_unix_nanos() {
992 let p = host_vtable();
993 let v = unsafe { &*p };
995 let now = unsafe { (v.clock_now_ns)() };
998 assert!(now > 1_577_836_800_000_000_000u64);
1000 }
1001
1002 #[rstest]
1003 fn host_vtable_log_does_not_panic() {
1004 let p = host_vtable();
1005 let v = unsafe { &*p };
1007 let target = BorrowedStr::from_str("nautilus_plugin_test");
1008 let message = BorrowedStr::from_str("test message");
1009 unsafe { (v.log)(HostLogLevel::Info, target, message) };
1012 }
1013
1014 #[rstest]
1015 fn validate_manifest_ptr_rejects_null() {
1016 let path = std::path::Path::new("/test/plugin.so");
1017 let err = validate_manifest_ptr(std::ptr::null(), path).unwrap_err();
1018 match err {
1019 LoadError::NullManifest { path: p } => assert_eq!(p, path),
1020 other => panic!("expected NullManifest, was {other:?}"),
1021 }
1022 }
1023
1024 #[rstest]
1025 fn validate_manifest_ptr_rejects_abi_mismatch() {
1026 let bad_manifest = PluginManifest {
1027 abi_version: NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
1028 plugin_name: BorrowedStr::from_str("bad"),
1029 plugin_vendor: BorrowedStr::from_str(""),
1030 plugin_version: BorrowedStr::from_str("0.0.0"),
1031 build_id: PluginBuildId::current(),
1032 custom_data: Slice::empty(),
1033 actors: Slice::empty(),
1034 strategies: Slice::empty(),
1035 controllers: Slice::empty(),
1036 };
1037 let path = std::path::Path::new("/test/plugin.so");
1038 let err = validate_manifest_ptr(&raw const bad_manifest, path).unwrap_err();
1039 match &err {
1040 LoadError::AbiMismatch {
1041 path: p,
1042 expected,
1043 actual,
1044 diagnostics,
1045 } => {
1046 assert_eq!(p, path);
1047 assert_eq!(*expected, NAUTILUS_PLUGIN_ABI_VERSION);
1048 assert_eq!(*actual, NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1));
1049 assert_eq!(diagnostics.plugin_name.as_str(), "bad");
1050 assert_eq!(diagnostics.plugin_version.as_str(), "0.0.0");
1051 assert_eq!(
1052 diagnostics.build_id.nautilus_plugin_version.as_str(),
1053 env!("CARGO_PKG_VERSION")
1054 );
1055 assert_eq!(diagnostics.build_id.fixed_precision, Some(FIXED_PRECISION));
1056 }
1057 other => panic!("expected AbiMismatch, was {other:?}"),
1058 }
1059
1060 let rendered = format!("{err}");
1061 assert!(rendered.contains("manifest name='bad'"));
1062 assert!(rendered.contains("nautilus_plugin_version='"));
1063 assert!(rendered.contains("rustc='"));
1064 assert!(rendered.contains("target='"));
1065 assert!(rendered.contains("profile='"));
1066 assert!(rendered.contains("precision_mode='"));
1067 assert!(rendered.contains("fixed_precision="));
1068 }
1069
1070 #[rstest]
1071 fn abi_mismatch_diagnostics_mark_unavailable_build_id_fields() {
1072 let bad_manifest = PluginManifest {
1073 abi_version: NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
1074 plugin_name: BorrowedStr::empty(),
1075 plugin_vendor: BorrowedStr::empty(),
1076 plugin_version: BorrowedStr::empty(),
1077 build_id: PluginBuildId {
1078 schema_version: 7,
1079 nautilus_plugin_version: BorrowedStr::empty(),
1080 rustc_version: BorrowedStr::empty(),
1081 target_triple: BorrowedStr::empty(),
1082 build_profile: BorrowedStr::empty(),
1083 precision_mode: BorrowedStr::empty(),
1084 fixed_precision: 0,
1085 },
1086 custom_data: Slice::empty(),
1087 actors: Slice::empty(),
1088 strategies: Slice::empty(),
1089 controllers: Slice::empty(),
1090 };
1091 let path = std::path::Path::new("/test/plugin.so");
1092 let err = validate_manifest_ptr(&raw const bad_manifest, path).unwrap_err();
1093 let rendered = format!("{err}");
1094
1095 assert!(rendered.contains("plug-in '/test/plugin.so' ABI mismatch"));
1096 assert!(rendered.contains(&format!("host = {NAUTILUS_PLUGIN_ABI_VERSION}")));
1097 assert!(rendered.contains(&format!(
1098 "plug-in = {}",
1099 NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1)
1100 )));
1101 assert!(rendered.contains("manifest name='<unknown>'"));
1102 assert!(rendered.contains("version='<unknown>'"));
1103 assert!(rendered.contains("build_id(schema=7"));
1104 assert!(rendered.contains("nautilus_plugin_version='<unknown>'"));
1105 assert!(rendered.contains("rustc='<unknown>'"));
1106 assert!(rendered.contains("target='<unknown>'"));
1107 assert!(rendered.contains("profile='<unknown>'"));
1108 assert!(rendered.contains("precision_mode='<unknown>'"));
1109 assert!(rendered.contains("fixed_precision=<unknown>"));
1110 }
1111
1112 #[rstest]
1113 fn validate_manifest_ptr_accepts_matching_manifest() {
1114 let registrations = Box::leak(Box::new([CustomDataRegistration {
1115 type_name: BorrowedStr::from_str("LoaderTestTick"),
1116 vtable: custom_data_vtable::<LoaderTestTick>(),
1117 }]));
1118 let good_manifest = PluginManifest {
1119 abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
1120 plugin_name: BorrowedStr::from_str("good"),
1121 plugin_vendor: BorrowedStr::from_str(""),
1122 plugin_version: BorrowedStr::from_str("0.0.0"),
1123 build_id: PluginBuildId::current(),
1124 custom_data: Slice::from_slice(registrations),
1125 actors: Slice::empty(),
1126 strategies: Slice::empty(),
1127 controllers: Slice::empty(),
1128 };
1129 let path = std::path::Path::new("/test/plugin.so");
1130 let manifest = validate_manifest_ptr(&raw const good_manifest, path)
1131 .expect("matching manifest accepted");
1132 let custom_data = manifest.custom_data().next().expect("custom data entry");
1133
1134 assert_eq!(manifest.plugin_name(), "good");
1135 assert_eq!(custom_data.type_name(), "LoaderTestTick");
1136 assert_eq!(custom_data.vtable().as_ptr(), registrations[0].vtable);
1137 }
1138
1139 #[rstest]
1140 fn validate_manifest_ptr_rejects_invalid_manifest_with_diagnostics() {
1141 static NULL_VTABLE_CUSTOM_DATA: [CustomDataRegistration; 1] = [CustomDataRegistration {
1142 type_name: BorrowedStr::from_str("BadTick"),
1143 vtable: std::ptr::null(),
1144 }];
1145
1146 let bad_manifest = PluginManifest {
1147 abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
1148 plugin_name: BorrowedStr::empty(),
1149 plugin_vendor: BorrowedStr::from_str(""),
1150 plugin_version: BorrowedStr::from_str("0.0.0"),
1151 build_id: PluginBuildId {
1152 schema_version: crate::PLUGIN_BUILD_ID_VERSION + 1,
1153 ..PluginBuildId::current()
1154 },
1155 custom_data: Slice::from_slice(&NULL_VTABLE_CUSTOM_DATA),
1156 actors: Slice::empty(),
1157 strategies: Slice::empty(),
1158 controllers: Slice::empty(),
1159 };
1160 let path = std::path::Path::new("/test/plugin.so");
1161 let err = validate_manifest_ptr(&raw const bad_manifest, path).unwrap_err();
1162
1163 match &err {
1164 LoadError::InvalidManifest {
1165 path: p,
1166 diagnostics,
1167 errors,
1168 } => {
1169 assert_eq!(p, path);
1170 assert_eq!(diagnostics.plugin_name.as_str(), "");
1171 assert_eq!(diagnostics.plugin_version.as_str(), "0.0.0");
1172 assert!(
1173 errors
1174 .messages()
1175 .iter()
1176 .any(|message| message == "plugin_name must not be empty")
1177 );
1178 assert!(
1179 errors
1180 .messages()
1181 .iter()
1182 .any(|message| message == "custom_data[0].vtable must not be null")
1183 );
1184 }
1185 other => panic!("expected InvalidManifest, was {other:?}"),
1186 }
1187
1188 let rendered = format!("{err}");
1189 assert!(rendered.contains("plug-in '/test/plugin.so' manifest validation failed"));
1190 assert!(rendered.contains("manifest name='<unknown>'"));
1191 assert!(rendered.contains("plugin_name must not be empty"));
1192 let expected_schema_error = format!(
1193 "build_id.schema_version {} does not match supported schema {}",
1194 crate::PLUGIN_BUILD_ID_VERSION + 1,
1195 crate::PLUGIN_BUILD_ID_VERSION
1196 );
1197 assert!(rendered.contains(&expected_schema_error));
1198 assert!(rendered.contains("custom_data[0].vtable must not be null"));
1199 }
1200
1201 #[rstest]
1202 fn validate_manifest_ptr_rejects_malformed_vtable_with_diagnostics() {
1203 let registrations = Box::leak(Box::new([CustomDataRegistration {
1204 type_name: BorrowedStr::from_str("BadTick"),
1205 vtable: custom_data_vtable_missing_to_json(),
1206 }]));
1207 let bad_manifest = PluginManifest {
1208 abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
1209 plugin_name: BorrowedStr::from_str("bad-vtable"),
1210 plugin_vendor: BorrowedStr::from_str(""),
1211 plugin_version: BorrowedStr::from_str("0.0.0"),
1212 build_id: PluginBuildId::current(),
1213 custom_data: Slice::from_slice(registrations),
1214 actors: Slice::empty(),
1215 strategies: Slice::empty(),
1216 controllers: Slice::empty(),
1217 };
1218 let path = std::path::Path::new("/test/plugin.so");
1219 let err = validate_manifest_ptr(&raw const bad_manifest, path).unwrap_err();
1220
1221 match &err {
1222 LoadError::InvalidManifest {
1223 path: p,
1224 diagnostics,
1225 errors,
1226 } => {
1227 assert_eq!(p, path);
1228 assert_eq!(diagnostics.plugin_name.as_str(), "bad-vtable");
1229 assert!(errors.messages().iter().any(|message| message
1230 == "custom_data[0] type 'BadTick' vtable.to_json must not be null"));
1231 }
1232 other => panic!("expected InvalidManifest, was {other:?}"),
1233 }
1234
1235 let rendered = format!("{err}");
1236 assert!(rendered.contains("manifest name='bad-vtable'"));
1237 assert!(rendered.contains("custom_data[0] type 'BadTick' vtable.to_json must not be null"));
1238 }
1239
1240 #[rstest]
1241 #[case::submit("submit_order is not wired into this host vtable")]
1242 #[case::cancel("cancel_order is not wired into this host vtable")]
1243 #[case::modify("modify_order is not wired into this host vtable")]
1244 #[case::submit_list("submit_order_list is not wired into this host vtable")]
1245 #[case::cancel_list("cancel_orders is not wired into this host vtable")]
1246 #[case::cancel_all("cancel_all_orders is not wired into this host vtable")]
1247 #[case::close_position("close_position is not wired into this host vtable")]
1248 #[case::close_all("close_all_positions is not wired into this host vtable")]
1249 #[case::query_account("query_account is not wired into this host vtable")]
1250 #[case::query_order("query_order is not wired into this host vtable")]
1251 fn host_order_command_stubs_return_not_implemented(#[case] expected: &str) {
1252 use nautilus_core::{UUID4, UnixNanos};
1253 use nautilus_model::{
1254 enums::{OrderSide, OrderType, TimeInForce},
1255 identifiers::{
1256 AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TraderId,
1257 },
1258 orders::{MarketOrder, OrderAny},
1259 types::Quantity,
1260 };
1261
1262 use crate::surfaces::commands::{
1263 CancelAllOrdersCommand, CancelOrderCommand, CancelOrdersCommand,
1264 CloseAllPositionsCommand, ClosePositionCommand, ModifyOrderCommand,
1265 QueryAccountCommand, QueryOrderCommand, SubmitOrderCommand, SubmitOrderListCommand,
1266 };
1267
1268 let _ = OrderType::Market;
1269
1270 let p = host_vtable();
1273 let v = unsafe { &*p };
1275 let ctx = std::ptr::null::<HostContext>();
1276 let order = OrderAny::Market(MarketOrder::new(
1277 TraderId::from("TRADER-001"),
1278 StrategyId::from("S-001"),
1279 InstrumentId::from("ETH-USDT.BINANCE"),
1280 ClientOrderId::from("O-1"),
1281 OrderSide::Buy,
1282 Quantity::from("1.0"),
1283 TimeInForce::Gtc,
1284 UUID4::new(),
1285 UnixNanos::default(),
1286 false,
1287 false,
1288 None,
1289 None,
1290 None,
1291 None,
1292 None,
1293 None,
1294 None,
1295 None,
1296 ));
1297 let submit_handle =
1298 SubmitOrderHandle::new(SubmitOrderCommand::new(order.clone(), None, None, None));
1299 let cancel_handle = CancelOrderHandle::new(CancelOrderCommand::new(
1300 ClientOrderId::from("O-1"),
1301 None,
1302 None,
1303 ));
1304 let modify_handle = ModifyOrderHandle::new(ModifyOrderCommand::new(
1305 ClientOrderId::from("O-1"),
1306 None,
1307 None,
1308 None,
1309 None,
1310 None,
1311 ));
1312 let submit_list_handle =
1313 SubmitOrderListHandle::new(SubmitOrderListCommand::new(vec![order], None, None, None));
1314 let cancel_orders_handle =
1315 CancelOrdersHandle::new(CancelOrdersCommand::new(vec![], None, None));
1316 let cancel_all_handle = CancelAllOrdersHandle::new(CancelAllOrdersCommand::new(
1317 InstrumentId::from("ETH-USDT.BINANCE"),
1318 None,
1319 None,
1320 None,
1321 ));
1322 let close_handle = ClosePositionHandle::new(ClosePositionCommand::new(
1323 PositionId::from("P-001"),
1324 None,
1325 None,
1326 None,
1327 None,
1328 None,
1329 ));
1330 let close_all_handle = CloseAllPositionsHandle::new(CloseAllPositionsCommand::new(
1331 InstrumentId::from("ETH-USDT.BINANCE"),
1332 None,
1333 None,
1334 None,
1335 None,
1336 None,
1337 None,
1338 ));
1339 let query_account_handle = QueryAccountHandle::new(QueryAccountCommand::new(
1340 AccountId::from("BINANCE-001"),
1341 None,
1342 None,
1343 ));
1344 let query_order_handle = QueryOrderHandle::new(QueryOrderCommand::new(
1345 ClientOrderId::from("O-1"),
1346 None,
1347 None,
1348 ));
1349
1350 let r = match expected {
1351 s if s.starts_with("submit_order_list") =>
1352 unsafe { (v.submit_order_list)(ctx, &raw const submit_list_handle) },
1354 s if s.starts_with("submit_order") =>
1355 unsafe { (v.submit_order)(ctx, &raw const submit_handle) },
1357 s if s.starts_with("cancel_orders") =>
1358 unsafe { (v.cancel_orders)(ctx, &raw const cancel_orders_handle) },
1360 s if s.starts_with("cancel_all_orders") =>
1361 unsafe { (v.cancel_all_orders)(ctx, &raw const cancel_all_handle) },
1363 s if s.starts_with("cancel_order") =>
1364 unsafe { (v.cancel_order)(ctx, &raw const cancel_handle) },
1366 s if s.starts_with("modify_order") =>
1367 unsafe { (v.modify_order)(ctx, &raw const modify_handle) },
1369 s if s.starts_with("close_position") =>
1370 unsafe { (v.close_position)(ctx, &raw const close_handle) },
1372 s if s.starts_with("close_all_positions") =>
1373 unsafe { (v.close_all_positions)(ctx, &raw const close_all_handle) },
1375 s if s.starts_with("query_account") =>
1376 unsafe { (v.query_account)(ctx, &raw const query_account_handle) },
1378 s if s.starts_with("query_order") =>
1379 unsafe { (v.query_order)(ctx, &raw const query_order_handle) },
1381 _ => unreachable!(),
1382 };
1383
1384 let err = r.into_result().unwrap_err();
1385 assert_eq!(err.code, PluginErrorCode::NotImplemented);
1386 assert_eq!(err.message_string(), expected);
1387 }
1388
1389 #[rstest]
1390 #[case::instrument("cache_instrument")]
1391 #[case::account("cache_account")]
1392 #[case::order("cache_order")]
1393 #[case::position("cache_position")]
1394 #[case::orders_for_strategy("cache_orders_for_strategy")]
1395 #[case::positions_for_strategy("cache_positions_for_strategy")]
1396 fn host_cache_stubs_return_not_implemented(#[case] method: &str) {
1397 let p = host_vtable();
1398 let v = unsafe { &*p };
1400 let ctx = std::ptr::null::<HostContext>();
1401 let value = BorrowedStr::from_str("VALUE");
1402
1403 let r = match method {
1404 "cache_instrument" => unsafe { (v.cache_instrument)(ctx, value) },
1406 "cache_account" => unsafe { (v.cache_account)(ctx, value) },
1408 "cache_order" => unsafe { (v.cache_order)(ctx, value) },
1410 "cache_position" => unsafe { (v.cache_position)(ctx, value) },
1412 "cache_orders_for_strategy" => unsafe { (v.cache_orders_for_strategy)(ctx, value) },
1414 "cache_positions_for_strategy" => unsafe {
1416 (v.cache_positions_for_strategy)(ctx, value)
1417 },
1418 _ => unreachable!(),
1419 };
1420
1421 let err = match r.into_result() {
1422 Ok(_) => panic!("{method} unexpectedly succeeded"),
1423 Err(e) => e,
1424 };
1425 assert_eq!(err.code, PluginErrorCode::NotImplemented);
1426 assert_eq!(
1427 err.message_string(),
1428 format!("{method} is not wired into this host vtable")
1429 );
1430 }
1431
1432 #[rstest]
1433 #[case::subscribe_quotes("subscribe_quotes")]
1434 #[case::unsubscribe_quotes("unsubscribe_quotes")]
1435 #[case::subscribe_trades("subscribe_trades")]
1436 #[case::unsubscribe_trades("unsubscribe_trades")]
1437 #[case::subscribe_bars("subscribe_bars")]
1438 #[case::unsubscribe_bars("unsubscribe_bars")]
1439 #[case::subscribe_book_deltas("subscribe_book_deltas")]
1440 #[case::unsubscribe_book_deltas("unsubscribe_book_deltas")]
1441 #[case::subscribe_book_at_interval("subscribe_book_at_interval")]
1442 #[case::unsubscribe_book_at_interval("unsubscribe_book_at_interval")]
1443 #[case::msgbus_publish("msgbus_publish")]
1444 #[case::set_time_alert("set_time_alert")]
1445 #[case::set_timer("set_timer")]
1446 #[case::cancel_timer("cancel_timer")]
1447 fn host_stateful_unit_stubs_return_not_implemented(#[case] method: &str) {
1448 let p = host_vtable();
1449 let v = unsafe { &*p };
1451 let ctx = std::ptr::null::<HostContext>();
1452 let value = BorrowedStr::from_str("VALUE");
1453 let empty = BorrowedStr::empty();
1454
1455 let r = match method {
1456 "subscribe_quotes" => unsafe { (v.subscribe_quotes)(ctx, value, empty, empty) },
1458 "unsubscribe_quotes" => unsafe { (v.unsubscribe_quotes)(ctx, value, empty, empty) },
1460 "subscribe_trades" => unsafe { (v.subscribe_trades)(ctx, value, empty, empty) },
1462 "unsubscribe_trades" => unsafe { (v.unsubscribe_trades)(ctx, value, empty, empty) },
1464 "subscribe_bars" => unsafe { (v.subscribe_bars)(ctx, value, empty, empty) },
1466 "unsubscribe_bars" => unsafe { (v.unsubscribe_bars)(ctx, value, empty, empty) },
1468 "subscribe_book_deltas" => unsafe {
1470 (v.subscribe_book_deltas)(ctx, value, 0, 0, empty, 0, empty)
1471 },
1472 "unsubscribe_book_deltas" => unsafe {
1474 (v.unsubscribe_book_deltas)(ctx, value, empty, empty)
1475 },
1476 "subscribe_book_at_interval" => unsafe {
1478 (v.subscribe_book_at_interval)(ctx, value, 0, 0, 1, empty, empty)
1479 },
1480 "unsubscribe_book_at_interval" => unsafe {
1482 (v.unsubscribe_book_at_interval)(ctx, value, 1, empty, empty)
1483 },
1484 "msgbus_publish" => unsafe { (v.msgbus_publish)(ctx, value, crate::Slice::empty()) },
1486 "set_time_alert" => unsafe { (v.set_time_alert)(ctx, value, 1, 0) },
1488 "set_timer" => unsafe { (v.set_timer)(ctx, value, 1, 0, 0, 0, 0) },
1490 "cancel_timer" => unsafe { (v.cancel_timer)(ctx, value) },
1492 _ => unreachable!(),
1493 };
1494
1495 let err = r.into_result().unwrap_err();
1496 assert_eq!(err.code, PluginErrorCode::NotImplemented);
1497 assert_eq!(
1498 err.message_string(),
1499 format!("{method} is not wired into this host vtable")
1500 );
1501 }
1502}