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