From 657fe14564b779ab41210d3f21acec566b12b3b8 Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Sun, 10 Mar 2024 22:36:34 +0000 Subject: [PATCH 01/12] Prototype submit orders from Numpy --- Cargo.lock | 1 + crates/order_book/src/types.rs | 30 +++++++++++++++ examples/random_trades.py | 8 +++- pyproject.toml | 1 + rust/Cargo.toml | 1 + rust/src/order_book.rs | 2 +- rust/src/step_sim.rs | 55 ++++++++++++++++++++++++++- rust/src/types.rs | 25 ++---------- tests/test_step_sim/test_numpy_api.py | 21 ++++++++++ 9 files changed, 118 insertions(+), 26 deletions(-) create mode 100644 tests/test_step_sim/test_numpy_api.py diff --git a/Cargo.lock b/Cargo.lock index f659e93..4e59d28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,7 @@ version = "0.3.1" dependencies = [ "bourse-book", "bourse-de", + "ndarray", "numpy", "pyo3", "rand_xoshiro", diff --git a/crates/order_book/src/types.rs b/crates/order_book/src/types.rs index a2a500c..4f331b7 100644 --- a/crates/order_book/src/types.rs +++ b/crates/order_book/src/types.rs @@ -24,6 +24,24 @@ pub enum Side { Ask, } +impl From for Side { + fn from(side: bool) -> Side { + match side { + true => Self::Bid, + false => Self::Ask, + } + } +} + +impl From for bool { + fn from(side: Side) -> bool { + match side { + Side::Bid => true, + Side::Ask => false, + } + } +} + /// Order status #[derive(Clone, PartialEq, Eq, Copy, Debug, Serialize, Deserialize)] pub enum Status { @@ -40,6 +58,18 @@ pub enum Status { Rejected, } +impl From for u8 { + fn from(status: Status) -> u8 { + match status { + Status::New => 0, + Status::Active => 1, + Status::Filled => 2, + Status::Cancelled => 3, + Status::Rejected => 4, + } + } +} + /// Order data #[derive(Clone, Copy, Serialize, Deserialize)] pub struct Order { diff --git a/examples/random_trades.py b/examples/random_trades.py index c843312..59a80e5 100644 --- a/examples/random_trades.py +++ b/examples/random_trades.py @@ -1,11 +1,15 @@ import bourse from bourse.step_sim.agents import RandomAgent +TICK_SIZE = 2 + def run(seed: int, n_steps: int, n_agents: int): - agents = [RandomAgent(i, 0.5, (10, 100), (20, 50), 2) for i in range(n_agents)] - env = bourse.core.StepEnv(seed, 0, 1, 100_000) + agents = [ + RandomAgent(i, 0.5, (10, 100), (20, 50), TICK_SIZE) for i in range(n_agents) + ] + env = bourse.core.StepEnv(seed, 0, TICK_SIZE, 100_000) market_data = bourse.step_sim.run(env, agents, n_steps, seed) diff --git a/pyproject.toml b/pyproject.toml index 9c0a715..aeb1313 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dependencies = [ "jupyter >= 1.0.0", "matplotlib >= 3.8.2", "pyarrow >= 14.0.2", + "numba >= 0.59.0", ] [tool.hatch.envs.jupyter.scripts] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1ecad65..af0ed0a 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,6 +11,7 @@ doctest = false [dependencies] pyo3 = { version="0.20.3", features = ["extension-module"] } numpy = "0.20.0" +ndarray = "0.15.6" rand_xoshiro.workspace = true bourse-book = { path = "../crates/order_book" } diff --git a/rust/src/order_book.rs b/rust/src/order_book.rs index 0e4dec4..6817911 100644 --- a/rust/src/order_book.rs +++ b/rust/src/order_book.rs @@ -201,7 +201,7 @@ impl OrderBook { /// no-trade period) /// pub fn order_status(&self, order_id: OrderId) -> u8 { - types::status_to_int(&self.0.order(order_id).status) + self.0.order(order_id).status.into() } /// place_order(bid: bool, vol: int, trader_id: int, price: int = None) -> int diff --git a/rust/src/step_sim.rs b/rust/src/step_sim.rs index f5e4788..19ef31f 100644 --- a/rust/src/step_sim.rs +++ b/rust/src/step_sim.rs @@ -1,9 +1,10 @@ use std::array; use std::collections::HashMap; -use super::types::{cast_order, cast_trade, status_to_int, PyOrder, PyTrade}; +use super::types::{cast_order, cast_trade, PyOrder, PyTrade}; use bourse_book::types::{Nanos, OrderCount, OrderId, Price, Side, TraderId, Vol}; use bourse_de::Env as BaseEnv; +use ndarray::Zip; use numpy::{PyArray1, ToPyArray}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -156,7 +157,7 @@ impl StepEnv { /// no-trade period) /// pub fn order_status(&self, order_id: OrderId) -> u8 { - status_to_int(&self.env.get_orderbook().order(order_id).status) + self.env.get_orderbook().order(order_id).status.into() } /// Enable trading @@ -352,6 +353,56 @@ impl StepEnv { ) } + /// submit_limit_order_array(orders: tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]) + /// + /// Submit new limit orders from a Numpy array + /// + /// Parameters + /// ---------- + /// orders: tuple[np.array, np.array, np.array, np.array] + /// Tuple of numpy arrays containing + /// + /// - Order side as a bool (``True`` if bid-side) + /// - Order volumes + /// - Trader ids + /// - Order prices + /// + pub fn submit_limit_order_array<'a>( + &mut self, + orders: ( + &'a PyArray1, + &'a PyArray1, + &'a PyArray1, + &'a PyArray1, + ), + ) -> PyResult<()> { + let orders = ( + orders.0.readonly(), + orders.1.readonly(), + orders.2.readonly(), + orders.3.readonly(), + ); + + let orders = ( + orders.0.as_array(), + orders.1.as_array(), + orders.2.as_array(), + orders.3.as_array(), + ); + + Zip::from(orders.0) + .and(orders.1) + .and(orders.2) + .and(orders.3) + .for_each(|side, vol, id, price| { + self.env + .place_order((*side).into(), *vol, *id, Some(*price)) + .unwrap(); + }); + + Ok(()) + } + /// get_trade_volumes() -> numpy.ndarray /// /// Get trade volume history diff --git a/rust/src/types.rs b/rust/src/types.rs index 1f5aea3..75a8ee7 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1,18 +1,11 @@ -use bourse_de::types::{Nanos, Order, OrderId, Price, Side, Status, Trade, TraderId, Vol}; - -pub fn is_bid(side: &Side) -> bool { - match side { - Side::Bid => true, - Side::Ask => false, - } -} +use bourse_de::types::{Nanos, Order, OrderId, Price, Trade, TraderId, Vol}; pub type PyTrade = (Nanos, bool, Price, Vol, OrderId, OrderId); pub fn cast_trade(trade: &Trade) -> PyTrade { ( trade.t, - is_bid(&trade.side), + trade.side.into(), trade.price, trade.vol, trade.active_order_id, @@ -20,22 +13,12 @@ pub fn cast_trade(trade: &Trade) -> PyTrade { ) } -pub fn status_to_int(status: &Status) -> u8 { - match status { - Status::New => 0, - Status::Active => 1, - Status::Filled => 2, - Status::Cancelled => 3, - Status::Rejected => 4, - } -} - pub type PyOrder = (bool, u8, Nanos, Nanos, Vol, Vol, Price, TraderId, OrderId); pub fn cast_order(order: &Order) -> PyOrder { ( - is_bid(&order.side), - status_to_int(&order.status), + order.side.into(), + order.status.into(), order.arr_time, order.end_time, order.vol, diff --git a/tests/test_step_sim/test_numpy_api.py b/tests/test_step_sim/test_numpy_api.py new file mode 100644 index 0000000..af82475 --- /dev/null +++ b/tests/test_step_sim/test_numpy_api.py @@ -0,0 +1,21 @@ +import numpy as np + +import bourse + + +def test_submit_limit_orders_numpy(): + + env = bourse.core.StepEnv(101, 0, 1, 100_000) + + sides = np.array([True, True, True, False, False, False]) + vols = np.array([10, 11, 12, 10, 11, 12], dtype=np.uint32) + ids = np.array([1, 1, 1, 2, 2, 2], dtype=np.uint32) + prices = np.array([20, 20, 19, 22, 22, 23], dtype=np.uint32) + + env.submit_limit_order_array((sides, vols, ids, prices)) + + env.step() + + assert env.bid_ask == (20, 22) + assert env.best_bid_vol_and_orders == (21, 2) + assert env.best_ask_vol_and_orders == (21, 2) From 9d657bb73d52b134a36fabd877054c95a135e09c Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Mon, 11 Mar 2024 22:49:50 +0000 Subject: [PATCH 02/12] Cancel orders form numpy array --- rust/src/step_sim.rs | 21 ++++++++++++++++ tests/test_step_sim/test_numpy_api.py | 36 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/rust/src/step_sim.rs b/rust/src/step_sim.rs index 19ef31f..fbebef2 100644 --- a/rust/src/step_sim.rs +++ b/rust/src/step_sim.rs @@ -390,6 +390,7 @@ impl StepEnv { orders.3.as_array(), ); + // TODO: would be nice to return better error here Zip::from(orders.0) .and(orders.1) .and(orders.2) @@ -403,6 +404,26 @@ impl StepEnv { Ok(()) } + /// submit_cancel_order_array(order_ids: numpy.ndarray) + /// + /// Submit a Numpy array of order ids to cancel + /// + /// Parameters + /// ---------- + /// order_ids: np.array + /// Numpy array of order-ids to be cancelled + /// + pub fn submit_cancel_order_array(&mut self, order_ids: &'_ PyArray1) -> PyResult<()> { + let order_ids = order_ids.readonly(); + let order_ids = order_ids.as_array(); + + order_ids.for_each(|id| { + self.env.cancel_order(*id); + }); + + Ok(()) + } + /// get_trade_volumes() -> numpy.ndarray /// /// Get trade volume history diff --git a/tests/test_step_sim/test_numpy_api.py b/tests/test_step_sim/test_numpy_api.py index af82475..5504610 100644 --- a/tests/test_step_sim/test_numpy_api.py +++ b/tests/test_step_sim/test_numpy_api.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import bourse @@ -19,3 +20,38 @@ def test_submit_limit_orders_numpy(): assert env.bid_ask == (20, 22) assert env.best_bid_vol_and_orders == (21, 2) assert env.best_ask_vol_and_orders == (21, 2) + + +def test_raise_from_bad_order(): + + env = bourse.core.StepEnv(101, 0, 2, 100_000) + + sides = np.array([True, True]) + vols = np.array([10, 11], dtype=np.uint32) + ids = np.array([1, 1], dtype=np.uint32) + prices = np.array([20, 21], dtype=np.uint32) + + with pytest.raises(BaseException): + env.submit_limit_order_array((sides, vols, ids, prices)) + + +def test_cancel_orders_from_array(): + + env = bourse.core.StepEnv(101, 0, 1, 100_000) + + sides = np.array([True, True, True, False, False, False]) + vols = np.array([10, 11, 12, 10, 11, 12], dtype=np.uint32) + ids = np.array([1, 1, 1, 2, 2, 2], dtype=np.uint32) + prices = np.array([20, 20, 19, 22, 22, 23], dtype=np.uint32) + + env.submit_limit_order_array((sides, vols, ids, prices)) + + env.step() + + env.submit_cancel_order_array(np.array([0, 1, 3, 4], dtype=np.uint64)) + + env.step() + + assert env.bid_ask == (19, 23) + assert env.best_bid_vol_and_orders == (12, 1) + assert env.best_ask_vol_and_orders == (12, 1) From ae0241f2d2e9e3117553158efdbc48a010b3a745 Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Thu, 14 Mar 2024 23:02:08 +0000 Subject: [PATCH 03/12] Prototype numpy order placement agent --- docs/source/conf.py | 7 +- docs/source/pages/api.rst | 2 + src/bourse/step_sim/agents/__init__.py | 2 +- src/bourse/step_sim/agents/random_agent.py | 74 ++++++++++++++++++++++ tests/test_step_sim/test_benchmarks.py | 27 +++++++- tests/test_step_sim/test_numpy_api.py | 12 ++++ 6 files changed, 120 insertions(+), 4 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 7c2ba46..87a17b4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -59,8 +59,8 @@ python_apigen_modules = { "bourse.data_processing": "pages/generated/data_processing/", "bourse.step_sim": "pages/generated/step_sim/", - "bourse.step_sim.agents.base_agent": "pages/generated/step_sim/agents/base", - "bourse.step_sim.agents.random_agent": "pages/generated/step_sim/agents/random", + "bourse.step_sim.agents.base_agent": "pages/generated/step_sim/agents/base/", + "bourse.step_sim.agents.random_agent": "pages/generated/step_sim/agents/random/", "bourse.core": "pages/generated/core/", } @@ -73,6 +73,9 @@ (r"class:.*RandomAgent.*", "random_agent_class"), (r"method:.*RandomAgent.*", "RandomAgent Methods"), (r"attribute:.*RandomAgent.*", "RandomAgent Attributes"), + (r"class:.*NumpyRandomAgents.*", "n_random_agent_class"), + (r"method:.*NumpyRandomAgents.*", "NumpyRandomAgents Methods"), + (r"attribute:.*NumpyRandomAgents.*", "NumpyRandomAgents Attributes"), (r"class:.*OrderBook.*", "order_book_class"), (r"method:.*OrderBook.*", "OrderBook Methods"), (r"attribute:.*OrderBook.*", "OrderBook Attributes"), diff --git a/docs/source/pages/api.rst b/docs/source/pages/api.rst index 33d86f0..bd98340 100644 --- a/docs/source/pages/api.rst +++ b/docs/source/pages/api.rst @@ -10,6 +10,8 @@ API Reference .. python-apigen-group:: random_agent_class +.. python-apigen-group:: n_random_agent_class + .. python-apigen-group:: order_book_class .. python-apigen-group:: step_env_class diff --git a/src/bourse/step_sim/agents/__init__.py b/src/bourse/step_sim/agents/__init__.py index a122d32..66cdee7 100644 --- a/src/bourse/step_sim/agents/__init__.py +++ b/src/bourse/step_sim/agents/__init__.py @@ -2,4 +2,4 @@ Discrete event simulation agent implementations """ from .base_agent import BaseAgent -from .random_agent import RandomAgent +from .random_agent import NumpyRandomAgents, RandomAgent diff --git a/src/bourse/step_sim/agents/random_agent.py b/src/bourse/step_sim/agents/random_agent.py index c8ac015..e0db288 100644 --- a/src/bourse/step_sim/agents/random_agent.py +++ b/src/bourse/step_sim/agents/random_agent.py @@ -89,3 +89,77 @@ def update(self, rng: np.random.Generator, env: core.StepEnv): self.order_id = env.place_order( side, vol, self.i, price=tick * self.tick_size ) + + +class NumpyRandomAgents(BaseAgent): + """ + Simple agent set that places random orders via Numpy arrays + + Agents that place random orders each step of the simulation, new + orders are returned as a tuple of Numpy arrays. These orders + can then be submitted to a discrete event environment using + :py:meth:`bourse.core.StepEnv.submit_limit_order_array`. + + This agent type is designed to represent a group of agents all + placing individual orders at each step (rather than a single agent). + """ + + def __init__( + self, + n_agents: int, + tick_range: typing.Tuple[int, int], + vol_range: typing.Tuple[int, int], + tick_size: int, + ): + """ + Initialise NumpyRandomAgents + + Parameters + ---------- + n_agents: int + Number of agents in the set + tick_range: tuple[int, int] + Tick range to sample from. + vol_range: tuple[int, int] + Volume range to sample from. + tick_size: int + Size of a market tick + """ + self.n_agents = n_agents + self.tick_range = tick_range + self.vol_range = vol_range + self.tick_size = tick_size + + def update( + self, rng: np.random.Generator, env: core.StepEnv + ) -> typing.Tuple[typing.Tuple, np.array]: + """ + Update the agents, sampling new orders to place + + Parameters + ---------- + rng: numpy.random.Generator + Numpy random generator. + env: bourse.core.StepEnv + Discrete event simulation environment. + + Returns + ------- + tuple + Tuple containing: + + - A tuple of arrays representing new orders to be placed + - An array of orders to cancel (always empty) + """ + sides = rng.choice([True, False], size=self.n_agents).astype(bool) + vols = rng.integers(*self.tick_range, size=self.n_agents, dtype=np.uint32) + ids = np.arange(self.n_agents, dtype=np.uint32) + prices = ( + rng.integers(*self.tick_range, size=self.n_agents, dtype=np.uint32) + * self.tick_size + ) + + # No cancellations created + cancellations = np.array([], dtype=np.uint64) + + return (sides, vols, ids, prices), cancellations diff --git a/tests/test_step_sim/test_benchmarks.py b/tests/test_step_sim/test_benchmarks.py index 0340b7f..4ea7639 100644 --- a/tests/test_step_sim/test_benchmarks.py +++ b/tests/test_step_sim/test_benchmarks.py @@ -1,7 +1,8 @@ +import numpy as np import pytest import bourse -from bourse.step_sim.agents import RandomAgent +from bourse.step_sim.agents import NumpyRandomAgents, RandomAgent SEED = 101 N_AGENTS = 200 @@ -20,11 +21,35 @@ def agents(): ] +@pytest.fixture +def numpy_agents(): + return [ + NumpyRandomAgents(N_AGENTS, (10, 100), (20, 50), TiCK_SIZE), + ] + + def run_sim(n_steps, seed, e, a): return bourse.step_sim.run(e, a, n_steps, seed) +def run_numpy_sim(n_steps, seed, e, a): + + rng = np.random.default_rng(seed) + + for _ in range(n_steps): + for agent in a: + orders, cancels = agent.update(rng, e) + e.submit_limit_order_array(orders) + e.submit_cancel_order_array(cancels) + + def test_simulation_benchmark(benchmark, env, agents): n_steps = 100 seed = 101 benchmark(run_sim, n_steps, seed, env, agents) + + +def test_numpy_simulation_benchmark(benchmark, env, numpy_agents): + n_steps = 100 + seed = 101 + benchmark(run_numpy_sim, n_steps, seed, env, numpy_agents) diff --git a/tests/test_step_sim/test_numpy_api.py b/tests/test_step_sim/test_numpy_api.py index 5504610..54d7007 100644 --- a/tests/test_step_sim/test_numpy_api.py +++ b/tests/test_step_sim/test_numpy_api.py @@ -55,3 +55,15 @@ def test_cancel_orders_from_array(): assert env.bid_ask == (19, 23) assert env.best_bid_vol_and_orders == (12, 1) assert env.best_ask_vol_and_orders == (12, 1) + + +def test_numpy_random_agent(): + + env = bourse.core.StepEnv(101, 0, 1, 100_000) + agents = bourse.step_sim.agents.NumpyRandomAgents(20, (10, 60), (10, 20), 2) + rng = np.random.default_rng(101) + + orders, cancellations = agents.update(rng, None) + + env.submit_limit_order_array(orders) + env.submit_cancel_order_array(cancellations) From 084788d7ac4123ae1eee0850b4dc13f98c92e89e Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Mon, 18 Mar 2024 22:35:04 +0000 Subject: [PATCH 04/12] Get market data as numpy array --- rust/src/step_sim.rs | 64 +++++++++++++++++++++++++++ tests/test_step_sim/test_numpy_api.py | 14 ++++++ 2 files changed, 78 insertions(+) diff --git a/rust/src/step_sim.rs b/rust/src/step_sim.rs index fbebef2..0db2451 100644 --- a/rust/src/step_sim.rs +++ b/rust/src/step_sim.rs @@ -424,6 +424,70 @@ impl StepEnv { Ok(()) } + /// level_1_data_array() -> numpy.ndarray + /// + /// Get current level 1 data as a Numpy array + /// + /// Returns a Numpy array with values at indices: + /// + /// - 0: Bid touch price + /// - 1: Ask touch price + /// - 2: Bid total volume + /// - 3: Ask total volume + /// - 4: Bid touch volume + /// - 5: Number of buy orders at touch + /// - 6: Ask touch volume + /// - 7: Number of sell orders at touch + /// + pub fn level_1_data_array<'a>(&self, py: Python<'a>) -> &'a PyArray1 { + let data = self.env.level_2_data(); + let data_vec = [ + data.bid_price, + data.ask_price, + data.ask_vol, + data.bid_vol, + data.bid_price_levels[0].0, + data.bid_price_levels[0].1, + data.ask_price_levels[0].0, + data.ask_price_levels[0].1, + ]; + + data_vec.to_pyarray(py) + } + + /// level_2_data_array() -> numpy.ndarray + /// + /// Get current level 2 data as a Numpy array + /// + /// Returns a Numpy array with values at indices: + /// + /// - 0: Bid touch price + /// - 1: Ask touch price + /// - 2: Bid total volume + /// - 3: Ask total volume + /// - 4: Bid touch volume + /// + /// the following 40 values are data for each + /// price level below/above the touch + /// + /// - Bid volume at level + /// - Number of buy orders at level + /// - Ask volume at level + /// - Number of sell orders at level + /// + pub fn level_2_data_array<'a>(&self, py: Python<'a>) -> &'a PyArray1 { + let data = self.env.level_2_data(); + let mut data_vec = vec![data.bid_price, data.ask_price, data.ask_vol, data.bid_vol]; + for i in 0..10 { + data_vec.push(data.bid_price_levels[i].0); + data_vec.push(data.bid_price_levels[i].1); + data_vec.push(data.ask_price_levels[i].0); + data_vec.push(data.ask_price_levels[i].1); + } + + data_vec.to_pyarray(py) + } + /// get_trade_volumes() -> numpy.ndarray /// /// Get trade volume history diff --git a/tests/test_step_sim/test_numpy_api.py b/tests/test_step_sim/test_numpy_api.py index 54d7007..3f2696c 100644 --- a/tests/test_step_sim/test_numpy_api.py +++ b/tests/test_step_sim/test_numpy_api.py @@ -21,6 +21,20 @@ def test_submit_limit_orders_numpy(): assert env.best_bid_vol_and_orders == (21, 2) assert env.best_ask_vol_and_orders == (21, 2) + assert np.array_equal( + env.level_1_data_array(), + np.array([20, 22, 33, 33, 21, 2, 21, 2], dtype=np.uint32), + ) + + l2_data = env.level_2_data_array() + + assert l2_data.shape == (44,) + assert np.array_equal( + l2_data[:12], + np.array([20, 22, 33, 33, 21, 2, 21, 2, 12, 1, 12, 1], dtype=np.uint32), + ) + assert np.array_equal(l2_data[12:], np.zeros(32, dtype=np.uint32)) + def test_raise_from_bad_order(): From 2de521ef25fc7bce08e2d68aed3438e5ae0c1590 Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Fri, 22 Mar 2024 23:09:58 +0000 Subject: [PATCH 05/12] Return order-ids --- rust/src/step_sim.rs | 11 ++++++----- tests/test_step_sim/test_numpy_api.py | 4 +++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/rust/src/step_sim.rs b/rust/src/step_sim.rs index 0db2451..ac4f601 100644 --- a/rust/src/step_sim.rs +++ b/rust/src/step_sim.rs @@ -369,13 +369,14 @@ impl StepEnv { /// pub fn submit_limit_order_array<'a>( &mut self, + py: Python<'a>, orders: ( &'a PyArray1, &'a PyArray1, &'a PyArray1, &'a PyArray1, ), - ) -> PyResult<()> { + ) -> PyResult<&'a PyArray1> { let orders = ( orders.0.readonly(), orders.1.readonly(), @@ -391,17 +392,17 @@ impl StepEnv { ); // TODO: would be nice to return better error here - Zip::from(orders.0) + let order_ids = Zip::from(orders.0) .and(orders.1) .and(orders.2) .and(orders.3) - .for_each(|side, vol, id, price| { + .map_collect(|side, vol, id, price| { self.env .place_order((*side).into(), *vol, *id, Some(*price)) - .unwrap(); + .unwrap() }); - Ok(()) + Ok(order_ids.to_pyarray(py)) } /// submit_cancel_order_array(order_ids: numpy.ndarray) diff --git a/tests/test_step_sim/test_numpy_api.py b/tests/test_step_sim/test_numpy_api.py index 3f2696c..3033cea 100644 --- a/tests/test_step_sim/test_numpy_api.py +++ b/tests/test_step_sim/test_numpy_api.py @@ -13,10 +13,12 @@ def test_submit_limit_orders_numpy(): ids = np.array([1, 1, 1, 2, 2, 2], dtype=np.uint32) prices = np.array([20, 20, 19, 22, 22, 23], dtype=np.uint32) - env.submit_limit_order_array((sides, vols, ids, prices)) + ids = env.submit_limit_order_array((sides, vols, ids, prices)) env.step() + assert np.array_equal(ids, np.arange(6)) + assert env.bid_ask == (20, 22) assert env.best_bid_vol_and_orders == (21, 2) assert env.best_ask_vol_and_orders == (21, 2) From f427410a6659800c2b7501ba7ec40784ccd5ab03 Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Tue, 26 Mar 2024 12:12:31 +0000 Subject: [PATCH 06/12] Move Numpy API into dedicated class --- rust/src/lib.rs | 2 + rust/src/step_sim.rs | 73 ---- rust/src/step_sim_numpy.rs | 472 +++++++++++++++++++++++++ rust/src/types.rs | 10 + tests/test_step_sim/test_benchmarks.py | 13 +- tests/test_step_sim/test_numpy_api.py | 46 ++- 6 files changed, 515 insertions(+), 101 deletions(-) create mode 100644 rust/src/step_sim_numpy.rs diff --git a/rust/src/lib.rs b/rust/src/lib.rs index acca5b5..69746ef 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,5 +1,6 @@ mod order_book; mod step_sim; +mod step_sim_numpy; mod types; use pyo3::prelude::*; @@ -8,6 +9,7 @@ use pyo3::prelude::*; fn core(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(order_book::order_book_from_json, m)?)?; Ok(()) } diff --git a/rust/src/step_sim.rs b/rust/src/step_sim.rs index ac4f601..9627d5c 100644 --- a/rust/src/step_sim.rs +++ b/rust/src/step_sim.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; use super::types::{cast_order, cast_trade, PyOrder, PyTrade}; use bourse_book::types::{Nanos, OrderCount, OrderId, Price, Side, TraderId, Vol}; use bourse_de::Env as BaseEnv; -use ndarray::Zip; use numpy::{PyArray1, ToPyArray}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -353,78 +352,6 @@ impl StepEnv { ) } - /// submit_limit_order_array(orders: tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]) - /// - /// Submit new limit orders from a Numpy array - /// - /// Parameters - /// ---------- - /// orders: tuple[np.array, np.array, np.array, np.array] - /// Tuple of numpy arrays containing - /// - /// - Order side as a bool (``True`` if bid-side) - /// - Order volumes - /// - Trader ids - /// - Order prices - /// - pub fn submit_limit_order_array<'a>( - &mut self, - py: Python<'a>, - orders: ( - &'a PyArray1, - &'a PyArray1, - &'a PyArray1, - &'a PyArray1, - ), - ) -> PyResult<&'a PyArray1> { - let orders = ( - orders.0.readonly(), - orders.1.readonly(), - orders.2.readonly(), - orders.3.readonly(), - ); - - let orders = ( - orders.0.as_array(), - orders.1.as_array(), - orders.2.as_array(), - orders.3.as_array(), - ); - - // TODO: would be nice to return better error here - let order_ids = Zip::from(orders.0) - .and(orders.1) - .and(orders.2) - .and(orders.3) - .map_collect(|side, vol, id, price| { - self.env - .place_order((*side).into(), *vol, *id, Some(*price)) - .unwrap() - }); - - Ok(order_ids.to_pyarray(py)) - } - - /// submit_cancel_order_array(order_ids: numpy.ndarray) - /// - /// Submit a Numpy array of order ids to cancel - /// - /// Parameters - /// ---------- - /// order_ids: np.array - /// Numpy array of order-ids to be cancelled - /// - pub fn submit_cancel_order_array(&mut self, order_ids: &'_ PyArray1) -> PyResult<()> { - let order_ids = order_ids.readonly(); - let order_ids = order_ids.as_array(); - - order_ids.for_each(|id| { - self.env.cancel_order(*id); - }); - - Ok(()) - } - /// level_1_data_array() -> numpy.ndarray /// /// Get current level 1 data as a Numpy array diff --git a/rust/src/step_sim_numpy.rs b/rust/src/step_sim_numpy.rs new file mode 100644 index 0000000..55520c1 --- /dev/null +++ b/rust/src/step_sim_numpy.rs @@ -0,0 +1,472 @@ +use std::array; +use std::collections::HashMap; + +use super::types::{cast_order, cast_trade, NumpyInstructions, PyOrder, PyTrade}; +use bourse_book::types::{Nanos, OrderId, Price, TraderId, Vol}; +use bourse_de::{Env as BaseEnv, OrderError}; +use ndarray::Zip; +use numpy::{PyArray1, ToPyArray}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use rand_xoshiro::rand_core::SeedableRng; +use rand_xoshiro::Xoroshiro128StarStar; + +/// Discrete event simulation environment +/// +/// Simulation environment wrapping an orderbook +/// and functionality to update the state of +/// the simulation. This environment is designed +/// for discrete event simulations, where each +/// step agents submit transactions to the market that +/// are shuffled and executed as a batch at the end +/// of each step. Hence there is no guarantee of +/// the ordering of transactions. Agents do not +/// directly alter the state of the market, +/// rather they do by submitting transactions +/// to be processed. +/// +/// This environment returns data and receives +/// instructions via Numpy arrays. +/// +/// Examples +/// -------- +/// +/// .. testcode:: step_sim_numpy_docstring +/// +/// import numpy as np +/// import bourse +/// +/// seed = 101 +/// start_time = 0 +/// tick_size = 1 +/// step_size = 1000 +/// +/// env = bourse.core.StepEnvNumpy( +/// seed, start_time, tick_size, step_size +/// ) +/// +/// # Submit orders via Numpy arrays +/// order_ids = env.submit_limit_orders( +/// ( +/// np.array([True, False]), +/// np.array([10, 20]), +/// np.array([101, 202]), +/// np.array([50, 55]), +/// ), +/// ) +/// +/// # Update the environment +/// env.step() +/// +/// # Cancel orders +/// env.submit_cancellations(order_ids) +/// +/// # Get level-2 data history +/// level_2_data = env.get_market_data() +/// +#[pyclass] +pub struct StepEnvNumpy { + env: BaseEnv, + rng: Xoroshiro128StarStar, +} + +#[pymethods] +impl StepEnvNumpy { + #[new] + #[pyo3(signature = (seed, start_time, tick_size, step_size, trading=true))] + pub fn new( + seed: u64, + start_time: Nanos, + tick_size: Price, + step_size: Nanos, + trading: bool, + ) -> PyResult { + let env = BaseEnv::new(start_time, tick_size, step_size, trading); + let rng = Xoroshiro128StarStar::seed_from_u64(seed); + Ok(Self { env, rng }) + } + + /// Enable trading + /// + /// When enabled order will be matched and executed. + /// + pub fn enable_trading(&mut self) { + self.env.enable_trading(); + } + + /// Disable trading + /// + /// When disabled orders can be placed and modified + /// but will not be matched. + /// + /// Warnings + /// -------- + /// There is currently no market uncrossing algorithm + /// implemented. + /// + pub fn disable_trading(&mut self) { + self.env.disable_trading(); + } + + /// Update the state of the environment + /// + /// Perform one `step` of the simulation updating it's + /// state, each update performs the following steps + /// + /// - Shuffle the order of transactions in the current + /// queue + /// - Execute the instructions in sequence + /// - Update the market time + /// - Record the new state of the market + /// + /// Transactions should be submitted by agents + /// prior to calling ``step``, where all + /// transactions currently in the queue will be + /// processed. + /// + pub fn step(&mut self) -> PyResult<()> { + self.env.step(&mut self.rng); + Ok(()) + } + + /// submit_limit_orders(orders: tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]) + /// + /// Submit new limit orders from a Numpy array + /// + /// Parameters + /// ---------- + /// orders: tuple[np.array, np.array, np.array, np.array] + /// Tuple of numpy arrays containing + /// + /// - Order side as a bool (``True`` if bid-side) + /// - Order volumes + /// - Trader ids + /// - Order prices + /// + pub fn submit_limit_orders<'a>( + &mut self, + py: Python<'a>, + orders: ( + &'a PyArray1, + &'a PyArray1, + &'a PyArray1, + &'a PyArray1, + ), + ) -> PyResult<&'a PyArray1> { + let orders = ( + orders.0.readonly(), + orders.1.readonly(), + orders.2.readonly(), + orders.3.readonly(), + ); + + let orders = ( + orders.0.as_array(), + orders.1.as_array(), + orders.2.as_array(), + orders.3.as_array(), + ); + + // TODO: would be nice to return better error here + let order_ids = Zip::from(orders.0) + .and(orders.1) + .and(orders.2) + .and(orders.3) + .map_collect(|side, vol, id, price| { + self.env + .place_order((*side).into(), *vol, *id, Some(*price)) + .unwrap() + }); + + Ok(order_ids.to_pyarray(py)) + } + + /// submit_cancellations(order_ids: numpy.ndarray) + /// + /// Submit a Numpy array of order ids to cancel + /// + /// Parameters + /// ---------- + /// order_ids: np.array + /// Numpy array of order-ids to be cancelled + /// + pub fn submit_cancellations(&mut self, order_ids: &'_ PyArray1) -> PyResult<()> { + let order_ids = order_ids.readonly(); + let order_ids = order_ids.as_array(); + + order_ids.for_each(|id| { + self.env.cancel_order(*id); + }); + + Ok(()) + } + + /// submit_instructions(instructions: tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]) + /// + /// Submit market instructions as a tuple of Numpy arrays. This allows + /// new limit orders and cancellations to be submitted from a tuple + /// of Numpy arrays. Values that are not used for instructions (e.g. + /// order-id for a new-order) can be set to a default values that will be ignored. + /// + /// Parameters + /// ---------- + /// instructions: tuple[np.array, np.array, np.array, np.array, np.array, np.array] + /// Tuple of numpy arrays containing + /// + /// - Instruction type, an integer representing + /// + /// - ``0``: No change/null instruction + /// - ``1``: New order + /// - ``2``: Cancel order + /// + /// - Order sides (as bool, ``True`` for bid side) (used for new order) + /// - Trader ids (used for new order) + /// - Order prices (used for new order) + /// - Order price (used for new order) + /// - Order id (used for cancellations) + /// + /// Returns + /// ------- + /// np.ndarray + /// Array of ids of newly placed orders. For cancellations + /// or null instructions the default value of a max usize + /// is returned. + /// + #[allow(clippy::type_complexity)] + pub fn submit_instructions<'a>( + &mut self, + py: Python<'a>, + instructions: NumpyInstructions, + ) -> PyResult<&'a PyArray1> { + let instructions = ( + instructions.0.readonly(), + instructions.1.readonly(), + instructions.2.readonly(), + instructions.3.readonly(), + instructions.4.readonly(), + instructions.5.readonly(), + ); + + let action = instructions.0.as_array(); + let sides = instructions.1.as_array(); + let volumes = instructions.2.as_array(); + let trader_ids = instructions.3.as_array(); + let prices = instructions.4.as_array(); + let order_ids = instructions.5.as_array(); + + let ids: Result, OrderError> = (0..instructions.0.len()) + .map(|i| match action[i] { + 0 => Ok(OrderId::MAX), + 1 => self.env.place_order( + sides[i].into(), + volumes[i], + trader_ids[i], + Some(prices[i]), + ), + 2 => { + self.env.cancel_order(order_ids[i]); + Ok(OrderId::MAX) + } + _ => Ok(OrderId::MAX), + }) + .collect(); + + match ids { + Ok(i) => Ok(i.to_pyarray(py)), + Err(e) => Err(PyValueError::new_err(e.to_string())), + } + } + + /// level_1_data() -> numpy.ndarray + /// + /// Get current level 1 data as a Numpy array + /// + /// Returns a Numpy array with values at indices: + /// + /// - 0: Trade volume (in the last step) + /// - 1: Bid touch price + /// - 2: Ask touch price + /// - 3: Bid total volume + /// - 4: Ask total volume + /// - 5: Bid touch volume + /// - 6: Number of buy orders at touch + /// - 7: Ask touch volume + /// - 8: Number of sell orders at touch + /// + pub fn level_1_data<'a>(&self, py: Python<'a>) -> &'a PyArray1 { + let data = self.env.level_2_data(); + let data_vec = [ + self.env.get_orderbook().get_trade_vol(), + data.bid_price, + data.ask_price, + data.ask_vol, + data.bid_vol, + data.bid_price_levels[0].0, + data.bid_price_levels[0].1, + data.ask_price_levels[0].0, + data.ask_price_levels[0].1, + ]; + + data_vec.to_pyarray(py) + } + + /// level_2_data() -> numpy.ndarray + /// + /// Get current level 2 data as a Numpy array + /// + /// Returns a Numpy array with values at indices: + /// + /// - 0: Trade volume (in the last step) + /// - 1: Bid touch price + /// - 2: Ask touch price + /// - 3: Bid total volume + /// - 4: Ask total volume + /// + /// the following 40 values are data for each + /// price level below/above the touch + /// + /// - Bid volume at level + /// - Number of buy orders at level + /// - Ask volume at level + /// - Number of sell orders at level + /// + pub fn level_2_data<'a>(&self, py: Python<'a>) -> &'a PyArray1 { + let data = self.env.level_2_data(); + let mut data_vec = vec![ + self.env.get_orderbook().get_trade_vol(), + data.bid_price, + data.ask_price, + data.ask_vol, + data.bid_vol, + ]; + for i in 0..10 { + data_vec.push(data.bid_price_levels[i].0); + data_vec.push(data.bid_price_levels[i].1); + data_vec.push(data.ask_price_levels[i].0); + data_vec.push(data.ask_price_levels[i].1); + } + + data_vec.to_pyarray(py) + } + + /// get_orders() -> list[tuple] + /// + /// Get order data + /// + /// Return a list of all orders created for the market + /// including all completed/cancelled/rejected orders. + /// + /// Returns + /// ------- + /// list + /// List of tuples records representing all orders created, + /// with fields: + /// + /// - side (``True`` indicates bid-side) + /// - status of the order + /// - arrival time of the order + /// - end time of the order + /// - Remaining volume of the order + /// - Starting volume of the order + /// - Price of the order + /// - Id of the trader/agent who placed the order + /// - Id of the order + /// + pub fn get_orders(&self) -> Vec { + self.env.get_orders().into_iter().map(cast_order).collect() + } + + /// get_trades() -> list[tuple] + /// + /// Get trade data + /// + /// Get a list of trades executed in the environment. + /// + /// Returns + /// ------- + /// list + /// A list of tuple trade records with fields + /// + /// - Trade time + /// - Side flag (``True`` for bid side) + /// - Trade price + /// - Trade volume + /// - Id of the aggressive order + /// - Id of the passive order + /// + pub fn get_trades(&self) -> Vec { + self.env.get_trades().iter().map(cast_trade).collect() + } + + /// get_market_data() -> dict[str, numpy.ndarray] + /// + /// Get simulation market data + /// + /// Get a dictionary containing level 2 market data over the simulation + /// + /// - Bid and ask touch prices + /// - Bid and ask volumes + /// - Volumes and number of orders at 10 levels from the touch + /// + /// Returns + /// ------- + /// dict[str, np.ndarray] + /// Dictionary containing level 1 data with keys: + /// + /// - ``bid_price`` - Touch price + /// - ``ask_price`` - Touch price + /// - ``bid_vol`` - Total volume + /// - ``ask_vol`` - Total volume + /// - ``trade_vol`` - Total trade vol over a step + /// - ``bid_vol_`` - Volumes at 10 levels from bid touch + /// - ``ask_vol_`` - Volumes at 10 levels from ask touch + /// - ``n_bid_`` - Number of orders at 10 levels from the bid + /// - ``n_ask_`` - Number of orders at 10 levels from the ask + /// + pub fn get_market_data<'a>(&self, py: Python<'a>) -> HashMap> { + let data = self.env.get_level_2_data_history(); + let trade_volumes = self.env.get_trade_vols().to_pyarray(py); + + let bid_vols: [(String, &'a PyArray1); 10] = array::from_fn(|i| { + ( + format!("bid_vol_{i}"), + data.volumes_at_levels.0[i].to_pyarray(py), + ) + }); + let ask_vols: [(String, &'a PyArray1); 10] = array::from_fn(|i| { + ( + format!("ask_vol_{i}"), + data.volumes_at_levels.1[i].to_pyarray(py), + ) + }); + + let bid_orders: [(String, &'a PyArray1); 10] = array::from_fn(|i| { + ( + format!("n_bid_{i}"), + data.orders_at_levels.0[i].to_pyarray(py), + ) + }); + let ask_orders: [(String, &'a PyArray1); 10] = array::from_fn(|i| { + ( + format!("n_ask_{i}"), + data.orders_at_levels.1[i].to_pyarray(py), + ) + }); + + let mut py_data = HashMap::from([ + ("bid_price".to_string(), data.prices.0.to_pyarray(py)), + ("ask_price".to_string(), data.prices.1.to_pyarray(py)), + ("bid_vol".to_string(), data.volumes.0.to_pyarray(py)), + ("ask_vol".to_string(), data.volumes.1.to_pyarray(py)), + ("trade_vol".to_string(), trade_volumes), + ]); + + py_data.extend(bid_vols); + py_data.extend(ask_vols); + + py_data.extend(bid_orders); + py_data.extend(ask_orders); + + py_data + } +} diff --git a/rust/src/types.rs b/rust/src/types.rs index 75a8ee7..52df77e 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1,4 +1,5 @@ use bourse_de::types::{Nanos, Order, OrderId, Price, Trade, TraderId, Vol}; +use numpy::PyArray1; pub type PyTrade = (Nanos, bool, Price, Vol, OrderId, OrderId); @@ -28,3 +29,12 @@ pub fn cast_order(order: &Order) -> PyOrder { order.order_id, ) } + +pub type NumpyInstructions<'a> = ( + &'a PyArray1, + &'a PyArray1, + &'a PyArray1, + &'a PyArray1, + &'a PyArray1, + &'a PyArray1, +); diff --git a/tests/test_step_sim/test_benchmarks.py b/tests/test_step_sim/test_benchmarks.py index 4ea7639..82a7f57 100644 --- a/tests/test_step_sim/test_benchmarks.py +++ b/tests/test_step_sim/test_benchmarks.py @@ -14,6 +14,11 @@ def env(): return bourse.core.StepEnv(SEED, 0, TiCK_SIZE, 100_000) +@pytest.fixture +def numpy_env(): + return bourse.core.StepEnvNumpy(SEED, 0, TiCK_SIZE, 100_000) + + @pytest.fixture def agents(): return [ @@ -39,8 +44,8 @@ def run_numpy_sim(n_steps, seed, e, a): for _ in range(n_steps): for agent in a: orders, cancels = agent.update(rng, e) - e.submit_limit_order_array(orders) - e.submit_cancel_order_array(cancels) + e.submit_limit_orders(orders) + e.submit_cancellations(cancels) def test_simulation_benchmark(benchmark, env, agents): @@ -49,7 +54,7 @@ def test_simulation_benchmark(benchmark, env, agents): benchmark(run_sim, n_steps, seed, env, agents) -def test_numpy_simulation_benchmark(benchmark, env, numpy_agents): +def test_numpy_simulation_benchmark(benchmark, numpy_env, numpy_agents): n_steps = 100 seed = 101 - benchmark(run_numpy_sim, n_steps, seed, env, numpy_agents) + benchmark(run_numpy_sim, n_steps, seed, numpy_env, numpy_agents) diff --git a/tests/test_step_sim/test_numpy_api.py b/tests/test_step_sim/test_numpy_api.py index 3033cea..69423bd 100644 --- a/tests/test_step_sim/test_numpy_api.py +++ b/tests/test_step_sim/test_numpy_api.py @@ -6,41 +6,37 @@ def test_submit_limit_orders_numpy(): - env = bourse.core.StepEnv(101, 0, 1, 100_000) + env = bourse.core.StepEnvNumpy(101, 0, 1, 100_000) sides = np.array([True, True, True, False, False, False]) vols = np.array([10, 11, 12, 10, 11, 12], dtype=np.uint32) ids = np.array([1, 1, 1, 2, 2, 2], dtype=np.uint32) prices = np.array([20, 20, 19, 22, 22, 23], dtype=np.uint32) - ids = env.submit_limit_order_array((sides, vols, ids, prices)) + ids = env.submit_limit_orders((sides, vols, ids, prices)) env.step() assert np.array_equal(ids, np.arange(6)) - assert env.bid_ask == (20, 22) - assert env.best_bid_vol_and_orders == (21, 2) - assert env.best_ask_vol_and_orders == (21, 2) - assert np.array_equal( - env.level_1_data_array(), - np.array([20, 22, 33, 33, 21, 2, 21, 2], dtype=np.uint32), + env.level_1_data(), + np.array([0, 20, 22, 33, 33, 21, 2, 21, 2], dtype=np.uint32), ) - l2_data = env.level_2_data_array() + l2_data = env.level_2_data() - assert l2_data.shape == (44,) + assert l2_data.shape == (45,) assert np.array_equal( - l2_data[:12], - np.array([20, 22, 33, 33, 21, 2, 21, 2, 12, 1, 12, 1], dtype=np.uint32), + l2_data[:13], + np.array([0, 20, 22, 33, 33, 21, 2, 21, 2, 12, 1, 12, 1], dtype=np.uint32), ) - assert np.array_equal(l2_data[12:], np.zeros(32, dtype=np.uint32)) + assert np.array_equal(l2_data[13:], np.zeros(32, dtype=np.uint32)) def test_raise_from_bad_order(): - env = bourse.core.StepEnv(101, 0, 2, 100_000) + env = bourse.core.StepEnvNumpy(101, 0, 2, 100_000) sides = np.array([True, True]) vols = np.array([10, 11], dtype=np.uint32) @@ -48,38 +44,40 @@ def test_raise_from_bad_order(): prices = np.array([20, 21], dtype=np.uint32) with pytest.raises(BaseException): - env.submit_limit_order_array((sides, vols, ids, prices)) + env.submit_limit_orders((sides, vols, ids, prices)) def test_cancel_orders_from_array(): - env = bourse.core.StepEnv(101, 0, 1, 100_000) + env = bourse.core.StepEnvNumpy(101, 0, 1, 100_000) sides = np.array([True, True, True, False, False, False]) vols = np.array([10, 11, 12, 10, 11, 12], dtype=np.uint32) ids = np.array([1, 1, 1, 2, 2, 2], dtype=np.uint32) prices = np.array([20, 20, 19, 22, 22, 23], dtype=np.uint32) - env.submit_limit_order_array((sides, vols, ids, prices)) + env.submit_limit_orders((sides, vols, ids, prices)) env.step() - env.submit_cancel_order_array(np.array([0, 1, 3, 4], dtype=np.uint64)) + env.submit_cancellations(np.array([0, 1, 3, 4], dtype=np.uint64)) env.step() - assert env.bid_ask == (19, 23) - assert env.best_bid_vol_and_orders == (12, 1) - assert env.best_ask_vol_and_orders == (12, 1) + level_1_data = env.level_1_data() + + assert (level_1_data[1], level_1_data[2]) == (19, 23) + assert (level_1_data[5], level_1_data[6]) == (12, 1) + assert (level_1_data[7], level_1_data[8]) == (12, 1) def test_numpy_random_agent(): - env = bourse.core.StepEnv(101, 0, 1, 100_000) + env = bourse.core.StepEnvNumpy(101, 0, 1, 100_000) agents = bourse.step_sim.agents.NumpyRandomAgents(20, (10, 60), (10, 20), 2) rng = np.random.default_rng(101) orders, cancellations = agents.update(rng, None) - env.submit_limit_order_array(orders) - env.submit_cancel_order_array(cancellations) + env.submit_limit_orders(orders) + env.submit_cancellations(cancellations) From ae194df71874da59f3af46367f6d0d162658aeec Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Wed, 27 Mar 2024 11:28:29 +0000 Subject: [PATCH 07/12] Update docs --- docs/source/conf.py | 9 +- docs/source/pages/api.rst | 2 + docs/source/pages/discrete_event_usage.rst | 54 ++++++++ .../pages/numpy_discrete_event_usage.rst | 102 ++++++++++++++ docs/source/pages/order_book_usage.rst | 64 +++++++++ docs/source/pages/usage.rst | 130 ++---------------- rust/src/step_sim_numpy.rs | 14 +- 7 files changed, 244 insertions(+), 131 deletions(-) create mode 100644 docs/source/pages/discrete_event_usage.rst create mode 100644 docs/source/pages/numpy_discrete_event_usage.rst create mode 100644 docs/source/pages/order_book_usage.rst diff --git a/docs/source/conf.py b/docs/source/conf.py index 87a17b4..7c0e9a6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -44,12 +44,10 @@ html_theme_options = { "repo_url": "https://github.com/zombie-einstein/bourse", - "icon": { - "repo": "fontawesome/brands/github", - }, + "icon": {"repo": "fontawesome/brands/github", "logo": "material/chart-line"}, "palette": { "scheme": "slate", - "primary": "teal", + "primary": "black", }, "toc_title_is_page_title": True, } @@ -82,4 +80,7 @@ (r"class:.*StepEnv.*", "step_env_class"), (r"method:.*StepEnv.*", "StepEnv Methods"), (r"attribute:.*StepEnv.*", "StepEnv Attributes"), + (r"class:.*StepEnvNumpy.*", "step_env_numpy_class"), + (r"method:.*StepEnvNumpy.*", "StepEnvNumpy Methods"), + (r"attribute:.*StepEnvNumpy.*", "StepEnvNumpy Attributes"), ] diff --git a/docs/source/pages/api.rst b/docs/source/pages/api.rst index bd98340..811c4ae 100644 --- a/docs/source/pages/api.rst +++ b/docs/source/pages/api.rst @@ -16,4 +16,6 @@ API Reference .. python-apigen-group:: step_env_class +.. python-apigen-group:: step_env_numpy_class + .. python-apigen-group:: public-members diff --git a/docs/source/pages/discrete_event_usage.rst b/docs/source/pages/discrete_event_usage.rst new file mode 100644 index 0000000..76f08ac --- /dev/null +++ b/docs/source/pages/discrete_event_usage.rst @@ -0,0 +1,54 @@ +Discrete Event Simulation Environment +------------------------------------- + +A discrete event simulation environment can be initialised from +a random seed, start-time, tick-size, and step-size (i.e. how +long in time each simulated step is) + +.. testcode:: sim_usage + + import bourse + + seed = 101 + start_time = 0 + tick_size = 2 + step_size = 100_000 + + env = bourse.core.StepEnv(seed, start_time, tick_size, step_size) + +The state of the simulation is updated in discrete +steps, with transactions submitted to a queue to +be processed at the end of the step. For example +placing new orders + +.. testcode:: sim_usage + + order_id_a = env.place_order(False, 100, 101, price=60) + order_id_b = env.place_order(True, 100, 101, price=70) + +To actually update the state of the simulation we call +:py:meth:`bourse.core.StepEnv.step` which shuffles and +processes the queued instructions. Each step also increments +time to correctly order transactions. + +The simulation environment also tracks market data for each +step, for example bid-ask prices can be retrieved using + +.. testcode:: sim_usage + + bid_prices, ask_prices = env.get_prices() + +the full level 2 data (price and volumes along with volumes +and number of orders at top 10 levels) records can be +retrieved with + +.. testcode:: sim_usage + + level_2_data = env.get_market_data() + +See :py:class:`bourse.core.StepEnv` for full details +of the environment API. + +:py:meth:`bourse.step_sim.run` is a utility for running a +simulation from an environment and set of agents. See +:ref:`Simulation Example` for a full simulation example. diff --git a/docs/source/pages/numpy_discrete_event_usage.rst b/docs/source/pages/numpy_discrete_event_usage.rst new file mode 100644 index 0000000..2f6d74e --- /dev/null +++ b/docs/source/pages/numpy_discrete_event_usage.rst @@ -0,0 +1,102 @@ +Numpy Discrete Event Simulation Environment +------------------------------------------- + +This environment allows market state and market instructions +to be returned/submitted as Numpy arrays. This has the +potential for higher performance (over native Python) using +vectorisation (with some limitations on functionality) in +particular for ML and RL use-cases. + +The simulation environment can be initialised from +a random seed, start-time, tick-size, and step-size (i.e. how +long in time each simulated step is) + +.. testcode:: numpy_sim_usage + + import numpy as np + import bourse + + seed = 101 + start_time = 0 + tick_size = 2 + step_size = 100_000 + + env = bourse.core.StepEnvNumpy(seed, start_time, tick_size, step_size) + +New order and cancellations can be submitted as Numpy arrays + +.. testcode:: numpy_sim_usage + + new_orders = ( + # Order sides + np.array([True, False]), + # Order volumes + np.array([10, 20], dtype=np.uint32), + # Trader ids + np.array([101, 202], dtype=np.uint32), + # Order prices + np.array([50, 60], dtype=np.uint32), + ) + + new_order_ids = env.submit_limit_orders(new_orders) + + # Update the environment state (placing orders) + env.step() + + # Cancel the new orders + env.submit_cancellations(new_order_ids) + +Multiple instruction types can be submitted as a tuple of arrays: + +- The instruction type where ``0 = no action``, ``1 = new-order``, and + ``2 = cancellation``. +- Order sides (as bool, ``True`` for bid side) (used for new orders) +- Order volumes (used for new orders) +- Trader ids (used for new orders) +- Order prices (used for new orders) +- Order ids (used for cancellations) + +.. note:: + + Values that are not used for a given action (e.g. order-ids for + new orders) are ignored, so can be set to an arbitrary default. + +For example, if we want to submit one instruction with no change +and one new-order we could use: + +.. testcode:: numpy_sim_usage + + instructions = ( + np.array([0, 1], dtype=np.uint32), + np.array([True, True]), + np.array([0, 20], dtype=np.uint32), + np.array([0, 101], dtype=np.uint32), + np.array([0, 50], dtype=np.uint32), + np.array([0, 0], dtype=np.uint64) + ) + + new_order_ids = env.submit_instructions(instructions) + + env.step() + +.. warning:: + + This method currently only supports submitting limit + orders and cancelling orders. + +The state of the order book can be retrieved as an array +of values representing the current touch-prices, volumes and +volumes and orders at price levels + +.. testcode:: numpy_sim_usage + + level_1_data = env.level_1_data() + + level_2_data = env.level_2_data() + +where the level-1 data only contains the touch volume and +number of orders, and level-2 data contains the volume and +number of orders for the first 10 price levels from the touch. + +See :py:class:`bourse.core.StepEnvNumpy` for full details +of the API. diff --git a/docs/source/pages/order_book_usage.rst b/docs/source/pages/order_book_usage.rst new file mode 100644 index 0000000..ad1a321 --- /dev/null +++ b/docs/source/pages/order_book_usage.rst @@ -0,0 +1,64 @@ +Orderbook +--------- + +An orderbook is initialised with a start time +(this is the time used to record events) and a +tick-size + +.. testcode:: book_usage + + import bourse + + start_time = 0 + tick_size = 1 + + book = bourse.core.OrderBook(start_time, tick_size) + +The state of the orderbook an then be directly +updated, for example placing a limit bid order + +.. testcode:: book_usage + + order_vol = 10 + trader_id = 101 + order_id = book.place_order( + True, order_vol, trader_id, price=50 + ) + +or cancelling the same order + +.. testcode:: book_usage + + book.cancel_order(order_id) + +When directly interacting with the orderbook +updates are immediately applied and the state +of the market updated. + +The orderbook also tracks updates, for example +trades executed on the order book can be +retrieved with + +.. testcode:: book_usage + + trades = book.get_trades() + # Convert trade data to a dataframe + trade_df = bourse.data_processing.trades_to_dataframe( + trades + ) + +The state of the order book can be written to a JSON +file using :py:meth:`bourse.core.OrderBook.save_json_snapshot`, +the same snapshot can then be used to initialise an +orderbook using :py:meth:`bourse.core.order_book_from_json` + +.. code-block:: python + + # Save order book state to foo.json + book.save_json_snapshot("foo.json") + + # Create a new order book with state from the snapshot + loaded_book = bourse.core.order_book_from_json("foo.json") + +See :py:class:`bourse.core.OrderBook` +for details of the full order book API. diff --git a/docs/source/pages/usage.rst b/docs/source/pages/usage.rst index d245956..56c643b 100644 --- a/docs/source/pages/usage.rst +++ b/docs/source/pages/usage.rst @@ -1,8 +1,8 @@ Usage ===== -Bourse allows Python users to interact -with two core pieces of functionality +Bourse allows Python users/programs to interact +with three core pieces of functionality from the Rust package: - An orderbook that allow orders to be directly @@ -11,123 +11,13 @@ from the Rust package: allows Python agents to submit trade instructions with functionality to update simulation state and track simulation data. +- A discrete event simulation environment can receive + instructions and returns data in the format of + Numpy arrays. -Orderbook ---------- +.. toctree:: + :maxdepth: 2 -An orderbook is initialised with a start time -(this is the time used to record events) and a -tick-size - -.. testcode:: book_usage - - import bourse - - start_time = 0 - tick_size = 1 - - book = bourse.core.OrderBook(start_time, tick_size) - -The state of the orderbook an then be directly -updated, for example placing a limit bid order - -.. testcode:: book_usage - - order_vol = 10 - trader_id = 101 - order_id = book.place_order( - True, order_vol, trader_id, price=50 - ) - -or cancelling the same order - -.. testcode:: book_usage - - book.cancel_order(order_id) - -When directly interacting with the orderbook -updates are immediately applied and the state -of the market updated. - -The orderbook also tracks updates, for example -trades executed on the order book can be -retrieved with - -.. testcode:: book_usage - - trades = book.get_trades() - # Convert trade data to a dataframe - trade_df = bourse.data_processing.trades_to_dataframe( - trades - ) - -The state of the order book can be written to a JSON -file using :py:meth:`bourse.core.OrderBook.save_json_snapshot`, -the same snapshot can then be used to initialise an -orderbook using :py:meth:`bourse.core.order_book_from_json` - -.. code-block:: python - - # Save order book state to foo.json - book.save_json_snapshot("foo.json") - - # Create a new order book with state from the snapshot - loaded_book = bourse.core.order_book_from_json("foo.json") - -See :py:class:`bourse.core.OrderBook` -for details of the full order book API. - -Discrete Event Simulation Environment -------------------------------------- - -A discrete event simulation environment can be initialised from -a random seed, start-time, tick-size, and step-size (i.e. how -long in time each simulated step is) - -.. testcode:: sim_usage - - import bourse - - seed = 101 - start_time = 0 - tick_size = 2 - step_size = 100_000 - - env = bourse.core.StepEnv(seed, start_time, tick_size, step_size) - -The state of the simulation is updated in discrete -steps, with transactions submitted to a queue to -be processed at the end of the step. For example -placing new orders - -.. testcode:: sim_usage - - order_id_a = env.place_order(False, 100, 101, price=60) - order_id_b = env.place_order(True, 100, 101, price=70) - -To actually update the state of the simulation we call -:py:meth:`bourse.core.StepEnv.step` which shuffles and -processes the queued instructions. Each step also increments -time to correctly order transactions. - -The simulation environment also tracks market data for each -step, for example bid-ask prices can be retrieved using - -.. testcode:: sim_usage - - bid_prices, ask_prices = env.get_prices() - -the full level 2 data (price and volumes along with volumes -and number of orders at top 10 levels) records can be -retrieved with - -.. testcode:: sim_usage - - level_2_data = env.get_market_data() - -See :py:class:`bourse.core.StepEnv` for full details -of the environment API. - -:py:meth:`bourse.step_sim.run` is a utility for running a -simulation from an environment and set of agents. See -:ref:`Simulation Example` for a full simulation example. + order_book_usage + discrete_event_usage + numpy_discrete_event_usage diff --git a/rust/src/step_sim_numpy.rs b/rust/src/step_sim_numpy.rs index 55520c1..4945b97 100644 --- a/rust/src/step_sim_numpy.rs +++ b/rust/src/step_sim_numpy.rs @@ -49,9 +49,9 @@ use rand_xoshiro::Xoroshiro128StarStar; /// order_ids = env.submit_limit_orders( /// ( /// np.array([True, False]), -/// np.array([10, 20]), -/// np.array([101, 202]), -/// np.array([50, 55]), +/// np.array([10, 20], dtype=np.uint32), +/// np.array([101, 202], dtype=np.uint32), +/// np.array([50, 55], dtype=np.uint32), /// ), /// ) /// @@ -219,10 +219,10 @@ impl StepEnvNumpy { /// - ``1``: New order /// - ``2``: Cancel order /// - /// - Order sides (as bool, ``True`` for bid side) (used for new order) - /// - Trader ids (used for new order) - /// - Order prices (used for new order) - /// - Order price (used for new order) + /// - Order sides (as bool, ``True`` for bid side) (used for new orders) + /// - Order volumes (used for new orders) + /// - Trader ids (used for new orders) + /// - Order prices (used for new orders) /// - Order id (used for cancellations) /// /// Returns From b47174f82dc994e46703ca40c2b72958c1736889 Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Wed, 27 Mar 2024 16:13:49 +0000 Subject: [PATCH 08/12] Add numpy api agent type --- docs/source/conf.py | 3 + docs/source/pages/api.rst | 2 + rust/src/step_sim_numpy.rs | 10 +-- src/bourse/step_sim/agents/__init__.py | 2 +- src/bourse/step_sim/agents/base_agent.py | 87 ++++++++++++++++++++++ src/bourse/step_sim/agents/random_agent.py | 29 ++++---- tests/test_step_sim/test_benchmarks.py | 5 +- tests/test_step_sim/test_numpy_api.py | 5 +- 8 files changed, 117 insertions(+), 26 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 7c0e9a6..4d9cf02 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -68,6 +68,9 @@ (r"class:.*BaseAgent.*", "base_agent_class"), (r"method:.*BaseAgent.*", "BaseAgent Methods"), (r"attribute:.*BaseAgent.*", "BaseAgent Attributes"), + (r"class:.*BaseNumpyAgent.*", "base_numpy_agent_class"), + (r"method:.*BaseNumpyAgent.*", "BaseNumpyAgent Methods"), + (r"attribute:.*BaseNumpyAgent.*", "BaseNumpyAgent Attributes"), (r"class:.*RandomAgent.*", "random_agent_class"), (r"method:.*RandomAgent.*", "RandomAgent Methods"), (r"attribute:.*RandomAgent.*", "RandomAgent Attributes"), diff --git a/docs/source/pages/api.rst b/docs/source/pages/api.rst index 811c4ae..f1a094b 100644 --- a/docs/source/pages/api.rst +++ b/docs/source/pages/api.rst @@ -8,6 +8,8 @@ API Reference .. python-apigen-group:: base_agent_class +.. python-apigen-group:: base_numpy_agent_class + .. python-apigen-group:: random_agent_class .. python-apigen-group:: n_random_agent_class diff --git a/rust/src/step_sim_numpy.rs b/rust/src/step_sim_numpy.rs index 4945b97..226d5f8 100644 --- a/rust/src/step_sim_numpy.rs +++ b/rust/src/step_sim_numpy.rs @@ -206,18 +206,18 @@ impl StepEnvNumpy { /// Submit market instructions as a tuple of Numpy arrays. This allows /// new limit orders and cancellations to be submitted from a tuple /// of Numpy arrays. Values that are not used for instructions (e.g. - /// order-id for a new-order) can be set to a default values that will be ignored. + /// order-id for a new-order) can be set to a default value that will be ignored. /// /// Parameters /// ---------- /// instructions: tuple[np.array, np.array, np.array, np.array, np.array, np.array] - /// Tuple of numpy arrays containing + /// Tuple of numpy arrays containing: /// /// - Instruction type, an integer representing /// - /// - ``0``: No change/null instruction - /// - ``1``: New order - /// - ``2``: Cancel order + /// - ``0``: No change/null instruction + /// - ``1``: New order + /// - ``2``: Cancel order /// /// - Order sides (as bool, ``True`` for bid side) (used for new orders) /// - Order volumes (used for new orders) diff --git a/src/bourse/step_sim/agents/__init__.py b/src/bourse/step_sim/agents/__init__.py index 66cdee7..b2915fa 100644 --- a/src/bourse/step_sim/agents/__init__.py +++ b/src/bourse/step_sim/agents/__init__.py @@ -1,5 +1,5 @@ """ Discrete event simulation agent implementations """ -from .base_agent import BaseAgent +from .base_agent import BaseAgent, BaseNumpyAgent, InstructionArrays from .random_agent import NumpyRandomAgents, RandomAgent diff --git a/src/bourse/step_sim/agents/base_agent.py b/src/bourse/step_sim/agents/base_agent.py index c0a36d3..17c6c2b 100644 --- a/src/bourse/step_sim/agents/base_agent.py +++ b/src/bourse/step_sim/agents/base_agent.py @@ -1,6 +1,8 @@ """ Base discrete event agent pattern """ +import typing + import numpy as np from bourse import core @@ -27,3 +29,88 @@ def update(self, rng: np.random.Generator, env: core.StepEnv): Discrete event simulation environment. """ raise NotImplementedError + + +InstructionArrays = typing.Tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray +] + + +class BaseNumpyAgent: + """ + Base discrete event agent using Numpy data + + Examples + -------- + + .. testcode:: base_numpy_agent + + import numpy as np + from bourse.step_sim.agents import BaseNumpyAgent + + class Agent(BaseNumpyAgent): + # This agent just gets the current touch prices + # and places an order either side of the spread + def update(self, rng, level_2_data): + bid, ask = level_2_data[1], level_2_data[2] + + return ( + np.array([1, 1], dtype=np.uint32), + np.array([True, False]), + np.array([10, 20], dtype=np.uint32), + np.array([101, 202], dtype=np.uint32), + np.array([bid, ask], dtype=np.uint32), + np.array([0, 0], dtype=np.uint64), + ) + """ + + def update( + self, rng: np.random.Generator, level_2_data: np.ndarray + ) -> InstructionArrays: + """ + Update the state of the agent and return new market instructions + + Update function called at each step of the simulation, + should update the state of the agents(s) and return Numpy arrays + represent instructions to submit to the market. + + Parameters + ---------- + rng: numpy.random.Generator + Numpy random generator. + level_2_data: np.ndarray + Numpy array representing the current state of the market. + Contains the following values at positions: + + - 0: Trade volume (in the last step) + - 1: Bid touch price + - 2: Ask touch price + - 3: Bid total volume + - 4: Ask total volume + + the following 40 values are data for each + of the 10 price level below/above the touch + + - Bid volume at level + - Number of buy orders at level + - Ask volume at level + - Number of sell orders at level + + Returns + ------- + tuple + Tuple of numpy arrays containing: + + - Instruction type, an integer representing + + - ``0``: No change/null instruction + - ``1``: New order + - ``2``: Cancel order + + - Order sides (as bool, ``True`` for bid side) (used for new orders) + - Order volumes (used for new orders) + - Trader ids (used for new orders) + - Order prices (used for new orders) + - Order id (used for cancellations) + """ + raise NotImplementedError diff --git a/src/bourse/step_sim/agents/random_agent.py b/src/bourse/step_sim/agents/random_agent.py index e0db288..5dcfdd7 100644 --- a/src/bourse/step_sim/agents/random_agent.py +++ b/src/bourse/step_sim/agents/random_agent.py @@ -6,7 +6,7 @@ import numpy as np from bourse import core -from bourse.step_sim.agents import BaseAgent +from bourse.step_sim.agents import BaseAgent, BaseNumpyAgent, InstructionArrays class RandomAgent(BaseAgent): @@ -91,7 +91,7 @@ def update(self, rng: np.random.Generator, env: core.StepEnv): ) -class NumpyRandomAgents(BaseAgent): +class NumpyRandomAgents(BaseNumpyAgent): """ Simple agent set that places random orders via Numpy arrays @@ -131,8 +131,8 @@ def __init__( self.tick_size = tick_size def update( - self, rng: np.random.Generator, env: core.StepEnv - ) -> typing.Tuple[typing.Tuple, np.array]: + self, rng: np.random.Generator, level_2_data: np.ndarray + ) -> InstructionArrays: """ Update the agents, sampling new orders to place @@ -140,16 +140,13 @@ def update( ---------- rng: numpy.random.Generator Numpy random generator. - env: bourse.core.StepEnv - Discrete event simulation environment. + level_2_data: bourse.core.StepEnv + Level-2 market data Returns ------- tuple - Tuple containing: - - - A tuple of arrays representing new orders to be placed - - An array of orders to cancel (always empty) + Tuple containing new order instructions """ sides = rng.choice([True, False], size=self.n_agents).astype(bool) vols = rng.integers(*self.tick_range, size=self.n_agents, dtype=np.uint32) @@ -159,7 +156,11 @@ def update( * self.tick_size ) - # No cancellations created - cancellations = np.array([], dtype=np.uint64) - - return (sides, vols, ids, prices), cancellations + return ( + np.ones(self.n_agents, dtype=np.uint32), + sides, + vols, + ids, + prices, + np.zeros(self.n_agents, dtype=np.uint64), + ) diff --git a/tests/test_step_sim/test_benchmarks.py b/tests/test_step_sim/test_benchmarks.py index 82a7f57..4c09acd 100644 --- a/tests/test_step_sim/test_benchmarks.py +++ b/tests/test_step_sim/test_benchmarks.py @@ -43,9 +43,8 @@ def run_numpy_sim(n_steps, seed, e, a): for _ in range(n_steps): for agent in a: - orders, cancels = agent.update(rng, e) - e.submit_limit_orders(orders) - e.submit_cancellations(cancels) + instructions = agent.update(rng, e) + e.submit_instructions(instructions) def test_simulation_benchmark(benchmark, env, agents): diff --git a/tests/test_step_sim/test_numpy_api.py b/tests/test_step_sim/test_numpy_api.py index 69423bd..fc854a2 100644 --- a/tests/test_step_sim/test_numpy_api.py +++ b/tests/test_step_sim/test_numpy_api.py @@ -77,7 +77,6 @@ def test_numpy_random_agent(): agents = bourse.step_sim.agents.NumpyRandomAgents(20, (10, 60), (10, 20), 2) rng = np.random.default_rng(101) - orders, cancellations = agents.update(rng, None) + instructions = agents.update(rng, env.level_2_data()) - env.submit_limit_orders(orders) - env.submit_cancellations(cancellations) + env.submit_instructions(instructions) From 9bd0b21a25a2bfcc5423a4262158c9b310181871 Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Wed, 27 Mar 2024 16:55:45 +0000 Subject: [PATCH 09/12] Implement Numpy api runner --- src/bourse/step_sim/runner.py | 45 +++++++++++++++++++++++---- tests/test_step_sim/test_env.py | 2 +- tests/test_step_sim/test_numpy_api.py | 38 ++++++++++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/bourse/step_sim/runner.py b/src/bourse/step_sim/runner.py index 10cc808..dcf59b2 100644 --- a/src/bourse/step_sim/runner.py +++ b/src/bourse/step_sim/runner.py @@ -10,11 +10,12 @@ def run( - env: bourse.core.StepEnv, + env: typing.Union[bourse.core.StepEnv, bourse.core.StepEnvNumpy], agents: typing.Iterable, n_steps: int, seed: int, show_progress: bool = True, + use_numpy: bool = False, ) -> typing.Dict[str, np.ndarray]: """ Run a discrete event simulation for fixed number of steps @@ -51,7 +52,13 @@ def run( agents: list or tuple Iterable containing initialised agents. Agents should have an ``update`` method that interacts - with the simulation environment. + with the simulation environment, or if ``use_numpy`` + is ``True`` then agents should return a tuple + of Numpy array instructions. See + :py:class:`bourse.step_sim.agents.base_agent.BaseAgent` + and + :py:class:`bourse.step_sim.agents.base_agent.BaseNumpyAgent` + for more details. n_steps: int Number of simulation steps to run. seed: int @@ -59,6 +66,9 @@ def run( show_progress: bool, optional If ``True`` a progress bar will be displayed, default ``True`` + use_numpy: bool, optional + If ``True`` use numpy api to for market state and + to submit market instructions. Default ``False`` Returns ------- @@ -76,12 +86,35 @@ def run( - ``n_ask_``: Number of ask orders at top 10 levels at each step """ + if use_numpy: + assert isinstance(env, bourse.core.StepEnvNumpy) + assert all( + [isinstance(a, bourse.step_sim.agents.BaseNumpyAgent) for a in agents] + ), "Agents should implement bourse.step_sim.agents.BaseNumpyAgent" + else: + assert isinstance(env, bourse.core.StepEnv) + assert all( + [isinstance(a, bourse.step_sim.agents.BaseAgent) for a in agents] + ), "Agents should implement bourse.step_sim.agents.BaseAgent" + rng = np.random.default_rng(seed) + it = tqdm.trange(n_steps, disable=not show_progress) + + if use_numpy: + for _ in it: + + level_2_data = env.level_2_data() + + for agent in agents: + instructions = agent.update(rng, level_2_data) + env.submit_instructions(instructions) - for _ in tqdm.trange(n_steps, disable=not show_progress): - for agent in agents: - agent.update(rng, env) + env.step() + else: + for _ in it: + for agent in agents: + agent.update(rng, env) - env.step() + env.step() return env.get_market_data() diff --git a/tests/test_step_sim/test_env.py b/tests/test_step_sim/test_env.py index f98dbd5..4edbd11 100644 --- a/tests/test_step_sim/test_env.py +++ b/tests/test_step_sim/test_env.py @@ -116,7 +116,7 @@ def test_incorrect_price(): def test_runner(): - class TestAgent: + class TestAgent(bourse.step_sim.agents.BaseAgent): def __init__(self, side: bool, start_price: int): self.side = side self.start_price = start_price diff --git a/tests/test_step_sim/test_numpy_api.py b/tests/test_step_sim/test_numpy_api.py index fc854a2..2ab1594 100644 --- a/tests/test_step_sim/test_numpy_api.py +++ b/tests/test_step_sim/test_numpy_api.py @@ -80,3 +80,41 @@ def test_numpy_random_agent(): instructions = agents.update(rng, env.level_2_data()) env.submit_instructions(instructions) + + +def test_runner(): + class TestAgent(bourse.step_sim.agents.BaseNumpyAgent): + def __init__(self, side: bool, start_price: int): + self.side = side + self.start_price = start_price + self.step = 0 + + def update(self, _rng, _level_2_data): + if self.side: + new_price = self.start_price + self.step + else: + new_price = self.start_price - self.step + + self.step += 1 + + return ( + np.array([1], dtype=np.uint32), + np.array([self.side]), + np.array([10], dtype=np.uint32), + np.array([101], dtype=np.uint32), + np.array([new_price], dtype=np.uint32), + np.array([0], dtype=np.uint64), + ) + + env = bourse.core.StepEnvNumpy(101, 0, 1, 100_000) + agents = [TestAgent(True, 10), TestAgent(False, 50)] + + data = bourse.step_sim.run(env, agents, 10, 101, use_numpy=True) + + assert np.array_equal(data["bid_price"], 10 + np.arange(10)) + assert np.array_equal(data["ask_price"], 50 - np.arange(10)) + assert np.array_equal(data["bid_vol"], 10 * np.arange(1, 11)) + assert np.array_equal(data["ask_vol"], 10 * np.arange(1, 11)) + assert np.array_equal(data["bid_vol_0"], 10 * np.ones(10)) + assert np.array_equal(data["ask_vol_0"], 10 * np.ones(10)) + assert np.array_equal(data["trade_vol"], np.zeros(10)) From 44aea66d091dff1a81f4f5a6006832f428404a4b Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Wed, 27 Mar 2024 21:24:09 +0000 Subject: [PATCH 10/12] Extend docs --- docs/source/pages/example.rst | 2 +- .../pages/numpy_discrete_event_usage.rst | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/source/pages/example.rst b/docs/source/pages/example.rst index 7c4076b..b9d8b3e 100644 --- a/docs/source/pages/example.rst +++ b/docs/source/pages/example.rst @@ -15,7 +15,7 @@ then define an agent class .. testcode:: random_example - class RandomAgent: + class RandomAgent(bourse.step_sim.agents.BaseAgent): def __init__(self, i, price_range): self.i = i self.price_range = price_range diff --git a/docs/source/pages/numpy_discrete_event_usage.rst b/docs/source/pages/numpy_discrete_event_usage.rst index 2f6d74e..eaac180 100644 --- a/docs/source/pages/numpy_discrete_event_usage.rst +++ b/docs/source/pages/numpy_discrete_event_usage.rst @@ -100,3 +100,47 @@ number of orders for the first 10 price levels from the touch. See :py:class:`bourse.core.StepEnvNumpy` for full details of the API. + +Agents that interact with the Numpy API can implement +:py:class:`bourse.step_sim.agents.base_agent.BaseNumpyAgent` with an +``update`` method that takes a random number generator +and array representing the current level 2 data of the +order book (the current touch price, and volumes and orders +at the top 10 price levels). It should return a tuple of +arrays encoding market instructions, for example this +agent simply places new orders either side of the spread + +.. testcode:: numpy_sim_usage + + from bourse.step_sim.agents import BaseNumpyAgent + + class Agent(BaseNumpyAgent): + + def update(self, rng, level_2_data): + bid = max(level_2_data[1], 20) + ask = min(level_2_data[2], 40) + + return ( + np.array([1, 1], dtype=np.uint32), + np.array([True, False]), + np.array([10, 20], dtype=np.uint32), + np.array([101, 202], dtype=np.uint32), + np.array([bid, ask], dtype=np.uint32), + np.array([0, 0], dtype=np.uint64), + ) + +These agents can be used in simulation by setting the +``use_numpy`` argument, and passing an array +of agents implementing :py:class:`bourse.step_sim.agents.base_agent.BaseNumpyAgent`, +for example + +.. testcode:: numpy_sim_usage + + agents = [Agent()] + + n_steps = 50 + seed = 101 + + market_data = bourse.step_sim.run( + env, agents, n_steps, seed, use_numpy = True + ) From 5218520e32066a23b958777df6cbad7e7e4718fc Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Wed, 27 Mar 2024 22:15:04 +0000 Subject: [PATCH 11/12] Remove dependency --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aeb1313..9c0a715 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,6 @@ dependencies = [ "jupyter >= 1.0.0", "matplotlib >= 3.8.2", "pyarrow >= 14.0.2", - "numba >= 0.59.0", ] [tool.hatch.envs.jupyter.scripts] From 8012a21a1bc6dd8767d7350b78a4a8f208092232 Mon Sep 17 00:00:00 2001 From: zombie-einstein <13398815+zombie-einstein@users.noreply.github.com> Date: Wed, 27 Mar 2024 22:29:00 +0000 Subject: [PATCH 12/12] Better error handling when placing orders from array --- rust/src/step_sim_numpy.rs | 30 ++++++++++++--------------- tests/test_step_sim/test_numpy_api.py | 2 +- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/rust/src/step_sim_numpy.rs b/rust/src/step_sim_numpy.rs index 226d5f8..371e7af 100644 --- a/rust/src/step_sim_numpy.rs +++ b/rust/src/step_sim_numpy.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; use super::types::{cast_order, cast_trade, NumpyInstructions, PyOrder, PyTrade}; use bourse_book::types::{Nanos, OrderId, Price, TraderId, Vol}; use bourse_de::{Env as BaseEnv, OrderError}; -use ndarray::Zip; use numpy::{PyArray1, ToPyArray}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -160,25 +159,22 @@ impl StepEnvNumpy { orders.3.readonly(), ); - let orders = ( - orders.0.as_array(), - orders.1.as_array(), - orders.2.as_array(), - orders.3.as_array(), - ); + let sides = orders.0.as_array(); + let volumes = orders.1.as_array(); + let trader_ids = orders.2.as_array(); + let prices = orders.3.as_array(); - // TODO: would be nice to return better error here - let order_ids = Zip::from(orders.0) - .and(orders.1) - .and(orders.2) - .and(orders.3) - .map_collect(|side, vol, id, price| { + let ids: Result, OrderError> = (0..orders.0.len()) + .map(|i| { self.env - .place_order((*side).into(), *vol, *id, Some(*price)) - .unwrap() - }); + .place_order(sides[i].into(), volumes[i], trader_ids[i], Some(prices[i])) + }) + .collect(); - Ok(order_ids.to_pyarray(py)) + match ids { + Ok(i) => Ok(i.to_pyarray(py)), + Err(e) => Err(PyValueError::new_err(e.to_string())), + } } /// submit_cancellations(order_ids: numpy.ndarray) diff --git a/tests/test_step_sim/test_numpy_api.py b/tests/test_step_sim/test_numpy_api.py index 2ab1594..f3ad3ec 100644 --- a/tests/test_step_sim/test_numpy_api.py +++ b/tests/test_step_sim/test_numpy_api.py @@ -43,7 +43,7 @@ def test_raise_from_bad_order(): ids = np.array([1, 1], dtype=np.uint32) prices = np.array([20, 21], dtype=np.uint32) - with pytest.raises(BaseException): + with pytest.raises(ValueError): env.submit_limit_orders((sides, vols, ids, prices))