Skip to main content

nautilus_plugin/bridge/
strategy.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 adapter that wraps a plug-in strategy cdylib as a
17//! [`Strategy`].
18//!
19//! Mirrors [`PluginActorAdapter`](crate::bridge::actor::PluginActorAdapter)
20//! shape but owns a [`StrategyCore`] and forwards the strategy-only
21//! callbacks (order lifecycle and position events) as well as the actor
22//! callback set. The plug-in issues `submit_order` / `cancel_order` /
23//! `modify_order` back through [`HostVTable`];
24//! the host vtable looks the adapter up by the per-instance
25//! [`HostContextInner`] and calls
26//! the matching [`Strategy`] method so the production cache/risk pipeline
27//! runs.
28
29#![allow(unsafe_code)]
30#![allow(
31    clippy::multiple_unsafe_ops_per_block,
32    reason = "vtable deref and FFI call form a single boundary callback; \
33              SAFETY comments cover both ops together"
34)]
35
36use std::{
37    any::Any,
38    fmt::Debug,
39    panic::{AssertUnwindSafe, catch_unwind},
40};
41
42use nautilus_common::{actor::DataActor, signal::Signal, timer::TimeEvent};
43use nautilus_model::{
44    data::{
45        Bar, CustomData, FundingRateUpdate, IndexPriceUpdate, InstrumentClose, InstrumentStatus,
46        MarkPriceUpdate, OptionChainSlice, OptionGreeks, OrderBookDelta, OrderBookDeltas,
47        OrderBookDepth10, QuoteTick, TradeTick,
48    },
49    events::{
50        OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied, OrderEmulated,
51        OrderExpired, OrderFilled, OrderInitialized, OrderModifyRejected, OrderPendingCancel,
52        OrderPendingUpdate, OrderRejected, OrderReleased, OrderSubmitted, OrderTriggered,
53        OrderUpdated, PositionChanged, PositionClosed, PositionOpened,
54    },
55    identifiers::ActorId,
56    instruments::InstrumentAny,
57    orderbook::OrderBook,
58};
59use nautilus_trading::{
60    nautilus_strategy,
61    strategy::{Strategy, StrategyConfig, StrategyCore},
62};
63
64use crate::{
65    boundary::{BorrowedStr, PluginResult, Slice},
66    bridge::{
67        custom_data::{try_custom_data_boundary_ref, try_historical_custom_data_boundary_ref},
68        registry::{HostContextInner, drop_host_context, leak_host_context},
69    },
70    host::{HostContext, HostVTable},
71    manifest::ValidatedStrategyVTable,
72    surfaces::{
73        book::{OrderBookDeltasHandle, OrderBookHandle},
74        custom_data::PluginCustomDataRef,
75        instrument::InstrumentAnyHandle,
76        option_chain::OptionChainSliceHandle,
77        strategy::PluginStrategyHandle,
78    },
79};
80
81/// Adapts a plug-in strategy (vtable + handle from a cdylib) into a host-side
82/// [`Strategy`] the live node can
83/// register and route through the production cache, risk, and event pipeline.
84pub struct PluginStrategyAdapter {
85    core: StrategyCore,
86    plugin_name: String,
87    type_name: String,
88    vtable: ValidatedStrategyVTable,
89    handle: *mut PluginStrategyHandle,
90    ctx: *const HostContext,
91}
92
93// SAFETY: the adapter owns the plug-in handle exclusively and never aliases
94// it across threads. The vtable pointer is process-lifetime static. The
95// engine drives the adapter from a single trader thread; the bound is only
96// required to satisfy the trait bounds upstream.
97unsafe impl Send for PluginStrategyAdapter {}
98
99impl Debug for PluginStrategyAdapter {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct(stringify!(PluginStrategyAdapter))
102            .field("plugin_name", &self.plugin_name)
103            .field("type_name", &self.type_name)
104            .field("actor_id", &self.core.actor_id())
105            .finish()
106    }
107}
108
109impl PluginStrategyAdapter {
110    /// Constructs a new adapter by calling the plug-in's `create` thunk.
111    ///
112    /// `host` must be the same vtable pointer the host handed the plug-in at
113    /// load time. `strategy_config` defines the strategy ID and other core
114    /// state on the host side. `config_json` is forwarded verbatim to the
115    /// plug-in's `PluginStrategy::new` so the cdylib can read instance
116    /// configuration.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if `strategy_config.strategy_id` is `None`, or if the
121    /// plug-in's `create` thunk returns a null handle.
122    ///
123    /// `strategy_config.strategy_id` must be `Some(_)` so that
124    /// `Trader::prepare_strategy_for_registration`'s `change_id` call is a
125    /// no-op on the adapter's `actor_id`. Otherwise the trader would derive
126    /// a fresh tag-suffixed id at registration time, while the host context
127    /// would still carry the pre-registration address-based default, and
128    /// every `submit_order` / `cancel_order` / `modify_order` callback from
129    /// the plug-in would fail to resolve the registered adapter.
130    ///
131    /// # Safety
132    ///
133    /// `host` must be the same vtable pointer the host registered with the
134    /// plug-in at load time.
135    pub unsafe fn new(
136        strategy_config: StrategyConfig,
137        plugin_name: impl Into<String>,
138        type_name: impl Into<String>,
139        vtable: ValidatedStrategyVTable,
140        host: *const HostVTable,
141        config_json: &str,
142    ) -> anyhow::Result<Self> {
143        if strategy_config.strategy_id.is_none() {
144            anyhow::bail!(
145                "PluginStrategyAdapter requires StrategyConfig::strategy_id to be set so the \
146                 host context's actor_id stays stable across Trader::add_strategy"
147            );
148        }
149
150        let plugin_name = plugin_name.into();
151        let type_name = type_name.into();
152        // SAFETY: vtable comes from a validated manifest entry.
153        let create = unsafe { validated_slot!(StrategyVTable, vtable.as_ptr(), create) };
154        let core = StrategyCore::new(strategy_config);
155        let actor_id = ActorId::from(core.actor_id().inner().as_str());
156
157        let ctx = leak_host_context(HostContextInner {
158            actor_id,
159            is_strategy: true,
160        });
161
162        let cfg = BorrowedStr::from_str(config_json);
163        // SAFETY: vtable is non-null, host outlives the adapter, ctx + cfg
164        // are live across the call.
165        let handle = guard_call(&plugin_name, &type_name, "create", || unsafe {
166            create(host, ctx, cfg)
167        })
168        .ok_or_else(|| {
169            // SAFETY: ctx came from leak_host_context above.
170            unsafe { drop_host_context(ctx) };
171            anyhow::anyhow!("plug-in strategy '{type_name}' panicked in create")
172        })?;
173
174        if handle.is_null() {
175            // SAFETY: ctx came from leak_host_context above.
176            unsafe { drop_host_context(ctx) };
177            anyhow::bail!("plug-in strategy '{type_name}' returned a null handle from create");
178        }
179
180        Ok(Self {
181            core,
182            plugin_name,
183            type_name,
184            vtable,
185            handle,
186            ctx,
187        })
188    }
189
190    /// Returns the canonical type name reported by the plug-in.
191    #[must_use]
192    pub fn type_name(&self) -> &str {
193        &self.type_name
194    }
195
196    /// Returns the plug-in name (manifest `name`) the adapter wraps.
197    #[must_use]
198    pub fn plugin_name(&self) -> &str {
199        &self.plugin_name
200    }
201}
202
203impl Drop for PluginStrategyAdapter {
204    fn drop(&mut self) {
205        if !self.handle.is_null() {
206            let _ = catch_unwind(AssertUnwindSafe(|| {
207                // SAFETY: vtable + handle are live; drop_handle ignores null.
208                unsafe {
209                    validated_slot!(StrategyVTable, self.vtable.as_ptr(), drop_handle)(self.handle);
210                };
211            }));
212            self.handle = std::ptr::null_mut();
213        }
214        // SAFETY: ctx originated from leak_host_context in `new`.
215        unsafe { drop_host_context(self.ctx) };
216        self.ctx = std::ptr::null();
217    }
218}
219
220nautilus_strategy!(PluginStrategyAdapter, core, {
221    fn on_order_initialized(&mut self, event: OrderInitialized) {
222        log_strategy_hook_error(
223            "on_order_initialized",
224            self.forward_order_initialized(&event),
225        );
226    }
227
228    fn on_order_submitted(&mut self, event: OrderSubmitted) {
229        log_strategy_hook_error("on_order_submitted", self.forward_order_submitted(&event));
230    }
231
232    fn on_order_accepted(&mut self, event: OrderAccepted) {
233        log_strategy_hook_error("on_order_accepted", self.forward_order_accepted(&event));
234    }
235
236    fn on_order_rejected(&mut self, event: OrderRejected) {
237        log_strategy_hook_error("on_order_rejected", self.forward_order_rejected(&event));
238    }
239
240    fn on_order_expired(&mut self, event: OrderExpired) {
241        log_strategy_hook_error("on_order_expired", self.forward_order_expired(&event));
242    }
243
244    fn on_order_triggered(&mut self, event: OrderTriggered) {
245        log_strategy_hook_error("on_order_triggered", self.forward_order_triggered(&event));
246    }
247
248    fn on_order_denied(&mut self, event: OrderDenied) {
249        log_strategy_hook_error("on_order_denied", self.forward_order_denied(&event));
250    }
251
252    fn on_order_emulated(&mut self, event: OrderEmulated) {
253        log_strategy_hook_error("on_order_emulated", self.forward_order_emulated(&event));
254    }
255
256    fn on_order_released(&mut self, event: OrderReleased) {
257        log_strategy_hook_error("on_order_released", self.forward_order_released(&event));
258    }
259
260    fn on_order_pending_update(&mut self, event: OrderPendingUpdate) {
261        log_strategy_hook_error(
262            "on_order_pending_update",
263            self.forward_order_pending_update(&event),
264        );
265    }
266
267    fn on_order_pending_cancel(&mut self, event: OrderPendingCancel) {
268        log_strategy_hook_error(
269            "on_order_pending_cancel",
270            self.forward_order_pending_cancel(&event),
271        );
272    }
273
274    fn on_order_modify_rejected(&mut self, event: OrderModifyRejected) {
275        log_strategy_hook_error(
276            "on_order_modify_rejected",
277            self.forward_order_modify_rejected(&event),
278        );
279    }
280
281    fn on_order_cancel_rejected(&mut self, event: OrderCancelRejected) {
282        log_strategy_hook_error(
283            "on_order_cancel_rejected",
284            self.forward_order_cancel_rejected(&event),
285        );
286    }
287
288    fn on_order_updated(&mut self, event: OrderUpdated) {
289        log_strategy_hook_error("on_order_updated", self.forward_order_updated(&event));
290    }
291
292    fn on_position_opened(&mut self, event: PositionOpened) {
293        log_strategy_hook_error("on_position_opened", self.forward_position_opened(&event));
294    }
295
296    fn on_position_changed(&mut self, event: PositionChanged) {
297        log_strategy_hook_error("on_position_changed", self.forward_position_changed(&event));
298    }
299
300    fn on_position_closed(&mut self, event: PositionClosed) {
301        log_strategy_hook_error("on_position_closed", self.forward_position_closed(&event));
302    }
303
304    fn on_market_exit(&mut self) {
305        log_strategy_hook_error("on_market_exit", self.forward_market_exit());
306    }
307});
308
309impl DataActor for PluginStrategyAdapter {
310    fn on_start(&mut self) -> anyhow::Result<()> {
311        // Run the Strategy trait default first so GTD timer reactivation
312        // happens when `manage_gtd_expiry` is enabled, matching the Python
313        // strategy adapter pattern in crates/trading/src/python/strategy.rs.
314        Strategy::on_start(self)?;
315        invoke_lifecycle(self, "on_start", |adapter| unsafe {
316            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_start)(adapter.handle)
317        })
318    }
319
320    fn on_stop(&mut self) -> anyhow::Result<()> {
321        invoke_lifecycle(self, "on_stop", |adapter| unsafe {
322            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_stop)(adapter.handle)
323        })
324    }
325
326    fn on_resume(&mut self) -> anyhow::Result<()> {
327        invoke_lifecycle(self, "on_resume", |adapter| unsafe {
328            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_resume)(adapter.handle)
329        })
330    }
331
332    fn on_reset(&mut self) -> anyhow::Result<()> {
333        invoke_lifecycle(self, "on_reset", |adapter| unsafe {
334            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_reset)(adapter.handle)
335        })
336    }
337
338    fn on_dispose(&mut self) -> anyhow::Result<()> {
339        invoke_lifecycle(self, "on_dispose", |adapter| unsafe {
340            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_dispose)(adapter.handle)
341        })
342    }
343
344    fn on_degrade(&mut self) -> anyhow::Result<()> {
345        invoke_lifecycle(self, "on_degrade", |adapter| unsafe {
346            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_degrade)(adapter.handle)
347        })
348    }
349
350    fn on_fault(&mut self) -> anyhow::Result<()> {
351        invoke_lifecycle(self, "on_fault", |adapter| unsafe {
352            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_fault)(adapter.handle)
353        })
354    }
355
356    fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
357        // Run the Strategy trait default first so GTD-EXPIRY and
358        // MARKET_EXIT_CHECK timers fire before user code, matching the Python
359        // strategy adapter pattern in crates/trading/src/python/strategy.rs.
360        Strategy::on_time_event(self, event)?;
361        invoke_event(self, "on_time_event", event, |adapter, p| unsafe {
362            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_time_event)(
363                adapter.handle,
364                p,
365            )
366        })
367    }
368
369    fn on_data(&mut self, data: &CustomData) -> anyhow::Result<()> {
370        let Some(data_ref) = try_custom_data_boundary_ref(data) else {
371            return Ok(());
372        };
373        invoke_custom_data(self, "on_data", data_ref, |adapter, value| unsafe {
374            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_data)(adapter.handle, value)
375        })
376    }
377
378    fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
379        let handle = InstrumentAnyHandle::new(instrument.clone());
380        invoke_event(self, "on_instrument", &handle, |adapter, p| unsafe {
381            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_instrument)(
382                adapter.handle,
383                p,
384            )
385        })
386    }
387
388    fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
389        let handle = OrderBookDeltasHandle::new(deltas.clone());
390        invoke_event(self, "on_book_deltas", &handle, |adapter, p| unsafe {
391            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_book_deltas)(
392                adapter.handle,
393                p,
394            )
395        })
396    }
397
398    fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
399        let handle = OrderBookHandle::new(book.clone());
400        invoke_event(self, "on_book", &handle, |adapter, p| unsafe {
401            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_book)(adapter.handle, p)
402        })
403    }
404
405    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
406        invoke_event(self, "on_quote", quote, |adapter, p| unsafe {
407            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_quote)(adapter.handle, p)
408        })
409    }
410
411    fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
412        invoke_event(self, "on_trade", trade, |adapter, p| unsafe {
413            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_trade)(adapter.handle, p)
414        })
415    }
416
417    fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
418        invoke_event(self, "on_bar", bar, |adapter, p| unsafe {
419            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_bar)(adapter.handle, p)
420        })
421    }
422
423    fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
424        invoke_event(self, "on_mark_price", mark_price, |adapter, p| unsafe {
425            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_mark_price)(
426                adapter.handle,
427                p,
428            )
429        })
430    }
431
432    fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
433        invoke_event(self, "on_index_price", index_price, |adapter, p| unsafe {
434            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_index_price)(
435                adapter.handle,
436                p,
437            )
438        })
439    }
440
441    fn on_funding_rate(&mut self, funding_rate: &FundingRateUpdate) -> anyhow::Result<()> {
442        invoke_event(self, "on_funding_rate", funding_rate, |adapter, p| unsafe {
443            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_funding_rate)(
444                adapter.handle,
445                p,
446            )
447        })
448    }
449
450    fn on_option_greeks(&mut self, greeks: &OptionGreeks) -> anyhow::Result<()> {
451        invoke_event(self, "on_option_greeks", greeks, |adapter, p| unsafe {
452            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_option_greeks)(
453                adapter.handle,
454                p,
455            )
456        })
457    }
458
459    fn on_option_chain(&mut self, chain: &OptionChainSlice) -> anyhow::Result<()> {
460        let handle = OptionChainSliceHandle::new(chain.clone());
461        invoke_event(self, "on_option_chain", &handle, |adapter, p| unsafe {
462            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_option_chain)(
463                adapter.handle,
464                p,
465            )
466        })
467    }
468
469    fn on_instrument_status(&mut self, data: &InstrumentStatus) -> anyhow::Result<()> {
470        invoke_event(self, "on_instrument_status", data, |adapter, p| unsafe {
471            validated_slot!(
472                StrategyVTable,
473                adapter.vtable.as_ptr(),
474                on_instrument_status
475            )(adapter.handle, p)
476        })
477    }
478
479    fn on_instrument_close(&mut self, update: &InstrumentClose) -> anyhow::Result<()> {
480        invoke_event(self, "on_instrument_close", update, |adapter, p| unsafe {
481            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_instrument_close)(
482                adapter.handle,
483                p,
484            )
485        })
486    }
487
488    fn on_order_filled(&mut self, event: &OrderFilled) -> anyhow::Result<()> {
489        invoke_event(self, "on_order_filled", event, |adapter, p| unsafe {
490            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_filled)(
491                adapter.handle,
492                p,
493            )
494        })
495    }
496
497    fn on_order_canceled(&mut self, event: &OrderCanceled) -> anyhow::Result<()> {
498        invoke_event(self, "on_order_canceled", event, |adapter, p| unsafe {
499            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_canceled)(
500                adapter.handle,
501                p,
502            )
503        })
504    }
505
506    fn on_signal(&mut self, signal: &Signal) -> anyhow::Result<()> {
507        invoke_event(self, "on_signal", signal, |adapter, p| unsafe {
508            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_signal)(adapter.handle, p)
509        })
510    }
511
512    fn on_historical_data(&mut self, data: &dyn Any) -> anyhow::Result<()> {
513        let Some(data_ref) = try_historical_custom_data_boundary_ref(data) else {
514            return Ok(());
515        };
516        invoke_custom_data(
517            self,
518            "on_historical_data",
519            data_ref,
520            |adapter, value| unsafe {
521                validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_data)(
522                    adapter.handle,
523                    value,
524                )
525            },
526        )
527    }
528
529    fn on_historical_book_deltas(&mut self, deltas: &[OrderBookDelta]) -> anyhow::Result<()> {
530        invoke_slice(
531            self,
532            "on_historical_book_deltas",
533            deltas,
534            |adapter, s| unsafe {
535                validated_slot!(
536                    StrategyVTable,
537                    adapter.vtable.as_ptr(),
538                    on_historical_book_deltas
539                )(adapter.handle, s)
540            },
541        )
542    }
543
544    fn on_historical_book_depth(&mut self, depths: &[OrderBookDepth10]) -> anyhow::Result<()> {
545        invoke_slice(
546            self,
547            "on_historical_book_depth",
548            depths,
549            |adapter, s| unsafe {
550                validated_slot!(
551                    StrategyVTable,
552                    adapter.vtable.as_ptr(),
553                    on_historical_book_depth
554                )(adapter.handle, s)
555            },
556        )
557    }
558
559    fn on_historical_quotes(&mut self, quotes: &[QuoteTick]) -> anyhow::Result<()> {
560        invoke_slice(self, "on_historical_quotes", quotes, |adapter, s| unsafe {
561            validated_slot!(
562                StrategyVTable,
563                adapter.vtable.as_ptr(),
564                on_historical_quotes
565            )(adapter.handle, s)
566        })
567    }
568
569    fn on_historical_trades(&mut self, trades: &[TradeTick]) -> anyhow::Result<()> {
570        invoke_slice(self, "on_historical_trades", trades, |adapter, s| unsafe {
571            validated_slot!(
572                StrategyVTable,
573                adapter.vtable.as_ptr(),
574                on_historical_trades
575            )(adapter.handle, s)
576        })
577    }
578
579    fn on_historical_bars(&mut self, bars: &[Bar]) -> anyhow::Result<()> {
580        invoke_slice(self, "on_historical_bars", bars, |adapter, s| unsafe {
581            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_historical_bars)(
582                adapter.handle,
583                s,
584            )
585        })
586    }
587
588    fn on_historical_mark_prices(&mut self, mark_prices: &[MarkPriceUpdate]) -> anyhow::Result<()> {
589        invoke_slice(
590            self,
591            "on_historical_mark_prices",
592            mark_prices,
593            |adapter, s| unsafe {
594                validated_slot!(
595                    StrategyVTable,
596                    adapter.vtable.as_ptr(),
597                    on_historical_mark_prices
598                )(adapter.handle, s)
599            },
600        )
601    }
602
603    fn on_historical_index_prices(
604        &mut self,
605        index_prices: &[IndexPriceUpdate],
606    ) -> anyhow::Result<()> {
607        invoke_slice(
608            self,
609            "on_historical_index_prices",
610            index_prices,
611            |adapter, s| unsafe {
612                validated_slot!(
613                    StrategyVTable,
614                    adapter.vtable.as_ptr(),
615                    on_historical_index_prices
616                )(adapter.handle, s)
617            },
618        )
619    }
620
621    fn on_historical_funding_rates(
622        &mut self,
623        funding_rates: &[FundingRateUpdate],
624    ) -> anyhow::Result<()> {
625        invoke_slice(
626            self,
627            "on_historical_funding_rates",
628            funding_rates,
629            |adapter, s| unsafe {
630                validated_slot!(
631                    StrategyVTable,
632                    adapter.vtable.as_ptr(),
633                    on_historical_funding_rates
634                )(adapter.handle, s)
635            },
636        )
637    }
638}
639
640/// Forwarders for the strategy-only callbacks the live engine dispatches
641/// through `Strategy::handle_order_event` and `handle_position_event`. Each
642/// forwarder runs the matching plug-in vtable callback through the same
643/// `catch_unwind` + `PluginResult` plumbing as the actor surface.
644impl PluginStrategyAdapter {
645    fn forward_order_initialized(&self, event: &OrderInitialized) -> anyhow::Result<()> {
646        invoke_event(self, "on_order_initialized", event, |adapter, p| unsafe {
647            validated_slot!(
648                StrategyVTable,
649                adapter.vtable.as_ptr(),
650                on_order_initialized
651            )(adapter.handle, p)
652        })
653    }
654
655    fn forward_order_submitted(&self, event: &OrderSubmitted) -> anyhow::Result<()> {
656        invoke_event(self, "on_order_submitted", event, |adapter, p| unsafe {
657            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_submitted)(
658                adapter.handle,
659                p,
660            )
661        })
662    }
663
664    fn forward_order_accepted(&self, event: &OrderAccepted) -> anyhow::Result<()> {
665        invoke_event(self, "on_order_accepted", event, |adapter, p| unsafe {
666            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_accepted)(
667                adapter.handle,
668                p,
669            )
670        })
671    }
672
673    fn forward_order_rejected(&self, event: &OrderRejected) -> anyhow::Result<()> {
674        invoke_event(self, "on_order_rejected", event, |adapter, p| unsafe {
675            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_rejected)(
676                adapter.handle,
677                p,
678            )
679        })
680    }
681
682    fn forward_order_expired(&self, event: &OrderExpired) -> anyhow::Result<()> {
683        invoke_event(self, "on_order_expired", event, |adapter, p| unsafe {
684            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_expired)(
685                adapter.handle,
686                p,
687            )
688        })
689    }
690
691    fn forward_order_triggered(&self, event: &OrderTriggered) -> anyhow::Result<()> {
692        invoke_event(self, "on_order_triggered", event, |adapter, p| unsafe {
693            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_triggered)(
694                adapter.handle,
695                p,
696            )
697        })
698    }
699
700    fn forward_order_denied(&self, event: &OrderDenied) -> anyhow::Result<()> {
701        invoke_event(self, "on_order_denied", event, |adapter, p| unsafe {
702            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_denied)(
703                adapter.handle,
704                p,
705            )
706        })
707    }
708
709    fn forward_order_emulated(&self, event: &OrderEmulated) -> anyhow::Result<()> {
710        invoke_event(self, "on_order_emulated", event, |adapter, p| unsafe {
711            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_emulated)(
712                adapter.handle,
713                p,
714            )
715        })
716    }
717
718    fn forward_order_released(&self, event: &OrderReleased) -> anyhow::Result<()> {
719        invoke_event(self, "on_order_released", event, |adapter, p| unsafe {
720            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_released)(
721                adapter.handle,
722                p,
723            )
724        })
725    }
726
727    fn forward_order_pending_update(&self, event: &OrderPendingUpdate) -> anyhow::Result<()> {
728        invoke_event(
729            self,
730            "on_order_pending_update",
731            event,
732            |adapter, p| unsafe {
733                validated_slot!(
734                    StrategyVTable,
735                    adapter.vtable.as_ptr(),
736                    on_order_pending_update
737                )(adapter.handle, p)
738            },
739        )
740    }
741
742    fn forward_order_pending_cancel(&self, event: &OrderPendingCancel) -> anyhow::Result<()> {
743        invoke_event(
744            self,
745            "on_order_pending_cancel",
746            event,
747            |adapter, p| unsafe {
748                validated_slot!(
749                    StrategyVTable,
750                    adapter.vtable.as_ptr(),
751                    on_order_pending_cancel
752                )(adapter.handle, p)
753            },
754        )
755    }
756
757    fn forward_order_modify_rejected(&self, event: &OrderModifyRejected) -> anyhow::Result<()> {
758        invoke_event(
759            self,
760            "on_order_modify_rejected",
761            event,
762            |adapter, p| unsafe {
763                validated_slot!(
764                    StrategyVTable,
765                    adapter.vtable.as_ptr(),
766                    on_order_modify_rejected
767                )(adapter.handle, p)
768            },
769        )
770    }
771
772    fn forward_order_cancel_rejected(&self, event: &OrderCancelRejected) -> anyhow::Result<()> {
773        invoke_event(
774            self,
775            "on_order_cancel_rejected",
776            event,
777            |adapter, p| unsafe {
778                validated_slot!(
779                    StrategyVTable,
780                    adapter.vtable.as_ptr(),
781                    on_order_cancel_rejected
782                )(adapter.handle, p)
783            },
784        )
785    }
786
787    fn forward_order_updated(&self, event: &OrderUpdated) -> anyhow::Result<()> {
788        invoke_event(self, "on_order_updated", event, |adapter, p| unsafe {
789            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_order_updated)(
790                adapter.handle,
791                p,
792            )
793        })
794    }
795
796    fn forward_position_opened(&self, event: &PositionOpened) -> anyhow::Result<()> {
797        invoke_event(self, "on_position_opened", event, |adapter, p| unsafe {
798            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_position_opened)(
799                adapter.handle,
800                p,
801            )
802        })
803    }
804
805    fn forward_position_changed(&self, event: &PositionChanged) -> anyhow::Result<()> {
806        invoke_event(self, "on_position_changed", event, |adapter, p| unsafe {
807            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_position_changed)(
808                adapter.handle,
809                p,
810            )
811        })
812    }
813
814    fn forward_position_closed(&self, event: &PositionClosed) -> anyhow::Result<()> {
815        invoke_event(self, "on_position_closed", event, |adapter, p| unsafe {
816            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_position_closed)(
817                adapter.handle,
818                p,
819            )
820        })
821    }
822
823    fn forward_market_exit(&self) -> anyhow::Result<()> {
824        invoke_lifecycle(self, "on_market_exit", |adapter| unsafe {
825            validated_slot!(StrategyVTable, adapter.vtable.as_ptr(), on_market_exit)(adapter.handle)
826        })
827    }
828}
829
830fn log_strategy_hook_error(method: &str, r: anyhow::Result<()>) {
831    if let Err(e) = r {
832        log::error!(target: "nautilus_plugin", "{method}: {e}");
833    }
834}
835
836fn guard_call<R>(plugin: &str, type_name: &str, method: &str, f: impl FnOnce() -> R) -> Option<R> {
837    match catch_unwind(AssertUnwindSafe(f)) {
838        Ok(r) => Some(r),
839        Err(_payload) => {
840            log::error!(
841                target: "nautilus_plugin",
842                "plug-in '{plugin}' ({type_name}) panicked in {method}",
843            );
844            None
845        }
846    }
847}
848
849fn invoke_lifecycle(
850    adapter: &PluginStrategyAdapter,
851    method: &str,
852    f: impl FnOnce(&PluginStrategyAdapter) -> PluginResult<()>,
853) -> anyhow::Result<()> {
854    let plugin_name = adapter.plugin_name.clone();
855    let type_name = adapter.type_name.clone();
856    let result = guard_call(&plugin_name, &type_name, method, || f(adapter));
857    finish(result, &plugin_name, &type_name, method)
858}
859
860fn invoke_event<T>(
861    adapter: &PluginStrategyAdapter,
862    method: &str,
863    payload: &T,
864    f: impl FnOnce(&PluginStrategyAdapter, *const T) -> PluginResult<()>,
865) -> anyhow::Result<()> {
866    let plugin_name = adapter.plugin_name.clone();
867    let type_name = adapter.type_name.clone();
868    let ptr: *const T = payload;
869    let result = guard_call(&plugin_name, &type_name, method, || f(adapter, ptr));
870    finish(result, &plugin_name, &type_name, method)
871}
872
873fn invoke_custom_data(
874    adapter: &PluginStrategyAdapter,
875    method: &str,
876    payload: PluginCustomDataRef,
877    f: impl FnOnce(&PluginStrategyAdapter, PluginCustomDataRef) -> PluginResult<()>,
878) -> anyhow::Result<()> {
879    let plugin_name = adapter.plugin_name.clone();
880    let type_name = adapter.type_name.clone();
881    let result = guard_call(&plugin_name, &type_name, method, || f(adapter, payload));
882    finish(result, &plugin_name, &type_name, method)
883}
884
885fn invoke_slice<T>(
886    adapter: &PluginStrategyAdapter,
887    method: &str,
888    payload: &[T],
889    f: impl FnOnce(&PluginStrategyAdapter, Slice<'_, T>) -> PluginResult<()>,
890) -> anyhow::Result<()> {
891    let plugin_name = adapter.plugin_name.clone();
892    let type_name = adapter.type_name.clone();
893    let slice = Slice::from_slice(payload);
894    let result = guard_call(&plugin_name, &type_name, method, || f(adapter, slice));
895    finish(result, &plugin_name, &type_name, method)
896}
897
898fn finish(
899    result: Option<PluginResult<()>>,
900    plugin_name: &str,
901    type_name: &str,
902    method: &str,
903) -> anyhow::Result<()> {
904    match result {
905        Some(r) => r.into_result().map_err(|e| {
906            anyhow::anyhow!(
907                "plug-in '{plugin_name}' ({type_name}) {method} returned error: {}",
908                e.message_string()
909            )
910        }),
911        None => anyhow::bail!("plug-in '{plugin_name}' ({type_name}) panicked in {method}"),
912    }
913}
914
915#[cfg(test)]
916mod tests {
917    use nautilus_model::identifiers::StrategyId;
918    use rstest::rstest;
919
920    use super::*;
921    use crate::{
922        bridge::{
923            host::host_vtable,
924            registry::{host_context_live_count, host_context_test_lock},
925        },
926        surfaces::strategy::{PluginStrategy, strategy_vtable},
927    };
928
929    struct DropTestStrategy;
930    // SAFETY: empty unit struct holds no non-Send state.
931    unsafe impl Send for DropTestStrategy {}
932
933    impl PluginStrategy for DropTestStrategy {
934        const TYPE_NAME: &'static str = "DropTestStrategy";
935
936        fn new(_host: *const HostVTable, _ctx: *const HostContext, _config_json: &str) -> Self {
937            Self
938        }
939    }
940
941    fn test_strategy_config(strategy_id: &str) -> StrategyConfig {
942        StrategyConfig::builder()
943            .strategy_id(StrategyId::from(strategy_id))
944            .order_id_tag("001".to_string())
945            .build()
946    }
947
948    fn drop_test_strategy_vtable() -> ValidatedStrategyVTable {
949        // SAFETY: generated vtables are process-lifetime static and fill
950        // every required strategy slot.
951        unsafe {
952            ValidatedStrategyVTable::from_raw_unchecked(strategy_vtable::<DropTestStrategy>())
953        }
954    }
955
956    #[rstest]
957    fn new_rejects_config_without_strategy_id() {
958        // Regression: StrategyConfig::default() leaves strategy_id == None.
959        // Trader::prepare_strategy_for_registration would later call
960        // change_id which mutates actor_id, leaving the host context's
961        // actor_id pointing at the stale pre-registration default.
962        let config = StrategyConfig::default();
963        assert!(
964            config.strategy_id.is_none(),
965            "fixture assumes default has no strategy_id"
966        );
967
968        // SAFETY: documented error path.
969        let r = unsafe {
970            PluginStrategyAdapter::new(
971                config,
972                "plug-in",
973                DropTestStrategy::TYPE_NAME,
974                drop_test_strategy_vtable(),
975                host_vtable(),
976                "{}",
977            )
978        };
979        let err = r.unwrap_err();
980        assert!(
981            err.to_string().contains("strategy_id"),
982            "expected strategy_id error, was: {err}",
983        );
984    }
985
986    #[rstest]
987    fn drop_frees_host_context() {
988        let _guard = host_context_test_lock();
989        let before = host_context_live_count();
990        let config = test_strategy_config("PluginStrategyAdapter-Drop");
991        // SAFETY: host_vtable is process-lifetime static.
992        let adapter = unsafe {
993            PluginStrategyAdapter::new(
994                config,
995                "plug-in",
996                DropTestStrategy::TYPE_NAME,
997                drop_test_strategy_vtable(),
998                host_vtable(),
999                "{}",
1000            )
1001        }
1002        .expect("adapter construction");
1003        assert_eq!(host_context_live_count(), before + 1);
1004
1005        drop(adapter);
1006        assert_eq!(host_context_live_count(), before);
1007    }
1008}