Skip to main content

nautilus_live/python/
node.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//! Python bindings for live node.
17
18use std::{
19    cell::{Cell, Ref, RefCell, RefMut},
20    collections::HashMap,
21    fmt::Debug,
22    future::Future,
23    pin::Pin,
24    rc::Rc,
25    str::FromStr,
26    sync::{
27        Arc,
28        atomic::{AtomicBool, Ordering},
29        mpsc,
30    },
31    task::{Context, Poll, Waker},
32    thread,
33    time::{Duration, Instant},
34};
35
36use nautilus_common::{
37    actor::data_actor::ImportableActorConfig,
38    cache::CacheConfig,
39    enums::Environment,
40    live::get_runtime,
41    logging::logger::LoggerConfig,
42    msgbus::MessageBusConfig,
43    python::{
44        actor::{PyDataActor, prepare_python_actor},
45        cache::{PyCache, get_global_cache_database_factory_registry},
46        msgbus::get_global_msgbus_factory_registry,
47    },
48};
49#[cfg(feature = "examples")]
50use nautilus_core::python::to_pytype_err;
51use nautilus_core::{
52    UUID4,
53    python::{to_pyruntime_err, to_pyvalue_err},
54};
55use nautilus_model::{
56    enums::OmsType,
57    identifiers::{ActorId, ExecAlgorithmId, InstrumentId, TraderId},
58};
59use nautilus_portfolio::{config::PortfolioConfig, python::PyPortfolio};
60use nautilus_system::get_global_pyo3_registry;
61#[cfg(feature = "examples")]
62use nautilus_testkit::{DataTester, DataTesterConfig, ExecTester, ExecTesterConfig};
63#[cfg(feature = "examples")]
64use nautilus_trading::examples::{
65    actors::{BookImbalanceActor, BookImbalanceActorConfig},
66    strategies::{
67        CompositeMarketMaker, CompositeMarketMakerConfig, DeltaNeutralVol, DeltaNeutralVolConfig,
68        EmaCross, EmaCrossConfig, GridMarketMaker, GridMarketMakerConfig, HurstVpinDirectional,
69        HurstVpinDirectionalConfig,
70    },
71};
72use nautilus_trading::{
73    ImportableControllerConfig, ImportableExecutionAlgorithmConfig, ImportableStrategyConfig,
74    python::{algorithm::PyExecutionAlgorithm, strategy::PyStrategy},
75};
76use parking_lot::{Condvar, Mutex};
77use pyo3::{
78    ffi::c_str,
79    intern,
80    prelude::*,
81    sync::PyOnceLock,
82    types::{PyCFunction, PyDict, PyTuple},
83};
84use serde_json;
85
86// Re-exported so the `live` module registers every Python class through this module.
87pub use crate::node::NodeState;
88use crate::{
89    builder::LiveNodeBuilder,
90    config::{
91        LiveDataEngineConfig, LiveExecutionEngineConfig, LiveNodeConfig, LiveRiskEngineConfig,
92        PluginConfig,
93    },
94    node::{LiveNode, LiveNodeHandle, NodeRunMode, config::RoutingConfig},
95    python::config::coerce_json_config,
96};
97
98/// Python-facing wrapper owning a [`LiveNode`].
99///
100/// `run_async` moves the node into the returned awaitable, so the wrapper is empty for the
101/// remainder of a hosted run. Every method then fails with a clear error rather than aliasing the
102/// node the run future is driving. Capture `cache`, `portfolio`, and `handle` before starting a
103/// hosted run; each is an independent handle that stays usable while the node runs.
104#[pyo3::pyclass(module = "nautilus_trader.live", name = "LiveNode", unsendable)]
105#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")]
106#[derive(Debug)]
107pub struct PyLiveNode {
108    inner: Rc<RefCell<Option<LiveNode>>>,
109    handle: LiveNodeHandle,
110}
111
112impl PyLiveNode {
113    /// Wraps an owned node for Python.
114    #[must_use]
115    pub fn new(node: LiveNode) -> Self {
116        Self {
117            handle: node.handle(),
118            inner: Rc::new(RefCell::new(Some(node))),
119        }
120    }
121
122    fn node(&self) -> PyResult<Ref<'_, LiveNode>> {
123        let borrow = self.inner.try_borrow().map_err(|_| node_busy_err())?;
124        if borrow.is_none() {
125            return Err(node_consumed_err());
126        }
127
128        Ok(Ref::map(borrow, |node| {
129            node.as_ref().expect("node presence checked above")
130        }))
131    }
132
133    fn node_mut(&self) -> PyResult<RefMut<'_, LiveNode>> {
134        let borrow = self.inner.try_borrow_mut().map_err(|_| node_busy_err())?;
135        if borrow.is_none() {
136            return Err(node_consumed_err());
137        }
138
139        Ok(RefMut::map(borrow, |node| {
140            node.as_mut().expect("node presence checked above")
141        }))
142    }
143
144    fn is_consumed(&self) -> bool {
145        self.inner.try_borrow().is_ok_and(|node| node.is_none())
146    }
147}
148
149/// Thread-safe control handle for a [`LiveNode`].
150///
151/// Stays valid for the node's whole lifetime, including while a hosted run owns the node, and is
152/// safe to call from any thread or from a signal handler.
153#[pyo3::pyclass(
154    module = "nautilus_trader.live",
155    name = "LiveNodeHandle",
156    frozen,
157    skip_from_py_object
158)]
159#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")]
160#[derive(Clone, Debug)]
161pub struct PyLiveNodeHandle {
162    inner: LiveNodeHandle,
163}
164
165#[pyo3_stub_gen::derive::gen_stub_pymethods]
166#[pymethods]
167impl PyLiveNodeHandle {
168    /// Signals the node to stop, returning immediately.
169    ///
170    /// The node then runs its full shutdown sequence, so callers awaiting `run_async` observe
171    /// completion only once shutdown finishes.
172    #[pyo3(name = "stop")]
173    fn py_stop(&self) {
174        self.inner.stop();
175    }
176
177    /// Returns whether a stop has been requested.
178    #[getter]
179    #[pyo3(name = "is_stopping")]
180    fn py_is_stopping(&self) -> bool {
181        self.inner.should_stop()
182    }
183
184    /// Returns whether the node is currently running.
185    #[getter]
186    #[pyo3(name = "is_running")]
187    fn py_is_running(&self) -> bool {
188        self.inner.is_running()
189    }
190
191    /// Returns the node's current lifecycle state.
192    #[getter]
193    #[pyo3(name = "state")]
194    fn py_state(&self) -> NodeState {
195        self.inner.state()
196    }
197
198    fn __repr__(&self) -> String {
199        format!("LiveNodeHandle(state={:?})", self.inner.state())
200    }
201}
202
203/// How long `close` drives a still-running node before giving up.
204///
205/// A closed host loop can no longer resume the run, so the node is driven to completion inline.
206/// Bounded so interpreter teardown cannot hang indefinitely on an unresponsive venue.
207const CLOSE_DRIVE_TIMEOUT: Duration = Duration::from_secs(30);
208
209thread_local! {
210    /// Guards against a second hosted run on the same thread.
211    ///
212    /// The runner binds its senders into thread-local storage and the msgbus is thread-local, so
213    /// two interleaved hosted nodes would cross-wire each other's events rather than fail.
214    static HOSTED_RUN_ACTIVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
215}
216
217/// Waker for driving a run to completion without an event loop.
218///
219/// The host loop is gone by the time this is used, so waking must not touch Python: a tokio worker
220/// that blocked acquiring the GIL could not then wake the thread waiting here.
221#[derive(Debug, Default)]
222struct BlockingWake {
223    woken: Mutex<bool>,
224    signal: Condvar,
225}
226
227impl BlockingWake {
228    /// Waits for a wake, returning `false` once the deadline passes.
229    fn wait_until(&self, deadline: Instant) -> bool {
230        let mut woken = self.woken.lock();
231        while !*woken {
232            let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
233                return false;
234            };
235
236            let timeout = self.signal.wait_for(&mut woken, remaining);
237
238            if timeout.timed_out() && !*woken {
239                return false;
240            }
241        }
242
243        *woken = false;
244        true
245    }
246}
247
248impl std::task::Wake for BlockingWake {
249    fn wake(self: Arc<Self>) {
250        self.wake_by_ref();
251    }
252
253    fn wake_by_ref(self: &Arc<Self>) {
254        *self.woken.lock() = true;
255        self.signal.notify_all();
256    }
257}
258
259/// Wake state shared between a hosted run and the callback that resumes it.
260#[derive(Debug, Default)]
261struct RunWakeState {
262    pending: Mutex<Option<RunSuspension>>,
263    closed: AtomicBool,
264}
265
266#[derive(Debug)]
267struct RunSuspension {
268    generation: u64,
269    future: Py<PyAny>,
270}
271
272impl RunWakeState {
273    fn suspend(&self, generation: u64, future: Py<PyAny>) {
274        *self.pending.lock() = Some(RunSuspension { generation, future });
275    }
276
277    fn resume(&self, py: Python<'_>, generation: u64) -> PyResult<()> {
278        let mut pending = self.pending.lock();
279        let Some(suspension) = pending.as_ref() else {
280            return Ok(());
281        };
282
283        if self.closed.load(Ordering::Acquire) || suspension.generation != generation {
284            return Ok(());
285        }
286
287        let future = pending.take().expect("suspension presence checked").future;
288        drop(pending);
289
290        let future = future.bind(py);
291        if !future
292            .call_method0(intern!(py, "done"))?
293            .extract::<bool>()?
294        {
295            future.call_method1(intern!(py, "set_result"), (py.None(),))?;
296        }
297
298        Ok(())
299    }
300
301    fn close(&self) {
302        self.closed.store(true, Ordering::Release);
303        self.pending.lock().take();
304    }
305}
306
307/// Callable scheduled on the host event loop to resume a hosted run.
308#[pyo3::pyclass(name = "NodeRunWake", frozen)]
309struct PyNodeRunWake {
310    state: Arc<RunWakeState>,
311}
312
313#[pymethods]
314impl PyNodeRunWake {
315    fn __call__(&self, py: Python<'_>, generation: u64) -> PyResult<()> {
316        self.state.resume(py, generation)
317    }
318}
319
320enum HostWakeSignal {
321    Resume(u64),
322    Shutdown,
323}
324
325struct HostWakeControl {
326    sender: mpsc::Sender<HostWakeSignal>,
327    active: AtomicBool,
328    handle: LiveNodeHandle,
329}
330
331impl HostWakeControl {
332    fn resume(&self, generation: u64) {
333        if !self.active.load(Ordering::Acquire) {
334            return;
335        }
336
337        if self
338            .sender
339            .send(HostWakeSignal::Resume(generation))
340            .is_err()
341            && self.active.swap(false, Ordering::AcqRel)
342        {
343            // Nothing can resume a suspended run once the wake pump is gone. Request a stop,
344            // but the awaiting task may remain suspended.
345            log::error!("Hosted run wake pump stopped unexpectedly, stopping node");
346            self.handle.stop();
347        }
348    }
349
350    fn close(&self) {
351        if self.active.swap(false, Ordering::AcqRel) {
352            let _ = self.sender.send(HostWakeSignal::Shutdown);
353        }
354    }
355}
356
357/// Waker that signals the host wake pump from whichever thread completed the work.
358struct HostLoopWaker {
359    generation: u64,
360    scheduled: AtomicBool,
361    control: Arc<HostWakeControl>,
362}
363
364impl std::task::Wake for HostLoopWaker {
365    fn wake(self: Arc<Self>) {
366        self.wake_by_ref();
367    }
368
369    fn wake_by_ref(self: &Arc<Self>) {
370        if !self.scheduled.swap(true, Ordering::AcqRel) {
371            self.control.resume(self.generation);
372        }
373    }
374}
375
376/// Moves Python scheduling off the dependency thread that invoked the waker.
377struct HostWakePump {
378    control: Arc<HostWakeControl>,
379    thread: Option<thread::JoinHandle<()>>,
380}
381
382impl HostWakePump {
383    fn start(
384        event_loop: Py<PyAny>,
385        wake_callback: Py<PyAny>,
386        handle: LiveNodeHandle,
387    ) -> PyResult<Self> {
388        let (sender, receiver) = mpsc::channel();
389        let control = Arc::new(HostWakeControl {
390            sender,
391            active: AtomicBool::new(true),
392            handle: handle.clone(),
393        });
394
395        let control_for_thread = control.clone();
396
397        let thread = thread::Builder::new()
398            .name("nautilus-host-wake".to_string())
399            .spawn(move || {
400                while let Ok(signal) = receiver.recv() {
401                    match signal {
402                        HostWakeSignal::Resume(generation) => {
403                            if !control_for_thread.active.load(Ordering::Acquire) {
404                                continue;
405                            }
406
407                            let Some(result) = Python::try_attach(|py| {
408                                event_loop.bind(py).call_method1(
409                                    intern!(py, "call_soon_threadsafe"),
410                                    (wake_callback.bind(py), generation),
411                                )?;
412                                Ok::<(), PyErr>(())
413                            }) else {
414                                log::error!(
415                                    "Python unavailable while scheduling hosted run wake-up, stopping node"
416                                );
417                                handle.stop();
418                                break;
419                            };
420
421                            if let Err(e) = result {
422                                log::error!(
423                                    "Failed to schedule hosted run wake-up, stopping node: {e}"
424                                );
425                                handle.stop();
426                                break;
427                            }
428                        }
429                        HostWakeSignal::Shutdown => break,
430                    }
431                }
432
433                control_for_thread.active.store(false, Ordering::Release);
434            })
435            .map_err(|e| to_pyruntime_err(format!("failed to start hosted run wake pump: {e}")))?;
436
437        Ok(Self {
438            control,
439            thread: Some(thread),
440        })
441    }
442
443    fn waker(&self, generation: u64) -> Waker {
444        Waker::from(Arc::new(HostLoopWaker {
445            generation,
446            scheduled: AtomicBool::new(false),
447            control: self.control.clone(),
448        }))
449    }
450
451    fn close(&self) {
452        self.control.close();
453    }
454
455    fn join(&mut self, py: Option<Python<'_>>) {
456        let Some(thread) = self.thread.take() else {
457            return;
458        };
459        let mut thread = Some(thread);
460
461        let result = match py {
462            Some(py) => py.detach(|| thread.take().expect("thread presence checked").join()),
463            None => Python::try_attach(|py| {
464                py.detach(|| thread.take().expect("thread presence checked").join())
465            })
466            .unwrap_or_else(|| thread.take().expect("thread presence checked").join()),
467        };
468
469        if result.is_err() {
470            log::error!("Hosted run wake pump panicked while stopping");
471        }
472    }
473}
474
475impl Drop for HostWakePump {
476    fn drop(&mut self) {
477        self.close();
478        self.join(None);
479    }
480}
481
482struct SendPtr<T>(*mut T);
483
484// SAFETY: the owner of a `SendPtr` holds the only reference to the pointee for the pointer's
485// lifetime, and the pointee outlives every use.
486#[allow(unsafe_code)]
487unsafe impl<T> Send for SendPtr<T> {}
488
489/// Awaitable driving a [`LiveNode`] on the host's asyncio event loop.
490///
491/// Polls the node's run future from loop callbacks, so the node shares the host's thread and never
492/// blocks it. Awaiting resolves once the node has fully stopped.
493#[pyo3::pyclass(name = "NodeRun", unsendable)]
494pub struct PyNodeRun {
495    // Declared before `node` so the future is dropped first; it borrows the node.
496    future: Option<Pin<Box<dyn Future<Output = anyhow::Result<()>>>>>,
497    node: Option<Box<LiveNode>>,
498    owner: Rc<RefCell<Option<LiveNode>>>,
499    handle: LiveNodeHandle,
500    event_loop: Py<PyAny>,
501    wake_pump: HostWakePump,
502    state: Arc<RunWakeState>,
503    generation: u64,
504    pending_throw: Option<PyErr>,
505}
506
507#[allow(
508    clippy::missing_fields_in_debug,
509    reason = "the future and node fields have no useful debug representation"
510)]
511impl Debug for PyNodeRun {
512    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513        f.debug_struct(stringify!(PyNodeRun))
514            .field("state", &self.handle.state())
515            .field("completed", &self.future.is_none())
516            .finish()
517    }
518}
519
520impl Drop for PyNodeRun {
521    fn drop(&mut self) {
522        self.restore_node(None);
523    }
524}
525
526impl PyNodeRun {
527    #[allow(
528        unsafe_code,
529        reason = "the run future borrows the boxed node this type owns"
530    )]
531    fn new(
532        node: LiveNode,
533        owner: Rc<RefCell<Option<LiveNode>>>,
534        event_loop: Bound<'_, PyAny>,
535        state: Arc<RunWakeState>,
536        wake_pump: HostWakePump,
537    ) -> Self {
538        let handle = node.handle();
539        let mut node = Box::new(node);
540        let node_ptr = SendPtr(std::ptr::from_mut::<LiveNode>(node.as_mut()));
541
542        let future: Pin<Box<dyn Future<Output = anyhow::Result<()>>>> = Box::pin(async move {
543            let ptr = node_ptr;
544            // SAFETY: the node is boxed, so its address is stable for as long as this type owns
545            // it. `run_async` moved the node out of the wrapper, so no other reference exists,
546            // and `future` is declared before `node` so it is dropped first.
547            unsafe { (*ptr.0).run_with_mode(NodeRunMode::Hosted).await }
548        });
549
550        Self {
551            future: Some(future),
552            node: Some(node),
553            owner,
554            handle,
555            event_loop: event_loop.unbind(),
556            wake_pump,
557            state,
558            generation: 0,
559            pending_throw: None,
560        }
561    }
562
563    /// Returns the node to the wrapper, dropping the future that borrows it first.
564    ///
565    /// Releases the per-thread run guard here rather than only on drop, so a completed run does
566    /// not block the next one until the coroutine object happens to be collected.
567    fn restore_node(&mut self, py: Option<Python<'_>>) {
568        self.state.close();
569        self.wake_pump.close();
570        self.future = None;
571        self.wake_pump.join(py);
572
573        if let Some(node) = self.node.take() {
574            *self.owner.borrow_mut() = Some(*node);
575
576            // Release inside the take arm so this runs exactly once, and only for the run that
577            // set the guard. Clearing unconditionally would let a late drop release a newer run's.
578            HOSTED_RUN_ACTIVE.set(false);
579        }
580    }
581
582    /// Returns whether the host event loop is currently running.
583    ///
584    /// Treats an unavailable answer as running, because blocking a live loop is the worse error.
585    fn host_loop_is_running(&self, py: Python<'_>) -> bool {
586        self.event_loop
587            .bind(py)
588            .call_method0(intern!(py, "is_running"))
589            .and_then(|running| running.extract::<bool>())
590            .unwrap_or(true)
591    }
592
593    /// Drives the run to completion on this thread, bounded by [`CLOSE_DRIVE_TIMEOUT`].
594    fn drive_to_completion(&mut self, py: Python<'_>) {
595        let signal = Arc::new(BlockingWake::default());
596        let waker = Waker::from(signal.clone());
597        let deadline = Instant::now() + CLOSE_DRIVE_TIMEOUT;
598
599        loop {
600            let Some(future) = self.future.as_mut() else {
601                return;
602            };
603
604            let poll = {
605                let _guard = get_runtime().enter();
606                future.as_mut().poll(&mut Context::from_waker(&waker))
607            };
608
609            if let Poll::Ready(result) = poll {
610                if let Err(e) = result {
611                    log::error!("Hosted run failed during inline shutdown: {e}");
612                }
613
614                self.restore_node(Some(py));
615                return;
616            }
617
618            // Release the GIL so tokio workers that need it can make progress.
619            if !py.detach(|| signal.wait_until(deadline)) {
620                log::error!(
621                    "Hosted run did not stop within {}s of the host loop closing; abandoning it \
622                     with resources still held",
623                    CLOSE_DRIVE_TIMEOUT.as_secs()
624                );
625                return;
626            }
627        }
628    }
629
630    /// Polls the run future once, returning the asyncio future to suspend on when still running.
631    fn step(&mut self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
632        let Some(future) = self.future.as_mut() else {
633            return Err(to_pyruntime_err("Hosted run has already completed"));
634        };
635
636        let generation = self
637            .generation
638            .checked_add(1)
639            .ok_or_else(|| to_pyruntime_err("Hosted run wake generation exhausted"))?;
640        self.generation = generation;
641
642        // A distinct waker prevents a late wake from an older poll resuming this suspension
643        let waker = self.wake_pump.waker(generation);
644
645        // Timers and the reactor are only reachable inside the runtime context
646        let poll = {
647            let _guard = get_runtime().enter();
648            future.as_mut().poll(&mut Context::from_waker(&waker))
649        };
650
651        match poll {
652            Poll::Ready(result) => {
653                self.restore_node(Some(py));
654
655                if let Err(e) = &result {
656                    log::error!("Hosted run failed: {e}");
657                }
658
659                if let Some(raised) = self.pending_throw.take() {
660                    // Shutdown finished, so the injected exception is now honoured. Reporting
661                    // success here would break `asyncio.timeout`, `wait_for`, and task groups.
662                    return Err(raised);
663                }
664
665                result.map(|()| None).map_err(to_pyruntime_err)
666            }
667            Poll::Pending => {
668                let suspended = self
669                    .event_loop
670                    .bind(py)
671                    .call_method0(intern!(py, "create_future"))?;
672                suspended.setattr(intern!(py, "_asyncio_future_blocking"), true)?;
673
674                // `call_soon_threadsafe` queues the callback, so this publishes before it can run
675                self.state.suspend(generation, suspended.clone().unbind());
676
677                Ok(Some(suspended.unbind()))
678            }
679        }
680    }
681}
682
683#[pymethods]
684impl PyNodeRun {
685    fn __await__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
686        slf
687    }
688
689    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
690        slf
691    }
692
693    fn __next__(&mut self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
694        self.step(py)
695    }
696
697    #[pyo3(signature = (value=None))]
698    fn send(
699        &mut self,
700        py: Python<'_>,
701        value: Option<&Bound<'_, PyAny>>,
702    ) -> PyResult<Option<Py<PyAny>>> {
703        let _ = value;
704        self.step(py)
705    }
706
707    /// Converts an injected exception into a graceful stop, then re-raises once the node stopped.
708    ///
709    /// Later throws are absorbed while shutdown runs, so a half-stopped node is never abandoned
710    /// with clients connected and channels undrained. The first exception is the one raised.
711    #[pyo3(signature = (*args))]
712    fn throw(&mut self, py: Python<'_>, args: &Bound<'_, PyTuple>) -> PyResult<Option<Py<PyAny>>> {
713        let raised = args.get_item(0)?;
714
715        if self.pending_throw.is_none() {
716            let cancelled_type = cancelled_error_type(py)?;
717            let is_cancelled = raised.is_instance(&cancelled_type)? || raised.is(&cancelled_type);
718            if is_cancelled {
719                log::info!("Hosted run cancelled, requesting graceful shutdown");
720            } else {
721                log::warn!("Exception thrown into hosted run, requesting graceful shutdown");
722            }
723
724            self.pending_throw = Some(PyErr::from_value(raised));
725            self.handle.stop();
726        }
727
728        self.step(py)
729    }
730
731    /// Stops the node, and drives shutdown inline only when no host loop can finish it.
732    ///
733    /// Python calls this when the awaiting task is discarded. With the loop still running, this
734    /// requests the stop and returns, because driving inline would block every host callback; the
735    /// run is then dropped with shutdown incomplete, which the warning names. With no running loop
736    /// nothing else can finish the shutdown, so it is driven here rather than dropping the node
737    /// with venue connections still open.
738    fn close(&mut self, py: Python<'_>) {
739        if self.future.is_none() {
740            return;
741        }
742
743        self.handle.stop();
744
745        // Driving inline blocks this thread, so only do it when the host loop is not running and
746        // therefore cannot finish the shutdown itself.
747        if self.host_loop_is_running(py) {
748            log::warn!(
749                "Hosted run discarded while its event loop is running; shutdown was requested but \
750                 not completed, await the run instead of discarding it"
751            );
752            return;
753        }
754
755        log::warn!("Hosted run closed with no running event loop, draining shutdown inline");
756        self.drive_to_completion(py);
757    }
758
759    fn __repr__(&self) -> String {
760        format!("{self:?}")
761    }
762}
763
764/// Returns the driver that wraps a [`PyNodeRun`] in a native coroutine.
765///
766/// `asyncio.create_task` accepts only coroutines, so returning the awaitable directly would force
767/// every caller to wrap it. Awaiting the coroutine drives the same object, and a cancellation
768/// delivered to the task reaches [`PyNodeRun::throw`] unchanged.
769fn node_run_driver(py: Python<'_>) -> PyResult<&Py<PyAny>> {
770    static DRIVER: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
771
772    DRIVER.get_or_try_init(py, || {
773        let module = PyModule::from_code(
774            py,
775            c_str!("async def drive(run):\n    return await run\n"),
776            c_str!("nautilus_trader/live/_hosted_run.py"),
777            c_str!("nautilus_trader._hosted_run"),
778        )?;
779        Ok(module.getattr("drive")?.unbind())
780    })
781}
782
783/// Returns the `asyncio.CancelledError` type.
784fn cancelled_error_type(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
785    py.import("asyncio")?.getattr(intern!(py, "CancelledError"))
786}
787
788/// Reports a borrow that cannot be served because the node is busy inside a call.
789fn node_busy_err() -> PyErr {
790    to_pyruntime_err(
791        "LiveNode is busy servicing another call; do not re-enter the node from a component \
792         callback, use the cache, portfolio, and handle captured beforehand",
793    )
794}
795
796fn node_consumed_err() -> PyErr {
797    to_pyruntime_err(
798        "LiveNode is being run by `run_async`; use the handle returned by `handle()` to stop it, \
799         and the `cache` and `portfolio` captured before the run to read state",
800    )
801}
802
803#[pyo3_stub_gen::derive::gen_stub_pymethods]
804#[pymethods]
805impl PyLiveNode {
806    /// Creates a new `LiveNode` directly from a kernel name and optional configuration.
807    ///
808    /// This is a convenience method for creating a live node with a pre-configured
809    /// kernel configuration, bypassing the builder pattern. If no config is provided,
810    /// a default configuration will be used.
811    ///
812    /// # Errors
813    ///
814    /// Returns an error if kernel construction fails.
815    #[staticmethod]
816    #[pyo3(name = "build")]
817    #[pyo3(signature = (name, config=None))]
818    fn py_build(name: String, config: Option<LiveNodeConfig>) -> PyResult<Self> {
819        LiveNode::build(name, config)
820            .map(Self::new)
821            .map_err(to_pyruntime_err)
822    }
823
824    /// Creates a new `LiveNodeBuilder` for fluent configuration.
825    ///
826    /// # Errors
827    ///
828    /// Returns an error if the environment is invalid for live trading.
829    #[staticmethod]
830    #[pyo3(name = "builder")]
831    fn py_builder(
832        name: String,
833        trader_id: TraderId,
834        environment: Environment,
835    ) -> PyResult<PyLiveNodeBuilder> {
836        match LiveNode::builder(trader_id, environment) {
837            Ok(builder) => Ok(PyLiveNodeBuilder {
838                state: Rc::new(Cell::new(PyLiveNodeBuilderState::Ready(Box::new(
839                    builder.with_name(name),
840                )))),
841            }),
842            Err(e) => Err(to_pyruntime_err(e)),
843        }
844    }
845
846    /// Gets the node's environment.
847    #[getter]
848    #[pyo3(name = "environment")]
849    fn py_environment(&self) -> PyResult<Environment> {
850        Ok(self.node()?.environment())
851    }
852
853    /// Gets the node's trader ID.
854    #[getter]
855    #[pyo3(name = "trader_id")]
856    fn py_trader_id(&self) -> PyResult<TraderId> {
857        Ok(self.node()?.trader_id())
858    }
859
860    /// Gets the node's instance ID.
861    #[getter]
862    #[pyo3(name = "instance_id")]
863    fn py_instance_id(&self) -> PyResult<UUID4> {
864        Ok(self.node()?.instance_id())
865    }
866
867    /// Checks if the live node is currently running.
868    ///
869    /// Answered from the handle, so this stays truthful while a hosted run holds the node.
870    #[getter]
871    #[pyo3(name = "is_running")]
872    fn py_is_running(&self) -> bool {
873        self.handle.is_running()
874    }
875
876    /// Returns the cache shared with the kernel and registered components.
877    #[getter]
878    #[pyo3(name = "cache")]
879    fn py_cache(&self) -> PyResult<PyCache> {
880        Ok(PyCache::from_rc(self.node()?.kernel().cache()))
881    }
882
883    /// Returns the portfolio shared with the kernel and registered components.
884    #[getter]
885    #[pyo3(name = "portfolio")]
886    fn py_portfolio(&self) -> PyResult<PyPortfolio> {
887        Ok(PyPortfolio::from_rc(
888            self.node()?.kernel().portfolio.clone(),
889        ))
890    }
891
892    /// Returns a thread-safe handle for controlling and observing this node.
893    ///
894    /// The handle stays valid for the node's whole lifetime, including while `run_async` owns the
895    /// node, and is the supported way to stop a hosted run.
896    #[pyo3(name = "handle")]
897    fn py_handle(&self) -> PyLiveNodeHandle {
898        PyLiveNodeHandle {
899            inner: self.handle.clone(),
900        }
901    }
902
903    /// Runs the live node on the caller's asyncio event loop.
904    ///
905    /// Takes the node and returns an awaitable that resolves once the node has stopped. The host
906    /// owns the loop and its signal handling, so this installs no signal handlers. Stop the node
907    /// through the handle from `handle()`; cancelling the awaiting task requests the same graceful
908    /// shutdown, waits for it to finish, then re-raises the cancellation.
909    ///
910    /// Capture `cache`, `portfolio`, and `handle()` before calling this. They stay usable while the
911    /// node runs, whereas the node itself is owned by the returned awaitable.
912    ///
913    /// # Limitations
914    ///
915    /// A node configured with a cache database backing is rejected. Those backings wait for their
916    /// worker task by blocking the calling thread, which stalls the host loop rather than merely
917    /// slowing it. Use `run()` for a database-backed node until the backings can be driven without
918    /// blocking a host loop.
919    ///
920    /// # Errors
921    ///
922    /// Returns an error if the node has already been taken, no event loop is running, a cache
923    /// database backing is configured, or another node is already running on this loop.
924    #[gen_stub(override_return_type(
925        type_repr = "collections.abc.Coroutine[typing.Any, typing.Any, None]",
926        imports = ("collections.abc", "typing")
927    ))]
928    #[pyo3(name = "run_async")]
929    fn py_run_async(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
930        let event_loop = py
931            .import("asyncio")?
932            .call_method0("get_running_loop")
933            .map_err(|_| {
934                to_pyruntime_err(
935                    "`run_async` requires a running asyncio event loop; use `run` to have the node \
936                     own the thread instead",
937                )
938            })?;
939
940        // A node runs once. Rejecting anything past `Idle` here turns a second run into an
941        // immediate error rather than a coroutine that fails only when awaited.
942        let state = self.node()?.state();
943        if state != NodeState::Idle {
944            return Err(to_pyruntime_err(format!(
945                "LiveNode cannot be run from state {state:?}; build a new node to run again"
946            )));
947        }
948
949        // The Redis and SQL cache backings block the calling thread while awaiting their worker
950        // task, which would freeze the host loop inside a poll rather than merely slow it down.
951        // Fail before taking the node so the caller gets a clear boundary instead of a hang.
952        if self.node()?.has_pending_cache_database() {
953            return Err(to_pyruntime_err(
954                "a cache database backing is not supported on a host event loop, because its \
955                 blocking calls would stall the loop; use `run()` to have the node own the thread, \
956                 or configure the node without a cache database",
957            ));
958        }
959
960        if HOSTED_RUN_ACTIVE.get() {
961            return Err(to_pyruntime_err(
962                "another LiveNode is already running on this event loop; run one concurrent \
963                 LiveNode per process, or run additional nodes in separate processes",
964            ));
965        }
966
967        // Everything fallible happens before the node is taken, so a failure here cannot strand
968        // it. `PyNodeRun` restores the node on drop, covering the paths after this point.
969        let driver = node_run_driver(py)?.clone_ref(py);
970        let state = Arc::new(RunWakeState::default());
971
972        let wake_callback = Py::new(
973            py,
974            PyNodeRunWake {
975                state: state.clone(),
976            },
977        )?
978        .into_any();
979
980        let wake_pump = HostWakePump::start(
981            event_loop.clone().unbind(),
982            wake_callback,
983            self.handle.clone(),
984        )?;
985
986        let node = self
987            .inner
988            .borrow_mut()
989            .take()
990            .ok_or_else(node_consumed_err)?;
991        let run = PyNodeRun::new(node, self.inner.clone(), event_loop, state, wake_pump);
992        HOSTED_RUN_ACTIVE.set(true);
993
994        driver.call1(py, (Py::new(py, run)?,))
995    }
996
997    /// Run the live node with automatic shutdown handling.
998    ///
999    /// This method starts the node, runs indefinitely, and handles graceful shutdown
1000    /// on interrupt signals.
1001    ///
1002    /// # Thread Safety
1003    ///
1004    /// The event loop runs directly on the current thread (not spawned) because the
1005    /// msgbus uses thread-local storage. Endpoints registered by the kernel are only
1006    /// accessible from the same thread.
1007    ///
1008    /// # Shutdown Sequence
1009    ///
1010    /// 1. Signal received (SIGINT, SIGTERM, or handle stop).
1011    /// 2. Trader components stopped (triggers order cancellations, etc.).
1012    /// 3. Event loop continues processing residual events for the configured grace period.
1013    /// 4. Kernel finalized, clients disconnected, remaining events drained.
1014    ///
1015    /// # Errors
1016    ///
1017    /// Returns an error if the node fails to start or encounters a runtime error.
1018    #[pyo3(name = "run")]
1019    fn py_run(&self, py: Python) -> PyResult<()> {
1020        if self.node()?.is_running() {
1021            return Err(to_pyruntime_err("LiveNode is already running"));
1022        }
1023
1024        // Get a handle for coordinating with the signal checker
1025        let handle = self.node()?.handle();
1026
1027        // Import signal module
1028        let signal_module = py.import("signal")?;
1029        let original_handler =
1030            signal_module.call_method1("signal", (2, signal_module.getattr("SIG_DFL")?))?; // Save original SIGINT handler (signal 2)
1031
1032        // Set up a custom signal handler that uses our handle
1033        let handle_for_signal = handle;
1034        let signal_callback = new_sync_py_callback(
1035            py,
1036            move |_args: &pyo3::Bound<'_, PyTuple>,
1037                  _kwargs: Option<&pyo3::Bound<'_, PyDict>>|
1038                  -> PyResult<()> {
1039                log::info!("Python signal handler called");
1040                handle_for_signal.stop();
1041                Ok(())
1042            },
1043        )?;
1044
1045        // Install our signal handler
1046        signal_module.call_method1("signal", (2, signal_callback))?;
1047
1048        // Run the node and restore signal handler afterward
1049        let mut node = self.node_mut()?;
1050        let result = run_live_node_detached(py, &mut node);
1051
1052        // Restore original signal handler
1053        signal_module.call_method1("signal", (2, original_handler))?;
1054
1055        result
1056    }
1057
1058    /// Stop the live node.
1059    ///
1060    /// This method stops the trader, waits for the configured grace period to allow
1061    /// residual events to be processed, then finalizes the shutdown sequence.
1062    ///
1063    /// # Errors
1064    ///
1065    /// Returns an error if shutdown fails.
1066    #[pyo3(name = "stop")]
1067    fn py_stop(&self, py: Python<'_>) -> PyResult<()> {
1068        let mut node = self.node_mut()?;
1069        if !node.is_running() {
1070            return Err(to_pyruntime_err("LiveNode is not running"));
1071        }
1072
1073        stop_live_node_detached(py, &mut node)
1074    }
1075
1076    /// Disposes the live node kernel and releases resources.
1077    ///
1078    /// Does nothing while a hosted run holds the node. The run returns the node when it finishes,
1079    /// so a `try`/`finally` cleanup that outlives the run disposes it as usual.
1080    #[pyo3(name = "dispose")]
1081    fn py_dispose(&self, py: Python<'_>) -> PyResult<()> {
1082        if self.is_consumed() {
1083            return Ok(());
1084        }
1085
1086        let mut node = self.node_mut()?;
1087        let stop_result = if node.is_running() {
1088            stop_live_node_detached(py, &mut node)
1089        } else {
1090            Ok(())
1091        };
1092
1093        if let Err(ref err) = stop_result {
1094            log::error!("Failed to stop LiveNode during dispose: {err}");
1095        }
1096
1097        node.dispose();
1098        stop_result
1099    }
1100
1101    /// Adds a constructed Python actor to the trader.
1102    ///
1103    /// # Errors
1104    ///
1105    /// Returns an error if the node is running, the actor is invalid, or registration fails.
1106    #[pyo3(name = "add_actor")]
1107    fn py_add_actor(&self, actor: &Bound<'_, PyAny>) -> PyResult<()> {
1108        if self.node()?.state() != NodeState::Idle {
1109            return Err(to_pyruntime_err(
1110                "Cannot add actor while node is running, add actors before running the node",
1111            ));
1112        }
1113
1114        log::debug!("`add_actor` with a constructed instance");
1115
1116        let actor = actor.clone().unbind();
1117        let actor_id = Python::attach(|py| {
1118            let actor = actor.bind(py);
1119            let config = actor
1120                .getattr("config")
1121                .ok()
1122                .filter(|config| !config.is_none());
1123            prepare_python_actor(actor, config.as_ref())
1124        })
1125        .map_err(to_pyruntime_err)?;
1126
1127        self.register_python_actor(&actor, actor_id)
1128    }
1129
1130    #[pyo3(name = "add_actor_from_config")]
1131    #[expect(clippy::needless_pass_by_value)]
1132    fn py_add_actor_from_config(&self, _py: Python, config: ImportableActorConfig) -> PyResult<()> {
1133        log::debug!("`add_actor_from_config` with: {config:?}");
1134
1135        // Extract module and class name from actor_path
1136        let parts: Vec<&str> = config.actor_path.split(':').collect();
1137        if parts.len() != 2 {
1138            return Err(to_pyvalue_err(
1139                "actor_path must be in format 'module.path:ClassName'",
1140            ));
1141        }
1142        let (module_name, class_name) = (parts[0], parts[1]);
1143
1144        log::info!("Importing actor from module: {module_name} class: {class_name}");
1145
1146        let (python_actor, actor_id) =
1147            Python::attach(|py| -> anyhow::Result<(Py<PyAny>, ActorId)> {
1148                let actor_module = py
1149                    .import(module_name)
1150                    .map_err(|e| anyhow::anyhow!("Failed to import module {module_name}: {e}"))?;
1151                let actor_class = actor_module
1152                    .getattr(class_name)
1153                    .map_err(|e| anyhow::anyhow!("Failed to get class {class_name}: {e}"))?;
1154
1155                let config_instance =
1156                    create_config_instance(py, &config.config_path, &config.config)?;
1157
1158                let python_actor = if let Some(config_obj) = config_instance.as_ref() {
1159                    actor_class.call1((config_obj,))?
1160                } else {
1161                    actor_class.call0()?
1162                };
1163
1164                log::debug!("Created Python actor instance: {python_actor:?}");
1165
1166                let actor_id = prepare_python_actor(&python_actor, config_instance.as_ref())?;
1167
1168                Ok((python_actor.unbind(), actor_id))
1169            })
1170            .map_err(to_pyruntime_err)?;
1171
1172        self.register_python_actor(&python_actor, actor_id)
1173    }
1174
1175    /// Adds a strategy to the trader.
1176    ///
1177    /// Strategies are registered in both the component registry (for lifecycle management)
1178    /// and the actor registry (for data callbacks via msgbus).
1179    ///
1180    /// # Errors
1181    ///
1182    /// Returns an error if:
1183    /// - The node is currently running.
1184    /// - A strategy with the same ID is already registered.
1185    /// - The strategy configures one or more external order claims and the request repeats
1186    ///   an instrument, or either tier already contains a requested claim.
1187    /// - The strategy configures one or more external order claims or an OMS type override,
1188    ///   and the execution engine is already borrowed. A strategy configuring neither does
1189    ///   not take the borrow and cannot fail this way.
1190    #[allow(
1191        unsafe_code,
1192        reason = "Required for Python strategy component registration"
1193    )]
1194    #[pyo3(name = "add_strategy")]
1195    fn py_add_strategy(&self, strategy: &Bound<'_, PyAny>) -> PyResult<()> {
1196        if self.node()?.state() != NodeState::Idle {
1197            return Err(to_pyruntime_err(
1198                "Cannot add strategy while node is running, add strategies before running the node",
1199            ));
1200        }
1201
1202        log::debug!("`add_strategy` with a constructed instance");
1203
1204        let strategy = strategy.clone().unbind();
1205
1206        let strategy_id = self
1207            .node_mut()?
1208            .kernel_mut()
1209            .trader
1210            .borrow_mut()
1211            .prepare_python_strategy_instance(&strategy)
1212            .map_err(to_pyruntime_err)?;
1213
1214        let (external_order_claims, oms_type) = Python::attach(
1215            |py| -> anyhow::Result<(Option<Vec<InstrumentId>>, Option<OmsType>)> {
1216                let bound = strategy.bind(py);
1217                let config_obj = bound
1218                    .getattr("config")
1219                    .ok()
1220                    .filter(|config| !config.is_none());
1221
1222                let mut py_strategy_ref = bound
1223                    .extract::<PyRefMut<PyStrategy>>()
1224                    .map_err(Into::<PyErr>::into)
1225                    .map_err(|e| anyhow::anyhow!("Failed to extract PyStrategy: {e}"))?;
1226
1227                if let Some(config_obj) = config_obj.as_ref()
1228                    && let Some(claims) = extract_external_order_claims_config_attr(config_obj)?
1229                {
1230                    py_strategy_ref.set_external_order_claims(Some(claims));
1231                }
1232
1233                let claims = py_strategy_ref.external_order_claims();
1234                let oms_type = config_obj
1235                    .as_ref()
1236                    .and_then(|cfg| cfg.getattr("oms_type").ok())
1237                    .filter(|value| !value.is_none())
1238                    .and_then(|value| value.extract::<OmsType>().ok());
1239
1240                Ok((claims, oms_type))
1241            },
1242        )
1243        .map_err(to_pyruntime_err)?;
1244
1245        if let Some(claims) = external_order_claims.filter(|claims| !claims.is_empty()) {
1246            self.node_mut()?
1247                .register_external_order_claims(strategy_id, &claims)
1248                .map_err(to_pyruntime_err)?;
1249        }
1250
1251        self.node_mut()?
1252            .kernel_mut()
1253            .trader
1254            .borrow_mut()
1255            .commit_python_strategy_instance(&strategy)
1256            .map_err(to_pyruntime_err)?;
1257
1258        if let Some(oms_type) = oms_type {
1259            self.node_mut()?
1260                .kernel()
1261                .exec_engine
1262                .borrow_mut()
1263                .register_oms_type(strategy_id, oms_type);
1264        }
1265
1266        log::info!("Registered Python strategy {strategy_id}");
1267        Ok(())
1268    }
1269
1270    #[pyo3(name = "add_strategy_from_config")]
1271    #[expect(clippy::needless_pass_by_value)]
1272    fn py_add_strategy_from_config(
1273        &self,
1274        _py: Python,
1275        config: ImportableStrategyConfig,
1276    ) -> PyResult<()> {
1277        log::debug!("`add_strategy_from_config` with: {config:?}");
1278
1279        // Extract module and class name from strategy_path
1280        let parts: Vec<&str> = config.strategy_path.split(':').collect();
1281        if parts.len() != 2 {
1282            return Err(to_pyvalue_err(
1283                "strategy_path must be in format 'module.path:ClassName'",
1284            ));
1285        }
1286        let (module_name, class_name) = (parts[0], parts[1]);
1287
1288        log::info!("Importing strategy from module: {module_name} class: {class_name}");
1289
1290        // Phase 1: Create the Python strategy, then prepare it for registration so the strategy ID,
1291        // order ID tag, and logging flags are sourced from the same config as the instance path
1292        let python_strategy = Python::attach(|py| -> anyhow::Result<Py<PyAny>> {
1293            let strategy_module = py
1294                .import(module_name)
1295                .map_err(|e| anyhow::anyhow!("Failed to import module {module_name}: {e}"))?;
1296            let strategy_class = strategy_module
1297                .getattr(class_name)
1298                .map_err(|e| anyhow::anyhow!("Failed to get class {class_name}: {e}"))?;
1299
1300            let config_instance = create_config_instance(py, &config.config_path, &config.config)?;
1301
1302            let python_strategy = if let Some(config_obj) = config_instance {
1303                strategy_class.call1((config_obj,))?
1304            } else {
1305                strategy_class.call0()?
1306            };
1307
1308            log::debug!("Created Python strategy instance: {python_strategy:?}");
1309
1310            Ok(python_strategy.unbind())
1311        })
1312        .map_err(to_pyruntime_err)?;
1313
1314        let strategy_id = self
1315            .node_mut()?
1316            .kernel_mut()
1317            .trader
1318            .borrow_mut()
1319            .prepare_python_strategy_instance(&python_strategy)
1320            .map_err(to_pyruntime_err)?;
1321
1322        Python::attach(|py| -> anyhow::Result<()> {
1323            let bound = python_strategy.bind(py);
1324            let config_obj = bound
1325                .getattr("config")
1326                .ok()
1327                .filter(|config| !config.is_none());
1328
1329            let mut py_strategy_ref = bound
1330                .extract::<PyRefMut<PyStrategy>>()
1331                .map_err(Into::<PyErr>::into)
1332                .map_err(|e| anyhow::anyhow!("Failed to extract PyStrategy: {e}"))?;
1333
1334            if let Some(config_obj) = config_obj.as_ref()
1335                && let Some(claims) = extract_external_order_claims_config_attr(config_obj)?
1336            {
1337                py_strategy_ref.set_external_order_claims(Some(claims));
1338            }
1339
1340            Ok(())
1341        })
1342        .map_err(to_pyruntime_err)?;
1343
1344        // Phase 2: Claim external orders before committing, matching the instance path
1345        let external_order_claims = Python::attach(|py| -> anyhow::Result<Option<Vec<_>>> {
1346            let py_strategy = python_strategy.bind(py);
1347            let py_strategy_ref = py_strategy
1348                .extract::<PyRef<PyStrategy>>()
1349                .map_err(Into::<PyErr>::into)
1350                .map_err(|e| anyhow::anyhow!("Failed to extract PyStrategy: {e}"))?;
1351
1352            Ok(py_strategy_ref.external_order_claims())
1353        })
1354        .map_err(to_pyruntime_err)?;
1355
1356        if let Some(claims) = external_order_claims.filter(|claims| !claims.is_empty()) {
1357            self.node_mut()?
1358                .register_external_order_claims(strategy_id, &claims)
1359                .map_err(to_pyruntime_err)?;
1360        }
1361
1362        // Phase 3: Register the strategy through the trader's single Python registration path
1363        self.node_mut()?
1364            .kernel_mut()
1365            .trader
1366            .borrow_mut()
1367            .commit_python_strategy_instance(&python_strategy)
1368            .map_err(to_pyruntime_err)?;
1369
1370        log::info!("Registered Python strategy {strategy_id}");
1371        Ok(())
1372    }
1373
1374    /// Adds an execution algorithm to the trader.
1375    ///
1376    /// Execution algorithms are registered in both the component registry (for lifecycle
1377    /// management) and the actor registry (for data callbacks via msgbus).
1378    ///
1379    /// # Errors
1380    ///
1381    /// Returns an error if:
1382    /// - The node is currently running.
1383    /// - An execution algorithm with the same ID is already registered.
1384    #[pyo3(name = "add_exec_algorithm")]
1385    fn py_add_exec_algorithm(&self, exec_algorithm: &Bound<'_, PyAny>) -> PyResult<()> {
1386        if self.node()?.state() != NodeState::Idle {
1387            return Err(to_pyruntime_err(
1388                "Cannot add exec algorithm while node is running, add exec algorithms before running the node",
1389            ));
1390        }
1391
1392        log::debug!("`add_exec_algorithm` with a constructed instance");
1393
1394        let exec_algorithm = exec_algorithm.clone().unbind();
1395        let py_exec_algorithm = Python::attach(|py| -> anyhow::Result<PyExecutionAlgorithm> {
1396            let bound = exec_algorithm.bind(py);
1397            let config = bound
1398                .getattr("config")
1399                .ok()
1400                .filter(|config| !config.is_none());
1401            let mut py_exec_algorithm_ref = bound
1402                .extract::<PyRefMut<PyExecutionAlgorithm>>()
1403                .map_err(Into::<PyErr>::into)
1404                .map_err(|e| {
1405                    anyhow::anyhow!(
1406                        "LiveNode.add_exec_algorithm requires a Python v2 ExecutionAlgorithm instance; use add_exec_algorithm_from_config for DataActor-based algorithms: {e}"
1407                    )
1408                })?;
1409
1410            if let Some(config) = config.as_ref() {
1411                py_exec_algorithm_ref.configure_from_py_config(config)?;
1412            }
1413
1414            py_exec_algorithm_ref.set_python_instance(bound)?;
1415            Ok(py_exec_algorithm_ref.clone())
1416        })
1417        .map_err(to_pyruntime_err)?;
1418
1419        let exec_algorithm_id = self
1420            .node_mut()?
1421            .kernel_mut()
1422            .trader
1423            .borrow_mut()
1424            .add_py_execution_algorithm_instance(py_exec_algorithm, &exec_algorithm)
1425            .map_err(to_pyruntime_err)?;
1426
1427        log::info!("Registered Python exec algorithm {exec_algorithm_id}");
1428        Ok(())
1429    }
1430
1431    #[pyo3(name = "add_exec_algorithm_from_config")]
1432    #[expect(clippy::needless_pass_by_value)]
1433    fn py_add_exec_algorithm_from_config(
1434        &self,
1435        _py: Python,
1436        config: ImportableExecutionAlgorithmConfig,
1437    ) -> PyResult<()> {
1438        if self.node()?.is_running() {
1439            return Err(to_pyruntime_err(
1440                "Cannot add exec algorithm while node is running",
1441            ));
1442        }
1443
1444        log::debug!("`add_exec_algorithm_from_config` with: {config:?}");
1445
1446        let parts: Vec<&str> = config.exec_algorithm_path.split(':').collect();
1447        if parts.len() != 2 {
1448            return Err(to_pyvalue_err(
1449                "exec_algorithm_path must be in format 'module.path:ClassName'",
1450            ));
1451        }
1452        let (module_name, class_name) = (parts[0], parts[1]);
1453
1454        log::info!("Importing exec algorithm from module: {module_name} class: {class_name}");
1455
1456        // Phase 1: Create and configure the Python exec algorithm.
1457        let (python_exec_algorithm, py_execution_algorithm, actor_id) = Python::attach(
1458            |py| -> anyhow::Result<(Py<PyAny>, Option<PyExecutionAlgorithm>, ActorId)> {
1459                let algo_module = py
1460                    .import(module_name)
1461                    .map_err(|e| anyhow::anyhow!("Failed to import module {module_name}: {e}"))?;
1462                let algo_class = algo_module
1463                    .getattr(class_name)
1464                    .map_err(|e| anyhow::anyhow!("Failed to get class {class_name}: {e}"))?;
1465
1466                let config_instance =
1467                    create_config_instance(py, &config.config_path, &config.config)?;
1468
1469                let python_exec_algorithm = if let Some(config_obj) = config_instance.clone() {
1470                    algo_class.call1((config_obj,))?
1471                } else {
1472                    algo_class.call0()?
1473                };
1474
1475                log::debug!("Created Python exec algorithm instance: {python_exec_algorithm:?}");
1476
1477                if let Ok(mut py_exec_algorithm_ref) =
1478                    python_exec_algorithm.extract::<PyRefMut<PyExecutionAlgorithm>>()
1479                {
1480                    if let Some(config_obj) = config_instance.as_ref() {
1481                        py_exec_algorithm_ref.configure_from_py_config(config_obj)?;
1482                    }
1483
1484                    py_exec_algorithm_ref.set_python_instance(&python_exec_algorithm)?;
1485                    let actor_id =
1486                        ActorId::from(py_exec_algorithm_ref.exec_algorithm_id().inner().as_str());
1487
1488                    return Ok((
1489                        python_exec_algorithm.unbind(),
1490                        Some(py_exec_algorithm_ref.clone()),
1491                        actor_id,
1492                    ));
1493                }
1494
1495                let mut py_data_actor_ref = python_exec_algorithm
1496                    .extract::<PyRefMut<PyDataActor>>()
1497                    .map_err(Into::<PyErr>::into)
1498                    .map_err(|e| anyhow::anyhow!("Failed to extract PyDataActor: {e}"))?;
1499
1500                // Extract ID from config: prefer exec_algorithm_id, fall back to actor_id
1501                if let Some(config_obj) = config_instance.as_ref() {
1502                    let id_attr = config_obj
1503                        .getattr("exec_algorithm_id")
1504                        .ok()
1505                        .filter(|v| !v.is_none())
1506                        .or_else(|| config_obj.getattr("actor_id").ok().filter(|v| !v.is_none()));
1507
1508                    if let Some(id_value) = id_attr {
1509                        let actor_id_val = if let Ok(eaid) = id_value.extract::<ExecAlgorithmId>() {
1510                            ActorId::new(eaid.inner().as_str())
1511                        } else if let Ok(aid) = id_value.extract::<ActorId>() {
1512                            aid
1513                        } else if let Ok(aid_str) = id_value.extract::<String>() {
1514                            ActorId::new_checked(&aid_str)?
1515                        } else {
1516                            anyhow::bail!("Invalid `exec_algorithm_id`/`actor_id` type");
1517                        };
1518                        py_data_actor_ref.set_actor_id(actor_id_val);
1519                    }
1520
1521                    if let Some(val) = extract_bool_config_attr(config_obj, "log_events") {
1522                        py_data_actor_ref.set_log_events(val);
1523                    }
1524
1525                    if let Some(val) = extract_bool_config_attr(config_obj, "log_commands") {
1526                        py_data_actor_ref.set_log_commands(val);
1527                    }
1528                }
1529
1530                py_data_actor_ref.set_python_instance(&python_exec_algorithm)?;
1531
1532                let actor_id = py_data_actor_ref.actor_id();
1533
1534                Ok((python_exec_algorithm.unbind(), None, actor_id))
1535            },
1536        )
1537        .map_err(to_pyruntime_err)?;
1538
1539        let exec_algorithm_id = if let Some(py_execution_algorithm) = py_execution_algorithm {
1540            // This branch registered through `LiveNode::add_exec_algorithm` before the trader owned
1541            // the path, so it keeps that method's stricter state requirement
1542            if self.node()?.state() != NodeState::Idle {
1543                return Err(to_pyruntime_err(
1544                    "Cannot add exec algorithm while node is running, add exec algorithms before running the node",
1545                ));
1546            }
1547
1548            self.node_mut()?
1549                .kernel_mut()
1550                .trader
1551                .borrow_mut()
1552                .add_py_execution_algorithm_instance(py_execution_algorithm, &python_exec_algorithm)
1553                .map_err(to_pyruntime_err)?
1554        } else {
1555            // Phase 2: Register the DataActor-backed algorithm through the trader's single Python
1556            // registration path
1557            self.node_mut()?
1558                .kernel_mut()
1559                .trader
1560                .borrow_mut()
1561                .add_python_exec_algorithm_instance(&python_exec_algorithm, actor_id)
1562                .map_err(to_pyruntime_err)?
1563        };
1564
1565        log::info!("Registered Python exec algorithm {exec_algorithm_id}");
1566        Ok(())
1567    }
1568
1569    /// Loads and registers one plug-in instance.
1570    ///
1571    /// # Errors
1572    ///
1573    /// Returns an error because dynamic plug-in hosting lives in the host-side integration.
1574    #[pyo3(name = "add_plugin", signature = (path, type_name, config=None, sha256=None))]
1575    fn py_add_plugin(
1576        &self,
1577        path: String,
1578        type_name: String,
1579        config: Option<HashMap<String, Py<PyAny>>>,
1580        sha256: Option<String>,
1581    ) -> PyResult<()> {
1582        let config = PluginConfig {
1583            path,
1584            type_name,
1585            config: match config {
1586                Some(config) => coerce_json_config(config)?,
1587                None => HashMap::new(),
1588            },
1589            sha256,
1590        };
1591
1592        self.node_mut()?
1593            .add_plugin(config)
1594            .map_err(to_pyruntime_err)
1595    }
1596
1597    /// Adds a built-in example actor from its type name and config.
1598    ///
1599    /// This method exists only to single-source bundled example actor code across
1600    /// Rust and Python tests/examples. It is not a first-class extension path for
1601    /// adding native actors.
1602    #[cfg(feature = "examples")]
1603    #[pyo3(name = "add_builtin_actor")]
1604    fn py_add_builtin_actor(&self, type_name: &str, config: &Bound<'_, PyAny>) -> PyResult<()> {
1605        let register = builtin_actor_register(type_name).ok_or_else(|| {
1606            to_pytype_err(format!("Unsupported built-in actor type: {type_name}"))
1607        })?;
1608        let mut node = self.node_mut()?;
1609        register(&mut node, config)
1610    }
1611
1612    /// Adds a built-in example strategy from its type name and config.
1613    ///
1614    /// This method exists only to single-source bundled example strategy code across
1615    /// Rust and Python tests/examples. It is not a first-class extension path for
1616    /// adding native strategies.
1617    #[cfg(feature = "examples")]
1618    #[pyo3(name = "add_builtin_strategy")]
1619    fn py_add_builtin_strategy(&self, type_name: &str, config: &Bound<'_, PyAny>) -> PyResult<()> {
1620        let register = builtin_strategy_register(type_name).ok_or_else(|| {
1621            to_pytype_err(format!("Unsupported built-in strategy type: {type_name}"))
1622        })?;
1623        let mut node = self.node_mut()?;
1624        register(&mut node, config)
1625    }
1626
1627    fn __repr__(&self) -> String {
1628        format!(
1629            "LiveNode(trader_id={}, environment={}, running={})",
1630            self.node()
1631                .map_or_else(|_| "<running>".to_string(), |n| n.trader_id().to_string()),
1632            self.node().map_or_else(
1633                |_| "<running>".to_string(),
1634                |n| format!("{:?}", n.environment())
1635            ),
1636            self.py_is_running()
1637        )
1638    }
1639}
1640
1641impl PyLiveNode {
1642    fn register_python_actor(&self, actor: &Py<PyAny>, actor_id: ActorId) -> PyResult<()> {
1643        if self
1644            .node()?
1645            .kernel()
1646            .trader
1647            .borrow()
1648            .actor_ids()
1649            .contains(&actor_id)
1650        {
1651            return Err(to_pyruntime_err(format!(
1652                "Actor '{actor_id}' is already registered"
1653            )));
1654        }
1655
1656        self.node_mut()?
1657            .kernel_mut()
1658            .trader
1659            .borrow_mut()
1660            .add_python_actor_instance(actor, actor_id)
1661            .map_err(to_pyruntime_err)?;
1662
1663        log::info!("Registered Python actor {actor_id}");
1664        Ok(())
1665    }
1666}
1667
1668fn new_sync_py_callback<F>(py: Python<'_>, closure: F) -> PyResult<Bound<'_, PyCFunction>>
1669where
1670    F: Fn(&Bound<'_, PyTuple>, Option<&Bound<'_, PyDict>>) -> PyResult<()> + Send + Sync + 'static,
1671{
1672    PyCFunction::new_closure(py, None, None, closure)
1673}
1674
1675#[allow(unsafe_code)]
1676fn run_live_node_detached(py: Python<'_>, node: &mut LiveNode) -> PyResult<()> {
1677    let node_ptr = SendPtr(std::ptr::from_mut::<LiveNode>(node));
1678
1679    // SAFETY: `py_run` holds the only mutable reference to `LiveNode` until
1680    // `run()` returns, and the detached closure completes before `py_run` can
1681    // access `node` again.
1682    unsafe {
1683        py.detach(move || {
1684            let ptr = node_ptr;
1685            get_runtime().block_on(async { (*ptr.0).run().await })
1686        })
1687    }
1688    .map_err(to_pyruntime_err)
1689}
1690
1691#[allow(unsafe_code)]
1692fn stop_live_node_detached(py: Python<'_>, node: &mut LiveNode) -> PyResult<()> {
1693    let node_ptr = SendPtr(std::ptr::from_mut::<LiveNode>(node));
1694
1695    // SAFETY: the Python binding holds the only mutable reference to `LiveNode`
1696    // until `stop()` returns, and the detached closure completes before the
1697    // caller can access `node` again.
1698    unsafe {
1699        py.detach(move || {
1700            let ptr = node_ptr;
1701            get_runtime().block_on(async { (*ptr.0).stop().await })
1702        })
1703    }
1704    .map_err(to_pyruntime_err)
1705}
1706
1707/// Creates a Python config instance from a config path and config dictionary.
1708///
1709/// This helper is shared between `add_actor_from_config` and `add_strategy_from_config`.
1710/// It handles:
1711/// 1. Importing the config class from the module path
1712/// 2. Converting the `HashMap<String, serde_json::Value>` to a Python dict
1713/// 3. Trying kwargs-first construction, falling back to default + setattr
1714/// 4. Calling `__post_init__` for dataclasses when using the setattr path
1715fn create_config_instance<'py>(
1716    py: Python<'py>,
1717    config_path: &str,
1718    config: &HashMap<String, serde_json::Value>,
1719) -> anyhow::Result<Option<Bound<'py, PyAny>>> {
1720    if config_path.is_empty() && config.is_empty() {
1721        log::debug!("No config_path or empty config, using None");
1722        return Ok(None);
1723    }
1724
1725    let config_parts: Vec<&str> = config_path.split(':').collect();
1726    if config_parts.len() != 2 {
1727        anyhow::bail!("config_path must be in format 'module.path:ClassName', was {config_path}");
1728    }
1729    let (config_module_name, config_class_name) = (config_parts[0], config_parts[1]);
1730
1731    log::debug!(
1732        "Importing config class from module: {config_module_name} class: {config_class_name}"
1733    );
1734
1735    let config_module = py
1736        .import(config_module_name)
1737        .map_err(|e| anyhow::anyhow!("Failed to import config module {config_module_name}: {e}"))?;
1738    let config_class = config_module
1739        .getattr(config_class_name)
1740        .map_err(|e| anyhow::anyhow!("Failed to get config class {config_class_name}: {e}"))?;
1741
1742    // Convert config dict to Python dict
1743    let py_dict = PyDict::new(py);
1744
1745    for (key, value) in config {
1746        let py_value = config_value_to_py(py, key, value)?;
1747        py_dict.set_item(key, py_value)?;
1748    }
1749
1750    log::debug!("Created config dict: {py_dict:?}");
1751
1752    // Try kwargs first, then default constructor with setattr
1753    let config_instance = match config_class.call((), Some(&py_dict)) {
1754        Ok(instance) => {
1755            log::debug!("Created config instance with kwargs");
1756            instance
1757        }
1758        Err(kwargs_err) => {
1759            log::debug!("Failed to create config with kwargs: {kwargs_err}");
1760
1761            match config_class.call0() {
1762                Ok(instance) => {
1763                    log::debug!("Created default config instance, setting attributes");
1764                    for (key, value) in config {
1765                        let py_value = config_value_to_py(py, key, value)?;
1766
1767                        if let Err(setattr_err) = instance.setattr(key, py_value) {
1768                            log::warn!("Failed to set attribute {key}: {setattr_err}");
1769                        }
1770                    }
1771
1772                    // Only call __post_init__ if it exists (setattr path
1773                    // needs it, kwargs path already triggered it via __init__)
1774                    if instance.hasattr("__post_init__")? {
1775                        instance.call_method0("__post_init__")?;
1776                    }
1777
1778                    instance
1779                }
1780                Err(default_err) => {
1781                    anyhow::bail!(
1782                        "Failed to create config instance. \
1783                         Tried kwargs: {kwargs_err}, default: {default_err}"
1784                    );
1785                }
1786            }
1787        }
1788    };
1789
1790    log::debug!("Created config instance: {config_instance:?}");
1791
1792    Ok(Some(config_instance))
1793}
1794
1795fn config_value_to_py<'py>(
1796    py: Python<'py>,
1797    key: &str,
1798    value: &serde_json::Value,
1799) -> anyhow::Result<Bound<'py, PyAny>> {
1800    if key == "actor_id"
1801        && let Some(actor_id) = value.as_str()
1802    {
1803        return Ok(ActorId::new_checked(actor_id)?
1804            .into_pyobject(py)?
1805            .into_any());
1806    }
1807
1808    let json_str = serde_json::to_string(value)
1809        .map_err(|e| anyhow::anyhow!("Failed to serialize config value: {e}"))?;
1810    Ok(PyModule::import(py, "json")?
1811        .call_method("loads", (json_str,), None)?
1812        .into_any())
1813}
1814
1815/// Extracts an optional boolean attribute from a Python config object.
1816///
1817/// Returns `None` if the attribute doesn't exist or isn't a bool,
1818/// without raising an error (config fields are optional overrides).
1819fn extract_bool_config_attr(config_obj: &Bound<'_, PyAny>, attr: &str) -> Option<bool> {
1820    config_obj
1821        .getattr(attr)
1822        .ok()
1823        .and_then(|val| val.extract::<bool>().ok())
1824}
1825
1826fn extract_external_order_claims_config_attr(
1827    config_obj: &Bound<'_, PyAny>,
1828) -> anyhow::Result<Option<Vec<InstrumentId>>> {
1829    let Ok(claims) = config_obj.getattr("external_order_claims") else {
1830        return Ok(None);
1831    };
1832
1833    if claims.is_none() {
1834        return Ok(None);
1835    }
1836
1837    if let Ok(claims) = claims.extract::<Vec<InstrumentId>>() {
1838        return Ok(Some(claims));
1839    }
1840
1841    let claim_strings = claims
1842        .extract::<Vec<String>>()
1843        .map_err(|e| anyhow::anyhow!("Invalid `external_order_claims` type: {e}"))?;
1844    let claims = claim_strings
1845        .into_iter()
1846        .map(|claim| {
1847            InstrumentId::from_str(&claim).map_err(|e| {
1848                anyhow::anyhow!("Invalid `external_order_claims` instrument ID {claim}: {e}")
1849            })
1850        })
1851        .collect::<anyhow::Result<Vec<_>>>()?;
1852
1853    Ok(Some(claims))
1854}
1855
1856#[cfg(feature = "examples")]
1857type BuiltinActorRegister = for<'py> fn(&mut LiveNode, &Bound<'py, PyAny>) -> PyResult<()>;
1858
1859#[cfg(feature = "examples")]
1860type BuiltinStrategyRegister = for<'py> fn(&mut LiveNode, &Bound<'py, PyAny>) -> PyResult<()>;
1861
1862#[cfg(feature = "examples")]
1863fn builtin_actor_register(type_name: &str) -> Option<BuiltinActorRegister> {
1864    match type_name {
1865        "BookImbalanceActor" => Some(register_book_imbalance_actor),
1866        "DataTester" => Some(register_data_tester),
1867        _ => None,
1868    }
1869}
1870
1871#[cfg(feature = "examples")]
1872fn builtin_strategy_register(type_name: &str) -> Option<BuiltinStrategyRegister> {
1873    match type_name {
1874        "CompositeMarketMaker" => Some(register_composite_market_maker),
1875        "DeltaNeutralVol" => Some(register_delta_neutral_vol),
1876        "EmaCross" => Some(register_ema_cross),
1877        "ExecTester" => Some(register_exec_tester),
1878        "GridMarketMaker" => Some(register_grid_market_maker),
1879        "HurstVpinDirectional" => Some(register_hurst_vpin_directional),
1880        _ => None,
1881    }
1882}
1883
1884#[cfg(feature = "examples")]
1885fn register_composite_market_maker(node: &mut LiveNode, config: &Bound<'_, PyAny>) -> PyResult<()> {
1886    let config = config.extract::<CompositeMarketMakerConfig>()?;
1887    node.add_strategy(CompositeMarketMaker::new(config))
1888        .map_err(to_pyruntime_err)
1889}
1890
1891#[cfg(feature = "examples")]
1892fn register_delta_neutral_vol(node: &mut LiveNode, config: &Bound<'_, PyAny>) -> PyResult<()> {
1893    let config = config.extract::<DeltaNeutralVolConfig>()?;
1894    node.add_strategy(DeltaNeutralVol::new(config))
1895        .map_err(to_pyruntime_err)
1896}
1897
1898#[cfg(feature = "examples")]
1899fn register_ema_cross(node: &mut LiveNode, config: &Bound<'_, PyAny>) -> PyResult<()> {
1900    let config = config.extract::<EmaCrossConfig>()?;
1901    node.add_strategy(EmaCross::from_config(config))
1902        .map_err(to_pyruntime_err)
1903}
1904
1905#[cfg(feature = "examples")]
1906fn register_exec_tester(node: &mut LiveNode, config: &Bound<'_, PyAny>) -> PyResult<()> {
1907    let config = config.extract::<ExecTesterConfig>()?;
1908    node.add_strategy(ExecTester::new(config))
1909        .map_err(to_pyruntime_err)
1910}
1911
1912#[cfg(feature = "examples")]
1913fn register_grid_market_maker(node: &mut LiveNode, config: &Bound<'_, PyAny>) -> PyResult<()> {
1914    let config = config.extract::<GridMarketMakerConfig>()?;
1915    node.add_strategy(GridMarketMaker::new(config))
1916        .map_err(to_pyruntime_err)
1917}
1918
1919#[cfg(feature = "examples")]
1920fn register_hurst_vpin_directional(node: &mut LiveNode, config: &Bound<'_, PyAny>) -> PyResult<()> {
1921    let config = config.extract::<HurstVpinDirectionalConfig>()?;
1922    node.add_strategy(HurstVpinDirectional::new(config))
1923        .map_err(to_pyruntime_err)
1924}
1925
1926#[cfg(feature = "examples")]
1927fn register_book_imbalance_actor(node: &mut LiveNode, config: &Bound<'_, PyAny>) -> PyResult<()> {
1928    let config = config.extract::<BookImbalanceActorConfig>()?;
1929    node.add_actor(BookImbalanceActor::from_config(config))
1930        .map_err(to_pyruntime_err)
1931}
1932
1933#[cfg(feature = "examples")]
1934fn register_data_tester(node: &mut LiveNode, config: &Bound<'_, PyAny>) -> PyResult<()> {
1935    let config = config.extract::<DataTesterConfig>()?;
1936    node.add_actor(DataTester::new(config))
1937        .map_err(to_pyruntime_err)
1938}
1939
1940/// Python wrapper for `LiveNodeBuilder` that uses interior mutability
1941/// to work around PyO3's shared ownership model.
1942#[pyclass(name = "LiveNodeBuilder", module = "nautilus_trader.live", unsendable)]
1943#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")]
1944pub struct PyLiveNodeBuilder {
1945    state: Rc<Cell<PyLiveNodeBuilderState>>,
1946}
1947
1948#[pyo3_stub_gen::derive::gen_stub_pymethods]
1949#[pymethods]
1950impl PyLiveNodeBuilder {
1951    #[pyo3(name = "with_instance_id")]
1952    fn py_with_instance_id(&self, instance_id: UUID4) -> PyResult<Self> {
1953        self.update_builder(|builder| builder.with_instance_id(instance_id))
1954    }
1955
1956    #[pyo3(name = "with_load_state")]
1957    fn py_with_load_state(&self, load_state: bool) -> PyResult<Self> {
1958        self.update_builder(|builder| builder.with_load_state(load_state))
1959    }
1960
1961    #[pyo3(name = "with_save_state")]
1962    fn py_with_save_state(&self, save_state: bool) -> PyResult<Self> {
1963        self.update_builder(|builder| builder.with_save_state(save_state))
1964    }
1965
1966    #[pyo3(name = "with_timeout_connection")]
1967    fn py_with_timeout_connection(&self, timeout_secs: u64) -> PyResult<Self> {
1968        self.update_builder(|builder| builder.with_timeout_connection(timeout_secs))
1969    }
1970
1971    #[pyo3(name = "with_timeout_reconciliation")]
1972    fn py_with_timeout_reconciliation(&self, timeout_secs: u64) -> PyResult<Self> {
1973        self.update_builder(|builder| builder.with_timeout_reconciliation(timeout_secs))
1974    }
1975
1976    #[pyo3(name = "with_timeout_portfolio")]
1977    fn py_with_timeout_portfolio(&self, timeout_secs: u64) -> PyResult<Self> {
1978        self.update_builder(|builder| builder.with_timeout_portfolio(timeout_secs))
1979    }
1980
1981    #[pyo3(name = "with_timeout_disconnection_secs")]
1982    fn py_with_timeout_disconnection_secs(&self, timeout_secs: u64) -> PyResult<Self> {
1983        self.update_builder(|builder| builder.with_timeout_disconnection_secs(timeout_secs))
1984    }
1985
1986    #[pyo3(name = "with_delay_post_stop_secs")]
1987    fn py_with_delay_post_stop_secs(&self, delay_secs: u64) -> PyResult<Self> {
1988        self.update_builder(|builder| builder.with_delay_post_stop_secs(delay_secs))
1989    }
1990
1991    #[pyo3(name = "with_delay_shutdown_secs")]
1992    fn py_with_delay_shutdown_secs(&self, delay_secs: u64) -> PyResult<Self> {
1993        self.update_builder(|builder| builder.with_delay_shutdown_secs(delay_secs))
1994    }
1995
1996    #[pyo3(name = "with_reconciliation")]
1997    fn py_with_reconciliation(&self, reconciliation: bool) -> PyResult<Self> {
1998        self.update_builder(|builder| builder.with_reconciliation(reconciliation))
1999    }
2000
2001    #[pyo3(name = "with_controller")]
2002    fn py_with_controller(&self, controller: ImportableControllerConfig) -> PyResult<Self> {
2003        self.update_builder(|builder| builder.with_controller(controller))
2004    }
2005
2006    #[pyo3(name = "with_reconciliation_lookback_mins")]
2007    fn py_with_reconciliation_lookback_mins(&self, mins: u32) -> PyResult<Self> {
2008        self.update_builder(|builder| builder.with_reconciliation_lookback_mins(mins))
2009    }
2010
2011    #[pyo3(name = "with_cache_config")]
2012    fn py_with_cache_config(&self, config: CacheConfig) -> PyResult<Self> {
2013        self.update_builder(|builder| builder.with_cache_config(config))
2014    }
2015
2016    #[pyo3(name = "with_cache_database_factory")]
2017    fn py_with_cache_database_factory(&self, factory: Py<PyAny>) -> PyResult<Self> {
2018        let mut operation = self.begin_operation()?;
2019        let factory =
2020            Python::attach(|py| get_global_cache_database_factory_registry().extract(py, factory))?;
2021        let builder = operation.take_builder()?;
2022        operation.complete(builder.with_cache_database_factory(factory));
2023        Ok(self.shared())
2024    }
2025
2026    #[pyo3(name = "with_msgbus_config")]
2027    fn py_with_msgbus_config(&self, config: MessageBusConfig) -> PyResult<Self> {
2028        self.update_builder(|builder| builder.with_msgbus_config(config))
2029    }
2030
2031    #[pyo3(name = "with_external_msgbus_factory")]
2032    fn py_with_external_msgbus_factory(&self, factory: Py<PyAny>) -> PyResult<Self> {
2033        let mut operation = self.begin_operation()?;
2034        let factory =
2035            Python::attach(|py| get_global_msgbus_factory_registry().extract(py, factory))?;
2036        let builder = operation.take_builder()?;
2037        operation.complete(builder.with_external_msgbus_factory(factory));
2038        Ok(self.shared())
2039    }
2040
2041    #[pyo3(name = "with_portfolio_config")]
2042    fn py_with_portfolio_config(&self, config: PortfolioConfig) -> PyResult<Self> {
2043        self.update_builder(|builder| builder.with_portfolio_config(config))
2044    }
2045
2046    #[pyo3(name = "with_data_engine_config")]
2047    fn py_with_data_engine_config(&self, config: LiveDataEngineConfig) -> PyResult<Self> {
2048        self.update_builder(|builder| builder.with_data_engine_config(config))
2049    }
2050
2051    #[pyo3(name = "with_risk_engine_config")]
2052    fn py_with_risk_engine_config(&self, config: LiveRiskEngineConfig) -> PyResult<Self> {
2053        self.update_builder(|builder| builder.with_risk_engine_config(config))
2054    }
2055
2056    #[pyo3(name = "with_exec_engine_config")]
2057    fn py_with_exec_engine_config(&self, config: LiveExecutionEngineConfig) -> PyResult<Self> {
2058        self.update_builder(|builder| builder.with_exec_engine_config(config))
2059    }
2060
2061    #[pyo3(name = "with_logging")]
2062    fn py_with_logging(&self, logging: LoggerConfig) -> PyResult<Self> {
2063        self.update_builder(|builder| builder.with_logging(logging))
2064    }
2065
2066    #[pyo3(name = "add_data_client", signature = (name, factory, config, routing=None))]
2067    #[expect(clippy::needless_pass_by_value)]
2068    fn py_add_data_client(
2069        &self,
2070        name: Option<String>,
2071        factory: Py<PyAny>,
2072        config: Py<PyAny>,
2073        routing: Option<RoutingConfig>,
2074    ) -> PyResult<Self> {
2075        let mut operation = self.begin_operation()?;
2076        Python::attach(|py| -> PyResult<Self> {
2077            let registry = get_global_pyo3_registry();
2078            let boxed_factory = registry.extract_factory(py, factory.clone_ref(py))?;
2079            let boxed_config = registry.extract_config(py, config.clone_ref(py))?;
2080            let factory_name = factory
2081                .getattr(py, "name")?
2082                .call0(py)?
2083                .extract::<String>(py)?;
2084            let client_name = name.unwrap_or(factory_name);
2085            let builder = operation.take_builder()?;
2086            let updated_builder = match routing {
2087                Some(routing) => builder.add_data_client_with_routing(
2088                    Some(client_name),
2089                    boxed_factory,
2090                    boxed_config,
2091                    routing,
2092                ),
2093                None => builder.add_data_client(Some(client_name), boxed_factory, boxed_config),
2094            }
2095            .map_err(|e| to_pyruntime_err(format!("Failed to add data client: {e}")))?;
2096            operation.complete(updated_builder);
2097            Ok(self.shared())
2098        })
2099    }
2100
2101    #[pyo3(name = "add_exec_client", signature = (name, factory, config, routing=None))]
2102    #[expect(clippy::needless_pass_by_value)]
2103    fn py_add_exec_client(
2104        &self,
2105        name: Option<String>,
2106        factory: Py<PyAny>,
2107        config: Py<PyAny>,
2108        routing: Option<RoutingConfig>,
2109    ) -> PyResult<Self> {
2110        let mut operation = self.begin_operation()?;
2111        Python::attach(|py| -> PyResult<Self> {
2112            let registry = get_global_pyo3_registry();
2113            let boxed_factory = registry.extract_exec_factory(py, factory.clone_ref(py))?;
2114            let boxed_config = registry.extract_config(py, config.clone_ref(py))?;
2115            let factory_name = factory
2116                .getattr(py, "name")?
2117                .call0(py)?
2118                .extract::<String>(py)?;
2119            let client_name = name.unwrap_or(factory_name);
2120            let builder = operation.take_builder()?;
2121            let updated_builder = match routing {
2122                Some(routing) => builder.add_exec_client_with_routing(
2123                    Some(client_name),
2124                    boxed_factory,
2125                    boxed_config,
2126                    routing,
2127                ),
2128                None => builder.add_exec_client(Some(client_name), boxed_factory, boxed_config),
2129            }
2130            .map_err(|e| to_pyruntime_err(format!("Failed to add exec client: {e}")))?;
2131            operation.complete(updated_builder);
2132            Ok(self.shared())
2133        })
2134    }
2135
2136    #[pyo3(name = "add_simulated_exec_client")]
2137    #[expect(clippy::needless_pass_by_value)]
2138    fn py_add_simulated_exec_client(
2139        &self,
2140        name: Option<String>,
2141        factory: Py<PyAny>,
2142        config: Py<PyAny>,
2143    ) -> PyResult<Self> {
2144        let mut operation = self.begin_operation()?;
2145        Python::attach(|py| -> PyResult<Self> {
2146            let registry = get_global_pyo3_registry();
2147            let boxed_factory = registry.extract_sim_exec_factory(py, factory.clone_ref(py))?;
2148            let boxed_config = registry.extract_config(py, config.clone_ref(py))?;
2149            let factory_name = factory
2150                .getattr(py, "name")?
2151                .call0(py)?
2152                .extract::<String>(py)?;
2153            let client_name = name.unwrap_or(factory_name);
2154            let builder = operation.take_builder()?;
2155            let updated_builder = builder
2156                .add_simulated_exec_client(Some(client_name), boxed_factory, boxed_config)
2157                .map_err(|e| {
2158                    to_pyruntime_err(format!("Failed to add simulated exec client: {e}"))
2159                })?;
2160            operation.complete(updated_builder);
2161            Ok(self.shared())
2162        })
2163    }
2164
2165    #[pyo3(name = "build")]
2166    fn py_build(&self) -> PyResult<PyLiveNode> {
2167        let mut operation = self.begin_operation()?;
2168        let builder = operation.take_builder()?;
2169        builder
2170            .build()
2171            .map(PyLiveNode::new)
2172            .map_err(to_pyruntime_err)
2173    }
2174
2175    fn __repr__(&self) -> String {
2176        format!("{self:?}")
2177    }
2178}
2179
2180const BUILDER_OPERATION_IN_PROGRESS: &str = "Builder operation already in progress";
2181const BUILDER_OPERATION_VALUE_TAKEN: &str = "Builder operation value already taken";
2182
2183enum PyLiveNodeBuilderState {
2184    Ready(Box<LiveNodeBuilder>),
2185    InProgress,
2186    Consumed,
2187}
2188
2189struct PyLiveNodeBuilderOperation<'a> {
2190    state: &'a Cell<PyLiveNodeBuilderState>,
2191    builder: Option<LiveNodeBuilder>,
2192}
2193
2194impl PyLiveNodeBuilder {
2195    fn begin_operation(&self) -> PyResult<PyLiveNodeBuilderOperation<'_>> {
2196        match self.state.replace(PyLiveNodeBuilderState::InProgress) {
2197            PyLiveNodeBuilderState::Ready(builder) => Ok(PyLiveNodeBuilderOperation {
2198                state: &self.state,
2199                builder: Some(*builder),
2200            }),
2201            PyLiveNodeBuilderState::InProgress => {
2202                self.state.set(PyLiveNodeBuilderState::InProgress);
2203                Err(to_pyruntime_err(BUILDER_OPERATION_IN_PROGRESS))
2204            }
2205            PyLiveNodeBuilderState::Consumed => {
2206                self.state.set(PyLiveNodeBuilderState::Consumed);
2207                Err(to_pyruntime_err("Builder already consumed"))
2208            }
2209        }
2210    }
2211
2212    fn update_builder<F>(&self, update: F) -> PyResult<Self>
2213    where
2214        F: FnOnce(LiveNodeBuilder) -> LiveNodeBuilder,
2215    {
2216        let mut operation = self.begin_operation()?;
2217        let builder = operation.take_builder()?;
2218        operation.complete(update(builder));
2219        Ok(self.shared())
2220    }
2221
2222    fn shared(&self) -> Self {
2223        Self {
2224            state: self.state.clone(),
2225        }
2226    }
2227}
2228
2229impl Debug for PyLiveNodeBuilder {
2230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2231        // Preserve the existing Python repr despite the internal state change
2232        let state = self.state.replace(PyLiveNodeBuilderState::InProgress);
2233        let result = match &state {
2234            PyLiveNodeBuilderState::Ready(builder) => write!(
2235                f,
2236                "PyLiveNodeBuilder {{ inner: RefCell {{ value: Some({builder:?}) }} }}"
2237            ),
2238            PyLiveNodeBuilderState::InProgress => {
2239                f.write_str("PyLiveNodeBuilder { inner: <operation active> }")
2240            }
2241            PyLiveNodeBuilderState::Consumed => {
2242                f.write_str("PyLiveNodeBuilder { inner: RefCell { value: None } }")
2243            }
2244        };
2245        self.state.set(state);
2246        result
2247    }
2248}
2249
2250impl PyLiveNodeBuilderOperation<'_> {
2251    fn take_builder(&mut self) -> PyResult<LiveNodeBuilder> {
2252        self.builder
2253            .take()
2254            .ok_or_else(|| to_pyruntime_err(BUILDER_OPERATION_VALUE_TAKEN))
2255    }
2256
2257    fn complete(&mut self, builder: LiveNodeBuilder) {
2258        self.builder = Some(builder);
2259    }
2260}
2261
2262impl Drop for PyLiveNodeBuilderOperation<'_> {
2263    fn drop(&mut self) {
2264        self.state.set(match self.builder.take() {
2265            Some(builder) => PyLiveNodeBuilderState::Ready(Box::new(builder)),
2266            None => PyLiveNodeBuilderState::Consumed,
2267        });
2268    }
2269}
2270
2271#[cfg(all(test, feature = "python"))]
2272#[allow(
2273    clippy::await_holding_refcell_ref,
2274    reason = "each test owns its node exclusively, so the wrapper borrow cannot contend"
2275)]
2276mod tests {
2277    use std::{
2278        any::Any,
2279        cell::RefCell,
2280        collections::HashMap,
2281        ffi::CString,
2282        fmt::Debug,
2283        rc::Rc,
2284        sync::{
2285            Arc,
2286            atomic::{AtomicBool, AtomicUsize, Ordering},
2287            mpsc,
2288        },
2289        thread,
2290        time::{Duration, Instant},
2291    };
2292
2293    use async_trait::async_trait;
2294    use indexmap::IndexMap;
2295    use nautilus_common::{
2296        actor::DataActor,
2297        cache::{
2298            CacheConfig, CacheView,
2299            database::{CacheDatabaseAdapter, CacheDatabaseFactory},
2300        },
2301        clients::DataClient,
2302        clock::Clock,
2303        enums::Environment,
2304        factories::{ClientConfig, DataClientFactory},
2305        live::{runner::get_data_event_sender, runtime::get_runtime},
2306        messages::{
2307            DataEvent, DataResponse,
2308            data::{BarsResponse, RequestBars},
2309            execution::{CancelAllOrders, SubmitOrder, TradingCommand},
2310        },
2311        msgbus::{
2312            BusMessage, MessageBusBacking, MessageBusBackingFactory, MessageBusConfig,
2313            MessagingSwitchboard, get_message_bus,
2314        },
2315        python::{
2316            actor::PyDataActor, cache::get_global_cache_database_factory_registry,
2317            msgbus::get_global_msgbus_factory_registry,
2318        },
2319        runner::{TradingCommandMessage, get_trading_cmd_sender},
2320    };
2321    use nautilus_core::{UUID4, UnixNanos};
2322    use nautilus_execution::engine::stubs::StubExecutionClient;
2323    use nautilus_model::{
2324        data::{Bar, BarType},
2325        enums::{OmsType, OrderStatus, OrderType},
2326        identifiers::{
2327            AccountId, ActorId, ClientId, InstrumentId, PositionId, StrategyId, TraderId, Venue,
2328        },
2329        instruments::{Instrument, InstrumentAny, stubs::crypto_perpetual_ethusdt},
2330        orders::{Order, OrderTestBuilder},
2331        types::{Price, Quantity},
2332    };
2333    use nautilus_testkit::cache::{TestCacheDatabase, TestCacheDatabaseControl};
2334    use nautilus_trading::{
2335        ImportableStrategyConfig, nautilus_strategy,
2336        python::strategy::PyStrategy,
2337        strategy::{StrategyConfig, StrategyCore},
2338    };
2339    use parking_lot::Mutex;
2340    use pyo3::{
2341        Py, PyRef, Python,
2342        ffi::c_str,
2343        types::{PyAnyMethods, PyDict, PyModule, PyModuleMethods},
2344    };
2345    use rstest::rstest;
2346
2347    use super::{
2348        BUILDER_OPERATION_IN_PROGRESS, LiveNode, PyLiveNode, PyLiveNodeBuilder,
2349        PyLiveNodeBuilderState, get_global_pyo3_registry,
2350    };
2351    use crate::node::config::RoutingConfig;
2352
2353    #[derive(Clone, Copy, Debug)]
2354    enum ShutdownRunPath {
2355        Native,
2356        PyO3,
2357    }
2358
2359    static TEST_MSGBUS_FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
2360
2361    #[derive(Debug, Clone)]
2362    #[pyo3::pyclass(name = "TestMessageBusFactory", from_py_object)]
2363    struct TestMessageBusFactory;
2364
2365    impl MessageBusBackingFactory for TestMessageBusFactory {
2366        fn create(
2367            &self,
2368            trader_id: TraderId,
2369            _instance_id: UUID4,
2370            config: MessageBusConfig,
2371        ) -> anyhow::Result<Box<dyn MessageBusBacking>> {
2372            TEST_MSGBUS_FACTORY_CALLS.fetch_add(1, Ordering::SeqCst);
2373
2374            anyhow::ensure!(
2375                trader_id == TraderId::from("TESTER-001"),
2376                "unexpected trader ID: {trader_id}"
2377            );
2378            anyhow::ensure!(
2379                config.external_streams == Some(vec!["external-stream".to_string()]),
2380                "unexpected external streams: {:?}",
2381                config.external_streams
2382            );
2383
2384            let (_tx, rx) = tokio::sync::mpsc::channel(1);
2385            Ok(Box::new(TestMessageBusBacking {
2386                rx: Some(rx),
2387                closed: false,
2388            }))
2389        }
2390    }
2391
2392    #[derive(Debug)]
2393    struct TestMessageBusBacking {
2394        rx: Option<tokio::sync::mpsc::Receiver<BusMessage>>,
2395        closed: bool,
2396    }
2397
2398    impl MessageBusBacking for TestMessageBusBacking {
2399        fn is_closed(&self) -> bool {
2400            self.closed
2401        }
2402
2403        fn publish(&self, _message: BusMessage) {}
2404
2405        fn take_receiver(&mut self) -> anyhow::Result<tokio::sync::mpsc::Receiver<BusMessage>> {
2406            self.rx
2407                .take()
2408                .ok_or_else(|| anyhow::anyhow!("Stream receiver already taken"))
2409        }
2410
2411        fn close(&mut self) {
2412            self.closed = true;
2413        }
2414    }
2415
2416    #[expect(clippy::needless_pass_by_value)]
2417    fn extract_test_msgbus_factory(
2418        py: Python<'_>,
2419        factory: Py<pyo3::PyAny>,
2420    ) -> pyo3::PyResult<Box<dyn MessageBusBackingFactory>> {
2421        Ok(Box::new(factory.extract::<TestMessageBusFactory>(py)?))
2422    }
2423
2424    #[rstest]
2425    fn test_python_builder_installs_external_msgbus_factory() {
2426        TEST_MSGBUS_FACTORY_CALLS.store(0, Ordering::SeqCst);
2427        get_global_msgbus_factory_registry()
2428            .register(
2429                "TestMessageBusFactory".to_string(),
2430                extract_test_msgbus_factory,
2431            )
2432            .unwrap();
2433        Python::initialize();
2434
2435        Python::attach(|py| {
2436            let factory = Py::new(py, TestMessageBusFactory).unwrap().into_any();
2437            let builder = PyLiveNode::py_builder(
2438                "TEST".to_string(),
2439                TraderId::from("TESTER-001"),
2440                Environment::Sandbox,
2441            )
2442            .unwrap()
2443            .py_with_msgbus_config(MessageBusConfig {
2444                external_streams: Some(vec!["external-stream".to_string()]),
2445                ..Default::default()
2446            })
2447            .unwrap()
2448            .py_with_external_msgbus_factory(factory)
2449            .unwrap();
2450
2451            let node = builder.py_build().unwrap();
2452            let consumed_error = builder
2453                .py_with_msgbus_config(MessageBusConfig::default())
2454                .unwrap_err();
2455
2456            assert!(!node.node_mut().unwrap().is_running());
2457            assert_eq!(TEST_MSGBUS_FACTORY_CALLS.load(Ordering::SeqCst), 1);
2458            assert_eq!(
2459                consumed_error.to_string(),
2460                "RuntimeError: Builder already consumed"
2461            );
2462            assert_eq!(
2463                builder.__repr__(),
2464                "PyLiveNodeBuilder { inner: RefCell { value: None } }"
2465            );
2466            get_message_bus().borrow_mut().dispose();
2467        });
2468    }
2469
2470    static TEST_CACHE_DATABASE_FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
2471
2472    #[derive(Debug, Clone)]
2473    #[pyo3::pyclass(name = "TestCacheDatabaseFactory", from_py_object)]
2474    struct TestCacheDatabaseFactory {
2475        database: Arc<parking_lot::Mutex<Option<TestCacheDatabase>>>,
2476        instance_id: Arc<parking_lot::Mutex<Option<UUID4>>>,
2477    }
2478
2479    impl TestCacheDatabaseFactory {
2480        fn new(database: Option<TestCacheDatabase>) -> Self {
2481            Self {
2482                database: Arc::new(parking_lot::Mutex::new(database)),
2483                instance_id: Arc::new(parking_lot::Mutex::new(None)),
2484            }
2485        }
2486
2487        fn received_instance_id(&self) -> Option<UUID4> {
2488            *self.instance_id.lock()
2489        }
2490    }
2491
2492    #[async_trait]
2493    impl CacheDatabaseFactory for TestCacheDatabaseFactory {
2494        async fn create(
2495            &self,
2496            trader_id: TraderId,
2497            instance_id: UUID4,
2498            config: CacheConfig,
2499        ) -> anyhow::Result<Box<dyn CacheDatabaseAdapter>> {
2500            TEST_CACHE_DATABASE_FACTORY_CALLS.fetch_add(1, Ordering::SeqCst);
2501            *self.instance_id.lock() = Some(instance_id);
2502
2503            anyhow::ensure!(
2504                trader_id == TraderId::from("TESTER-001"),
2505                "unexpected trader ID: {trader_id}"
2506            );
2507            anyhow::ensure!(
2508                config.buffer_interval_ms == Some(25),
2509                "unexpected buffer interval: {:?}",
2510                config.buffer_interval_ms
2511            );
2512
2513            let database = self
2514                .database
2515                .lock()
2516                .take()
2517                .ok_or_else(|| anyhow::anyhow!("Test cache database unavailable"))?;
2518            Ok(Box::new(database))
2519        }
2520    }
2521
2522    #[expect(clippy::needless_pass_by_value)]
2523    fn extract_test_cache_database_factory(
2524        py: Python<'_>,
2525        factory: Py<pyo3::PyAny>,
2526    ) -> pyo3::PyResult<Box<dyn CacheDatabaseFactory>> {
2527        Ok(Box::new(factory.extract::<TestCacheDatabaseFactory>(py)?))
2528    }
2529
2530    fn state_factory_builder(factory: &TestCacheDatabaseFactory) -> PyLiveNodeBuilder {
2531        Python::attach(|py| {
2532            PyLiveNode::py_builder(
2533                "TEST".to_string(),
2534                TraderId::from("TESTER-001"),
2535                Environment::Sandbox,
2536            )
2537            .unwrap()
2538            .py_with_cache_config(CacheConfig {
2539                buffer_interval_ms: Some(25),
2540                ..Default::default()
2541            })
2542            .unwrap()
2543            .py_with_cache_database_factory(Py::new(py, factory.clone()).unwrap().into_any())
2544            .unwrap()
2545            .py_with_reconciliation(false)
2546            .unwrap()
2547            .py_with_timeout_connection(0)
2548            .unwrap()
2549            .py_with_timeout_reconciliation(0)
2550            .unwrap()
2551            .py_with_timeout_portfolio(0)
2552            .unwrap()
2553            .py_with_timeout_disconnection_secs(0)
2554            .unwrap()
2555            .py_with_delay_post_stop_secs(0)
2556            .unwrap()
2557            .py_with_delay_shutdown_secs(0)
2558            .unwrap()
2559        })
2560    }
2561
2562    #[tokio::test]
2563    async fn test_python_builder_installs_cache_database_factory_on_start() {
2564        TEST_CACHE_DATABASE_FACTORY_CALLS.store(0, Ordering::SeqCst);
2565        get_global_cache_database_factory_registry()
2566            .register(
2567                "TestCacheDatabaseFactory".to_string(),
2568                extract_test_cache_database_factory,
2569            )
2570            .unwrap();
2571        Python::initialize();
2572
2573        let (database, control) = TestCacheDatabaseControl::create();
2574        let actor_id = ActorId::from("PY-CACHE-FACTORY-ACTOR");
2575        let actor_state = IndexMap::from([("loaded".to_string(), b"value".to_vec())]);
2576        control.set_actor_state(actor_id, &actor_state);
2577        let factory = TestCacheDatabaseFactory::new(Some(database));
2578
2579        let node = state_factory_builder(&factory).py_build().unwrap();
2580
2581        assert_eq!(TEST_CACHE_DATABASE_FACTORY_CALLS.load(Ordering::SeqCst), 0);
2582        assert!(
2583            !node
2584                .node_mut()
2585                .unwrap()
2586                .kernel()
2587                .cache()
2588                .borrow()
2589                .has_backing()
2590        );
2591
2592        node.node_mut().unwrap().start().await.unwrap();
2593
2594        assert_eq!(TEST_CACHE_DATABASE_FACTORY_CALLS.load(Ordering::SeqCst), 1);
2595        assert_eq!(
2596            factory.received_instance_id(),
2597            Some(node.node_mut().unwrap().instance_id())
2598        );
2599        assert!(
2600            node.node_mut()
2601                .unwrap()
2602                .kernel()
2603                .cache()
2604                .borrow()
2605                .has_backing()
2606        );
2607        assert_eq!(
2608            node.node_mut()
2609                .unwrap()
2610                .kernel()
2611                .cache()
2612                .borrow()
2613                .load_actor_state(&actor_id)
2614                .unwrap(),
2615            Some(actor_state)
2616        );
2617
2618        node.node_mut().unwrap().stop().await.unwrap();
2619        node.node_mut().unwrap().dispose();
2620        get_message_bus().borrow_mut().dispose();
2621    }
2622
2623    #[tokio::test]
2624    async fn test_python_builder_propagates_cache_database_factory_error_on_start() {
2625        get_global_cache_database_factory_registry()
2626            .register(
2627                "TestCacheDatabaseFactory".to_string(),
2628                extract_test_cache_database_factory,
2629            )
2630            .unwrap();
2631        Python::initialize();
2632
2633        let factory = TestCacheDatabaseFactory::new(None);
2634        let node = state_factory_builder(&factory).py_build().unwrap();
2635
2636        let error = node.node_mut().unwrap().start().await.unwrap_err();
2637
2638        assert_eq!(
2639            format!("{error:#}"),
2640            "failed to create cache database backing: Test cache database unavailable"
2641        );
2642        assert!(
2643            !node
2644                .node_mut()
2645                .unwrap()
2646                .kernel()
2647                .cache()
2648                .borrow()
2649                .has_backing()
2650        );
2651
2652        let retry_error = node.node_mut().unwrap().start().await.unwrap_err();
2653
2654        assert_eq!(
2655            format!("{retry_error:#}"),
2656            "failed to create cache database backing: Test cache database unavailable"
2657        );
2658        assert!(
2659            !node
2660                .node_mut()
2661                .unwrap()
2662                .kernel()
2663                .cache()
2664                .borrow()
2665                .has_backing()
2666        );
2667
2668        node.node_mut().unwrap().dispose();
2669        get_message_bus().borrow_mut().dispose();
2670    }
2671
2672    #[rstest]
2673    fn test_python_builder_rejects_unregistered_cache_database_factory() {
2674        Python::initialize();
2675
2676        Python::attach(|py| {
2677            let factory = PyDict::new(py).unbind().into_any();
2678            let builder = PyLiveNode::py_builder(
2679                "TEST".to_string(),
2680                TraderId::from("TESTER-001"),
2681                Environment::Sandbox,
2682            )
2683            .unwrap();
2684
2685            let error = builder.py_with_cache_database_factory(factory).unwrap_err();
2686
2687            assert_eq!(
2688                error.to_string(),
2689                "NotImplementedError: No cache database factory extractor registered for 'dict'"
2690            );
2691            builder
2692                .py_with_cache_config(CacheConfig::default())
2693                .unwrap();
2694        });
2695    }
2696
2697    #[rstest]
2698    fn test_python_builder_restores_state_after_factory_type_reentry() {
2699        Python::initialize();
2700
2701        Python::attach(|py| {
2702            let builder = Py::new(
2703                py,
2704                PyLiveNode::py_builder(
2705                    "TEST".to_string(),
2706                    TraderId::from("TESTER-001"),
2707                    Environment::Sandbox,
2708                )
2709                .unwrap(),
2710            )
2711            .unwrap();
2712            let locals = PyDict::new(py);
2713            locals.set_item("builder", &builder).unwrap();
2714            py.run(
2715                pyo3::ffi::c_str!(
2716                    "class ReentrantFactory:\n    def __getattribute__(self, name):\n        if name == '__class__':\n            builder.with_load_state(True)\n        return object.__getattribute__(self, name)\n\nfactory = ReentrantFactory()"
2717                ),
2718                Some(&locals),
2719                None,
2720            )
2721            .unwrap();
2722            let factory = locals.get_item("factory").unwrap();
2723
2724            let error = builder
2725                .call_method1(py, "with_cache_database_factory", (factory,))
2726                .unwrap_err();
2727
2728            assert_eq!(
2729                error.to_string(),
2730                format!("RuntimeError: {BUILDER_OPERATION_IN_PROGRESS}")
2731            );
2732            let builder_ref = builder.borrow(py);
2733            let state = builder_ref
2734                .state
2735                .replace(PyLiveNodeBuilderState::InProgress);
2736            let is_ready = matches!(&state, PyLiveNodeBuilderState::Ready(_));
2737            builder_ref.state.set(state);
2738            assert!(is_ready);
2739            locals.call_method0("clear").unwrap();
2740        });
2741    }
2742
2743    #[rstest]
2744    fn test_python_builder_rejects_unregistered_external_msgbus_factory() {
2745        Python::initialize();
2746
2747        Python::attach(|py| {
2748            let factory = PyDict::new(py).unbind().into_any();
2749            let builder = PyLiveNode::py_builder(
2750                "TEST".to_string(),
2751                TraderId::from("TESTER-001"),
2752                Environment::Sandbox,
2753            )
2754            .unwrap();
2755
2756            let error = builder
2757                .py_with_external_msgbus_factory(factory)
2758                .unwrap_err();
2759
2760            assert_eq!(
2761                error.to_string(),
2762                "NotImplementedError: No message bus factory extractor registered for 'dict'"
2763            );
2764            builder
2765                .py_with_msgbus_config(MessageBusConfig::default())
2766                .unwrap();
2767        });
2768    }
2769
2770    #[derive(Debug)]
2771    struct ShutdownCancelStrategy {
2772        core: StrategyCore,
2773        instrument_id: InstrumentId,
2774    }
2775
2776    impl ShutdownCancelStrategy {
2777        fn new(instrument_id: InstrumentId) -> Self {
2778            Self {
2779                core: StrategyCore::new(StrategyConfig {
2780                    strategy_id: Some(StrategyId::from("SHUTDOWN-CANCEL-001")),
2781                    ..Default::default()
2782                }),
2783                instrument_id,
2784            }
2785        }
2786    }
2787
2788    nautilus_strategy!(ShutdownCancelStrategy);
2789
2790    impl DataActor for ShutdownCancelStrategy {
2791        fn on_stop(&mut self) -> anyhow::Result<()> {
2792            get_trading_cmd_sender().execute(TradingCommandMessage::new(
2793                MessagingSwitchboard::exec_engine_execute(),
2794                TradingCommand::CancelAllOrders(CancelAllOrders::new(
2795                    TraderId::from("TESTER-001"),
2796                    None,
2797                    StrategyId::from("SHUTDOWN-CANCEL-001"),
2798                    self.instrument_id,
2799                    None,
2800                    UUID4::new(),
2801                    UnixNanos::default(),
2802                    None,
2803                    None,
2804                )),
2805            ));
2806            Ok(())
2807        }
2808    }
2809    #[derive(Debug, Default)]
2810    struct TestDataClientConfig;
2811
2812    impl ClientConfig for TestDataClientConfig {
2813        fn as_any(&self) -> &dyn Any {
2814            self
2815        }
2816    }
2817
2818    #[derive(Debug)]
2819    #[expect(
2820        clippy::struct_field_names,
2821        reason = "test counters intentionally share the count postfix"
2822    )]
2823    struct TestHistoricalBarsDataClientFactory {
2824        request_count: Arc<AtomicUsize>,
2825        response_sent_count: Arc<AtomicUsize>,
2826        handler_visible_count: Arc<AtomicUsize>,
2827    }
2828
2829    impl TestHistoricalBarsDataClientFactory {
2830        fn new(
2831            request_count: Arc<AtomicUsize>,
2832            response_sent_count: Arc<AtomicUsize>,
2833            handler_visible_count: Arc<AtomicUsize>,
2834        ) -> Self {
2835            Self {
2836                request_count,
2837                response_sent_count,
2838                handler_visible_count,
2839            }
2840        }
2841    }
2842
2843    impl DataClientFactory for TestHistoricalBarsDataClientFactory {
2844        fn create(
2845            &self,
2846            name: &str,
2847            _config: &dyn ClientConfig,
2848            _cache: CacheView,
2849            _clock: Rc<RefCell<dyn Clock>>,
2850        ) -> anyhow::Result<Box<dyn DataClient>> {
2851            Ok(Box::new(TestHistoricalBarsDataClient::new(
2852                ClientId::from(name),
2853                Venue::from("SIM"),
2854                self.request_count.clone(),
2855                self.response_sent_count.clone(),
2856                self.handler_visible_count.clone(),
2857            )))
2858        }
2859
2860        fn name(&self) -> &'static str {
2861            "TEST_DATA"
2862        }
2863
2864        fn config_type(&self) -> &'static str {
2865            "TestDataClientConfig"
2866        }
2867    }
2868
2869    #[derive(Debug)]
2870    struct TestDisconnectFailureDataClientFactory {
2871        dispose_count: Arc<AtomicUsize>,
2872    }
2873
2874    impl TestDisconnectFailureDataClientFactory {
2875        fn new(dispose_count: Arc<AtomicUsize>) -> Self {
2876            Self { dispose_count }
2877        }
2878    }
2879
2880    impl DataClientFactory for TestDisconnectFailureDataClientFactory {
2881        fn create(
2882            &self,
2883            name: &str,
2884            _config: &dyn ClientConfig,
2885            _cache: CacheView,
2886            _clock: Rc<RefCell<dyn Clock>>,
2887        ) -> anyhow::Result<Box<dyn DataClient>> {
2888            Ok(Box::new(TestDisconnectFailureDataClient::new(
2889                ClientId::from(name),
2890                Venue::from("SIM"),
2891                self.dispose_count.clone(),
2892            )))
2893        }
2894
2895        fn name(&self) -> &'static str {
2896            "TEST_DISCONNECT_FAILURE"
2897        }
2898
2899        fn config_type(&self) -> &'static str {
2900            "TestDataClientConfig"
2901        }
2902    }
2903
2904    #[derive(Debug)]
2905    struct TestDisconnectFailureDataClient {
2906        client_id: ClientId,
2907        venue: Venue,
2908        connected: Arc<AtomicBool>,
2909        dispose_count: Arc<AtomicUsize>,
2910    }
2911
2912    impl TestDisconnectFailureDataClient {
2913        fn new(client_id: ClientId, venue: Venue, dispose_count: Arc<AtomicUsize>) -> Self {
2914            Self {
2915                client_id,
2916                venue,
2917                connected: Arc::new(AtomicBool::new(false)),
2918                dispose_count,
2919            }
2920        }
2921    }
2922
2923    #[async_trait(?Send)]
2924    impl DataClient for TestDisconnectFailureDataClient {
2925        fn client_id(&self) -> ClientId {
2926            self.client_id
2927        }
2928
2929        fn venue(&self) -> Option<Venue> {
2930            Some(self.venue)
2931        }
2932
2933        fn start(&mut self) -> anyhow::Result<()> {
2934            Ok(())
2935        }
2936
2937        fn stop(&mut self) -> anyhow::Result<()> {
2938            Ok(())
2939        }
2940
2941        fn reset(&mut self) -> anyhow::Result<()> {
2942            Ok(())
2943        }
2944
2945        fn dispose(&mut self) -> anyhow::Result<()> {
2946            self.dispose_count.fetch_add(1, Ordering::Relaxed);
2947            Ok(())
2948        }
2949
2950        fn is_connected(&self) -> bool {
2951            self.connected.load(Ordering::Relaxed)
2952        }
2953
2954        fn is_disconnected(&self) -> bool {
2955            !self.is_connected()
2956        }
2957
2958        async fn connect(&mut self) -> anyhow::Result<()> {
2959            self.connected.store(true, Ordering::Relaxed);
2960            Ok(())
2961        }
2962
2963        async fn disconnect(&mut self) -> anyhow::Result<()> {
2964            self.connected.store(false, Ordering::Relaxed);
2965            anyhow::bail!("test disconnect failed")
2966        }
2967    }
2968
2969    struct VenueLessDataClient {
2970        client_id: ClientId,
2971    }
2972
2973    impl VenueLessDataClient {
2974        fn new(client_id: ClientId) -> Self {
2975            Self { client_id }
2976        }
2977    }
2978
2979    #[async_trait(?Send)]
2980    impl DataClient for VenueLessDataClient {
2981        fn client_id(&self) -> ClientId {
2982            self.client_id
2983        }
2984
2985        fn venue(&self) -> Option<Venue> {
2986            None
2987        }
2988
2989        fn start(&mut self) -> anyhow::Result<()> {
2990            Ok(())
2991        }
2992
2993        fn stop(&mut self) -> anyhow::Result<()> {
2994            Ok(())
2995        }
2996
2997        fn reset(&mut self) -> anyhow::Result<()> {
2998            Ok(())
2999        }
3000
3001        fn dispose(&mut self) -> anyhow::Result<()> {
3002            Ok(())
3003        }
3004
3005        fn is_connected(&self) -> bool {
3006            true
3007        }
3008
3009        fn is_disconnected(&self) -> bool {
3010            false
3011        }
3012
3013        async fn connect(&mut self) -> anyhow::Result<()> {
3014            Ok(())
3015        }
3016
3017        async fn disconnect(&mut self) -> anyhow::Result<()> {
3018            Ok(())
3019        }
3020    }
3021
3022    #[derive(Debug)]
3023    struct VenueLessDataClientFactory;
3024
3025    impl DataClientFactory for VenueLessDataClientFactory {
3026        fn create(
3027            &self,
3028            name: &str,
3029            _config: &dyn ClientConfig,
3030            _cache: CacheView,
3031            _clock: Rc<RefCell<dyn Clock>>,
3032        ) -> anyhow::Result<Box<dyn DataClient>> {
3033            Ok(Box::new(VenueLessDataClient::new(ClientId::from(name))))
3034        }
3035
3036        fn name(&self) -> &'static str {
3037            "VENUE_LESS"
3038        }
3039
3040        fn config_type(&self) -> &'static str {
3041            "TestDataClientConfig"
3042        }
3043    }
3044
3045    #[expect(
3046        clippy::unnecessary_wraps,
3047        reason = "signature must match the factory extractor function pointer"
3048    )]
3049    fn extract_reentrant_data_client_factory(
3050        _py: Python<'_>,
3051        _factory: Py<pyo3::PyAny>,
3052    ) -> pyo3::PyResult<Box<dyn DataClientFactory>> {
3053        Ok(Box::new(VenueLessDataClientFactory))
3054    }
3055
3056    #[expect(
3057        clippy::unnecessary_wraps,
3058        reason = "signature must match the config extractor function pointer"
3059    )]
3060    fn extract_reentrant_data_client_config(
3061        _py: Python<'_>,
3062        _config: Py<pyo3::PyAny>,
3063    ) -> pyo3::PyResult<Box<dyn ClientConfig>> {
3064        Ok(Box::new(TestDataClientConfig))
3065    }
3066
3067    #[rstest]
3068    fn test_python_builder_blocks_factory_name_reentry() {
3069        get_global_pyo3_registry()
3070            .register_factory_extractor(
3071                "REENTRANT_DATA".to_string(),
3072                extract_reentrant_data_client_factory,
3073            )
3074            .unwrap();
3075        get_global_pyo3_registry()
3076            .register_config_extractor(
3077                "ReentrantDataClientConfig".to_string(),
3078                extract_reentrant_data_client_config,
3079            )
3080            .unwrap();
3081        Python::initialize();
3082
3083        Python::attach(|py| {
3084            let builder = Py::new(
3085                py,
3086                PyLiveNode::py_builder(
3087                    "TEST".to_string(),
3088                    TraderId::from("TESTER-001"),
3089                    Environment::Sandbox,
3090                )
3091                .unwrap(),
3092            )
3093            .unwrap();
3094            let locals = PyDict::new(py);
3095            locals.set_item("builder", &builder).unwrap();
3096            py.run(
3097                pyo3::ffi::c_str!(
3098                    "class ReentrantDataClientFactory:\n    def __init__(self):\n        self.reprs = []\n        self.results = []\n\n    def name(self):\n        self.reprs.append(repr(builder))\n        try:\n            builder.with_save_state(True)\n        except RuntimeError as e:\n            self.results.append((type(e).__name__, str(e)))\n        return 'REENTRANT_DATA'\n\nclass ReentrantDataClientConfig:\n    pass\n\nfactory = ReentrantDataClientFactory()\nconfig = ReentrantDataClientConfig()"
3099                ),
3100                Some(&locals),
3101                None,
3102            )
3103            .unwrap();
3104            let factory = locals.get_item("factory").unwrap();
3105            let config = locals.get_item("config").unwrap();
3106
3107            builder
3108                .call_method1(py, "add_data_client", (py.None(), &factory, &config))
3109                .unwrap();
3110
3111            let results = factory
3112                .getattr("results")
3113                .unwrap()
3114                .extract::<Vec<(String, String)>>()
3115                .unwrap();
3116            assert_eq!(
3117                results,
3118                vec![
3119                    (
3120                        "RuntimeError".to_string(),
3121                        BUILDER_OPERATION_IN_PROGRESS.to_string(),
3122                    ),
3123                    (
3124                        "RuntimeError".to_string(),
3125                        BUILDER_OPERATION_IN_PROGRESS.to_string(),
3126                    ),
3127                ]
3128            );
3129            assert_eq!(
3130                factory
3131                    .getattr("reprs")
3132                    .unwrap()
3133                    .extract::<Vec<String>>()
3134                    .unwrap(),
3135                vec!["PyLiveNodeBuilder { inner: <operation active> }"; 2]
3136            );
3137            let builder_ref = builder.borrow(py);
3138            let state = builder_ref
3139                .state
3140                .replace(PyLiveNodeBuilderState::InProgress);
3141            let is_ready = matches!(&state, PyLiveNodeBuilderState::Ready(_));
3142            builder_ref.state.set(state);
3143            assert!(is_ready);
3144            drop(builder_ref);
3145
3146            let duplicate_error = builder
3147                .call_method1(py, "add_data_client", (py.None(), factory, config))
3148                .unwrap_err();
3149
3150            assert_eq!(
3151                duplicate_error.to_string(),
3152                "RuntimeError: Failed to add data client: Data client 'REENTRANT_DATA' is already registered"
3153            );
3154            let builder_ref = builder.borrow(py);
3155            let state = builder_ref
3156                .state
3157                .replace(PyLiveNodeBuilderState::InProgress);
3158            let is_consumed = matches!(&state, PyLiveNodeBuilderState::Consumed);
3159            builder_ref.state.set(state);
3160            assert!(is_consumed);
3161            locals.call_method0("clear").unwrap();
3162        });
3163    }
3164
3165    #[derive(Debug)]
3166    struct TestHistoricalBarsDataClient {
3167        client_id: ClientId,
3168        venue: Venue,
3169        connected: Arc<AtomicBool>,
3170        request_count: Arc<AtomicUsize>,
3171        response_sent_count: Arc<AtomicUsize>,
3172        handler_visible_count: Arc<AtomicUsize>,
3173    }
3174
3175    impl TestHistoricalBarsDataClient {
3176        fn new(
3177            client_id: ClientId,
3178            venue: Venue,
3179            request_count: Arc<AtomicUsize>,
3180            response_sent_count: Arc<AtomicUsize>,
3181            handler_visible_count: Arc<AtomicUsize>,
3182        ) -> Self {
3183            Self {
3184                client_id,
3185                venue,
3186                connected: Arc::new(AtomicBool::new(false)),
3187                request_count,
3188                response_sent_count,
3189                handler_visible_count,
3190            }
3191        }
3192
3193        fn make_bar(bar_type: BarType) -> Bar {
3194            Bar::new(
3195                bar_type,
3196                Price::from("1.0000"),
3197                Price::from("1.1000"),
3198                Price::from("0.9000"),
3199                Price::from("1.0500"),
3200                Quantity::from("1000"),
3201                UnixNanos::from(1_700_000_000_000_000_000u64),
3202                UnixNanos::from(1_700_000_000_000_000_001u64),
3203            )
3204        }
3205    }
3206
3207    #[async_trait(?Send)]
3208    impl DataClient for TestHistoricalBarsDataClient {
3209        fn client_id(&self) -> ClientId {
3210            self.client_id
3211        }
3212
3213        fn venue(&self) -> Option<Venue> {
3214            Some(self.venue)
3215        }
3216
3217        fn start(&mut self) -> anyhow::Result<()> {
3218            Ok(())
3219        }
3220
3221        fn stop(&mut self) -> anyhow::Result<()> {
3222            Ok(())
3223        }
3224
3225        fn reset(&mut self) -> anyhow::Result<()> {
3226            Ok(())
3227        }
3228
3229        fn dispose(&mut self) -> anyhow::Result<()> {
3230            Ok(())
3231        }
3232
3233        fn is_connected(&self) -> bool {
3234            self.connected.load(Ordering::Relaxed)
3235        }
3236
3237        fn is_disconnected(&self) -> bool {
3238            !self.is_connected()
3239        }
3240
3241        async fn connect(&mut self) -> anyhow::Result<()> {
3242            self.connected.store(true, Ordering::Relaxed);
3243            Ok(())
3244        }
3245
3246        async fn disconnect(&mut self) -> anyhow::Result<()> {
3247            self.connected.store(false, Ordering::Relaxed);
3248            Ok(())
3249        }
3250
3251        fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
3252            self.request_count.fetch_add(1, Ordering::Relaxed);
3253
3254            if get_message_bus()
3255                .borrow()
3256                .get_response_handler(&request.request_id)
3257                .is_some()
3258            {
3259                self.handler_visible_count.fetch_add(1, Ordering::Relaxed);
3260            }
3261
3262            let sender = get_data_event_sender();
3263            let client_id = self.client_id;
3264            let response_sent_count = self.response_sent_count.clone();
3265            let response = BarsResponse::new(
3266                request.request_id,
3267                client_id,
3268                request.bar_type,
3269                vec![Self::make_bar(request.bar_type)],
3270                None,
3271                None,
3272                UnixNanos::from(1_700_000_000_000_000_002u64),
3273                None,
3274            );
3275
3276            tokio::spawn(async move {
3277                tokio::time::sleep(Duration::from_millis(10)).await;
3278                response_sent_count.fetch_add(1, Ordering::Relaxed);
3279                sender
3280                    .send(DataEvent::Response(DataResponse::Bars(response)))
3281                    .expect("test bars response should send");
3282            });
3283
3284            Ok(())
3285        }
3286    }
3287
3288    fn install_tracking_strategy_module(py: Python<'_>, module_name: &str) {
3289        let module = PyModule::new(py, module_name).expect("test module should create");
3290        module
3291            .setattr("Strategy", py.get_type::<PyStrategy>())
3292            .expect("Strategy type should bind");
3293        module
3294            .setattr("BarType", py.get_type::<BarType>())
3295            .expect("BarType type should bind");
3296        module
3297            .setattr("RESULTS", PyDict::new(py))
3298            .expect("RESULTS should bind");
3299
3300        let code = CString::new(
3301            r#"
3302RESULTS["on_start"] = 0
3303RESULTS["on_historical_bars"] = 0
3304RESULTS["historical_bar_count"] = 0
3305RESULTS["last_request_id"] = ""
3306
3307class HistoricalBarsStrategy(Strategy):
3308    def __init__(self):
3309        super().__init__()
3310        self.bar_type = BarType.from_str("AUDUSD.SIM-1-MINUTE-LAST-EXTERNAL")
3311
3312    def on_start(self):
3313        RESULTS["on_start"] += 1
3314        RESULTS["last_request_id"] = self.request_bars(self.bar_type)
3315
3316    def on_stop(self):
3317        pass
3318
3319    def on_historical_bars(self, bars):
3320        RESULTS["on_historical_bars"] += 1
3321        RESULTS["historical_bar_count"] += len(bars)
3322"#,
3323        )
3324        .expect("python test code should be valid CString");
3325
3326        py.run(code.as_c_str(), Some(&module.dict()), None)
3327            .expect("test strategy code should execute");
3328
3329        let sys_modules = py
3330            .import("sys")
3331            .expect("sys should import")
3332            .getattr("modules")
3333            .expect("sys.modules should exist");
3334        sys_modules
3335            .set_item(module_name, module)
3336            .expect("test strategy module should register");
3337    }
3338
3339    fn get_results(py: Python<'_>, module_name: &str) -> (usize, usize, usize) {
3340        let module = py
3341            .import(module_name)
3342            .expect("test strategy module should import");
3343        let results_obj = module.getattr("RESULTS").expect("RESULTS should exist");
3344        let results = results_obj
3345            .cast::<PyDict>()
3346            .expect("RESULTS should be a dict");
3347
3348        let on_start = results
3349            .get_item("on_start")
3350            .expect("on_start key should exist")
3351            .extract::<usize>()
3352            .expect("on_start should extract");
3353        let on_historical_bars = results
3354            .get_item("on_historical_bars")
3355            .expect("on_historical_bars key should exist")
3356            .extract::<usize>()
3357            .expect("on_historical_bars should extract");
3358        let historical_bar_count = results
3359            .get_item("historical_bar_count")
3360            .expect("historical_bar_count key should exist")
3361            .extract::<usize>()
3362            .expect("historical_bar_count should extract");
3363
3364        (on_start, on_historical_bars, historical_bar_count)
3365    }
3366
3367    fn install_timer_strategy_module(py: Python<'_>, module_name: &str) {
3368        let module = PyModule::new(py, module_name).expect("test module should create");
3369        module
3370            .setattr("Strategy", py.get_type::<PyStrategy>())
3371            .expect("Strategy type should bind");
3372        module
3373            .setattr("RESULTS", PyDict::new(py))
3374            .expect("RESULTS should bind");
3375
3376        let code = CString::new(
3377            r#"
3378RESULTS["on_start"] = 0
3379RESULTS["callback_timer_count"] = 0
3380RESULTS["default_timer_count"] = 0
3381RESULTS["callback_event_type"] = ""
3382RESULTS["default_event_type"] = ""
3383RESULTS["callback_event_name"] = ""
3384RESULTS["default_event_name"] = ""
3385
3386class LiveTimerStrategy(Strategy):
3387    def __init__(self):
3388        super().__init__()
3389
3390    def on_start(self):
3391        RESULTS["on_start"] += 1
3392        self.clock.set_timer_ns(
3393            "explicit_timer",
3394            1_000_000,
3395            callback=self._on_timer,
3396            fire_immediately=True,
3397        )
3398        self.clock.set_timer_ns(
3399            "default_timer",
3400            1_000_000,
3401            fire_immediately=True,
3402        )
3403
3404    def on_stop(self):
3405        pass
3406
3407    def _on_timer(self, event):
3408        RESULTS["callback_timer_count"] += 1
3409        RESULTS["callback_event_type"] = type(event).__name__
3410        RESULTS["callback_event_name"] = event.name
3411
3412    def on_time_event(self, event):
3413        RESULTS["default_timer_count"] += 1
3414        RESULTS["default_event_type"] = type(event).__name__
3415        RESULTS["default_event_name"] = event.name
3416"#,
3417        )
3418        .expect("python test code should be valid CString");
3419
3420        py.run(code.as_c_str(), Some(&module.dict()), None)
3421            .expect("test strategy code should execute");
3422
3423        let sys_modules = py
3424            .import("sys")
3425            .expect("sys should import")
3426            .getattr("modules")
3427            .expect("sys.modules should exist");
3428        sys_modules
3429            .set_item(module_name, module)
3430            .expect("test strategy module should register");
3431    }
3432
3433    fn install_claim_strategy_module(py: Python<'_>, module_name: &str) {
3434        let module = PyModule::new(py, module_name).expect("test module should create");
3435        module
3436            .setattr("Strategy", py.get_type::<PyStrategy>())
3437            .expect("Strategy type should bind");
3438
3439        let code = CString::new(
3440            "
3441class ClaimsConfig:
3442    def __init__(
3443        self,
3444        strategy_id=None,
3445        order_id_tag=None,
3446        external_order_claims=None,
3447        oms_type=None,
3448    ):
3449        self.strategy_id = strategy_id
3450        self.order_id_tag = order_id_tag
3451        self.external_order_claims = external_order_claims
3452        self.oms_type = oms_type
3453
3454class ClaimsStrategy(Strategy):
3455    def __init__(self, config):
3456        super().__init__(config)
3457",
3458        )
3459        .expect("python test code should be valid CString");
3460
3461        py.run(code.as_c_str(), Some(&module.dict()), None)
3462            .expect("test strategy code should execute");
3463
3464        let sys_modules = py
3465            .import("sys")
3466            .expect("sys should import")
3467            .getattr("modules")
3468            .expect("sys.modules should exist");
3469        sys_modules
3470            .set_item(module_name, module)
3471            .expect("test strategy module should register");
3472    }
3473
3474    #[derive(Debug)]
3475    struct TimerStrategyResults {
3476        on_start: usize,
3477        callback_timer_count: usize,
3478        default_timer_count: usize,
3479        callback_event_type: String,
3480        default_event_type: String,
3481        callback_event_name: String,
3482        default_event_name: String,
3483    }
3484
3485    fn get_timer_results(py: Python<'_>, module_name: &str) -> TimerStrategyResults {
3486        let module = py
3487            .import(module_name)
3488            .expect("test strategy module should import");
3489        let results_obj = module.getattr("RESULTS").expect("RESULTS should exist");
3490        let results = results_obj
3491            .cast::<PyDict>()
3492            .expect("RESULTS should be a dict");
3493
3494        TimerStrategyResults {
3495            on_start: results
3496                .get_item("on_start")
3497                .expect("on_start key should exist")
3498                .extract::<usize>()
3499                .expect("on_start should extract"),
3500            callback_timer_count: results
3501                .get_item("callback_timer_count")
3502                .expect("callback_timer_count key should exist")
3503                .extract::<usize>()
3504                .expect("callback_timer_count should extract"),
3505            default_timer_count: results
3506                .get_item("default_timer_count")
3507                .expect("default_timer_count key should exist")
3508                .extract::<usize>()
3509                .expect("default_timer_count should extract"),
3510            callback_event_type: results
3511                .get_item("callback_event_type")
3512                .expect("callback_event_type key should exist")
3513                .extract::<String>()
3514                .expect("callback_event_type should extract"),
3515            default_event_type: results
3516                .get_item("default_event_type")
3517                .expect("default_event_type key should exist")
3518                .extract::<String>()
3519                .expect("default_event_type should extract"),
3520            callback_event_name: results
3521                .get_item("callback_event_name")
3522                .expect("callback_event_name key should exist")
3523                .extract::<String>()
3524                .expect("callback_event_name should extract"),
3525            default_event_name: results
3526                .get_item("default_event_name")
3527                .expect("default_event_name key should exist")
3528                .extract::<String>()
3529                .expect("default_event_name should extract"),
3530        }
3531    }
3532
3533    #[cfg(feature = "examples")]
3534    #[rstest]
3535    #[case("CompositeMarketMaker")]
3536    #[case("DeltaNeutralVol")]
3537    #[case("EmaCross")]
3538    #[case("ExecTester")]
3539    #[case("GridMarketMaker")]
3540    #[case("HurstVpinDirectional")]
3541    fn test_builtin_strategy_register_accepts_supported_names(#[case] type_name: &str) {
3542        assert!(super::builtin_strategy_register(type_name).is_some());
3543    }
3544
3545    #[cfg(feature = "examples")]
3546    #[rstest]
3547    #[case("BookImbalanceActor")]
3548    #[case("DataTester")]
3549    fn test_builtin_actor_register_accepts_supported_names(#[case] type_name: &str) {
3550        assert!(super::builtin_actor_register(type_name).is_some());
3551    }
3552
3553    #[cfg(feature = "examples")]
3554    #[rstest]
3555    fn test_builtin_register_rejects_unknown_names() {
3556        assert!(super::builtin_strategy_register("UnknownStrategy").is_none());
3557        assert!(super::builtin_actor_register("UnknownActor").is_none());
3558    }
3559
3560    #[rstest]
3561    fn test_inspection_wrappers_share_kernel_state() {
3562        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
3563            .unwrap()
3564            .with_reconciliation(false)
3565            .build()
3566            .map(PyLiveNode::new)
3567            .unwrap();
3568
3569        let cache = node.py_cache().unwrap();
3570        let portfolio = node.py_portfolio().unwrap();
3571
3572        assert!(Rc::ptr_eq(
3573            &cache.cache_rc(),
3574            &node.node_mut().unwrap().kernel().cache
3575        ));
3576        assert!(Rc::ptr_eq(
3577            &portfolio.portfolio_rc(),
3578            &node.node_mut().unwrap().kernel().portfolio
3579        ));
3580    }
3581
3582    #[cfg(feature = "examples")]
3583    #[rstest]
3584    fn test_builtin_strategy_register_rejects_mismatched_config() {
3585        Python::initialize();
3586
3587        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
3588            .unwrap()
3589            .with_reconciliation(false)
3590            .build()
3591            .map(PyLiveNode::new)
3592            .unwrap();
3593
3594        Python::attach(|py| {
3595            let register = super::builtin_strategy_register("EmaCross").unwrap();
3596            let config = PyDict::new(py);
3597            let error = register(&mut node.node_mut().unwrap(), config.as_any()).unwrap_err();
3598
3599            assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
3600        });
3601    }
3602
3603    #[cfg(feature = "examples")]
3604    #[rstest]
3605    fn test_builtin_actor_register_rejects_mismatched_config() {
3606        Python::initialize();
3607
3608        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
3609            .unwrap()
3610            .with_reconciliation(false)
3611            .build()
3612            .map(PyLiveNode::new)
3613            .unwrap();
3614
3615        Python::attach(|py| {
3616            let register = super::builtin_actor_register("DataTester").unwrap();
3617            let config = PyDict::new(py);
3618            let error = register(&mut node.node_mut().unwrap(), config.as_any()).unwrap_err();
3619
3620            assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
3621        });
3622    }
3623
3624    #[rstest]
3625    #[case(ShutdownRunPath::Native)]
3626    #[case(ShutdownRunPath::PyO3)]
3627    fn test_native_and_python_shutdown_paths_drain_cancel_command(
3628        #[case] run_path: ShutdownRunPath,
3629    ) {
3630        Python::initialize();
3631
3632        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
3633            .unwrap()
3634            .with_reconciliation(false)
3635            .with_delay_post_stop_secs(1)
3636            .with_timeout_connection(1)
3637            .build()
3638            .map(PyLiveNode::new)
3639            .unwrap();
3640        node.node_mut()
3641            .unwrap()
3642            .add_strategy(ShutdownCancelStrategy::new(InstrumentId::from(
3643                "TEST.POLYMARKET",
3644            )))
3645            .unwrap();
3646
3647        let handle = node.node_mut().unwrap().handle();
3648        let stop_handle = handle.clone();
3649
3650        let stop_thread = thread::spawn(move || {
3651            let deadline = Instant::now() + Duration::from_secs(5);
3652            while !stop_handle.is_running() && Instant::now() < deadline {
3653                thread::sleep(Duration::from_millis(10));
3654            }
3655            stop_handle.stop();
3656        });
3657
3658        match run_path {
3659            ShutdownRunPath::Native => get_runtime()
3660                .block_on(node.node_mut().unwrap().run())
3661                .expect("native LiveNode run should stop cleanly"),
3662            ShutdownRunPath::PyO3 => Python::attach(|py| {
3663                super::run_live_node_detached(py, &mut node.node_mut().unwrap())
3664                    .expect("Python LiveNode run should stop cleanly");
3665            }),
3666        }
3667
3668        stop_thread.join().expect("stop thread should join");
3669        let metrics = handle.metrics_snapshot();
3670
3671        assert_eq!(metrics.exec_commands.dispatched, 1);
3672        assert_eq!(metrics.exec_commands.queue_depth, 0);
3673        assert!(!handle.is_running());
3674    }
3675
3676    #[rstest]
3677    fn test_run_live_node_detached_releases_gil() {
3678        Python::initialize();
3679
3680        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
3681            .unwrap()
3682            .with_reconciliation(false)
3683            .with_delay_post_stop_secs(0)
3684            .with_timeout_connection(1)
3685            .build()
3686            .map(PyLiveNode::new)
3687            .unwrap();
3688
3689        let handle = node.node_mut().unwrap().handle();
3690        let (gil_tx, gil_rx) = mpsc::channel();
3691        let acquired_before_stop = Arc::new(AtomicBool::new(false));
3692        let acquired_before_stop_for_thread = acquired_before_stop.clone();
3693
3694        let stop_thread = thread::spawn(move || {
3695            if gil_rx.recv_timeout(Duration::from_secs(1)).is_ok() {
3696                acquired_before_stop_for_thread.store(true, Ordering::SeqCst);
3697            }
3698            handle.stop();
3699        });
3700
3701        let gil_thread = thread::spawn(move || {
3702            Python::attach(|_| {});
3703            let _ = gil_tx.send(());
3704        });
3705
3706        Python::attach(|py| {
3707            super::run_live_node_detached(py, &mut node.node_mut().unwrap())
3708                .expect("node should run cleanly");
3709        });
3710
3711        stop_thread.join().expect("stop thread should join");
3712        gil_thread.join().expect("GIL thread should join");
3713
3714        assert!(
3715            acquired_before_stop.load(Ordering::SeqCst),
3716            "worker thread should acquire the GIL while LiveNode::run is blocked"
3717        );
3718    }
3719
3720    #[rstest]
3721    fn test_host_loop_waker_coalesces_redundant_wakes() {
3722        let (waker, receiver, _) = host_loop_waker_probe();
3723
3724        std::task::Wake::wake(waker.clone());
3725        let consuming_wake = receiver.recv_timeout(Duration::from_secs(1));
3726        std::task::Wake::wake_by_ref(&waker);
3727        let redundant_wake = receiver.try_recv();
3728
3729        assert!(matches!(
3730            consuming_wake,
3731            Ok(super::HostWakeSignal::Resume(7))
3732        ));
3733        assert!(matches!(redundant_wake, Err(mpsc::TryRecvError::Empty)));
3734    }
3735
3736    #[rstest]
3737    fn test_host_loop_waker_ignores_wake_after_close() {
3738        let (waker, receiver, handle) = host_loop_waker_probe();
3739        waker.control.close();
3740
3741        std::task::Wake::wake_by_ref(&waker);
3742
3743        assert!(matches!(
3744            receiver.recv().unwrap(),
3745            super::HostWakeSignal::Shutdown
3746        ));
3747        assert!(matches!(
3748            receiver.try_recv(),
3749            Err(mpsc::TryRecvError::Empty)
3750        ));
3751        assert!(!handle.should_stop());
3752    }
3753
3754    #[rstest]
3755    fn test_host_loop_waker_stops_node_when_pump_is_gone() {
3756        let (waker, receiver, handle) = host_loop_waker_probe();
3757        drop(receiver);
3758
3759        std::task::Wake::wake_by_ref(&waker);
3760
3761        assert!(!waker.control.active.load(Ordering::Acquire));
3762        assert!(handle.should_stop());
3763    }
3764
3765    fn host_loop_waker_probe() -> (
3766        Arc<super::HostLoopWaker>,
3767        mpsc::Receiver<super::HostWakeSignal>,
3768        crate::node::LiveNodeHandle,
3769    ) {
3770        let (sender, receiver) = mpsc::channel();
3771        let handle = crate::node::LiveNodeHandle::new();
3772        let control = Arc::new(super::HostWakeControl {
3773            sender,
3774            active: AtomicBool::new(true),
3775            handle: handle.clone(),
3776        });
3777        let waker = Arc::new(super::HostLoopWaker {
3778            generation: 7,
3779            scheduled: AtomicBool::new(false),
3780            control,
3781        });
3782
3783        (waker, receiver, handle)
3784    }
3785
3786    #[rstest]
3787    fn test_host_loop_waker_breaks_gil_dependency_lock_cycle() {
3788        Python::initialize();
3789
3790        let (waker, mut wake_pump, event_loop) = Python::attach(|py| {
3791            let event_loop = py
3792                .import("asyncio")
3793                .unwrap()
3794                .call_method0("new_event_loop")
3795                .unwrap();
3796            let state = Arc::new(super::RunWakeState::default());
3797            let wake_callback = Py::new(py, super::PyNodeRunWake { state })
3798                .unwrap()
3799                .into_any();
3800            let event_loop = event_loop.unbind();
3801            let wake_pump = super::HostWakePump::start(
3802                event_loop.clone_ref(py),
3803                wake_callback,
3804                crate::node::LiveNodeHandle::new(),
3805            )
3806            .unwrap();
3807            let waker = wake_pump.waker(1);
3808
3809            (waker, wake_pump, event_loop)
3810        });
3811        let (start_tx, start_rx) = mpsc::channel();
3812        let (locked_tx, locked_rx) = mpsc::channel();
3813        let dependency_lock = Arc::new(Mutex::new(()));
3814        let dependency_lock_for_thread = dependency_lock.clone();
3815        let wake_thread = thread::spawn(move || {
3816            let _guard = dependency_lock_for_thread.lock();
3817            locked_tx.send(()).unwrap();
3818            start_rx.recv().unwrap();
3819            waker.wake_by_ref();
3820        });
3821        locked_rx
3822            .recv_timeout(Duration::from_secs(1))
3823            .expect("wake thread should hold the dependency lock");
3824
3825        let dependency_released_while_gil_held = Python::attach(|_| {
3826            start_tx.send(()).unwrap();
3827            let deadline = Instant::now() + Duration::from_secs(1);
3828
3829            loop {
3830                match dependency_lock.try_lock() {
3831                    Some(_) => break true,
3832                    None if Instant::now() < deadline => {
3833                        thread::yield_now();
3834                    }
3835                    None => break false,
3836                }
3837            }
3838        });
3839        wake_thread.join().unwrap();
3840        Python::attach(|py| {
3841            wake_pump.close();
3842            wake_pump.join(Some(py));
3843            event_loop.bind(py).call_method0("close").unwrap();
3844        });
3845
3846        assert!(dependency_released_while_gil_held);
3847    }
3848
3849    #[rstest]
3850    fn test_run_wake_state_ignores_stale_generation() {
3851        Python::initialize();
3852
3853        Python::attach(|py| {
3854            let event_loop = py
3855                .import("asyncio")
3856                .unwrap()
3857                .call_method0("new_event_loop")
3858                .unwrap();
3859            let future = event_loop.call_method0("create_future").unwrap();
3860            let state = super::RunWakeState::default();
3861            state.suspend(2, future.clone().unbind());
3862
3863            state.resume(py, 1).unwrap();
3864            let done_after_stale: bool = future.call_method0("done").unwrap().extract().unwrap();
3865            state.resume(py, 2).unwrap();
3866            let done_after_current: bool = future.call_method0("done").unwrap().extract().unwrap();
3867            event_loop.call_method0("close").unwrap();
3868
3869            assert!(!done_after_stale);
3870            assert!(done_after_current);
3871        });
3872    }
3873
3874    #[rstest]
3875    fn test_host_wake_pump_resumes_current_suspension() {
3876        Python::initialize();
3877
3878        Python::attach(|py| {
3879            let asyncio = py.import("asyncio").unwrap();
3880            let event_loop = asyncio.call_method0("new_event_loop").unwrap();
3881            let future = event_loop.call_method0("create_future").unwrap();
3882            let state = Arc::new(super::RunWakeState::default());
3883            state.suspend(7, future.clone().unbind());
3884            let wake_callback = Py::new(py, super::PyNodeRunWake { state })
3885                .unwrap()
3886                .into_any();
3887            let mut wake_pump = super::HostWakePump::start(
3888                event_loop.clone().unbind(),
3889                wake_callback,
3890                crate::node::LiveNodeHandle::new(),
3891            )
3892            .unwrap();
3893
3894            wake_pump.waker(7).wake_by_ref();
3895            let wait_for = asyncio.call_method1("wait_for", (&future, 1.0)).unwrap();
3896            let result = event_loop
3897                .call_method1("run_until_complete", (wait_for,))
3898                .unwrap();
3899            wake_pump.close();
3900            wake_pump.join(Some(py));
3901            event_loop.call_method0("close").unwrap();
3902
3903            assert!(result.is_none());
3904        });
3905    }
3906
3907    #[rstest]
3908    fn test_host_wake_pump_stops_node_when_loop_is_closed() {
3909        Python::initialize();
3910
3911        Python::attach(|py| {
3912            let event_loop = py
3913                .import("asyncio")
3914                .unwrap()
3915                .call_method0("new_event_loop")
3916                .unwrap();
3917            event_loop.call_method0("close").unwrap();
3918            let state = Arc::new(super::RunWakeState::default());
3919            let wake_callback = Py::new(py, super::PyNodeRunWake { state })
3920                .unwrap()
3921                .into_any();
3922            let handle = crate::node::LiveNodeHandle::new();
3923            let mut wake_pump =
3924                super::HostWakePump::start(event_loop.unbind(), wake_callback, handle.clone())
3925                    .unwrap();
3926
3927            wake_pump.waker(1).wake_by_ref();
3928            wake_pump.join(Some(py));
3929
3930            assert!(handle.should_stop());
3931        });
3932    }
3933
3934    #[rstest]
3935    fn test_hosted_run_completes_after_stop() {
3936        Python::initialize();
3937
3938        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
3939            .unwrap()
3940            .with_reconciliation(false)
3941            .with_delay_post_stop_secs(0)
3942            .with_delay_shutdown_secs(0)
3943            .with_timeout_connection(0)
3944            .with_timeout_disconnection_secs(0)
3945            .build()
3946            .map(PyLiveNode::new)
3947            .unwrap();
3948
3949        Python::attach(|py| {
3950            let locals = PyDict::new(py);
3951            let py_node = Py::new(py, node).unwrap();
3952            locals.set_item("node", &py_node).unwrap();
3953            py.run(
3954                pyo3::ffi::c_str!(
3955                    "import asyncio\n\nasync def run_node():\n    handle = node.handle()\n    asyncio.get_running_loop().call_soon(handle.stop)\n    await asyncio.wait_for(node.run_async(), timeout=5)\n\nasyncio.run(run_node())"
3956                ),
3957                Some(&locals),
3958                None,
3959            )
3960            .unwrap();
3961
3962            let node = py_node.borrow(py);
3963            assert!(!node.is_consumed());
3964            assert_eq!(
3965                node.node().unwrap().state(),
3966                crate::node::NodeState::Stopped
3967            );
3968        });
3969        get_message_bus().borrow_mut().dispose();
3970    }
3971
3972    #[rstest]
3973    fn test_build_routes_venue_less_data_client_with_venue_routing() {
3974        Python::initialize();
3975
3976        let routing = RoutingConfig::builder()
3977            .venues(vec!["IBIS".to_string()])
3978            .build();
3979        let node = LiveNode::builder(TraderId::from("TEST-001"), Environment::Sandbox)
3980            .unwrap()
3981            .with_reconciliation(false)
3982            .with_timeout_connection(1)
3983            .add_data_client_with_routing(
3984                Some("IB".to_string()),
3985                Box::new(VenueLessDataClientFactory),
3986                Box::new(TestDataClientConfig),
3987                routing,
3988            )
3989            .unwrap()
3990            .build();
3991
3992        assert!(node.is_ok(), "build should succeed: {:?}", node.err());
3993    }
3994
3995    #[rstest]
3996    fn test_build_routes_venue_less_data_client_with_default_and_venues() {
3997        Python::initialize();
3998
3999        let routing = RoutingConfig::builder()
4000            .default(true)
4001            .venues(vec!["IBIS".to_string()])
4002            .build();
4003        let node = LiveNode::builder(TraderId::from("TEST-001"), Environment::Sandbox)
4004            .unwrap()
4005            .with_reconciliation(false)
4006            .with_timeout_connection(1)
4007            .add_data_client_with_routing(
4008                Some("IB".to_string()),
4009                Box::new(VenueLessDataClientFactory),
4010                Box::new(TestDataClientConfig),
4011                routing,
4012            )
4013            .unwrap()
4014            .build();
4015
4016        assert!(node.is_ok(), "build should succeed: {:?}", node.err());
4017    }
4018
4019    #[rstest]
4020    fn test_stop_live_node_detached_releases_gil() {
4021        Python::initialize();
4022
4023        let node = LiveNode::builder(TraderId::from("TESTER-002"), Environment::Sandbox)
4024            .unwrap()
4025            .with_reconciliation(false)
4026            .with_delay_post_stop_secs(1)
4027            .with_timeout_connection(1)
4028            .build()
4029            .map(PyLiveNode::new)
4030            .unwrap();
4031
4032        get_runtime()
4033            .block_on(async { node.node_mut().unwrap().start().await })
4034            .expect("node should start");
4035
4036        let (attempt_tx, attempt_rx) = mpsc::channel();
4037        let acquired_before_stop_return = Arc::new(AtomicBool::new(false));
4038        let acquired_before_stop_return_for_thread = acquired_before_stop_return.clone();
4039        let stop_returned = Arc::new(AtomicBool::new(false));
4040        let stop_returned_for_thread = stop_returned.clone();
4041        let mut gil_thread = None;
4042
4043        Python::attach(|py| {
4044            gil_thread = Some(thread::spawn(move || {
4045                attempt_tx
4046                    .send(())
4047                    .expect("GIL acquisition attempt should send");
4048                Python::attach(|_| {});
4049
4050                if !stop_returned_for_thread.load(Ordering::SeqCst) {
4051                    acquired_before_stop_return_for_thread.store(true, Ordering::SeqCst);
4052                }
4053            }));
4054
4055            attempt_rx
4056                .recv_timeout(Duration::from_secs(1))
4057                .expect("worker thread should attempt to acquire the GIL");
4058
4059            super::stop_live_node_detached(py, &mut node.node_mut().unwrap())
4060                .expect("node should stop cleanly");
4061            stop_returned.store(true, Ordering::SeqCst);
4062        });
4063
4064        gil_thread
4065            .expect("GIL worker thread should be spawned")
4066            .join()
4067            .expect("GIL worker thread should join");
4068
4069        assert!(
4070            acquired_before_stop_return.load(Ordering::SeqCst),
4071            "worker thread should acquire the GIL while LiveNode::stop is blocked"
4072        );
4073        assert!(!node.node_mut().unwrap().is_running());
4074    }
4075
4076    #[rstest]
4077    fn test_py_dispose_disposes_kernel_after_stop_error() {
4078        Python::initialize();
4079
4080        let dispose_count = Arc::new(AtomicUsize::new(0));
4081        let factory = TestDisconnectFailureDataClientFactory::new(dispose_count.clone());
4082        let config = TestDataClientConfig;
4083        let node = LiveNode::builder(TraderId::from("TESTER-003"), Environment::Sandbox)
4084            .unwrap()
4085            .with_reconciliation(false)
4086            .with_delay_post_stop_secs(0)
4087            .with_timeout_connection(1)
4088            .with_timeout_disconnection_secs(0)
4089            .add_data_client(
4090                Some("TEST_DISCONNECT_FAILURE".to_string()),
4091                Box::new(factory),
4092                Box::new(config),
4093            )
4094            .unwrap()
4095            .build()
4096            .map(PyLiveNode::new)
4097            .unwrap();
4098
4099        let dispose_result = Python::attach(|py| {
4100            get_runtime()
4101                .block_on(async { node.node_mut().unwrap().start().await })
4102                .expect("node should start");
4103            assert!(node.node_mut().unwrap().is_running());
4104
4105            node.py_dispose(py)
4106        });
4107
4108        let error = dispose_result.expect_err("dispose should return the stop error");
4109
4110        assert!(error.to_string().contains("test disconnect failed"));
4111        assert_eq!(dispose_count.load(Ordering::Relaxed), 1);
4112        assert!(!node.node_mut().unwrap().is_running());
4113    }
4114
4115    #[rstest]
4116    fn test_live_node_pystrategy_timer_callbacks_run_on_event_loop() {
4117        Python::initialize();
4118
4119        let module_name = "test_live_node_timer_strategy";
4120        Python::attach(|py| install_timer_strategy_module(py, module_name));
4121
4122        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
4123            .unwrap()
4124            .with_reconciliation(false)
4125            .with_delay_post_stop_secs(0)
4126            .with_timeout_connection(1)
4127            .build()
4128            .map(PyLiveNode::new)
4129            .unwrap();
4130
4131        let importable = ImportableStrategyConfig {
4132            strategy_path: format!("{module_name}:LiveTimerStrategy"),
4133            config_path: String::new(),
4134            config: HashMap::new(),
4135        };
4136
4137        Python::attach(|py| {
4138            node.py_add_strategy_from_config(py, importable)
4139                .expect("strategy should register");
4140        });
4141
4142        let handle = node.node_mut().unwrap().handle();
4143        let stop_handle = handle.clone();
4144        let watchdog_handle = handle;
4145        let (done_tx, done_rx) = mpsc::channel();
4146        let module_name_for_stop = module_name.to_string();
4147
4148        let stop_thread = thread::spawn(move || {
4149            let deadline = Instant::now() + Duration::from_secs(5);
4150
4151            loop {
4152                let fired = Python::attach(|py| {
4153                    let results = get_timer_results(py, &module_name_for_stop);
4154                    results.callback_timer_count > 0 && results.default_timer_count > 0
4155                });
4156
4157                if fired || Instant::now() >= deadline {
4158                    break;
4159                }
4160
4161                thread::sleep(Duration::from_millis(20));
4162            }
4163
4164            stop_handle.stop();
4165        });
4166
4167        let watchdog_thread = thread::spawn(move || {
4168            if done_rx.recv_timeout(Duration::from_secs(5)).is_err() {
4169                watchdog_handle.stop();
4170            }
4171        });
4172
4173        Python::attach(|py| {
4174            super::run_live_node_detached(py, &mut node.node_mut().unwrap())
4175                .expect("node should run cleanly");
4176        });
4177
4178        let _ = done_tx.send(());
4179        stop_thread.join().expect("stop thread should join");
4180        watchdog_thread.join().expect("watchdog thread should join");
4181
4182        let results = Python::attach(|py| get_timer_results(py, module_name));
4183
4184        assert_eq!(results.on_start, 1);
4185        assert!(results.callback_timer_count > 0);
4186        assert!(results.default_timer_count > 0);
4187        assert_eq!(results.callback_event_type, "TimeEvent");
4188        assert_eq!(results.default_event_type, "TimeEvent");
4189        assert_eq!(results.callback_event_name, "explicit_timer");
4190        assert_eq!(results.default_event_name, "default_timer");
4191    }
4192
4193    #[rstest]
4194    fn test_add_actor_registers_constructed_python_instance() {
4195        Python::initialize();
4196
4197        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
4198            .unwrap()
4199            .with_reconciliation(false)
4200            .with_delay_post_stop_secs(0)
4201            .with_timeout_connection(1)
4202            .build()
4203            .map(PyLiveNode::new)
4204            .unwrap();
4205        let actor_id = ActorId::from("ACTOR-INSTANCE-001");
4206
4207        Python::attach(|py| {
4208            let config = py
4209                .eval(c_str!("type('_Cfg', (), {})()"), None, None)
4210                .unwrap();
4211            config.setattr("actor_id", actor_id.to_string()).unwrap();
4212            let actor = py
4213                .get_type::<PyDataActor>()
4214                .as_any()
4215                .call1((config,))
4216                .unwrap();
4217
4218            node.py_add_actor(&actor).expect("actor should register");
4219
4220            let actor = actor.extract::<PyRef<PyDataActor>>().unwrap();
4221            assert_eq!(actor.actor_id(), actor_id);
4222            assert!(actor.is_registered());
4223        });
4224
4225        assert_eq!(
4226            node.node_mut()
4227                .unwrap()
4228                .kernel()
4229                .trader
4230                .borrow()
4231                .actor_ids(),
4232            vec![actor_id]
4233        );
4234    }
4235
4236    #[rstest]
4237    fn test_add_actor_rejects_non_idle_node() {
4238        Python::initialize();
4239
4240        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
4241            .unwrap()
4242            .with_reconciliation(false)
4243            .with_delay_post_stop_secs(0)
4244            .with_timeout_connection(1)
4245            .build()
4246            .map(PyLiveNode::new)
4247            .unwrap();
4248
4249        Python::attach(|py| {
4250            let actor = py.get_type::<PyDataActor>().call0().unwrap();
4251            node.handle.set_starting();
4252
4253            let error = node
4254                .py_add_actor(&actor)
4255                .expect_err("a non-idle node should reject actor registration");
4256            let actor = actor.extract::<PyRef<PyDataActor>>().unwrap();
4257
4258            assert_eq!(
4259                error.to_string(),
4260                "RuntimeError: Cannot add actor while node is running, add actors before running the node"
4261            );
4262            assert!(!actor.is_registered());
4263        });
4264
4265        assert!(
4266            node.node()
4267                .unwrap()
4268                .kernel()
4269                .trader
4270                .borrow()
4271                .actor_ids()
4272                .is_empty()
4273        );
4274    }
4275
4276    #[rstest]
4277    fn test_add_strategy_from_config_registers_external_order_claims() {
4278        Python::initialize();
4279
4280        let module_name = "test_live_node_claim_strategy";
4281        Python::attach(|py| install_claim_strategy_module(py, module_name));
4282
4283        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
4284            .unwrap()
4285            .with_reconciliation(false)
4286            .with_delay_post_stop_secs(0)
4287            .with_timeout_connection(1)
4288            .build()
4289            .map(PyLiveNode::new)
4290            .unwrap();
4291
4292        let instrument_id = InstrumentId::from("AUDUSD.SIM");
4293        let strategy_id = StrategyId::from("CLAIMS-001");
4294        let mut config = HashMap::new();
4295        config.insert(
4296            "strategy_id".to_string(),
4297            serde_json::json!(strategy_id.to_string()),
4298        );
4299        config.insert(
4300            "external_order_claims".to_string(),
4301            serde_json::json!([instrument_id.to_string()]),
4302        );
4303        let importable = ImportableStrategyConfig {
4304            strategy_path: format!("{module_name}:ClaimsStrategy"),
4305            config_path: format!("{module_name}:ClaimsConfig"),
4306            config,
4307        };
4308
4309        Python::attach(|py| {
4310            node.py_add_strategy_from_config(py, importable)
4311                .expect("strategy should register");
4312        });
4313
4314        {
4315            let guard = node.node_mut().unwrap();
4316            let exec_engine = guard.kernel().exec_engine.borrow();
4317            assert_eq!(
4318                exec_engine.get_external_order_claim(&instrument_id),
4319                Some(strategy_id)
4320            );
4321        }
4322
4323        let result = node
4324            .node_mut()
4325            .unwrap()
4326            .exec_manager_mut()
4327            .claim_external_orders(instrument_id, StrategyId::from("OTHER-001"));
4328
4329        assert!(result.is_err());
4330        assert!(
4331            result
4332                .unwrap_err()
4333                .to_string()
4334                .contains("already exists for CLAIMS-001")
4335        );
4336    }
4337
4338    #[rstest]
4339    fn test_add_strategy_registers_constructed_python_instance() {
4340        Python::initialize();
4341
4342        let module_name = "test_live_node_add_strategy_instance";
4343        Python::attach(|py| install_claim_strategy_module(py, module_name));
4344
4345        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
4346            .unwrap()
4347            .with_reconciliation(false)
4348            .with_delay_post_stop_secs(0)
4349            .with_timeout_connection(1)
4350            .build()
4351            .map(PyLiveNode::new)
4352            .unwrap();
4353
4354        let instrument_id = InstrumentId::from("AUDUSD.SIM");
4355        let strategy_id = StrategyId::from("CLAIMS-002");
4356
4357        Python::attach(|py| {
4358            let module = py.import(module_name).expect("test module should import");
4359            let kwargs = PyDict::new(py);
4360            kwargs
4361                .set_item("strategy_id", strategy_id.to_string())
4362                .unwrap();
4363            kwargs
4364                .set_item("external_order_claims", vec![instrument_id.to_string()])
4365                .unwrap();
4366            let config = module
4367                .getattr("ClaimsConfig")
4368                .unwrap()
4369                .call((), Some(&kwargs))
4370                .unwrap();
4371            let strategy = module
4372                .getattr("ClaimsStrategy")
4373                .unwrap()
4374                .call1((config,))
4375                .unwrap();
4376
4377            node.py_add_strategy(&strategy)
4378                .expect("strategy should register");
4379        });
4380
4381        {
4382            let guard = node.node_mut().unwrap();
4383            let exec_engine = guard.kernel().exec_engine.borrow();
4384            assert_eq!(
4385                exec_engine.get_external_order_claim(&instrument_id),
4386                Some(strategy_id)
4387            );
4388        }
4389    }
4390
4391    #[rstest]
4392    fn test_add_strategy_constructed_python_instance_registers_oms_type() {
4393        Python::initialize();
4394
4395        let module_name = "test_live_node_add_strategy_instance_oms_type";
4396        Python::attach(|py| install_claim_strategy_module(py, module_name));
4397
4398        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
4399            .unwrap()
4400            .with_reconciliation(false)
4401            .with_delay_post_stop_secs(0)
4402            .with_timeout_connection(1)
4403            .build()
4404            .map(PyLiveNode::new)
4405            .unwrap();
4406        let strategy_id = StrategyId::from("FUNDING_ARBITRAGE-003");
4407
4408        Python::attach(|py| {
4409            let module = py.import(module_name).expect("test module should import");
4410            let kwargs = PyDict::new(py);
4411            kwargs
4412                .set_item("strategy_id", strategy_id.to_string())
4413                .unwrap();
4414            kwargs.set_item("oms_type", OmsType::Hedging).unwrap();
4415            let config = module
4416                .getattr("ClaimsConfig")
4417                .unwrap()
4418                .call((), Some(&kwargs))
4419                .unwrap();
4420            let strategy = module
4421                .getattr("ClaimsStrategy")
4422                .unwrap()
4423                .call1((config,))
4424                .unwrap();
4425
4426            node.py_add_strategy(&strategy)
4427                .expect("strategy should register");
4428        });
4429
4430        let instrument = crypto_perpetual_ethusdt();
4431        let instrument_id = instrument.id();
4432        let client_id = ClientId::from("STUB");
4433
4434        node.node_mut()
4435            .unwrap()
4436            .kernel()
4437            .cache
4438            .borrow_mut()
4439            .add_instrument(InstrumentAny::CryptoPerpetual(instrument))
4440            .unwrap();
4441        node.node_mut()
4442            .unwrap()
4443            .kernel()
4444            .exec_engine
4445            .borrow_mut()
4446            .register_client(Box::new(StubExecutionClient::new(
4447                client_id,
4448                AccountId::from("TEST-ACCOUNT"),
4449                instrument_id.venue,
4450                OmsType::Netting,
4451                None,
4452            )))
4453            .unwrap();
4454
4455        let order = OrderTestBuilder::new(OrderType::Market)
4456            .trader_id(node.node_mut().unwrap().trader_id())
4457            .strategy_id(strategy_id)
4458            .instrument_id(instrument_id)
4459            .quantity(Quantity::from("1.000"))
4460            .build();
4461        let position_id = PositionId::new("CUSTOM-POSITION-003");
4462
4463        node.node_mut()
4464            .unwrap()
4465            .kernel()
4466            .cache
4467            .borrow_mut()
4468            .add_order(order.clone(), Some(position_id), Some(client_id), true)
4469            .unwrap();
4470
4471        let submit_order = SubmitOrder::new(
4472            order.trader_id(),
4473            Some(client_id),
4474            strategy_id,
4475            instrument_id,
4476            order.client_order_id(),
4477            order.init_event().clone(),
4478            order.exec_algorithm_id(),
4479            Some(position_id),
4480            None,
4481            UUID4::new(),
4482            UnixNanos::default(),
4483            None,
4484        );
4485
4486        node.node_mut()
4487            .unwrap()
4488            .kernel()
4489            .exec_engine
4490            .borrow()
4491            .execute(TradingCommand::SubmitOrder(submit_order));
4492
4493        let guard = node.node_mut().unwrap();
4494        let exec_engine = guard.kernel().exec_engine.borrow();
4495        let cache = exec_engine.cache().borrow();
4496        let cached_order = cache
4497            .order(&order.client_order_id())
4498            .expect("Order should be cached");
4499
4500        assert_eq!(cached_order.status(), OrderStatus::Initialized);
4501    }
4502
4503    #[rstest]
4504    fn test_add_strategy_constructed_python_instance_claim_conflict_does_not_register() {
4505        Python::initialize();
4506
4507        let module_name = "test_live_node_add_strategy_instance_claim_conflict";
4508        Python::attach(|py| install_claim_strategy_module(py, module_name));
4509
4510        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
4511            .unwrap()
4512            .with_reconciliation(false)
4513            .with_delay_post_stop_secs(0)
4514            .with_timeout_connection(1)
4515            .build()
4516            .map(PyLiveNode::new)
4517            .unwrap();
4518        let instrument_id = InstrumentId::from("AUDUSD.SIM");
4519        let first_strategy_id = StrategyId::from("CLAIMS-PRIMARY-001");
4520        let conflicting_strategy_id = StrategyId::from("CLAIMS-CONFLICT-002");
4521
4522        let (error, conflicting_strategy_registered) = Python::attach(|py| {
4523            let module = py.import(module_name).expect("test module should import");
4524            let first_kwargs = PyDict::new(py);
4525            first_kwargs
4526                .set_item("strategy_id", first_strategy_id.to_string())
4527                .unwrap();
4528            first_kwargs
4529                .set_item("external_order_claims", vec![instrument_id.to_string()])
4530                .unwrap();
4531            let first_config = module
4532                .getattr("ClaimsConfig")
4533                .unwrap()
4534                .call((), Some(&first_kwargs))
4535                .unwrap();
4536            let first_strategy = module
4537                .getattr("ClaimsStrategy")
4538                .unwrap()
4539                .call1((first_config,))
4540                .unwrap();
4541            node.py_add_strategy(&first_strategy)
4542                .expect("first strategy should register");
4543
4544            let conflicting_kwargs = PyDict::new(py);
4545            conflicting_kwargs
4546                .set_item("strategy_id", conflicting_strategy_id.to_string())
4547                .unwrap();
4548            conflicting_kwargs
4549                .set_item("external_order_claims", vec![instrument_id.to_string()])
4550                .unwrap();
4551            let conflicting_config = module
4552                .getattr("ClaimsConfig")
4553                .unwrap()
4554                .call((), Some(&conflicting_kwargs))
4555                .unwrap();
4556            let conflicting_strategy = module
4557                .getattr("ClaimsStrategy")
4558                .unwrap()
4559                .call1((conflicting_config,))
4560                .unwrap();
4561            let error = node
4562                .py_add_strategy(&conflicting_strategy)
4563                .expect_err("conflicting claim should fail");
4564            let is_registered = conflicting_strategy
4565                .extract::<PyRef<PyStrategy>>()
4566                .unwrap()
4567                .is_registered();
4568
4569            (error, is_registered)
4570        });
4571
4572        let strategy_ids = node
4573            .node_mut()
4574            .unwrap()
4575            .kernel()
4576            .trader
4577            .borrow()
4578            .strategy_ids();
4579        let manager_claim = node
4580            .node_mut()
4581            .unwrap()
4582            .exec_manager()
4583            .get_external_order_claim(&instrument_id);
4584        let engine_claim = node
4585            .node_mut()
4586            .unwrap()
4587            .kernel()
4588            .exec_engine
4589            .borrow()
4590            .get_external_order_claim(&instrument_id);
4591
4592        assert!(
4593            error
4594                .to_string()
4595                .contains("already exists for CLAIMS-PRIMARY-001")
4596        );
4597        assert!(!conflicting_strategy_registered);
4598        assert_eq!(strategy_ids, vec![first_strategy_id]);
4599        assert_eq!(manager_claim, Some(first_strategy_id));
4600        assert_eq!(engine_claim, Some(first_strategy_id));
4601    }
4602
4603    #[rstest]
4604    fn test_add_strategy_constructed_python_instance_duplicate_tag_does_not_register() {
4605        Python::initialize();
4606
4607        let module_name = "test_live_node_add_strategy_instance_duplicate_tag";
4608        Python::attach(|py| install_claim_strategy_module(py, module_name));
4609
4610        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
4611            .unwrap()
4612            .with_reconciliation(false)
4613            .with_delay_post_stop_secs(0)
4614            .with_timeout_connection(1)
4615            .build()
4616            .map(PyLiveNode::new)
4617            .unwrap();
4618        let first_strategy_id = StrategyId::from("TAGGED-FIRST-777");
4619
4620        let (error, duplicate_strategy_registered) = Python::attach(|py| {
4621            let module = py.import(module_name).expect("test module should import");
4622            let first_kwargs = PyDict::new(py);
4623            first_kwargs
4624                .set_item("strategy_id", "TAGGED-FIRST")
4625                .unwrap();
4626            first_kwargs.set_item("order_id_tag", "777").unwrap();
4627            let first_config = module
4628                .getattr("ClaimsConfig")
4629                .unwrap()
4630                .call((), Some(&first_kwargs))
4631                .unwrap();
4632            let first_strategy = module
4633                .getattr("ClaimsStrategy")
4634                .unwrap()
4635                .call1((first_config,))
4636                .unwrap();
4637            node.py_add_strategy(&first_strategy)
4638                .expect("first strategy should register");
4639
4640            let duplicate_kwargs = PyDict::new(py);
4641            duplicate_kwargs
4642                .set_item("strategy_id", "TAGGED-SECOND")
4643                .unwrap();
4644            duplicate_kwargs.set_item("order_id_tag", "777").unwrap();
4645            let duplicate_config = module
4646                .getattr("ClaimsConfig")
4647                .unwrap()
4648                .call((), Some(&duplicate_kwargs))
4649                .unwrap();
4650            let duplicate_strategy = module
4651                .getattr("ClaimsStrategy")
4652                .unwrap()
4653                .call1((duplicate_config,))
4654                .unwrap();
4655            let error = node
4656                .py_add_strategy(&duplicate_strategy)
4657                .expect_err("duplicate order ID tag should fail");
4658            let is_registered = duplicate_strategy
4659                .extract::<PyRef<PyStrategy>>()
4660                .unwrap()
4661                .is_registered();
4662
4663            (error, is_registered)
4664        });
4665
4666        let strategy_ids = node
4667            .node_mut()
4668            .unwrap()
4669            .kernel()
4670            .trader
4671            .borrow()
4672            .strategy_ids();
4673
4674        assert!(error.to_string().contains("order_id_tag conflict"));
4675        assert!(!duplicate_strategy_registered);
4676        assert_eq!(strategy_ids, vec![first_strategy_id]);
4677    }
4678
4679    #[tokio::test(flavor = "current_thread")]
4680    async fn test_live_node_pystrategy_request_bars_dispatches_on_historical_bars() {
4681        Python::initialize();
4682
4683        let module_name = "test_live_node_historical_bars_strategy";
4684        Python::attach(|py| install_tracking_strategy_module(py, module_name));
4685
4686        let request_count = Arc::new(AtomicUsize::new(0));
4687        let response_sent_count = Arc::new(AtomicUsize::new(0));
4688        let handler_visible_count = Arc::new(AtomicUsize::new(0));
4689        let factory = TestHistoricalBarsDataClientFactory::new(
4690            request_count.clone(),
4691            response_sent_count.clone(),
4692            handler_visible_count.clone(),
4693        );
4694        let config = TestDataClientConfig;
4695
4696        let node = LiveNode::builder(TraderId::from("TESTER-001"), Environment::Sandbox)
4697            .unwrap()
4698            .with_reconciliation(false)
4699            .with_delay_post_stop_secs(0)
4700            .with_timeout_connection(1)
4701            .add_data_client(
4702                Some("TEST_DATA".to_string()),
4703                Box::new(factory),
4704                Box::new(config),
4705            )
4706            .unwrap()
4707            .build()
4708            .map(PyLiveNode::new)
4709            .unwrap();
4710
4711        let importable = ImportableStrategyConfig {
4712            strategy_path: format!("{module_name}:HistoricalBarsStrategy"),
4713            config_path: String::new(),
4714            config: HashMap::new(),
4715        };
4716
4717        Python::attach(|py| {
4718            node.py_add_strategy_from_config(py, importable)
4719                .expect("strategy should register");
4720        });
4721
4722        let handle = node.node_mut().unwrap().handle();
4723        let stop_handle = handle.clone();
4724        let response_sent_count_for_stop = response_sent_count.clone();
4725
4726        tokio::spawn(async move {
4727            let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
4728
4729            loop {
4730                if response_sent_count_for_stop.load(Ordering::Relaxed) == 1
4731                    || tokio::time::Instant::now() >= deadline
4732                {
4733                    break;
4734                }
4735                tokio::time::sleep(Duration::from_millis(20)).await;
4736            }
4737            tokio::time::sleep(Duration::from_millis(250)).await;
4738            stop_handle.stop();
4739        });
4740
4741        node.node_mut()
4742            .unwrap()
4743            .run()
4744            .await
4745            .expect("node should run cleanly");
4746
4747        let (on_start, on_historical_bars, historical_bar_count) =
4748            Python::attach(|py| get_results(py, module_name));
4749
4750        assert_eq!(request_count.load(Ordering::Relaxed), 1);
4751        assert_eq!(handler_visible_count.load(Ordering::Relaxed), 1);
4752        assert_eq!(response_sent_count.load(Ordering::Relaxed), 1);
4753        assert_eq!(on_start, 1);
4754        assert_eq!(on_historical_bars, 1);
4755        assert_eq!(historical_bar_count, 1);
4756    }
4757}