nautechsystems / nautilus_trader

A high-performance algorithmic trading platform and event-driven backtester
https://nautilustrader.io
GNU Lesser General Public License v3.0
1.7k stars 398 forks source link

Events & Handlers v2 #1711

Open twitu opened 2 weeks ago

twitu commented 2 weeks ago

Events and Handlers are at the core of the nautilus system. There are two main components that use them - Clocks and message buses and both are very similar in how their events and handlers work. Clocks and message buses generate events and user defined functions are registered as handlers and consume those events without returning anything.

Since nautilus is moving towards a rust core, we need a better way to work with handlers implemented in different languages. Different languages impose different constraints and events need to be converted accordingly as well.

Timers need to support all 3 handler styles while messagebus only needs to support pyo3 and Rust handlers.

Python

Parts of the Python flow are already implemented, which we can refer to here.

struct PythonHandler {
   callback: PyObject
}

If the event can be converted to a pyo3 Python object it can be passed directly to a the handler otherwise a capsule based conversion needs to be used. The handler then needs to extract the information using the capsule apis.

      let capsule: PyObject = PyCapsule::new(py, event, None)
         .expect("Error creating `PyCapsule`")
         .into_py(py);

      match handler.callback.call1(py, (capsule,)) {
         Ok(_) => {}
         Err(e) => error!("Error on callback: {:?}", e),
      };

Note: this means that the control flow is with Rust and it will acquire the GIL and call the Python function.

Rust

Making a generic callback in Rust is more tricky, since there can be two kinds of callbacks one that mutate state and one's that don't. Moreover since, Nautilus peripherals are async the handlers may be defined in one thread and passed to another so they need to implement Send.

Note that an Immutable handler with type Fn(Event) cannot mutate anything which means it can only possibly be used as a sink to write to stdout, logs, or other peripherals that don't need mutable access. Most functionality will need FnMut(Event).

struct ImmutableRustHandler {
   callback: Arc<dyn Fn(Event) + Send>
}

struct MutableRustHandler {
   callback: Box<dyn FnMut(Event) + Send>
}

However, it turns out that manual implementations of Fn* traits is unstable feature.^1 For the time being we can work around this by implementing two custom traits like

trait ImmutableCall<Args> {
   fn call(&self, args: Args)
}

trait MutableCall<Args> {
   fn call(&mut self, args: Args)
}

The handler is callback field is only a boxed trait. The specific implementation will contain the actual behaviour. For e.g. suppose a message handler needs to append news events to a list.

struct NewsEventHandler {
   events: Vec<NewsEvent>
}

impl MutableCall<NewsEvent> for NewsEventHandler {
   fn call(&mut self, args: NewsEvent) {
      self.data.push(args)
   }
}

This works fine for a single threaded implementation, however if the handler might be updated from multiple threads or need shared mutable access, it'll need to wrap it's internal contents with an Arc Mutex.

Safety: Locks must be acquired and dropped carefully, since handlers can call other handlers which might need access to the same resources causing a deadlock.

struct NewsEventHandler {
   events: Arc<Mutex<Vec<NewsEvent>>>
}

impl MutableCall<NewsEvent> for NewsEventHandler {
   fn (&mut self, args: NewsEvent) {
      let mut lock = self.data.lock().unwrap();
      lock.push(args);
      drop(lock); // hold lock for minimum duration

      // other logic
   }
}

Cython

For Cython, we can benefit from the Python implementation in many ways because Cython objects can be treated as Python objects only their calling convention and data format is different. Essentially, the events need to support #[repr(C)] and their C defintions exposed through bindgen. The event can then be boxed and passed as a pointer to the handler through a PyCapsule. The handler can perform the appropriate type casting to retrieve and use the C style rust struct.

struct CythonHandler {
   callback: PyObject
}

A capsule based conversion needs to be used. The handler then needs to extract the information using the capsule apis.

      let event = Box::new(event);
      let ptr = Box::into_raw(event);
      let capsule: PyObject = PyCapsule::new(py, event, None)
         .expect("Error creating `PyCapsule`")
         .into_py(py);

      match handler.callback.call1(py, (capsule,)) {
         Ok(_) => {}
         Err(e) => error!("Error on callback: {:?}", e),
      };

      Box::from_raw(ptr);
      // event is dropped and deallocated
cdef inline capsule_to_time_event(capsule):
    cdef Event_t* ptr = <Event_t*>PyCapsule_GetPointer(capsule, NULL)
    # logic

Safety: This is more error prone and potential for memory leak is higher than other options. The handler must be careful not to retain pointers to heap allocated data from the event.

Summary

Language Event representation Passing style
Python PyClass PyObject
Python fields PyCapsule
Cython #[repr(C)] PyCapsule
🦀 🦀 🦀

To reduce complexity of the structs storing handlers, it's best to make all the handlers different variations of an enum. This way different types of callbacks can coexist in the same component and the dispatcher can determine the event passing logic based on the handler variant.

pub enum EventHandler {
   PythonHandler(PyObject),
   CythonHandler(PyObject),
   ImmutableRustHandler(Arc<Fn(Event)>),
   MutableRustHandler(Box<FnMut(Event)>)
}