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