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